From 77a586d7d3affbb58b0b9b34a20fe99f3c4c7b72 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 10 Sep 2026 11:42:23 +0100 Subject: [PATCH 01/56] chore(repo): add comment policy to AGENTS.md and trim cli/complete.ts comments --- AGENTS.md | 48 ++ apps/cli/AGENTS.md | 4 +- apps/cli/src/cli/complete.ts | 961 +++++++---------------------------- 3 files changed, 236 insertions(+), 777 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 55b4edf074..0330b79907 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -237,6 +237,54 @@ pnpm test If a workspace exposes a different script set, use that workspace's `package.json` as the source of truth. +## Comments + +Comments exist for the next reader, not as the author's audit trail. Code states what happens; a comment states only the why that the code cannot carry. Most code needs no comment at all. + +### When to comment + +Write a comment only for one of these: + +- An invariant or constraint the types cannot express and that a later edit could silently break. +- A workaround for an external quirk (Docker, Postgres, an OS, a library), naming the symptom it avoids. +- A decision that would otherwise read as a bug, in one or two sentences. +- A pointer to where the full rationale lives: an ADR under `docs/adr/`, a `SIDE_EFFECTS.md`, a `docs/` page, or an upstream issue URL. + +If the rationale needs more than three lines, it does not belong in a comment. Move it to an ADR or a docs page and leave a one-line pointer. + +### How to comment + +- Prefer JSDoc (`/** … */`) on exported symbols. Lead with a one-sentence summary. Use `@param`, `@returns`, `@throws`, `@see`, `@deprecated`, `@example`, and `{@link}` where they carry the information more compactly than prose; skip `@param`/`@returns` when the name and type already say it. +- Skip JSDoc on internal helpers whose name and signature are self-explanatory. A helper that needs a paragraph to explain wants a better name or a smaller scope, not a comment. +- Keep inline `//` comments to one or two lines, placed above the statement they explain. +- Describe behavior in its own terms and in the present tense. +- Published packages (`@supabase/config`) ship JSDoc to consumers through `.d.ts`, so every public export there carries a one-line summary. +- This repo is public. Nothing internal (see "Pull Requests") goes into a comment. + +### Never write + +- Narration of the code: "loop over the rows", "return early if empty", "call the API". +- Provenance and history: Go file:line citations, commit SHAs, PR numbers, review-round notes, "previously", "no longer", "was renamed", "deleted in …". Git holds history. +- Evidence trails: "verified empirically", "confirmed by grepping every call site", "surveyed N call sites". Encode the evidence as a test instead. +- Ticket IDs as provenance. `CLI-1234` belongs only in a `TODO(CLI-1234):` or when it is the only home a decision has and no ADR exists. +- Emphasis: ALL-CAPS words, "deliberately", "DELIBERATE divergence", "crucially", "exactly". State the constraint once, plainly. +- Meta-commentary on the code's own shape: "not exported, callers reference it structurally", "hoisted to file scope so both suites share it", "kept separate on purpose". +- Section banners such as `// ---- Helpers ----`. Reorder or split the file instead. +- Restatements of docs. If a `SIDE_EFFECTS.md` or an ADR already explains it, link it. +- Go-parity framing. See "Source of Truth" in `apps/cli/AGENTS.md`. + +### Tests + +The test name carries the intent. Comment only non-obvious fixture setup, in one line. Do not annotate assertions with the reasoning behind them; put that reasoning into the test name or a more specific assertion. + +### Directive comments + +`// oxlint-disable-next-line`, `// @ts-expect-error`, `/// `, shebangs, and similar are instructions to tools, not prose. Keep them, and give every lint disable a short reason after `--`. + +### Size check + +A source file whose comment lines exceed a quarter of its code lines almost certainly holds prose that belongs in docs. Trim it before merging. + ## Workspace graph and task execution This repo uses pnpm workspaces and Turbo for task execution and dependency diff --git a/apps/cli/AGENTS.md b/apps/cli/AGENTS.md index 679a0609dd..cbb466baff 100644 --- a/apps/cli/AGENTS.md +++ b/apps/cli/AGENTS.md @@ -40,7 +40,9 @@ What this means in practice: definitions must keep matching the Go binary they forward to). - **Do not write new comments, doc sections, or helper names framed as "Go parity" or "matches Go".** Describe behavior in its own terms. When touching code that carries old Go-parity - framing, clean it up opportunistically. + framing, clean it up opportunistically. The repo-wide comment policy is the `## Comments` + section of the root `AGENTS.md`; Go `file.go:NN` citations and "verified against Go" notes + are provenance and never belong in a comment. - For history: Go source for ported commands was deleted in CLI-1970; the last commit with it intact is `7b469f5b3` (`internal/start` was deleted separately in CLI-1966; its pin is `a253ccba2`). [ADR 0016](../../docs/adr/0016-port-completion-and-go-cli-authority-scope.md) diff --git a/apps/cli/src/cli/complete.ts b/apps/cli/src/cli/complete.ts index c6d806a94e..310d594cd5 100644 --- a/apps/cli/src/cli/complete.ts +++ b/apps/cli/src/cli/complete.ts @@ -23,43 +23,13 @@ import { analyticsLayer } from "../telemetry/analytics.layer.ts"; import { formatCliError, normalizeCliError } from "../shared/output/normalize-error.ts"; /** - * Native TypeScript reimplementation of cobra's dynamic-completion protocol - * (`spf13/cobra@v1.10.2/completions.go`). Cobra-generated - * completion scripts (`supabase completion {bash,zsh,fish,powershell}`) call - * back into `supabase __complete ` on every tab press — or - * `supabase __completeNoDesc ` when the script was generated with - * `--no-descriptions` (cobra's alias for the same hidden command). This module - * bypasses Effect's structured argv parser entirely for that path (the args may - * include partial/malformed flag tokens, e.g. `--de` mid-completion, that the - * parser would reject) and instead reflects directly over `rootCommand` — the - * live Effect CLI command tree — to compute candidates. - * - * Deliberate, documented simplifications relative to real cobra: - * - No `--help`-style multi-paragraph usage error for zero completion args - * (`MinimumNArgs(1)` failure) — real generated shell scripts always pass at - * least one arg, so this path is realistically unreachable by real - * completion traffic. - * - No "Completion ended with directive: ..." trailer or `[Debug] [Error] ...` - * diagnostics — both are cobra-side stderr-only text every real generated - * completion script discards (`2>/dev/null` or equivalent), so reproducing - * them has zero observable effect on any user. - * - Mutually-exclusive flag-group hiding (cobra's `enforceFlagGroupsForCompletion`) - * is not reproduced — there is no equivalent flag-group annotation anywhere in - * this TS tree to mirror, and hand-building a shadow table carries a - * materially higher transcription-error risk than the small, stable tables - * below. Accepted as a documented gap. - * - Deprecated commands/flags (cobra's `IsAvailableCommand()`/`MarkDeprecated`) - * are not filtered out of candidates — this TS tree has no "deprecated" - * concept distinct from `hidden` today (deprecation is only reflected in - * description text), so filtering it out here would require tree-level - * metadata this port doesn't own. Accepted as a documented gap, expected to - * shrink as the tree's own deprecated-alias cleanup lands separately. + * Implements the shell completion protocol that cobra-generated scripts (`supabase + * completion {bash,zsh,fish,powershell}`) call into via `supabase __complete`/ + * `__completeNoDesc` on every tab press. Completion argv can contain partial or malformed + * flag tokens (e.g. `--de` mid-word), so this bypasses the structured CLI parser and + * reflects directly over `rootCommand` to compute candidates. */ -/* ========================================================================== */ -/* Types */ -/* ========================================================================== */ - export interface CompletionCandidate { readonly name: string; readonly description: string | undefined; @@ -70,9 +40,7 @@ export interface CompletionResult { readonly directive: number; } -/** - * The subset of cobra's `ShellCompDirective` bit flags this port ever emits. - */ +/** Bit flags for the shell completion protocol's directive value. */ export const CompletionDirective = { Default: 0, NoFileComp: 4, @@ -116,41 +84,16 @@ export interface CompleteDeps { readonly stdoutWrite: (message: string) => void; readonly stderrWrite: (message: string) => void; readonly exit: (code: number) => void; - /** - * Fires the `cli_command_executed` telemetry capture for this request — - * see `captureCompleteTelemetryEffect`'s doc comment for what it - * records and why. Injected the same way `stdoutWrite`/`exit` already are, - * so tests can run `captureCompleteTelemetryEffect` against a mocked - * `Analytics` boundary without spawning a real subprocess or touching the - * real, consent-gated production layer `defaultCompleteDeps` wires by - * default — see `complete.integration.test.ts`. - */ + /** Fires the `cli_command_executed` telemetry capture for this request. */ readonly captureTelemetry: (exitCode: number, durationMs: number) => Promise; } -/* ========================================================================== */ -/* Internal command field access (`param-introspection.ts` precedent) */ -/* ========================================================================== */ - /** - * `.config.flags` (a command's own declared flags), `.contextConfig.flags` - * (flags inherited via `Command.withSharedFlags`), and `.globalFlags` (a - * command's own declared global flags) are genuinely absent from the public - * `Command`/`Command.Any` TypeScript interface — only `name`, `description`, - * `shortDescription`, `alias`, `examples`, `subcommands`, `annotations`, and - * `hidden` are public — but they exist at runtime (`internal/command.ts`'s - * `makeCommand`, via `Object.assign`; that internal module is not importable - * — its package.json export map entry is `null` — so there is no type-safe - * import to reach for instead). - * - * A bare `as unknown as` here would silently paper over that gap (forbidden - * by this repo's typing rules — see `CLAUDE.md`), so this narrows through a - * runtime type guard instead, the same `"" in value` shape - * `param-introspection.ts`'s `isWrappedParam` already - * establishes for the identical problem (an internal-only field the public - * `effect/unstable/cli` types don't declare). If a future `effect` version - * ever drops one of these fields, this throws instead of silently completing - * against `undefined`. + * `config.flags`, `contextConfig.flags`, and `globalFlags` exist on `Command` + * at runtime but aren't part of its public type. Narrow through a runtime + * guard rather than an `as` cast, so a future `effect` upgrade that drops one + * of these fields throws here instead of silently completing against + * `undefined`. */ interface CommandInternal { readonly config: { readonly flags: ReadonlyArray }; @@ -177,19 +120,11 @@ function flattenSubcommands(command: Command.Command.Any): ReadonlyArray group.commands); } -/* ========================================================================== */ -/* Flag descriptors */ -/* ========================================================================== */ - /** - * `Flag.choice`/`Flag.choiceWithValue`'s `choiceKeys` (the valid value set) is - * attached to the `Choice`-tagged `Primitive` via `Object.assign` at - * runtime (`Primitive.choice`, - * `.repos/effect/packages/effect/src/unstable/cli/Primitive.ts`) but carries - * an `@internal` JSDoc tag and is absent from the public `Primitive` - * interface — the identical gap `CommandInternal` above already works - * around for `Command`, so this reuses the same runtime type-guard idiom - * instead of an `as` cast. + * `choiceKeys` (a `Choice` primitive's valid value set) is attached via + * `Object.assign` at runtime and isn't part of the public `Primitive` + * type, so this narrows through the same runtime-guard idiom as + * `CommandInternal`. */ interface ChoicePrimitive { readonly choiceKeys: ReadonlyArray; @@ -222,29 +157,10 @@ function flagDescriptorFromParam(param: Param.AnyFlag): FlagDescriptor | undefin } /** - * The full in-scope flag list for `commandChain`'s last element (the resolved - * command), ordered and grouped the way cobra's own completion path emits - * flag-name candidates: `InheritedFlags().VisitAll` (every ancestor's global - * and shared flags, as ONE pflag-alphabetically-sorted block), followed by - * `NonInheritedFlags().VisitAll` (the resolved command's own global flags, - * its own `--help`, the `--log-level`/`--wizard`/`--completions` built-ins, - * root's own `--version`, and its own local flags, as a SECOND, - * separately-sorted block) — pflag's `FlagSet.VisitAll` walks - * `sortedFormalFlags`, which sorts strictly by each flag's canonical long - * name: `db dump -` lists `--agent`, `--create-ticket`, `--debug`, ... - * alphabetically, THEN a second alphabetical run starting `--data-only`, - * `--db-url`, `--dry-run`, ... — not one merged alphabetical list and not - * this tree's own declaration order. - * - * The resolved command's own local flags win on a canonical-name collision — - * e.g. a command's own local `--output` (`db diff`'s file-path flag) must - * shadow the global `--output` choice flag declared at root — by being - * excluded from the inherited block entirely, mirroring pflag's - * `InheritedFlags()`, which skips any persistent flag shadowed by a - * same-named local one (rather than being present in both and "last write - * wins": a `Map`'s insertion-order position does not move on a same-key - * `.set()`, so a naive later-overwrite would leave the shadowed entry sitting - * in the wrong (inherited) sort position instead of removing it). + * Returns in-scope flags as two alphabetically sorted blocks — inherited + * flags, then the command's own — the order completion scripts expect. A + * local flag shadows a same-named inherited one rather than appearing twice + * (e.g. `db diff`'s own file-path `--output` shadows the global choice flag). */ export function collectInScopeFlags( root: Command.Command.Any, @@ -263,19 +179,12 @@ export function collectInScopeFlags( const ownParams: Array = [ ...globalFlagParamsOf(finalCommand), GlobalFlag.Help.flag, - // The built-ins `--log-level`, `--wizard`, and `--completions` are part - // of every resolved command's real flag set (`GlobalFlag.BuiltIns`, shown - // in `--help`), so the strict flag walk must resolve them or a typed - // `--log-level error` poisons the whole line like an unknown flag - // (issue #6482). Unlike `--help`/root `--version`, whose short-circuit - // below ends the completion request, these three keep the line - // completing normally. + // These built-ins must resolve like any other known flag, or a typed + // `--log-level error` poisons the rest of the completion line. GlobalFlag.LogLevel.flag, GlobalFlag.Wizard.flag, GlobalFlag.Completions.flag, - // Cobra's `InitDefaultVersionFlag` only registers `--version`, and only on - // the root command (gated on `c.Version != ""`, and non-persistent) — it - // is never inherited by subcommands the way `--help` is. + // `--version` only applies to the root command; it isn't inherited by subcommands. ...(commandChain.length === 1 ? [GlobalFlag.Version.flag] : []), ...internalCommand(finalCommand).config.flags, ]; @@ -298,22 +207,12 @@ export function collectInScopeFlags( return [...inherited, ...own]; } -/* ========================================================================== */ -/* Flag-token resolution */ -/* ========================================================================== */ - /** - * Resolves a bare flag token (`--project-ref`, `-p`, or a shorthand cluster - * like `-po`, where cobra's rule is "the character immediately before the - * value/`=`", i.e. the last character) to its owning in-scope flag. Mirrors - * cobra's `checkIfFlagCompletion` heuristic (`completions.go:676-681,702-707`, - * the documented `-asd` => `d` quirk from cobra issue #1257) for guessing - * which flag the CURRENT or immediately PRECEDING token is mid-way through - * value-completing — deliberately NOT the same algorithm as - * `resolveShortFlagCluster`, which mirrors the real, strict - * `ParseFlags()` parser instead (first character owns the value, not last). - * See that function's doc comment for why the two differ and where each is - * used. + * Resolves a bare flag token (`--project-ref`, `-p`, or a shorthand cluster like `-po`) to + * its owning in-scope flag, guessing which flag the current or preceding token is mid-way + * through value-completing by treating the character immediately before the value/`=` (the + * last character of a cluster) as the owner. This differs from `resolveShortFlagCluster`, + * which mirrors the strict parser's first-character rule; see that function's doc comment. */ function resolveFlagFromToken( token: string, @@ -330,57 +229,12 @@ function resolveFlagFromToken( return undefined; } -/* ========================================================================== */ -/* Command-path resolution */ -/* ========================================================================== */ - /** - * Descends from `root` through `trimmedArgs`, matching each non-flag token - * against the current command's subcommand names/aliases (exact, - * case-sensitive — no prefix or fuzzy matching). Mirrors cobra's `Find()`, - * which strips flags before matching positional command names - * (`completions.go:340`) via its own heuristic `stripFlags` - * (`pflag@v1.0.9/flag.go`) — a cruder, command-tree-only pre-pass distinct - * from the real flag parser `collectChangedFlagNames` mirrors: - * - * - A long flag (`--foo`) or a single-character short flag (`-f`) with no - * embedded `=` consumes the following token as its value UNLESS it's - * already known at this point in the descent to be boolean — this - * includes flags not yet in scope, e.g. a subcommand's own local flag - * typed before that subcommand is reached (`--db-url`, local to `db - * dump`, typed before `db`): `stripFlags`'s `hasNoOptDefVal` returns - * `false` for a name it can't find yet, so `!hasNoOptDefVal(...)` is - * `true` and it optimistically consumes a value anyway: `__complete - * --db-url postgres:// db dump --s` still offers `db dump`'s `--schema`, - * which requires descending past `--db-url postgres://` to reach `db - * dump` at all. - * - Anything else flag-shaped — a multi-character shorthand cluster - * (`-rj`), a flag containing `=`, or a bare `--` — is skipped without - * consuming a value. A bare `--` additionally stops the descent - * entirely: it's pflag's end-of-flags sentinel, so no token at or after - * it can ever match a subcommand (verified empirically: `__complete -- - * db ""` returns zero candidates with the Default directive, not `db`'s - * subcommands). - * - A bare `-` is NOT flag-shaped at all — pflag's own `isFlagArg` - * (`command.go:750-753`) requires at least 2 characters, so cobra's - * `stripFlags` (`command.go:674-706`) silently drops it from its - * subcommand-name scan (it matches none of that function's `switch` - * cases) without either consuming a value OR stopping the descent, and — - * critically — WITHOUT removing it from the leftover args the way a - * matched command name is (`argsMinusFirstX` only ever strips the exact - * matched name). It therefore must stay in `leftoverArgs` here too, while - * still letting the descent continue past it: `db - dump --da` still - * descends past the bare `-` into `dump` and offers `--data-only`, while - * `sso - --debug a` returns zero candidates with the Default directive — - * the surviving `-` keeps the `len(finalArgs) == 0` subcommand-listing - * gate below closed. - * - * Descent stops at the first non-flag token that doesn't match a subcommand, - * or at a `--` sentinel; that token and everything after it becomes - * `leftoverArgs` — the *positional* leftover cobra's `finalArgs` represents - * (`completions.go:397-399`), used to gate subcommand-name completion - * (`len(finalArgs) == 0`). Flag tokens and their consumed values are never - * part of `leftoverArgs`; a bare `-` is the one exception, per above. + * Descends from `root` through `trimmedArgs`, matching each non-flag token against the + * current command's subcommand names/aliases. A flag not yet known to be boolean is assumed + * to consume the next token as its value, even if the flag itself isn't in scope yet. A bare + * `--` stops the descent (end of flags); a bare `-` does not, but stays in `leftoverArgs` + * unlike a matched command name. */ export function resolveCommandPath( root: Command.Command.Any, @@ -399,14 +253,11 @@ export function resolveCommandPath( continue; } - if (token === "--") break; // pflag's end-of-flags sentinel: nothing at or after this can match a subcommand. + if (token === "--") break; // end of flags: nothing at or after this can match a subcommand. if (token === "-") { - // Not flag-shaped (pflag's `isFlagArg` requires length >= 2) and never - // a real subcommand name — skip it without consuming a value, without - // breaking the descent, and WITHOUT marking it consumed, so it survives - // into `leftoverArgs` exactly like real cobra's `finalArgs` does. See - // this function's doc comment for the empirical verification. + // Not flag-shaped and never a subcommand name; skip without stopping the descent, but + // leave it in `leftoverArgs` unlike a matched command name. index++; continue; } @@ -416,13 +267,10 @@ export function resolveCommandPath( const isLong = token.startsWith("--"); const isSingleCharShort = !isLong && token.length === 2; if (!token.includes("=") && (isLong || isSingleCharShort)) { - // The flags visible at this point of the descent are enough to tell - // whether this token consumes the next one as its value. const inScopeSoFar = collectInScopeFlags(root, commandChain); const resolved = resolveFlagFromToken(token, inScopeSoFar); - // An unrecognized flag is optimistically assumed to take a value too - // (see the doc comment above) — only a flag already known here to be - // boolean is exempt. + // An unrecognized flag is assumed to take a value too; only a flag already known + // here to be boolean is exempt. const takesValue = resolved === undefined || !resolved.isBoolean; if (takesValue && index + 1 < trimmedArgs.length) { consumedIndices.add(index + 1); @@ -449,14 +297,8 @@ export function resolveCommandPath( return { commandChain, matchedPath, leftoverArgs }; } -/* ========================================================================== */ -/* Classification */ -/* ========================================================================== */ - /** - * File-extension filtering for a small, fixed set of file-path flags — not - * derived from anything generic, so a small matching lookup table here is - * the right level of fidelity. Key = + * File-extension filtering for a small, fixed set of file-path flags. Key = * `:`. */ const COMPLETION_FLAG_FILE_EXTENSIONS: ReadonlyMap> = new Map([ @@ -467,30 +309,19 @@ const COMPLETION_FLAG_FILE_EXTENSIONS: ReadonlyMap ]); /** - * The set of flags cobra treats as unconditionally required during - * `__complete`/`__completeNoDesc`. A flag whose requirement is scoped inside - * a `PreRun`/`PreRunE` hook (conditional on other flags or TTY state) never - * applies to a real completion request, since completion never runs those - * hooks — deliberately excluded here for that reason: `db dump:data-only` - * (inside `PreRun`), `init:experimental` (inside `PreRun`), `projects - * create:{org-id,db-password,region}` (inside `PreRunE`), `link:project-ref` - * (inside `PreRunE`). - * - * Deliberately a hardcoded table, not derived from whether the TS flag is - * `Flag.optional`-wrapped: several of these TS flags are intentionally - * `Flag.optional` at parse time for validation-ordering reasons unrelated to - * completion (e.g. `vanity-subdomains activate --desired-subdomain` — see - * that command's own file comment), so "is this flag `Optional`-wrapped in - * TS" is not a faithful proxy for "does cobra mark it required." Key = - * `:`. + * Flags treated as required during completion. A flag whose requirement is conditional on + * other flags or TTY state is excluded, since completion never evaluates those conditions. + * This is a hardcoded table rather than derived from `Flag.optional`, since some flags are + * `Flag.optional` at parse time for validation-ordering reasons unrelated to completion. Key + * = `:`. */ const COMPLETION_REQUIRED_FLAGS: ReadonlySet = new Set([ - "domains create:custom-hostname", // cmd/domains.go:100 - "migration repair:status", // cmd/migration.go:122 - "gen bearer-jwt:role", // cmd/gen.go:175 - "sso add:type", // cmd/sso.go:165 - "vanity-subdomains activate:desired-subdomain", // cmd/vanitySubdomains.go:67 - "vanity-subdomains check-availability:desired-subdomain", // cmd/vanitySubdomains.go:69 + "domains create:custom-hostname", + "migration repair:status", + "gen bearer-jwt:role", + "sso add:type", + "vanity-subdomains activate:desired-subdomain", + "vanity-subdomains check-availability:desired-subdomain", ]); function isRequiredCompletionFlag(matchedPath: ReadonlyArray, flagName: string): boolean { @@ -498,16 +329,9 @@ function isRequiredCompletionFlag(matchedPath: ReadonlyArray, flagName: } /** - * Mirrors cobra's `InitDefaultCompletionCmd`, which registers - * `ValidArgsFunction: NoFileCompletions` on the `completion` group command - * itself and each of its `bash`/`zsh`/`fish`/`powershell` leaves. - * `getCompletions` always calls a resolved command's own - * `ValidArgsFunction` when one is registered, and that call OVERWRITES the - * directive outright — for a leaf like `completion bash`, which has no - * subcommands of its own to otherwise set NoFileComp, this is the ONLY - * thing that sets it: `completion bash ""` returns the NoFileComp directive - * with zero candidates, not Default. Key = space-joined `matchedPath` - * (excluding "supabase"). + * Commands whose completion always returns the `NoFileComp` directive with + * zero candidates, since they take no positional arguments to complete. Key = + * space-joined `matchedPath` (excluding "supabase"). */ const COMPLETION_NO_FILE_COMP_PATHS: ReadonlySet = new Set([ "completion", @@ -534,47 +358,11 @@ function flagNameCandidates( } /** - * A lightweight, string-only approximation of "which in-scope flags have - * already been provided" (not a real flag parser, but close enough to mirror - * pflag's actual `Set`-time behavior for the shapes real completion input - * takes) — correct for the overwhelming majority of real completion inputs. - * - * Stops at a bare `--` the same way `findUnresolvedFlagToken` and - * `resolveCommandPath` do — pflag's end-of-flags sentinel means - * nothing at or after it is ever parsed as a flag, so nothing past it can be - * "changed": `sso add -- --type --typ` still offers `--type`, since that - * token is positional, past the terminator, and never reaches pflag's flag - * parser at all. - * - * A LONG flag with no `=` that resolves to a non-boolean in-scope flag - * consumes the immediately following token as its value — that token is - * skipped here entirely, exactly like pflag's `parseLongArg` - * (`pflag@v1.0.10/flag.go:1013-1023`), so a value that happens to look like a - * flag (e.g. `--domains --type foo`, where `--type` is `--domains`'s value) - * is never itself marked changed. - * - * A short-flag token walks its shorthand cluster exactly like - * `pflag@v1.0.10`'s `parseSingleShortArg`: each character that resolves to a - * boolean (`NoOptDefVal != ""`) flag is marked changed and the walk continues - * to the next character in the SAME token; the first non-boolean character - * (or a `=value` suffix) is also marked changed but ends the walk there, - * since the rest of the token (or the next arg) is that flag's value, not - * another shorthand: after `storage cp -rj 2`, both `-r`/`--recursive` and - * `-j`/`--jobs` are "changed" — `--r` offers nothing further — whereas - * this function used to record only the cluster's last character. - * - * `classifyCompletion`'s `--help`/`--version` short-circuit reads THIS - * set (`changedFlagNames.has("help"/"version")`) rather than scanning raw - * tokens for a reason beyond DRY: pflag's `boolValue.Set` marks the flag - * `Changed` on an explicit-value spelling too (`--help=false`), and cobra's - * `helpOrVersionFlagPresent` checks `.Changed`, not the parsed value — so - * `--help=false`/`--version=false` short-circuit exactly like a bare - * `--help`/`--version`: `--help=false --d` and `--version=false br` both - * return zero candidates with the NoFileComp directive — and this - * function's name-collection above already marks a flag changed on ANY - * spelling, explicit-value included. A raw token scan misses the terminator- - * and value-consumption cases this function already handles instead (see - * `classifyCompletion`'s call site for the specific repros). + * A lightweight, string-only approximation of "which in-scope flags have already been + * provided", accurate for the input shapes shells actually send. Stops at a bare `--` + * terminator; a value-taking flag consumes its following token so that value is never + * itself mistaken for a flag; an explicit `--help=false`/`--version=false` still counts + * as "provided", matching how "changed" flag state is tracked independent of its value. */ function collectChangedFlagNames( trimmedArgs: ReadonlyArray, @@ -586,7 +374,7 @@ function collectChangedFlagNames( const token = trimmedArgs[index]; index++; if (token === undefined) continue; - if (token === "--") break; // pflag's end-of-flags sentinel: nothing at or after this is parsed as a flag. + if (token === "--") break; // end of flags: nothing at or after this is parsed as a flag. if (token.startsWith("--")) { const rest = token.slice(2); @@ -608,14 +396,9 @@ function collectChangedFlagNames( } /** - * Walks a short-flag token's shorthand cluster (e.g. `-rj`, `-o=json`), - * marking every shorthand consumed before — and including — the - * value-consuming one as changed. Returns `true` when the cluster ends on a - * non-boolean shorthand with no attached value (`-f`, or `-rf` ending on - * `f`) — the caller must then skip the immediately following token, since - * pflag consumes it as that shorthand's value rather than parsing it as its - * own flag. See `collectChangedFlagNames`'s doc comment for the pflag - * behavior this mirrors. + * Walks a short-flag cluster (e.g. `-rj`, `-o=json`), marking every shorthand up to and + * including the value-consuming one as changed. Returns `true` when the cluster ends on a + * non-boolean shorthand with no attached value, so the caller must skip the next token. */ function markChangedShorthandCluster( token: string, @@ -635,21 +418,10 @@ function markChangedShorthandCluster( } /** - * Whether `trimmedArgs` contains a genuine, unconsumed pflag end-of-flags - * sentinel — a bare `--` token that is NOT itself the value a preceding - * value-taking flag already consumed. `--file --` consumes the `--` as - * `--file`'s string value (`pflag@v1.0.10/flag.go:1013-1023`'s - * `parseLongArg`, which grabs the very next token unconditionally); pflag's - * sentinel check only ever inspects the CURRENT token being parsed, never - * one already claimed as a preceding flag's value, so a consumed `--` - * never disables later flag completion. A naive `trimmedArgs.includes("--")` - * treats that consumed token as a terminator too, wrongly shutting off - * flag-name/flag-value completion for the rest of the request: `db dump - * --file -- --s` still offers `--schema`, not zero candidates, while `db - * dump -- --s` — no preceding value flag to consume the `--` — correctly - * returns zero candidates. Walks the same long/short consumption rules - * `collectChangedFlagNames` does, reusing `resolveShortFlagCluster` for - * the short-flag case. + * Whether `trimmedArgs` contains a genuine, unconsumed `--` terminator — one that isn't + * itself the value a preceding value-taking flag already consumed (e.g. `--file --`). A + * naive `trimmedArgs.includes("--")` would treat that consumed token as a terminator too, + * wrongly shutting off flag completion for the rest of the request. */ function hasUnconsumedFlagTerminator( trimmedArgs: ReadonlyArray, @@ -683,19 +455,9 @@ function hasUnconsumedFlagTerminator( } /** - * `--jobs` (`functions deploy`, `storage cp`) and `--last` (`migration down`, - * `db reset`) are validated as `strconv.ParseUint(s, 0, 64)` values, which - * reject a leading `-`/`+` outright — unlike this TS tree's plain signed - * `Flag.integer("jobs"/"last")`. `isValidFlagValue` checks this table - * BEFORE dispatching on `primitiveTag`, since it must catch `storage cp - * --jobs` too, which is `Flag.string("jobs")` in TS (its own handler already - * calls `parseUintBase0` directly at parse time, `cp.command.ts`) - * rather than `Flag.integer` — a bare `primitiveTag` switch would never see - * it: `functions deploy --jobs -1 --p`, `migration down --last -1 --d`, `db - * reset --last -1 --d`, and `storage cp --jobs -1 --r` all return zero - * candidates with the Default directive. Key = `:`, matching `COMPLETION_REQUIRED_FLAGS`'s - * convention. + * Flags whose real validation rejects a leading `-`/`+`, unlike this tree's plain signed + * `Flag.integer`/`Flag.string` declarations — checked before `primitiveTag` dispatch since + * `storage cp --jobs` is declared as `Flag.string`. Key = `:`. */ const COMPLETION_UINT_FLAGS: ReadonlySet = new Set([ "functions deploy:jobs", @@ -705,28 +467,18 @@ const COMPLETION_UINT_FLAGS: ReadonlySet = new Set([ ]); /** - * `--query-timeout` (`gen types`) and `--valid-for` (`gen bearer-jwt`) are - * validated as `time.ParseDuration` values, unlike this TS tree's plain - * `Flag.string("query-timeout"/"valid-for")` — same shape as - * `COMPLETION_UINT_FLAGS` above, keyed the same way: `gen types - * --query-timeout bogus --l` and `gen bearer-jwt --role anon --valid-for - * bogus --p` both return zero candidates with the Default directive. + * Flags validated against Go duration syntax (see `isValidGoDuration`) rather than this + * tree's plain `Flag.string` declarations. */ const COMPLETION_DURATION_FLAGS: ReadonlySet = new Set([ "gen types:query-timeout", "gen bearer-jwt:valid-for", ]); -/** - * `--exp` (`gen bearer-jwt`) is validated as a `time.Parse(time.RFC3339, s)` - * value, unlike this TS tree's plain `Flag.string("exp")`: `gen bearer-jwt - * --role anon --exp bogus --p` returns zero candidates with the Default - * directive, while `--exp 2024-01-02T15:04:05Z --p` still offers - * `--profile`/`--payload`. - */ +/** `--exp` (`gen bearer-jwt`) is validated as an RFC 3339 timestamp, not a plain string. */ const COMPLETION_RFC3339_FLAGS: ReadonlySet = new Set(["gen bearer-jwt:exp"]); -/** Nanosecond scale for each unit `time.ParseDuration`'s `unitMap` accepts (`time/format.go`). */ +/** Nanosecond scale for each unit the Go duration grammar accepts. */ const GO_DURATION_UNIT_NANOS: ReadonlyMap = new Map([ ["ns", 1n], ["us", 1_000n], @@ -738,9 +490,8 @@ const GO_DURATION_UNIT_NANOS: ReadonlyMap = new Map([ ["h", 3_600_000_000_000n], ]); -// `time.ParseDuration` accumulates into a `uint64` and range-checks against -// `1<<63` mid-parse, only narrowing to the `int64` max (`1<<63 - 1`) in the -// final non-negative check — see `isValidGoDuration` below. +// Go's duration grammar accumulates into a `uint64` (range-checked against `1<<63` +// mid-parse) and narrows to the `int64` max in the final non-negative check below. const GO_DURATION_UINT64_OVERFLOW_BOUND = 1n << 63n; const GO_DURATION_MAX_INT64 = (1n << 63n) - 1n; @@ -749,36 +500,11 @@ function isAsciiDigit(char: string | undefined): boolean { } /** - * Faithful port of `time.ParseDuration` (`time/format.go`) — the exact - * parser pflag runs for a `DurationVar` flag's `Set` — as a syntax-and-range - * VERDICT (completion only needs a boolean, not the parsed `Duration`, - * mirroring `isValidBase0Int64`'s own shape). An optional sign, then - * either the literal `0` alone, or one or more `[.]` terms - * concatenated (`1h30m`, `1.5h`, `.5s`), accumulating nanoseconds: `BigInt` - * for the integer/fraction accumulators (`uint64` semantics, mirrored - * exactly — no JS `Number` precision loss), and a plain `Number` - * multiply-then-`Math.trunc` for the one step done in `float64` - * (`uint64(float64(f) * (float64(unit) / scale))`) — since a JS `number` IS - * an IEEE-754 double, `Number(bigIntValue)` round-trips through the exact - * same conversion `float64(f)` does, so this step is bit-for-bit identical, - * not merely an approximation of it. - * - * This replaces an earlier regex-only grammar check whose own doc comment - * dismissed the overflow bound as "unreachable through any realistic - * completion input" — disputed and disproven on review: the `int64` - * nanosecond range caps out at ~292 years, so `--query-timeout 2562048h` - * (one hour past the real max, a plausible fat-fingered value, not a - * contrived ~20-digit magnitude) is exactly the kind of input real - * completion traffic can produce, and the old check silently accepted it. - * `gen types --query-timeout 2562048h --l` returns zero candidates with the - * Default directive, matching `bogus`. Cross-checked this implementation - * against go1.26 `time.ParseDuration` across sign/fraction/multi-term/overflow - * edge cases, including the exact `int64` boundary - * (`2562047h47m16.854775807s` → valid, one ns more → invalid) and its - * negative-side asymmetry (`-2562047h47m16.854775808s`, `int64` min, valid — - * one ns more still invalid) — same two's-complement asymmetry - * `isValidBase0Int64` already encodes for - * `MAX_INT64_NEGATIVE_MAGNITUDE`. + * Validates the Go duration syntax (`1h30m`, `1.5h`, `.5s`) these flags accept, returning a + * boolean verdict rather than a parsed value. Uses `BigInt` for the integer/fraction + * accumulators to match Go's `uint64` overflow semantics exactly, and a plain `Number` for + * the one step Go performs in `float64` — a JS `number` is itself an IEEE-754 double, so + * this round-trips bit-for-bit rather than merely approximating it. */ function isValidGoDuration(value: string): boolean { let rest = value; @@ -794,8 +520,7 @@ function isValidGoDuration(value: string): boolean { while (rest.length > 0) { if (!(rest[0] === "." || isAsciiDigit(rest[0]))) return false; - // `leadingInt`: digits before the decimal point. Go returns an error - // immediately on overflow rather than continuing to consume digits. + // Digits before the decimal point; overflow fails immediately without consuming more. let i = 0; let intPart = 0n; while (isAsciiDigit(rest[i])) { @@ -807,8 +532,7 @@ function isValidGoDuration(value: string): boolean { const hasIntDigits = i > 0; rest = rest.slice(i); - // `leadingFraction`: digits after `.`. Go does NOT error on overflow - // here — it just stops accumulating precision and keeps consuming. + // Digits after `.`; overflow here stops accumulating precision but keeps consuming. let fracPart = 0n; let scale = 1n; let hasFracDigits = false; @@ -856,38 +580,17 @@ function isValidGoDuration(value: string): boolean { if (total > GO_DURATION_UINT64_OVERFLOW_BOUND) return false; } - // The final range check only runs for the non-negative case — the - // negative side already got the one-larger `1<<63` bound above, matching - // `int64`'s two's-complement asymmetry (see the doc comment). + // The negative side already got the larger `1<<63` bound above (int64's two's-complement + // asymmetry); only the non-negative case needs this final check. return negative || total <= GO_DURATION_MAX_INT64; } /** - * Mirrors `time.Parse(time.RFC3339, s)` — `2006-01-02T15:04:05Z07:00` - * — exact 4/2/2/2/2/2-digit date-time fields, a literal (case-sensitive) `T` - * separator, an optional fractional-seconds run of any length introduced by - * EITHER `.` or `,` (the `time` package accepts both spellings of the - * decimal mark per RFC 3339/ISO 8601: `"2024-01-02T15:04:05,5Z"` parses - * identically to `"...05.5Z"`; a bare separator with no following digit, - * e.g. `",Z"`, still fails, and mixing both separators in one timestamp, - * e.g. `".5,5Z"`, still fails too), and a `Z` or `±HH:MM` offset. - * Hour/minute/second are bounded to `0-23`/`0-59`/`0-59` (rejects `":60"` - * — no leap-second allowance — and `"25:"`). The offset's own hour/minute - * fields are ALSO bounded, but not to that same 0-23/0-59 range: the - * zone-offset parser independently caps the offset hour at 24 (not 23) and - * the offset minute at 60 (not 59), each checked in isolation rather than as - * a combined "total offset <= 24h": `"+24:00"`, `"+23:59"`, `"+24:60"`, and - * `"+00:60"` all parse successfully; `"+25:00"` and `"+00:61"` both fail - * with "time zone offset hour/minute out of range". Month/day validity - * (including leap years, and short months like April's 30 days) is checked - * by round-tripping the parsed year/month/day through `Date#setUTCFullYear` - * and comparing what comes back — that method (unlike the `Date` - * constructor or `Date.UTC`) does NOT special-case a 0-99 year into - * 1900+year, so it stays correct for the accepted `"0000-01-02T15:04:05Z"`, - * and its normal calendar-overflow behavior (Feb 29 rolling to Mar 1 in a - * non-leap year, day 32 rolling into the next month, month 13 rolling into - * the next year) exactly reproduces the same leap-year and day/month-bounds - * rejections without hand-rolling the calendar math. + * Matches RFC 3339 (`2006-01-02T15:04:05Z07:00`): fractional seconds may use either `.` or + * `,`, and the offset's own hour/minute are independently capped at 24/60 (not the 0-23/0-59 + * bounds used for the time fields themselves). Calendar validity is checked by round-tripping + * through `Date#setUTCFullYear`, which — unlike the `Date` constructor — doesn't special-case + * a 0-99 year into 1900+year. */ const GO_RFC3339_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:[.,]\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/; @@ -913,27 +616,16 @@ function isValidGoRfc3339(value: string): boolean { } /** - * Go registers `--sql-paths` (`db reset`, `cmd/db.go:714`) as a plain - * `StringArrayVar` — pflag stores each repeated occurrence verbatim, with NO - * CSV parsing — unlike every OTHER variadic (`isVariadic`) string flag - * reachable from this tree, which Go declares `StringSliceVar`/ - * `StringSliceVarP` (CSV-split per occurrence): `--domains` (sso - * add/update), `--schema`/`--exclude` (db dump/diff/pull/lint, gen types, db - * schema declarative generate/sync), `--config` (postgres-config - * delete/update), `--db-unban-ip` (network-bans remove), `--db-allow-cidr` - * (network-restrictions update), and `--exclude`/`--override-name` - * (start/status). This is the one, small exception — kept as an exclusion - * set rather than an inclusion table, since the inclusion side is the much - * longer list. Key = `:`. + * `db reset`'s `--sql-paths` stores each repeated occurrence verbatim, unlike every other + * variadic string flag in this tree, which CSV-splits each occurrence. Kept as a small + * exclusion set rather than an inclusion table, since the inclusion side is much longer. + * Key = `:`. */ const COMPLETION_NON_CSV_VARIADIC_FLAGS: ReadonlySet = new Set(["db reset:sql-paths"]); /** - * Validates a CSV-per-occurrence (`isVariadic`, pflag `StringSliceVar`) - * flag's value the same way `parseStringSliceFlag` does at real parse - * time — reused here directly rather than re-implemented, so the two never - * drift: `sso add --domains 'a,"b' --type` — an unterminated quote — returns - * zero candidates with the Default directive, not `--type`. + * Validates a CSV-per-occurrence variadic flag's value with `parseStringSliceFlag`, reused + * directly so the two never drift. */ function isValidCsvFlagValue(value: string): boolean { try { @@ -945,16 +637,10 @@ function isValidCsvFlagValue(value: string): boolean { } /** - * `--output`/`-o` is a command-scoped enum: the root persistent flag - * accepts `env|pretty|json|toml|yaml` while `db query`'s own local flag - * accepts `json|table|csv` — two value sets that only overlap on `json`, on - * what this TS tree models as a single global `OutputFlag` whose - * `choiceKeys` is the union of both (`go-output-flag.ts`), so - * `flag.choiceKeys` alone can't tell which enum applies at the resolved - * command. This restores per-command validation: `--output table ""` - * outside `db query`, and `db query --output env ""`, are BOTH rejected - * with zero candidates and the Default directive, even though each value is - * accepted on the OTHER side. + * `--output` is modeled as one global flag whose `choiceKeys` is the union of two distinct + * enums (`env|pretty|json|toml|yaml` everywhere else, `json|table|csv` for `db query`), so + * `flag.choiceKeys` alone can't tell which applies at the resolved command. This restores + * per-command validation. */ function outputFlagChoiceKeys(matchedPath: ReadonlyArray): ReadonlyArray { return matchedPath.length === 2 && matchedPath[0] === "db" && matchedPath[1] === "query" @@ -963,22 +649,11 @@ function outputFlagChoiceKeys(matchedPath: ReadonlyArray): ReadonlyArray } /** - * Validates a flag's value the way pflag's typed `Value.Set` does inside - * `finalCmd.ParseFlags()` — e.g. `-o not-a-format` (a `Choice`-typed - * `--output`) or `--debug=maybe` (a `Boolean`-typed `--debug`) fail to parse - * in real pflag, and cobra reports the parse error instead of generating any - * completions: both return zero candidates with the Default directive, - * exactly like an unresolved flag name. The command-dependent overrides - * (`COMPLETION_UINT_FLAGS`, `COMPLETION_DURATION_FLAGS`, - * `COMPLETION_RFC3339_FLAGS`, `COMPLETION_NON_CSV_VARIADIC_FLAGS`) - * are checked BEFORE the `primitiveTag` dispatch — a bare `FlagDescriptor` - * can't express any of them on its own (all need `matchedPath`, and the uint - * one specifically needs to catch a flag whose TS `primitiveTag` isn't - * `"Integer"` at all, e.g. `storage cp --jobs`; the duration/RFC3339 ones - * catch flags that are plain `Flag.string` in TS but a `Duration`/`Time` - * pflag value). Every other primitive shape pflag can actually reject is - * checked in the switch; `String`/`Path`/`Date`/etc. flags accept any string - * there too, so the default case is unconditionally valid. + * Validates a flag's value; an invalid value returns zero candidates with the Default + * directive, exactly like an unresolved flag name. The command-dependent overrides + * (`COMPLETION_UINT_FLAGS`, `COMPLETION_DURATION_FLAGS`, `COMPLETION_RFC3339_FLAGS`, + * `COMPLETION_NON_CSV_VARIADIC_FLAGS`) are checked before the `primitiveTag` dispatch, since + * they key on `matchedPath`, which a bare `FlagDescriptor` can't express. */ function isValidFlagValue( matchedPath: ReadonlyArray, @@ -1020,27 +695,12 @@ function isValidFlagValue( } /** - * Walks a short-flag cluster (`-o`, `-ojson`, `-rj`, `-o=json`) the same way - * pflag's `parseSingleShortArg` does (`pflag@v1.0.10/flag.go:1040-1114`) — - * first character owns the value, not last. This is deliberately a - * DIFFERENT algorithm from `resolveFlagFromToken`'s last-character - * resolution: that function mirrors cobra's OWN separate, narrower - * `checkIfFlagCompletion` heuristic, used only to guess "is the CURRENT or - * PRECEDING token mid-way through being value-completed" — not to strictly - * parse a token that's already fully typed. This function mirrors the real - * strict parser (`finalCmd.ParseFlags()`) instead, used by - * `findUnresolvedFlagToken`: `functions deploy -j4 --p` still offers - * `--profile`/`--project-ref`/`--prune` — `-j4` is a fully valid, already- - * resolved `--jobs=4`, not an unknown flag. - * - * Returns `undefined` if any character in the cluster doesn't resolve to an - * in-scope flag shorthand. Otherwise returns the flag that ultimately owns - * the cluster's (possibly absent) attached value — the first non-boolean - * shorthand encountered, or the cluster's last shorthand if every character - * in it is boolean — plus that attached value, which is `undefined` only - * when there is nothing left in the token to attach (`-o` alone, or an - * all-boolean cluster like `-rf`), meaning a following token supplies it - * instead. + * Walks a short-flag cluster (`-o`, `-ojson`, `-rj`, `-o=json`), where the first non-boolean + * character owns the value, not the last — unlike `resolveFlagFromToken`'s completion guess, + * this strictly parses a complete token, so `-j4` resolves to `--jobs=4` rather than an + * unknown flag. Returns `undefined` if any character fails to resolve; the returned + * `attachedValue` is `undefined` only when nothing is left to attach, meaning a following + * token supplies it instead. */ function resolveShortFlagCluster( token: string, @@ -1067,66 +727,11 @@ function resolveShortFlagCluster( } /** - * Finds the first token in `trimmedArgs` that either (a) looks like a flag - * (starts with `-`, excluding the bare `-` positional pflag itself treats as - * a non-flag argument) but does not resolve to anything in `inScopeFlags`, - * (b) resolves to a real flag whose value `isValidFlagValue` rejects, - * or (c) resolves to a real, non-boolean flag with NO value available at all - * — no attached suffix and no following token — AND `toComplete` itself is a - * bare flag-shaped token (starts with `-`). - * - * That last condition mirrors a real two-part cobra/pflag interaction: - * cobra's `checkIfFlagCompletion` only rescues a trailing incomplete flag - * from `ParseFlags()` (treating it as "the flag currently being - * value-completed" instead of a parse error) when `toComplete` is EMPTY or - * otherwise not itself flag-shaped (`completions.go:666-687`, the `prevArg` - * branch, which strips the dangling flag out of `finalArgs` before - * `ParseFlags` ever sees it). When `toComplete` IS flag-shaped, that rescue - * never happens — `checkIfFlagCompletion` either returns immediately without - * touching `finalArgs` (no `=`, `completions.go`'s "Normal flag completion" - * early return) or extracts a flag name from `toComplete`'s OWN prefix - * before its `=` (`completions.go`'s `flagWithEqual` branch) — neither path - * strips a DIFFERENT, already-dangling flag earlier in `finalArgs`, so the - * real `finalCmd.ParseFlags()` call (`completions.go:373-375`) fails - * outright on it. Whether `toComplete` itself contains `=` is irrelevant: - * that `=` only ever resolves `toComplete`'s own flag name, never rescues an - * earlier dangling one: `__complete -o --d` returns zero candidates with - * the Default directive — the `ParseFlags` error is "flag needs an - * argument: 'o' in -o" — while `__complete -o ''` and `__complete -o pre` - * both instead fall through to flag-VALUE completion for `--output`, per - * `classifyCompletion`'s Case 2; `__complete sso add --type saml - * --metadata-file --attribute-mapping-file=` ALSO returns zero candidates - * with the Default directive — the `ParseFlags` error is "flag needs an - * argument: --metadata-file" — even though the current token has an `=` and - * identifies a wholly separate flag, not `--metadata-file`'s value. - * - * Long flags (`--foo`, `--foo=bar`) resolve via `resolveFlagFromToken` - * (no first/last-character ambiguity for a `--name` token). Short flags - * resolve via `resolveShortFlagCluster` instead — see that function's - * doc comment for why this deliberately does NOT reuse - * `resolveFlagFromToken`'s last-character heuristic here. - * - * Consumes a following token as a non-boolean flag's value the same way - * `resolveCommandPath` does. A bare `--` ends the scan entirely without - * itself counting as unresolved — pflag's own end-of-flags sentinel, after - * which everything is positional, not a flag to validate (`pflag@v1.0.9`'s - * `parseArgs`: `if s[1] == '-' { if len(s) == 2 { ... terminates the flags`). - * Returns the offending token, or `undefined` if every flag-shaped token - * resolves to a real flag with a valid, available value. - * - * Mirrors cobra's real two-phase design: `Find()` tolerantly skips flags it - * doesn't recognize while walking for a subcommand name (see - * `resolveCommandPath`, which only needs to know "does this consume a - * value", not "is this real"), but the later `finalCmd.ParseFlags()` strictly - * validates every remaining flag token — both that it resolves AND that its - * value parses — against the fully-resolved command's complete flag set, and - * fails outright on the first one that doesn't (`completions.go:373-375`) — a - * failure so early it wins even over the `--help`/`--version` short-circuit - * below: both `__complete --bogus --help ""` and `__complete --help --bogus - * ""` report the unknown flag, not help; a bare `__complete --bogus ""` - * returns zero candidates with the Default directive, not the root - * subcommand list; `__complete -- ""` is unaffected and still lists every - * root subcommand. + * Finds the first token that's flag-shaped but unresolved, resolves to a flag whose value + * fails `isValidFlagValue`, or is a non-boolean flag with no value available (only when + * `toComplete` is itself flag-shaped; otherwise a trailing flag is understood to be + * mid-value-completion). A bare `--` ends the scan without counting as unresolved. This + * check takes priority even over the `--help`/`--version` short-circuit below. */ function findUnresolvedFlagToken( trimmedArgs: ReadonlyArray, @@ -1134,14 +739,8 @@ function findUnresolvedFlagToken( inScopeFlags: ReadonlyArray, matchedPath: ReadonlyArray, ): string | undefined { - // See this function's doc comment: only a `toComplete` that's itself a - // bare flag-shaped token blocks cobra's "rescue" of a trailing, - // value-less flag — whether that token also contains `=` is irrelevant, - // since the `=` only ever resolves `toComplete`'s OWN flag name, never an - // earlier, different dangling flag. Every non-flag-shaped `toComplete` - // leaves the trailing flag for flag-VALUE completion instead, so a - // missing value at the end of `trimmedArgs` is not, by itself, unresolved - // in that case. + // A trailing flag with no value counts as unresolved only when `toComplete` is itself + // flag-shaped; otherwise it's understood to be mid-value-completion instead. const trailingMissingValueIsFatal = toComplete.startsWith("-"); let index = 0; @@ -1174,12 +773,8 @@ function findUnresolvedFlagToken( const cluster = resolveShortFlagCluster(token, inScopeFlags); if (cluster === undefined) return token; - // An attached value (`-o=json`, or a non-boolean's `-ojson`) must be - // validated BEFORE the boolean short-circuit below — pflag treats - // `-f=value` as an explicit value for a boolean shorthand too - // (`pflag@v1.0.10/flag.go:1005-1033`), so a boolean owner does not, on - // its own, mean "nothing to validate": `storage cp -r=maybe --j` returns - // zero candidates with the Default directive, not `--jobs`. + // An attached value (`-o=json`, or a non-boolean's `-ojson`) is validated before the + // boolean short-circuit below, since a boolean shorthand can also take an explicit value. if (cluster.attachedValue !== undefined) { if (!isValidFlagValue(matchedPath, cluster.flag, cluster.attachedValue)) return token; continue; @@ -1212,30 +807,10 @@ function flagValueCompletion( } /** - * Mirrors cobra's auto-registered `help` command's own `ValidArgsFunction` - * (`command.go:1263-1310`, `InitDefaultHelpCmd`): `help` is a REAL subcommand - * of root, and its `ValidArgsFunction` re-resolves everything typed after it - * from root — via `c.Root().Find(args)` — then lists THAT resolved command's - * own visible subcommands, filtered by `toComplete`'s prefix. `help db d` - * therefore completes as if `d` were being completed inside `db` (`diff`, - * `dump`), not as an argument to `help` itself. - * - * Mirrors cobra's `legacyArgs` validator (`args.go:28-37`) for the "unknown - * command" error `Find` surfaces through `e`: it fires ONLY when the - * resolved command is root itself (no real descent happened at all) AND a - * token is left over — a token left over under any OTHER resolved command is - * never an error there (subcommands "will always accept arbitrary - * arguments"). On that error path cobra returns zero candidates, but still - * with the NoFileComp directive, same as the success path: `help bogus d` - * -> no candidates; `help db bogus d` -> still `db`'s subcommands, since - * `db` is not root. - * - * `root` here is always `finalCommand` from the outer resolution: `help` has - * no node anywhere in this TS tree (it is a synthesized candidate, not a - * real `Command.Command.Any` — see the comment where Case 3 pushes it - * below), so the outer `resolveCommandPath` call always stops at root - * immediately when `trimmedArgs[0] === "help"`, making `finalCommand` and - * real cobra's `c.Root()` the same command. + * `help ` completes as if `` were being typed directly, so `help db d` offers + * `db`'s own subcommands (`diff`, `dump`) filtered by `d`. An unresolved leftover positional + * returns no candidates only when nothing beyond `help` resolved at all (e.g. `help bogus`); + * a leftover under any resolved command still lists that command's subcommands. */ function helpArgumentCandidates( root: Command.Command.Any, @@ -1253,8 +828,7 @@ function helpArgumentCandidates( name: sub.name, description: sub.shortDescription ?? sub.description, })); - // Cobra's help command is itself one of root's `Commands()`, so re-resolving - // to root also re-lists `help` (verified empirically: `help h` -> `help`). + // `help` is included when nothing after it resolved, since it's one of root's own subcommands. if (matchedPath.length === 0) { candidates.push({ name: "help", description: "Help about any command" }); } @@ -1267,39 +841,11 @@ function helpArgumentCandidates( } /** - * Classifies a single completion request into candidates + directive, - * mirroring cobra's `checkIfFlagCompletion` and the branch in - * `getCompletions` that follows it: - * - * 0. A flag-shaped token that doesn't resolve to any in-scope flag - * short-circuits to no candidates with the Default directive — mirrors - * `finalCmd.ParseFlags()` failing outright on an unrecognized flag, which - * wins even over `--help`/`--version` below. - * 0.5. An unmatched ROOT-level positional (`matchedPath.length === 0` — no - * real descent happened at all — with a genuine leftover positional token) - * ALSO short-circuits to no candidates with the Default directive, and - * wins over `--help`/`--version` too — mirrors `Command.Find`'s own - * `legacyArgs` validator (`cobra@v1.10.2/args.go:28-37`), which returns - * `unknown command %q` precisely when the resolved command is root, has - * subcommands (root always does), and has a leftover non-flag positional - * — an error `getCompletions` surfaces as zero candidates before doing - * anything else with `finalCmd`. - * 1. `--help`/`-h` anywhere in `trimmedArgs` (or `--version`/`-v`, only when - * resolved to the root command) short-circuits to no candidates — these - * exit before any real completion runs. - * 2. A genuine, unconsumed bare `--` anywhere in `trimmedArgs` disables ALL - * flag-name and flag-value completion (Cases 3/4 below) for the rest of - * this request — mirrors cobra's `flagCompletion` gate, which goes false - * the moment a previous `--` is already present (`completions.go:364- - * 381`; see `hasUnconsumedFlagTerminator` below for why "unconsumed" - * matters). - * 3. `toComplete` is a bare flag with no `=` → flag-NAME completion. - * 4. `toComplete` (or the immediately preceding token) identifies a - * non-boolean flag's value slot → flag-VALUE completion. - * 5. Otherwise → subcommand-name + required-flag (noun) completion; five - * specific leaf paths (`completion[ bash|zsh|fish|powershell]`) force the - * directive to NoFileComp regardless of what the subcommand walk above - * computed — see `COMPLETION_NO_FILE_COMP_PATHS`. + * Classifies a completion request into candidates + directive, in priority order: an + * unresolved/invalid flag-shaped token or unmatched root positional short-circuits first, + * ahead of `--help`/`--version`; an unconsumed `--` then disables flag-name/flag-value + * completion; otherwise a bare flag with no `=` gets flag-name completion, a flag's value + * slot gets flag-value completion, and everything else gets subcommand-name completion. */ export function classifyCompletion(input: ClassifyCompletionInput): CompletionResult { const { finalCommand, matchedPath, leftoverArgs, trimmedArgs, toComplete, inScopeFlags } = input; @@ -1309,57 +855,21 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe return { candidates: [], directive: CompletionDirective.Default }; } - // Mirrors cobra's `stripFlags` (`command.go:674-710`), which the - // `legacyArgs` validator below runs its leftover-count check against — a - // bare `-` (and an empty string) is dropped from consideration, NOT - // counted as a genuine leftover positional, even though - // `resolveCommandPath` deliberately leaves a bare `-` IN - // `leftoverArgs` for other purposes: `__complete - --d` still offers - // root's own `--debug`/`--dns-resolver`, since `stripFlags` drops the lone - // `-` and leaves zero real leftover commands to error on — root cause - // shared with the `nosuch --d` case below. - // - // Exempts `trimmedArgs[0] === "help"`: real cobra's `help` is a REAL child - // node of root (`InitDefaultHelpCmd`), so `Find(["help", ...])` resolves - // INTO the help command itself rather than stopping at root — this TS - // tree has no such node (see `helpArgumentCandidates`'s doc - // comment), so the outer resolution below always sees "help" as an - // immediate non-match and would otherwise misfire this same root-level - // check for every legitimate `help ...` request. Case 3's own - // `isAtRoot && trimmedArgs[0] === "help"` branch re-resolves `help`'s own - // arguments from root separately and already reproduces cobra's real - // unknown-command handling for THAT inner resolution - // (`helpArgumentCandidates`'s `matchedPath.length === 0 && - // leftoverArgs.length > 0` check). + // A bare `-` or empty string doesn't count as a genuine leftover positional, even though + // `resolveCommandPath` keeps it in `leftoverArgs` for other purposes. `help` is exempted + // since `helpArgumentCandidates` handles its own leftover-positional case separately. const rootLeftoverPositionals = leftoverArgs.filter((arg) => arg !== "" && !arg.startsWith("-")); if (isAtRoot && trimmedArgs[0] !== "help" && rootLeftoverPositionals.length > 0) { - // Mirrors cobra's `Command.Find` -> `legacyArgs` (`args.go:28-37`): - // resolving to root itself (no descent at all) with a leftover - // positional is an "unknown command" error there, unlike a leftover - // positional under any OTHER resolved command, which is never an error: - // `nosuch --d` returns zero candidates with the Default directive — - // even ahead of the `--help`/`--version` short-circuit below, i.e. - // `nosuch --help` is ALSO zero candidates, not the help short-circuit's - // NoFileComp — while `db bogus --d`, where `db` itself resolves, still - // offers `db`'s own `--debug`/`--dns-resolver` normally. + // Resolving to root itself with a leftover positional is treated as an unknown command; a + // leftover positional under any other resolved command is not an error. return { candidates: [], directive: CompletionDirective.Default }; } - // `collectChangedFlagNames` (not a raw token scan) is load-bearing here: it - // already stops at a genuine, unconsumed `--` terminator and already skips - // a token consumed as a PRECEDING non-boolean flag's value — exactly the - // two cases pflag's own `Changed` tracking respects and a bare - // `trimmedArgs.some(...)` token scan does not: `db dump -- --help ""` - // still offers `db dump`'s own completions, not the help short-circuit — - // `--help` is positional, past the terminator; `--workdir --version br` - // still completes `branches`, not the version short-circuit — `--version` - // is consumed as `--workdir`'s string value, never parsed as a flag at all. + // `collectChangedFlagNames`, not a raw token scan, correctly treats a positional `--help` + // past a `--` terminator, or one consumed as a preceding flag's value, as not present. const changedFlagNames = collectChangedFlagNames(trimmedArgs, inScopeFlags); - // `version` is gated on `isAtRoot`: cobra's `--version` flag lives on the - // root command only (see `collectInScopeFlags`'s comment) — `help` is - // NOT gated the same way since every command registers its own local - // `--help` (present in `inScopeFlags`/`changedFlagNames` at every depth). + // `--version` only applies at root; `--help` is available at every depth. if (changedFlagNames.has("help") || (isAtRoot && changedFlagNames.has("version"))) { return { candidates: [], directive: CompletionDirective.NoFileComp }; } @@ -1370,13 +880,8 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe const toCompleteIsFlag = toComplete.startsWith("-"); const toCompleteEqualsIndex = toComplete.indexOf("="); - // Once a genuine, unconsumed bare `--` sentinel has already appeared, - // cobra never does flag-name or flag-value completion again for the rest - // of the request: `db dump -- --s` returns zero candidates with the - // Default directive, not `--schema`. See - // `hasUnconsumedFlagTerminator`'s doc comment for why a raw - // `trimmedArgs.includes("--")` over-triggers when a preceding value-taking - // flag consumed that `--` as its own value instead. + // Once a genuine `--` terminator has appeared, flag-name/flag-value completion never runs + // again for the rest of the request. const hasFlagTerminator = hasUnconsumedFlagTerminator(trimmedArgs, inScopeFlags); // Case 1: flag-NAME completion. @@ -1384,8 +889,7 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe const requiredCandidates = requiredFlags.flatMap((flag) => flagNameCandidates(flag, toComplete), ); - // Once ANY required flag is still unset, ONLY required flags are - // offered — this exactly mirrors cobra. + // If any required flag is still unset, only required flags are offered. if (requiredCandidates.length > 0) { return { candidates: requiredCandidates, directive: CompletionDirective.NoFileComp }; } @@ -1398,14 +902,8 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe // Case 2: flag-VALUE completion. if (!hasFlagTerminator) { if (toCompleteIsFlag) { - // toCompleteEqualsIndex !== -1 here — the no-`=` branch above returns. - // Cobra's checkIfFlagCompletion treats ANY `--flag=value` token - // (including a boolean's) as flag-value completion — the "reset to - // noun completion for a boolean" only applies in the separate no-`=` - // two-token case handled by the `else` branch below - // (`completions.go`'s `!flagWithEqual` guard around that reset): - // `--debug=maybe` returns zero candidates with the Default directive, - // not the root command list. + // A `--flag=value` token is always treated as flag-value completion, even for a + // boolean flag. const resolved = resolveFlagFromToken( toComplete.slice(0, toCompleteEqualsIndex), inScopeFlags, @@ -1416,35 +914,21 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe if ( precedingToken !== undefined && precedingToken.startsWith("-") && - // A bare `-` is excluded — pflag's `isFlagArg` (`command.go:750-753`) - // requires at least 2 characters, so real cobra's own equivalent - // "preceding token is flag-shaped" check never fires for it either, - // and this must fall through to Case 3 instead of hard-stopping: - // `help db - d` still lists `db`'s subcommands `diff`/`dump`, not - // zero candidates. + // A bare `-` is excluded, so it falls through to Case 3 instead of hard-stopping. precedingToken !== "-" && !precedingToken.includes("=") ) { const resolved = resolveFlagFromToken(precedingToken, inScopeFlags); if (resolved === undefined) { - // Cobra's checkIfFlagCompletion errors out here (a `flagCompError` - // short-circuits `getCompletions` outright) rather than falling - // through to noun completion — an unresolved trailing flag (per - // that function's OWN last-character heuristic, not - // `findUnresolvedFlagToken`'s strict first-character parse) - // before an empty/non-flag toComplete is a hard stop: `-ojson ""` - // returns zero candidates with the Default directive, even though - // `-ojson` is a perfectly valid `-o=json` under real pflag parsing - // — cobra's own heuristic looks at `-ojson`'s LAST character, `n`, - // which resolves to nothing. + // An unresolved trailing flag before an empty/non-flag toComplete is a hard stop, + // not a fall-through to noun completion. return { candidates: [], directive: CompletionDirective.Default }; } if (!resolved.isBoolean) { return flagValueCompletion(matchedPath, resolved.name); } - // A resolved BOOLEAN precedingToken falls through to Case 3 — it - // never consumed a value, so this wasn't really flag-value - // completion. + // A resolved boolean precedingToken falls through to Case 3, since it never + // consumed a value. } } @@ -1452,21 +936,14 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe const candidates: Array = []; let directive: number = CompletionDirective.Default; - // `help` is a real cobra subcommand with its own `ValidArgsFunction` that - // completes a SECOND, independent command-path lookup from root — see - // `helpArgumentCandidates`'s doc comment. `isAtRoot && - // trimmedArgs[0] === "help"` exactly identifies "this request is `help - // ...`": `help` isn't a node anywhere in this tree, so the outer - // `resolveCommandPath` call always stops at root immediately when - // it's the first token. + // A `help ...` request is resolved separately, since `help` isn't a real command node + // in this tree. if (isAtRoot && trimmedArgs[0] === "help") { return helpArgumentCandidates(finalCommand, trimmedArgs.slice(1), toComplete); } - // Once any flag or extra positional token has already appeared before this - // position, subcommand-name completion is suppressed entirely (cobra's - // `len(finalArgs) == 0` gate) — including the directive it would otherwise - // set, which stays at `Default` in that case (`completions.go:489,499-522`). + // Any flag or extra positional token before this position suppresses subcommand-name + // completion entirely, leaving the directive at Default. if (leftoverArgs.length === 0) { const visibleSubcommands = flattenSubcommands(finalCommand).filter((sub) => !sub.unlisted); if (visibleSubcommands.length > 0) { @@ -1475,22 +952,13 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe name: sub.name, description: sub.shortDescription ?? sub.description, })); - // Cobra's `InitDefaultHelpCmd` (`command.go:1100,1263-1266`) auto-registers a - // `help` subcommand on whichever command `ExecuteC()` is called against — - // here, always the root — but never recursively on descendants: - // `__complete db ""` does NOT surface it, only `__complete ""` does. - // This TS tree has no explicit `help` command node to walk, so - // synthesize the one candidate cobra would otherwise contribute, - // matching its literal `Short` text. + // `help` isn't a real command node in this tree; synthesize it as a candidate, but + // only at root, not recursively on descendants. if (isAtRoot) { subcommandCandidates.push({ name: "help", description: "Help about any command" }); } - // Cobra's own `Commands()` — what its subcommand-name completion walks — - // sorts alphabetically by name whenever `EnableCommandSorting` (the - // default) is on. This tree's subcommand declarations already happen to - // be listed alphabetically, so this sort is a no-op everywhere except at - // the root, where it places the synthetic "help" entry above in its - // correct alphabetical position. + // Sorted alphabetically so the synthetic "help" entry lands in its correct position + // at root; a no-op everywhere else, since subcommands are already declared in order. subcommandCandidates.sort((a, b) => a.name.localeCompare(b.name)); for (const candidate of subcommandCandidates) { if (candidate.name.startsWith(toComplete)) { @@ -1500,16 +968,13 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe } } - // Unconditional append in cobra — not gated on `leftoverArgs`. + // Required flags are always offered here, regardless of `leftoverArgs`. for (const flag of requiredFlags) { candidates.push(...flagNameCandidates(flag, toComplete)); } - // Cobra always invokes a resolved command's own `ValidArgsFunction` (when - // registered) at the very end of `getCompletions`, and that call - // OVERWRITES whatever directive the subcommand walk above already set - // (`completions.go:564-579`) — see `COMPLETION_NO_FILE_COMP_PATHS`'s - // doc comment for which paths this applies to and why. + // Overrides whatever directive the subcommand walk above set; see + // `COMPLETION_NO_FILE_COMP_PATHS`. if (COMPLETION_NO_FILE_COMP_PATHS.has(matchedPath.join(" "))) { directive = CompletionDirective.NoFileComp; } @@ -1517,16 +982,10 @@ export function classifyCompletion(input: ClassifyCompletionInput): CompletionRe return { candidates, directive }; } -/* ========================================================================== */ -/* Orchestration */ -/* ========================================================================== */ - /** - * The pure, deps-free completion algorithm: resolves the command path, - * collects in-scope flags, and classifies the request. Returns `undefined` - * when `argv[0]` isn't a completion request, or when cobra's `args` (i.e. - * `argv.slice(1)`) is empty — mirroring cobra's own `MinimumNArgs(1)` failure - * (see the module doc comment for why that case isn't otherwise reproduced). + * The pure, deps-free completion algorithm: resolves the command path, collects in-scope + * flags, and classifies the request. Returns `undefined` when `argv[0]` isn't a completion + * request, or when there are no args to classify. */ export function respondToComplete( root: Command.Command.Any | undefined, @@ -1555,10 +1014,6 @@ export function respondToComplete( }); } -/* ========================================================================== */ -/* Response formatting */ -/* ========================================================================== */ - const GO_TRUE_BOOL_SPELLINGS: ReadonlySet = new Set([ "1", "t", @@ -1583,12 +1038,9 @@ function parseGoBool(value: string): boolean | undefined { } /** - * Cobra's real, undocumented-to-users-but-real `getEnvConfig` behavior: - * `argv[0] === "__completeNoDesc"` always wins; otherwise - * `SUPABASE_COMPLETION_DESCRIPTIONS` (program-specific) is checked first, - * falling back to the generic `COBRA_COMPLETION_DESCRIPTIONS` when unset or - * empty. An unparseable value (per `strconv.ParseBool`'s accepted - * spellings) is ignored, leaving the `argv[0]`-derived default in place. + * `argv[0] === "__completeNoDesc"` always wins; otherwise `SUPABASE_COMPLETION_DESCRIPTIONS` + * is checked first, falling back to `COBRA_COMPLETION_DESCRIPTIONS` when unset or empty. An + * unparseable value is ignored, leaving the `argv[0]`-derived default in place. */ export function resolveIncludeDescriptions( argv0: string | undefined, @@ -1609,16 +1061,15 @@ function formatCompletionLine( ): string { if (!includeDescriptions) return candidate.name.trim(); const firstDescriptionLine = (candidate.description ?? "").split("\n")[0] ?? ""; - // `.trim()` on the whole joined string (not just the description) is what - // makes a candidate with no description end up as a bare name with no - // trailing tab, not `"name\t"` — reproduces cobra's exact `TrimSpace` step. + // Trimming the whole joined string, not just the description, drops the trailing tab + // when there's no description. return `${candidate.name}\t${firstDescriptionLine}`.trim(); } /** - * Formats a completion result the way cobra's generated shell scripts expect: - * one line per candidate (`name` or `name\tdescription`), then a final - * `:` line. Every line ends with `\n`. + * Formats a completion result as the shell completion protocol expects: one line per + * candidate (`name` or `name\tdescription`), then a final `:` line, each ending + * with `\n`. */ export function formatCompletionResponse( response: CompletionResult, @@ -1631,41 +1082,12 @@ export function formatCompletionResponse( return lines.map((line) => `${line}\n`).join(""); } -/* ========================================================================== */ -/* Entry point */ -/* ========================================================================== */ - /** - * The `cli_command_executed` capture for a `__complete`/`__completeNoDesc` - * request. This event fires for every resolved command, including cobra's - * hidden `__complete`, regardless of the handler's own outcome — this - * interceptor is the only thing that fires it for a completion request, - * since nothing else in the completion path runs a command handler. - * `command` is always the literal `"__complete"`, never - * `"__completeNoDesc"`: cobra registers `__completeNoDesc` as an ALIAS of - * `__complete` (`completions.go:234`), and the recorded value is derived - * from the command's own primary name, not the alias it was invoked as. - * `output_format` is the fixed literal `"text"`: `__complete` is - * `DisableFlagParsing: true`, so there is no resolved `--output`/`-o` value - * to mirror here. - * - * Deliberately narrower than `withCommandTelemetry` - * (`telemetry/command-telemetry.ts`): that wrapper is - * shaped for a real Effect `Command` handler running inside `runCli`'s full - * runtime (`CommandRuntime`/`Output`/`ProcessControl`/`Stdio`), none of which - * exist here — this interceptor runs before Effect's argv parser, before - * `runCli` ever bootstraps. This also deliberately does NOT reproduce - * profile loading, the workdir change, or the GitHub upgrade check — none of - * that has any bearing on the analytics contract, and real generated - * completion scripts discard this process's stderr outright, so reproducing - * the upgrade message would be a pure regression. Do not "fix" this back - * toward that fuller behavior. - * - * Only requires `Analytics` from context (not the concrete production - * layer) so tests can provide `mockAnalytics()` directly instead of the - * real, consent-gated `analyticsLayer` — see - * `captureCompleteTelemetry` below for the production wiring, and - * `complete.integration.test.ts` for the test double usage. + * Fires the `cli_command_executed` telemetry capture for a `__complete`/`__completeNoDesc` + * request — the only thing that captures it, since no command handler runs on this path. + * `command` is always `"__complete"` (never the alias); `output_format` is fixed to `"text"` + * since `__complete` disables flag parsing. Intentionally skips profile/workdir/upgrade + * bootstrapping, since none of it affects analytics and scripts discard this process's stderr. */ export function captureCompleteTelemetryEffect( exitCode: number, @@ -1689,27 +1111,19 @@ export function captureCompleteTelemetryEffect( const COMPLETE_TELEMETRY_TIMEOUT = "2 seconds"; -// `analyticsLayer` on its own still needs `CliSettings | RuntimeInfo | Tty` -// (via the `telemetryRuntimeLayer` it folds in) on top of the `FileSystem`/ -// `Path` platform layer — `shared/cli/run.ts` normally supplies those as part -// of its own much larger composed tree. `standaloneAnalyticsConfigLayer` -// packages the same small set for a caller running outside that tree. +// `analyticsLayer` needs `CliSettings`/`RuntimeInfo`/`Tty` plus a platform layer; +// `standaloneAnalyticsConfigLayer` packages that small set for a caller outside the full +// CLI runtime tree. const completeAnalyticsLayer = analyticsLayer.pipe( Layer.provide(standaloneAnalyticsConfigLayer), Layer.provide(BunServices.layer), ); /** - * Production default for `CompleteDeps.captureTelemetry`: runs - * `captureCompleteTelemetryEffect` against the real, consent-gated - * `analyticsLayer`. - * - * Best-effort and bounded: a missing consent, network hiccup, or DNS failure - * must never hang or fail a user's tab press. `deps.exit` (see - * `tryComplete` below) ultimately calls `process.exit`, which kills the - * process immediately without waiting for pending async work — callers must - * `await` this BEFORE exiting, or the capture will very likely never reach - * PostHog. + * Production default for `CompleteDeps.captureTelemetry`. Best-effort and bounded: a missing + * consent, network hiccup, or DNS failure must never hang or fail a user's tab press. Callers + * must `await` this before exiting, since `process.exit` kills the process without waiting + * for pending async work. */ function captureCompleteTelemetry(exitCode: number, durationMs: number): Promise { return Effect.runPromise( @@ -1722,15 +1136,10 @@ function captureCompleteTelemetry(exitCode: number, durationMs: number): Promise } /** - * Entry-point interceptor with the same shape/contract as the old - * `tryCompletePassthrough`: runs before Effect's CLI argv parser, returns - * `false` immediately (no side effects) when `deps.argv[0]` isn't a - * completion request, otherwise fully handles it and returns `true`. Async - * only because of `deps.captureTelemetry` above — every other helper in this - * file (`respondToComplete` and everything it calls) stays pure and - * synchronous; awaiting the capture here, before `deps.exit(...)`, is what - * lets it actually reach PostHog (see `captureCompleteTelemetry`'s doc - * comment). + * Runs before Effect's CLI argv parser, returning `false` immediately when `deps.argv[0]` + * isn't a completion request, otherwise fully handling it and returning `true`. Async only + * because it awaits `deps.captureTelemetry` before `deps.exit(...)`, so the capture actually + * reaches PostHog before the process exits. */ export async function tryComplete(deps: CompleteDeps): Promise { if (deps.argv[0] !== "__complete" && deps.argv[0] !== "__completeNoDesc") return false; From 3f5497aec1687b5a838fd09664dd819779d0931d Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 10 Sep 2026 11:56:40 +0100 Subject: [PATCH 02/56] chore(repo): trim comments, shard 12 (538 -> 343 comment lines) --- apps/cli/src/command-internal/hostname.ts | 74 +++---- apps/cli/src/command-internal/http-dns.ts | 68 ++---- .../command-internal/http-dns.unit.test.ts | 13 +- apps/cli/src/command-internal/http-errors.ts | 69 +++--- .../identity-stitch.integration.test.ts | 19 +- .../src/command-internal/identity-stitch.ts | 105 ++++------ apps/cli/src/command-internal/kong-auth.ts | 14 +- .../command-internal/kong-local-ca-cert.ts | 16 +- .../command-internal/link-services-core.ts | 18 +- apps/cli/src/command-internal/linked-state.ts | 197 +++++++----------- 10 files changed, 199 insertions(+), 394 deletions(-) diff --git a/apps/cli/src/command-internal/hostname.ts b/apps/cli/src/command-internal/hostname.ts index 108ae6cfc5..e000aa0592 100644 --- a/apps/cli/src/command-internal/hostname.ts +++ b/apps/cli/src/command-internal/hostname.ts @@ -6,15 +6,13 @@ import { join } from "node:path"; const LOCAL_HOST = "127.0.0.1"; const LOOPBACK_NO_PROXY = `localhost,${LOCAL_HOST},[::1]`; -/** Docker CLI's reserved "no context store entry" name (`docker/cli` `cli/command/cli.go`'s `DefaultContextName`). */ +/** Docker CLI's name for the default context, which has no context-store entry. */ const DEFAULT_CONTEXT_NAME = "default"; /** - * Docker CLI's config directory: `$DOCKER_CONFIG` or `~/.docker` - * (`docker/cli` `cliconfig.Dir()`), read from here rather than - * `client.Client`'s own resolution since this module never spawns a real - * Docker client — it only needs the same two on-disk files that resolution - * reads. + * Docker CLI's config directory (`$DOCKER_CONFIG` or `~/.docker`), read + * directly since this module only needs the on-disk config and context-store + * files, not a full Docker client. */ function dockerConfigDir(): string { const override = process.env["DOCKER_CONFIG"]; @@ -22,12 +20,9 @@ function dockerConfigDir(): string { } /** - * `cli.CurrentContext()` name resolution (`docker/cli` - * `cli/command/cli.go`'s `resolveContextName`): `DOCKER_CONTEXT` env, else the - * config file's `currentContext`, else `"default"`. Only reached when - * `DOCKER_HOST` is unset — `resolveContextName` itself forces `"default"` - * when `DOCKER_HOST`/`--host` is set, which {@link getHostname} below - * already handles as its own, earlier branch. + * Resolves the active Docker CLI context: `DOCKER_CONTEXT` env, else the + * config file's `currentContext`, else `"default"`. Only called when + * `DOCKER_HOST` is unset; {@link getHostname} handles that case separately. */ function currentDockerContextName(): string { const fromEnv = process.env["DOCKER_CONTEXT"]; @@ -42,8 +37,7 @@ function currentDockerContextName(): string { return config.currentContext; } } catch { - // Missing/malformed config.json → the default context, same as Go's own - // silent fallback when it can't load the config file here. + // Missing or malformed config.json falls back to the default context. } return DEFAULT_CONTEXT_NAME; } @@ -51,11 +45,8 @@ function currentDockerContextName(): string { /** * Reads a non-default context's daemon endpoint from Docker CLI's context * store: `/contexts/meta//meta.json`'s - * `Endpoints.docker.Host` (`docker/cli` `cli/context/store/metadatastore.go`). - * The `"default"` context has no store entry (it's Go's synthetic - * always-available context, resolved without a client, see - * `cli.Initialize`), so it's never looked up here — matching the earlier - * `"default"` short-circuit in {@link currentDockerContextName}'s caller. + * `Endpoints.docker.Host`. The `"default"` context has no store entry, so + * it's never looked up here. */ function dockerContextEndpointHost(contextName: string): string | undefined { if (contextName === DEFAULT_CONTEXT_NAME) { @@ -70,18 +61,15 @@ function dockerContextEndpointHost(contextName: string): string | undefined { const host = meta.Endpoints?.docker?.Host; return typeof host === "string" && host.length > 0 ? host : undefined; } catch { - // Missing/malformed context store entry → treat as unresolvable, same as - // Go silently falling back to the loopback default below. + // Missing or malformed context store entry: treat as unresolvable. return undefined; } } /** - * Extracts the bare host from a `tcp://host:port` daemon endpoint, mirroring - * `client.ParseHostURL` + `net.SplitHostPort`. Returns + * Extracts the bare host from a `tcp://host:port` daemon endpoint. Returns * `undefined` for a non-`tcp://` endpoint (e.g. `unix://`, `npipe://`) or an - * unparseable one, in which case the caller falls back to the loopback - * default, matching `net.SplitHostPort` failure/non-TCP handling. + * unparseable one. */ function hostFromTcpEndpoint(endpoint: string): string | undefined { try { @@ -89,10 +77,8 @@ function hostFromTcpEndpoint(endpoint: string): string | undefined { if (url.protocol !== "tcp:" || url.hostname.length === 0) { return undefined; } - // WHATWG `URL.hostname` returns an IPv6 host bracketed (`[::1]`), but Go's - // `net.SplitHostPort` returns the bare host (`::1`). Strip a - // single surrounding bracket pair so local-stack probes dial/compare the - // same host Go does; IPv4 and named hosts are returned unchanged. + // WHATWG URL.hostname brackets IPv6 (`[::1]`); strip the brackets so the + // returned host matches IPv4/named hosts' unbracketed form. const host = url.hostname; return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; } catch { @@ -101,28 +87,20 @@ function hostFromTcpEndpoint(endpoint: string): string | undefined { } /** - * Resolves the hostname used for local Supabase service connections, mirroring - * `utils.GetHostname`: + * Resolves the hostname used for local Supabase service connections: * - * 1. `SUPABASE_SERVICES_HOSTNAME` env override — set in dev containers or when - * the Docker daemon is not reachable on the container's own loopback. - * 2. The Docker daemon host when `DOCKER_HOST` is a `tcp://host:port` endpoint - * (`Docker.DaemonHost()` + `client.ParseHostURL` + `net.SplitHostPort`). - * 3. Otherwise, the ACTIVE DOCKER CONTEXT's daemon endpoint, when it's a - * `tcp://` one — `Docker.DaemonHost()` comes from a client built via - * `command.NewDockerCli()` + `cli.Initialize()`, whose endpoint resolution walks `DOCKER_HOST` -> - * `DOCKER_CONTEXT` -> the config file's `currentContext` -> the context - * store (`docker/cli` `cli/command/cli.go`'s `getDockerEndPoint`/ - * `resolveContextName`) — not just `DOCKER_HOST`. The `docker`/`podman` - * binary this module's callers shell out to for `ps`/`inspect` already - * resolves the same active context itself, so without this step `status` - * could correctly inspect a remote daemon while printing unusable - * `127.0.0.1` API/DB/Studio URLs for it. + * 1. `SUPABASE_SERVICES_HOSTNAME` env override — for dev containers or when + * the Docker daemon isn't reachable on the container's own loopback. + * 2. The Docker daemon host when `DOCKER_HOST` is a `tcp://host:port` endpoint. + * 3. Otherwise, the active Docker context's daemon endpoint when it's a + * `tcp://` one. The `docker`/`podman` binary this module's callers shell + * out to already resolves the active context itself, so without this step + * a remote daemon could be inspected correctly while printing unusable + * `127.0.0.1` URLs for it. * 4. `127.0.0.1` otherwise (the default unix-socket daemon, or an - * unresolvable/malformed context). + * unresolvable/malformed context). * - * Shared across commands that connect to the local stack (`gen types`, - * `test db`, `status`, `stop`, and later `db reset` / `db dump`). + * Shared by every command that connects to the local Supabase stack. */ export function getHostname(): string { const override = process.env["SUPABASE_SERVICES_HOSTNAME"]; diff --git a/apps/cli/src/command-internal/http-dns.ts b/apps/cli/src/command-internal/http-dns.ts index fa247eaf5d..e9d4ca9ce8 100644 --- a/apps/cli/src/command-internal/http-dns.ts +++ b/apps/cli/src/command-internal/http-dns.ts @@ -7,9 +7,8 @@ import { DbConnectError } from "./db-connection.errors.ts"; import { resolveHostsOverHttps } from "./db-dns.ts"; /** - * The result of transforming an original HTTPS request URL so the TCP - * connection dials the resolved IP directly while TLS still targets the - * original hostname — mirroring `withFallbackDNS` dial-context hook. + * The result of rewriting an HTTPS request URL so the TCP connection dials + * the resolved IP directly while TLS still targets the original hostname. */ export interface DohRequestShape { /** The rewritten URL with the IP literal as authority. */ @@ -21,15 +20,12 @@ export interface DohRequestShape { } /** - * Pure URL-rewrite helper. Swaps the authority of `originalUrl` to the - * resolved IP address while keeping the scheme, path, query, and fragment - * intact. IPv6 addresses are bracketed (`[::1]`) per RFC 2732. + * Swaps the authority of `originalUrl` to `resolvedIp` while keeping the + * scheme, path, query, and fragment intact. IPv6 addresses are bracketed + * (`[::1]`) per RFC 2732. * - * When `originalUrl`'s host is already an IP literal no rewrite is needed; - * callers should check `net.isIP(host) !== 0` and short-circuit before - * calling this function. If `resolvedIp` is not a valid IP this function - * throws — callers are expected to only pass values from `parseResolvedIps`, - * which already enforces `net.isIP !== 0`. + * Callers should short-circuit before calling this when the host is already + * an IP literal. Throws if `resolvedIp` is not a valid IP. * * @param originalUrl - Fully qualified HTTPS URL, e.g. `https://api.supabase.com/v1/projects`. * @param resolvedIp - IPv4 or IPv6 address from a DoH resolution. @@ -37,18 +33,15 @@ export interface DohRequestShape { */ export function buildDohRequest(originalUrl: string, resolvedIp: string): DohRequestShape { const parsed = new URL(originalUrl); - // URL.hostname may include brackets for IPv6 in Bun (e.g. "[::1]"). Callers - // pass a non-IP hostname, so we just record the raw value and strip any - // brackets for serverName (TLS SNI must be the bare hostname, never bracketed). + // TLS SNI must be the bare hostname; strip the brackets Bun adds for IPv6. const rawHostname = parsed.hostname; const originalHost = rawHostname.startsWith("[") && rawHostname.endsWith("]") ? rawHostname.slice(1, -1) : rawHostname; - const portSuffix = parsed.port !== "" ? `:${parsed.port}` : ""; // preserve explicit port + const portSuffix = parsed.port !== "" ? `:${parsed.port}` : ""; - // Bracket IPv6 in the URL authority (RFC 2732). Bun requires brackets when - // assigning an IPv6 address to URL.hostname. + // Bun requires brackets when assigning an IPv6 address to URL.hostname. const ipAuthority = net.isIPv6(resolvedIp) ? `[${resolvedIp}]` : resolvedIp; parsed.hostname = ipAuthority; @@ -60,16 +53,13 @@ export function buildDohRequest(originalUrl: string, resolvedIp: string): DohReq } /** - * The bare callable fetch signature. This is the call part of - * `typeof globalThis.fetch` minus the Bun-specific `preconnect` namespace - * member, so tests can pass a plain function as `innerFetch` without having to - * stub `preconnect`. + * The call part of `typeof globalThis.fetch`, without the Bun-specific + * `preconnect` namespace member, so tests can pass a plain function as + * `innerFetch` without stubbing `preconnect`. */ type FetchFn = (input: string | URL | Request, init?: RequestInit) => Promise; -/** - * Options for `dohFetch`. All fields are injectable for testing. - */ +/** Options for `dohFetch`. All fields are injectable for testing. */ export interface DohFetchOptions { /** The `--dns-resolver` flag value ("native" | "https"). */ readonly dnsResolver: "native" | "https"; @@ -91,10 +81,6 @@ export interface DohFetchOptions { * request hostname before dialing, then passes `tls.serverName` so Bun * validates the TLS certificate against the original hostname — not the IP. * - * Mirrors `withFallbackDNS` transport hook: - * use the first resolved IP - * (`ip[0]`), keep the Host header, keep TLS targeting the original name. - * * Returns a standard `fetch` function suitable for use as * `FetchHttpClient.Fetch`'s context value. * @@ -108,41 +94,32 @@ export function dohFetch(opts: DohFetchOptions): typeof globalThis.fetch { input: string | URL | Request, init?: RequestInit, ): Promise => { - // Normalise to string URL — same as what FetchHttpClient passes. const originalUrl = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; const parsed = new URL(originalUrl); - // URL.hostname returns bracketed IPv6 (e.g. "[::1]") in Bun. Strip brackets - // before the isIP check so net.isIP correctly identifies IPv6 literals. + // Strip Bun's IPv6 brackets (e.g. "[::1]") so net.isIP identifies the literal correctly. const rawHostname = parsed.hostname; const host = rawHostname.startsWith("[") && rawHostname.endsWith("]") ? rawHostname.slice(1, -1) : rawHostname; - // Short-circuit 1: not DoH mode or already an IP literal. if (dnsResolver !== "https" || net.isIP(host) !== 0) { return innerFetch(input, init); } - // DoH-resolve and take the first IP (Go's ip[0]). const ips = await Effect.runPromise(resolver(host)); const firstIp = ips[0]; if (firstIp === undefined) { - // resolver guarantees non-empty; this is a safety net. + // resolver guarantees a non-empty result; this is a safety net. return innerFetch(input, init); } const { url, serverName, hostHeader } = buildDohRequest(originalUrl, firstIp); - // `BunFetchRequestInit` is Bun's global fetch-init type; it extends the - // standard `RequestInit` with `tls`. Bun's fetch honors `tls.serverName`: - // the TLS handshake sends this as the SNI extension and validates the peer - // certificate against it — not against the IP in the URL. Evidence: - // `fetch('https://104.16.133.229/', { tls: { serverName: 'cloudflare.com' } })` - // returned HTTP 403 (cert validated OK against 'cloudflare.com'). CWE-350 - // guard: cert validation never falls back to the raw IP even though the URL - // authority is an IP literal. + // Bun's fetch sends `tls.serverName` as the SNI extension and validates + // the peer certificate against it, not against the IP used as the URL + // authority. const rewrittenInit: BunFetchRequestInit = { ...init, headers: { @@ -155,11 +132,8 @@ export function dohFetch(opts: DohFetchOptions): typeof globalThis.fetch { return innerFetch(url, rewrittenInit); }; - // `FetchHttpClient.Fetch` holds a `typeof globalThis.fetch`, which in Bun - // carries a `preconnect` namespace member alongside the call signature. - // Attach the real `preconnect` so the override is a structurally complete - // `fetch` — no cast needed. `preconnect` is a Bun-only perf hint and is never - // invoked by Effect's FetchHttpClient. + // `typeof globalThis.fetch` includes Bun's `preconnect` member; attach the + // real one so this override satisfies that type without a cast. return Object.assign(fetchImpl, { preconnect: globalThis.fetch.preconnect }); } diff --git a/apps/cli/src/command-internal/http-dns.unit.test.ts b/apps/cli/src/command-internal/http-dns.unit.test.ts index 06d29d39ea..094e5ff098 100644 --- a/apps/cli/src/command-internal/http-dns.unit.test.ts +++ b/apps/cli/src/command-internal/http-dns.unit.test.ts @@ -25,13 +25,11 @@ describe("buildDohRequest", () => { const result = buildDohRequest("https://api.supabase.com:8443/v1/projects", "203.0.113.10"); expect(result.url).toBe("https://203.0.113.10:8443/v1/projects"); expect(result.serverName).toBe("api.supabase.com"); - // Host header must include the port when it differs from the scheme default. expect(result.hostHeader).toBe("api.supabase.com:8443"); }); it("does not include the port in the Host header for the default HTTPS port", () => { const result = buildDohRequest("https://api.supabase.com:443/v1/projects", "203.0.113.10"); - // URL constructor normalises :443 away for https. expect(result.url).toBe("https://203.0.113.10/v1/projects"); expect(result.hostHeader).toBe("api.supabase.com"); }); @@ -46,8 +44,7 @@ describe("buildDohRequest", () => { it("sets serverName to the bare hostname, never the IP", () => { const result = buildDohRequest("https://api.supabase.com/", "203.0.113.10"); - // serverName must be the original hostname for TLS SNI + cert validation. - expect(net.isIP(result.serverName)).toBe(0); // not an IP + expect(net.isIP(result.serverName)).toBe(0); expect(result.serverName).toBe("api.supabase.com"); }); }); @@ -86,16 +83,11 @@ describe("dohFetch", () => { expect(captured).toHaveLength(1); const call = captured[0]!; - // URL authority is the first resolved IP. expect(new URL(call.url).hostname).toBe("203.0.113.10"); - // Path preserved. expect(new URL(call.url).pathname).toBe("/v1/projects"); - // TLS SNI set to original hostname (CWE-350 guard). expect(call.init.tls?.serverName).toBe("api.supabase.com"); - // Host header pinned to original hostname. const headers = call.init.headers as Record; expect(headers["Host"]).toBe("api.supabase.com"); - // Other headers preserved. expect(headers["authorization"]).toBe("Bearer tok"); }); @@ -113,7 +105,6 @@ describe("dohFetch", () => { await fetchFn("https://api.supabase.com/v1/projects"); - // Original URL passed through unchanged. expect(captured[0]?.url).toBe("https://api.supabase.com/v1/projects"); expect(resolverCalls).toHaveLength(0); }); @@ -185,7 +176,6 @@ describe("dohFetchLayer (Effect layer integration)", () => { }); return Effect.gen(function* () { - // Verify the DoH fetch rewrites the URL and sets serverName correctly. yield* Effect.promise(() => fakeFetch("https://api.supabase.com/v1/projects")); expect(captured).toHaveLength(1); @@ -200,7 +190,6 @@ describe("dohFetchLayer (Effect layer integration)", () => { }; return Effect.gen(function* () { - // With dnsResolver = "https", the layer should provide a function. const dohLayer = dohFetchLayer.pipe(Layer.provide(Layer.succeed(DnsResolverFlag, "https"))); const fetchFn = yield* FetchHttpClient.Fetch.pipe(Effect.provide(dohLayer)); expect(typeof fetchFn).toBe("function"); diff --git a/apps/cli/src/command-internal/http-errors.ts b/apps/cli/src/command-internal/http-errors.ts index e1273cd253..1d90ad9cdf 100644 --- a/apps/cli/src/command-internal/http-errors.ts +++ b/apps/cli/src/command-internal/http-errors.ts @@ -18,10 +18,9 @@ const RESPONSE_ERROR_TAGS: ReadonlySet MAX_BODY_LEN ? input.slice(0, MAX_BODY_LEN) : input; @@ -29,24 +28,20 @@ export function sanitizeErrorBody(input: string): string { } /** - * Sanitizes an API-provided NAME (branch/project/org) for inline embedding in - * a single-line terminal message. `sanitizeErrorBody` deliberately - * preserves `\n`/`\t` so JSON response bodies stay readable — but inside an - * inline name those characters let a hostile name forge additional CLI output - * lines (PR #6168 review). Collapse them to a single space on top of the - * body sanitizer's control-char stripping and length cap. + * Sanitizes an API-provided name (branch/project/org) for inline embedding in + * a single-line terminal message. Collapses `\n`/`\t` — preserved by + * `sanitizeErrorBody` for JSON readability — to a single space, so a hostile + * name can't forge additional output lines. */ export function sanitizeInlineName(input: string): string { return sanitizeErrorBody(input).replace(/[\n\t]+/g, " "); } /** - * Renders `name (ref)` when `name` is known and non-empty after - * sanitization, or bare `ref` otherwise — both sanitized via - * {@link sanitizeInlineName}. Computing the sanitized name FIRST (and - * gating on ITS length, not the raw input's) is defense-in-depth: a name - * that's entirely control characters must degrade to the bare-ref form, not - * render as `` (ref)`` with a phantom leading space. + * Renders `name (ref)` when `name` is non-empty after sanitization via + * {@link sanitizeInlineName}, or bare `ref` otherwise. Gating on the + * sanitized length (not the raw input's) keeps a name of only control + * characters from rendering as `` (ref)`` with a phantom leading space. */ export function formatNamedRef(name: string | undefined, ref: string): string { const safeRef = sanitizeInlineName(ref); @@ -54,25 +49,11 @@ export function formatNamedRef(name: string | undefined, ref: string): string { return safeName === undefined || safeName.length === 0 ? safeRef : `${safeName} (${safeRef})`; } -// Strip ASCII control characters from the response body before embedding it in an error -// message. The Management API is trusted, but defence-in-depth: a body containing `\r\n` -// could fracture a structured log line, and `\x00` could truncate output in shells that -// treat NUL as EOS. Tab is preserved so JSON whitespace round-trips visually intact. +// Strips ASCII control chars, DEL, C1 controls (U+0080-U+009F, e.g. the CSI +// equivalent U+009B), bidi override chars (can reorder/hide terminal text), +// and Unicode line separators — defense-in-depth against escape injection, +// spoofing, and log-line fracturing. `\n` and `\t` are kept for JSON readability. function stripControlChars(input: string): string { - // Strip ASCII control chars except \t (0x09), \n (0x0a), \r (0x0d) and DEL (0x7f). - // Then also strip CR — we keep \n and \t because they appear in legitimate JSON - // pretty-printing and shouldn't visually corrupt single-line stderr output. - // - // Also strips (benefits every caller of this function, and of - // `sanitizeInlineName`, which builds on it): - // - C1 controls (0x80-0x9F) — U+009B is CSI on some terminals, equivalent to - // the ESC+`[` sequence `sanitizeErrorBody`/friends are otherwise - // guarding against. - // - Bidi controls (U+200E, U+200F, U+202A-U+202E, U+2066-U+2069) — can - // visually reorder or hide surrounding text in a terminal/log. - // - Unicode line separators (U+2028, U+2029) — fracture single-line output - // the same way `\r`/`\n` do, without being one of the two whitespace - // characters this function otherwise keeps for JSON readability. let out = ""; for (let i = 0; i < input.length; i++) { const code = input.charCodeAt(i); @@ -117,13 +98,13 @@ export type StatusErrorFactory = new (args: { }) => E; /** - * Build an error mapper that classifies a `SupabaseApiError` into either a typed network - * error or a typed unexpected-status error. Pulled out of individual command families so - * they share the dispatch logic, the body truncation, and the `RESPONSE_ERROR_TAGS` policy. + * Builds an error mapper that classifies a `SupabaseApiError` into either a + * typed network error or a typed unexpected-status error. Shared by command + * families for consistent dispatch logic, body truncation, and the + * `RESPONSE_ERROR_TAGS` policy. * - * `networkMessage` and `statusMessage` are templates: they build the human-readable error - * string with the same exact phrasing the Go CLI uses, so Go-parity status messages and - * existing error-message assertions continue to hold. + * `networkMessage` and `statusMessage` build the exact error-message wording + * each command's established output and tests expect. */ export function mapHttpError(opts: { readonly networkError: NetworkErrorFactory; @@ -136,9 +117,8 @@ export function mapHttpError(opts: { return (cause) => Effect.gen(function* () { if (cause instanceof SupabaseApiInputError || cause instanceof HttpBody.HttpBodyError) { - // These failures occur while the generated client validates or builds - // the request. Keep their identity because this generic mapper cannot - // safely infer user provenance or reclassify them as response errors. + // Client-side validation/build failures: keep their identity, since + // this generic mapper can't safely reclassify them as response errors. return yield* Effect.fail(cause); } if (HttpClientError.isHttpClientError(cause)) { @@ -161,8 +141,7 @@ export function mapHttpError(opts: { new opts.networkError({ message: opts.networkMessage(description) }), ); } - // SchemaError — the server returned a response whose body failed schema - // decoding (a 200 the generated client could not parse). This is not a + // SchemaError: a 200 response whose body failed schema decoding. Not a // transport failure, so flag `decode` to classify it as an API-response // problem rather than a network problem. return yield* Effect.fail( diff --git a/apps/cli/src/command-internal/identity-stitch.integration.test.ts b/apps/cli/src/command-internal/identity-stitch.integration.test.ts index 2c346b821f..e36a9ee46e 100644 --- a/apps/cli/src/command-internal/identity-stitch.integration.test.ts +++ b/apps/cli/src/command-internal/identity-stitch.integration.test.ts @@ -6,9 +6,6 @@ import { BunFileSystem, BunPath } from "@effect/platform-bun"; import { mockAnalytics, mockTelemetryRuntime } from "../../tests/helpers/mocks.ts"; import { IdentityStitch, identityStitchLayer } from "./identity-stitch.ts"; -/** - * Build a minimal fake HttpClientResponse carrying the given headers. - */ function fakeResponse(headers: Record): HttpClientResponse.HttpClientResponse { const request = HttpClientRequest.get("https://api.supabase.com/v1/projects"); return HttpClientResponse.fromWeb(request, new Response(null, { status: 200, headers })); @@ -58,16 +55,12 @@ describe("identityStitchLayer — stitchedDistinctId()", () => { const svc = yield* IdentityStitch; - // Before any stitch, stitchedDistinctId() is undefined. expect(svc.stitchedDistinctId()).toBeUndefined(); - // Stitch with a response carrying x-gotrue-id. yield* svc.stitch(fakeResponse({ "x-gotrue-id": "gotrue-abc-123" })); - // Now stitchedDistinctId() returns the gotrue id. expect(svc.stitchedDistinctId()).toBe("gotrue-abc-123"); - // The alias was fired once. expect(analytics.aliased).toHaveLength(1); expect(analytics.aliased[0]).toEqual({ distinctId: "gotrue-abc-123", alias: "device-001" }); }).pipe( @@ -95,10 +88,8 @@ describe("identityStitchLayer — stitchedDistinctId()", () => { yield* svc.stitch(fakeResponse({ "x-gotrue-id": "first-id" })); yield* svc.stitch(fakeResponse({ "x-gotrue-id": "second-id" })); - // stitchedDistinctId() must still reflect the first stitched id. expect(svc.stitchedDistinctId()).toBe("first-id"); - // alias fired exactly once. expect(analytics.aliased).toHaveLength(1); expect(analytics.aliased[0]?.distinctId).toBe("first-id"); }).pipe( @@ -121,10 +112,7 @@ describe("identityStitchLayer — hybrid stamp/alias", () => { yield* svc.stitch(fakeResponse({ "x-gotrue-id": "gotrue-ci-1" })); - // Stamped in memory so this process's captures carry the real user id - // (restores CI/Docker/npx attribution)... expect(svc.stitchedDistinctId()).toBe("gotrue-ci-1"); - // ...but no alias is fired and nothing is persisted to the throwaway home. expect(analytics.aliased).toHaveLength(0); const exists = yield* fs.exists(path.join(configDir, "telemetry.json")); expect(exists).toBe(false); @@ -142,13 +130,10 @@ describe("identityStitchLayer — hybrid stamp/alias", () => { return Effect.gen(function* () { const svc = yield* IdentityStitch; - // An identity already exists (telemetry.json held a previous user, surfaced - // via runtime.identity) but the live token belongs to someone else. + // distinctId seeds an existing identity, so this exercises the no-realias branch. yield* svc.stitch(fakeResponse({ "x-gotrue-id": "new-user" })); - // Memory is stamped with the live user so captures attribute correctly... expect(svc.stitchedDistinctId()).toBe("new-user"); - // ...but we never alias — that would merge two unrelated person graphs. expect(analytics.aliased).toHaveLength(0); }).pipe( Effect.provide(makeStitchLayer({ analytics, configDir, distinctId: "old-user" })), @@ -172,8 +157,6 @@ describe("identityStitchLayer — hybrid stamp/alias", () => { const svc = yield* IdentityStitch; - // The stitchAttempted guard is set before the first yield, so two responses - // racing through the shared stitcher alias at most once. yield* Effect.all( [ svc.stitch(fakeResponse({ "x-gotrue-id": "id-a" })), diff --git a/apps/cli/src/command-internal/identity-stitch.ts b/apps/cli/src/command-internal/identity-stitch.ts index afc6e98e43..f4dcc93fd6 100644 --- a/apps/cli/src/command-internal/identity-stitch.ts +++ b/apps/cli/src/command-internal/identity-stitch.ts @@ -7,29 +7,14 @@ import { isEphemeralIdentityRuntime } from "../shared/telemetry/identity.ts"; import { readExistingState } from "../telemetry/telemetry-state.layer.ts"; /** - * Session identity stitching, a 1:1 port of `identityTransport` + - * `StitchLogin`. - * - * The transport wraps EVERY Management API response, so the first response - * of a session that carries `X-Gotrue-Id` stamps the user id in memory and, on a - * persistent machine, aliases the device id to the gotrue id and persists - * `distinct_id` to `telemetry.json`. Crucially, one `sync.Once`-equivalent installs in the - * root command context shared across every transport, so - * the alias + persist happen at most once per command no matter how many - * Management API responses (typed client, raw advisor GETs, linked-project cache) - * flow through it. - * - * Per the hybrid stitch+stamp model (docs/adr/0013), stamping the in-memory - * identity happens in EVERY runtime — including CI, Docker, and `npx supabase` — - * so captures in this process carry the real user id; the `$create_alias` (which - * merges pre-login history) and the `telemetry.json` write only happen where the - * file survives. Ephemeral runtimes stamp but never alias or persist. - * - * The TS port models that single guard with the {@link IdentityStitch} - * service: it owns the one `stitchAttempted` flag and every transport consumes - * the same service instance, so a command that touches several transports (e.g. - * `db advisors --linked` mints a temp role via the typed client AND issues raw - * advisor GETs) stamps/aliases/persists exactly once, matching Go. + * Session identity stitching. On the first Management API response of a + * session carrying `X-Gotrue-Id`, stamps the user id in memory in every + * runtime (including CI, Docker, `npx supabase`), and on a persistent machine + * also aliases the device id and persists `distinct_id` to `telemetry.json` — + * see docs/adr/0013-hybrid-stitch-stamp-identity-attribution.md. + * {@link IdentityStitch} guards this to at most once per command, shared + * across every transport (typed client, raw advisor GETs, linked-project + * cache) that command touches. */ const HEADER_GOTRUE_ID = "x-gotrue-id"; @@ -52,16 +37,11 @@ function gotrueIdFromResponse(response: HttpClientResponse.HttpClientResponse): } /** - * Builds a once-per-session stitcher. The returned function inspects a Management - * API response's `X-Gotrue-Id` header and stamps the in-memory identity on the - * first authenticated response; on a persistent machine it additionally aliases - * the device and persists `distinct_id` at most once. Never fails (telemetry is - * best-effort, matching the typed client's `Effect.exit` swallow). - * - * Internal: this is the implementation behind {@link identityStitchLayer}. - * Transports must NOT build their own stitcher (each would get a separate - * `stitchAttempted` flag and re-alias/re-persist); they consume the single - * {@link IdentityStitch} service instead. + * Builds a once-per-session stitcher: stamps identity from a response's + * `X-Gotrue-Id` header, aliasing/persisting `distinct_id` at most once on a + * persistent machine. Never fails — telemetry is best-effort. Transports must + * go through the shared {@link IdentityStitch} service instead of building + * their own, or each gets its own `stitchAttempted` flag. */ const makeIdentityStitcher: Effect.Effect< { @@ -85,29 +65,23 @@ const makeIdentityStitcher: Effect.Effect< const stitchIdentity = (gotrueId: string) => Effect.gen(function* () { if (runtime.consent !== "granted" || stitchAttempted) return; - // Mark before the first yield: every Management API response flows through - // this one shared stitcher, so concurrent authenticated responses must not - // both pass the guard and double-stitch. + // Mark before the first yield, so concurrent authenticated responses + // can't both pass the guard and double-stitch. stitchAttempted = true; if (hasIdentity()) { - // An identity already exists (telemetry.json holds a previous user, or a - // prior response in this session already stitched). Stamp memory so this - // process's captures carry the live user, but do NOT alias — re-aliasing - // the device to a second user would merge unrelated person graphs in - // PostHog. Mirrors Go's ObserveAuthenticatedUser. + // An identity already exists (from telemetry.json or an earlier + // response this session): stamp memory so captures carry the live + // user, but don't re-alias — that would merge unrelated person + // graphs in PostHog. runtime.identity.stamp(gotrueId); return; } const telemetryPath = path.join(runtime.configDir, "telemetry.json"); const existing = yield* fs.readFileString(telemetryPath).pipe(Effect.option); - // Reuses the same all-or-nothing decode as `loadOrCreateTelemetryState` - // (`decodeState`) instead of a second tolerant - // per-field parser: `StitchLogin` only ever mutates the state that - // `LoadOrCreateState` already decoded, it never re-parses the file itself. - // This also fixes a prior bug where a `consent: "denied"` file (no - // `enabled` key) was treated as `enabled: true`. + // Uses the same decode as `loadOrCreateTelemetryState` so a + // `consent: "denied"` file isn't misread as `enabled: true`. const prior = Option.match(existing, { onNone: () => undefined, onSome: readExistingState, @@ -115,11 +89,8 @@ const makeIdentityStitcher: Effect.Effect< const enabled = prior?.enabled ?? true; if (!enabled) return; - // The in-memory stamp always happens so subsequent captures in this process - // carry the user's id (restores attribution in CI/Docker/npx). The alias - // (merging pre-login history) and the telemetry.json write are only - // worthwhile where the file survives. Same rules as Go's StitchLogin. - // See docs/adr/0013-hybrid-stitch-stamp-identity-attribution.md. + // Alias and telemetry.json write only happen where the file survives; + // see docs/adr/0013-hybrid-stitch-stamp-identity-attribution.md. runtime.identity.stamp(gotrueId); if (isEphemeralIdentityRuntime(runtime)) return; @@ -140,10 +111,9 @@ const makeIdentityStitcher: Effect.Effect< yield* fs.makeDirectory(runtime.configDir, { recursive: true }); yield* fs.writeFileString( telemetryPath, - // Exact int64 token of the prior schema_version, when there is one: - // re-serializing `state.schema_version` directly would round tokens - // above 2^53 through `Number` (9007199254740993 → …992) — Go decodes - // and re-encodes the 64-bit `int` verbatim. + // Preserves the prior schema_version's exact int64 token: + // re-serializing `state.schema_version` through `Number` would round + // values above 2^53 (e.g. 9007199254740993 → …992). prior?.schemaVersionToken === undefined ? JSON.stringify(state) : JSON.stringify({ ...state, schema_version: JSON.rawJSON(prior.schemaVersionToken) }), @@ -163,24 +133,21 @@ interface IdentityStitchShape { /** Stitch the session identity from a Management API response, at most once. */ readonly stitch: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect; /** - * Returns the in-memory identity for this session — the gotrue id stamped from - * the first authenticated response, or the startup-persisted `distinct_id`, or - * `undefined` if neither exists yet. Read AFTER the command runs so the - * stitching transport has had a chance to stamp it (`s.distinctID()`, read - * post-run). Because stamping happens in every runtime (incl. CI), this - * attributes the post-run `cli_command_executed` event to the real user even - * where no alias/persist occurred. + * The in-memory identity for this session — the gotrue id stamped from the + * first authenticated response, the persisted `distinct_id`, or + * `undefined`. Read after the command runs, once a transport has had a + * chance to stamp it; since stamping happens in every runtime, this + * attributes `cli_command_executed` to the real user even without an + * alias/persist. */ readonly stitchedDistinctId: () => string | undefined; } /** - * The single per-command identity stitcher (Go's one root-context `sync.Once`). - * Every Management API transport in a command — the typed `CommandPlatformApi` - * client, the raw-HTTP advisor GETs, and the linked-project cache GET — consumes - * THIS one service so they share a single `stitchAttempted` flag and alias/persist - * at most once. Provided once per command runtime via {@link identityStitchLayer} - * (memoised by reference, so all consumers in a runtime get the same instance); + * The single per-command identity stitcher. Every Management API transport in + * a command (typed client, raw advisor GETs, linked-project cache) shares + * this one service so alias/persist happens at most once. Provided once per + * command runtime via {@link identityStitchLayer} (memoized by reference); * tests can mock it directly. */ export class IdentityStitch extends Context.Service()( diff --git a/apps/cli/src/command-internal/kong-auth.ts b/apps/cli/src/command-internal/kong-auth.ts index 94361ac462..9acfb11fb5 100644 --- a/apps/cli/src/command-internal/kong-auth.ts +++ b/apps/cli/src/command-internal/kong-auth.ts @@ -1,14 +1,10 @@ /** - * Auth headers for a request through the local Kong service gateway, mirroring - * Go's `fetcher.withAuthToken` (`apps/cli-go/pkg/fetcher/gateway.go:22-34`): - * `apikey` is always sent; `Authorization: Bearer ` is added only when the - * key is a JWT — an opaque `sb_...` secret key is not a bearer token, so Go's - * `sb_` prefix check omits the header for those. + * Auth headers for a request through the local Kong service gateway: `apikey` + * is always sent; `Authorization: Bearer ` is added only when the key is + * a JWT — an opaque `sb_...` secret key is not a bearer token. * - * Hoisted here because it is needed by every local Kong-gateway caller across - * command families: `storage-gateway.ts` (Storage, `seed buckets` / - * `storage ls/cp/mv/rm`) and `start`'s PostgREST HTTP-HEAD readiness probe - * (`command-internal/db-bootstrap/health-check.ts`). + * Shared by every local Kong-gateway caller across command families + * (Storage, `start`'s PostgREST readiness probe). */ export function kongAuthHeaders(apiKey: string): Readonly> { const isOpaqueServiceKey = apiKey.startsWith("sb_"); diff --git a/apps/cli/src/command-internal/kong-local-ca-cert.ts b/apps/cli/src/command-internal/kong-local-ca-cert.ts index 1cb4e41371..1871adcfb2 100644 --- a/apps/cli/src/command-internal/kong-local-ca-cert.ts +++ b/apps/cli/src/command-internal/kong-local-ca-cert.ts @@ -1,15 +1,11 @@ /** - * Embedded Kong local CA certificate for local HTTPS endpoints. + * Embedded CA certificate for the local Kong service's self-signed TLS cert + * (same PEM as `kong-local-tls.ts`'s `KONG_LOCAL_TLS_CERT`, which is + * self-signed and so acts as its own CA). Used as the default CA when + * `[api.tls] enabled = true` and `api.tls.cert_path` is unset. * - * This is the verbatim PEM of the self-signed CA bundled with the Go CLI at - * `apps/cli-go/pkg/config/templates/certs/kong.local.crt` (embedded via - * `//go:embed` in `pkg/config/config.go:364-367`). It is the default CA used - * when `[api.tls] enabled = true` and `api.tls.cert_path` is not set. - * - * Using an inline string constant (not a file-read) ensures the value survives - * Bun's compiled-binary bundler without additional asset embedding. - * - * @see apps/cli-go/pkg/config/config.go lines 364-367, 460-461, 845-851 + * Inlined as a string constant, not a file read, so the value survives Bun's + * compiled-binary bundler. */ export const KONG_LOCAL_CA_CERT = `-----BEGIN CERTIFICATE----- MIIFJTCCAw2gAwIBAgIURUQAFIOhttOg0D12xZvx+pScX4cwDQYJKoZIhvcNAQEL diff --git a/apps/cli/src/command-internal/link-services-core.ts b/apps/cli/src/command-internal/link-services-core.ts index da6449f828..e9df8970f1 100644 --- a/apps/cli/src/command-internal/link-services-core.ts +++ b/apps/cli/src/command-internal/link-services-core.ts @@ -14,9 +14,8 @@ import { export interface LinkServicesInput { readonly ref: string; /** - * Tenant API key used for the service version probes. `link` passes the - * service-role key; `bootstrap` passes the anon key (mirroring Go's - * `link.LinkServices(ctx, ref, tenant.NewApiKey(keys).Anon, …)`). + * Tenant API key used for the service version probes: `link` passes the + * service-role key, `bootstrap` passes the anon key. */ readonly serviceKey: string; readonly skipPooler: boolean; @@ -32,13 +31,12 @@ export interface LinkServicesInput { type WriteTempFile = (filePath: string, content: string) => Effect.Effect; /** - * Ports `link.LinkServices`: the - * best-effort portion of linking that writes `supabase/.temp/{storage-migration, - * pooler-url,rest-version,gotrue-version,storage-version}`. Every probe is - * best-effort — a single unreachable service never fails the caller. This core - * does NOT write `project-ref`, the linked-project cache, or fire - * `cli_project_linked`; `link.Run` (the standalone command) owns those, and - * `bootstrap` deliberately skips them by calling `LinkServices` directly. + * Writes the best-effort portion of linking: + * `supabase/.temp/{storage-migration,pooler-url,rest-version,gotrue-version, + * storage-version}`. Each probe is independently best-effort — an + * unreachable service never fails the caller. Does not write `project-ref`, + * the linked-project cache, or fire `cli_project_linked`; the `link` command + * owns those, and `bootstrap` calls this directly to skip them. */ export const linkServicesCore = Effect.fnUntraced(function* (input: LinkServicesInput) { const api = yield* CommandPlatformApi; diff --git a/apps/cli/src/command-internal/linked-state.ts b/apps/cli/src/command-internal/linked-state.ts index b8d9aebb6a..fd994c53f0 100644 --- a/apps/cli/src/command-internal/linked-state.ts +++ b/apps/cli/src/command-internal/linked-state.ts @@ -10,16 +10,14 @@ import { readProjectRefFile, tempPaths } from "./temp-paths.ts"; /** * Discriminated linked-state result. * - * - `parentRef` set → the linked ref (`projectRef`) is a BRANCH of a known, - * DIFFERENT parent. `branch` is the branch's own resolved name, present - * only when the best-effort API lookup found it — "assumed branch, name - * unknown" is exactly this shape with `branch` absent. - * - `parentRef` absent → a plain project link (or a linked ref with no - * evidence of a distinct parent at all). - * - `projectName`/`orgSlug`/`orgId` always describe whichever ref is shown - * on the "Project:" line (`parentRef ?? projectRef`) — sourced from - * `linked-project.json`, so only ever present when that cache is what - * supplied the parent candidate. + * - `parentRef` set: the linked ref (`projectRef`) is a branch of a known, + * different parent. `branch` is the branch's resolved name, present only + * when the best-effort lookup found it. + * - `parentRef` absent: a plain project link, or a linked ref with no + * evidence of a distinct parent. + * - `projectName`/`orgSlug`/`orgId` describe whichever ref is shown on the + * "Project:" line (`parentRef ?? projectRef`), present only when + * `linked-project.json` supplied the parent candidate. */ export type LinkedState = | { readonly linked: false } @@ -34,26 +32,19 @@ export type LinkedState = }; /** - * Soft "currently linked ref" lookup: env `SUPABASE_PROJECT_ID` → the - * `/supabase/.temp/project-ref` file, never a prompt, never a - * failure. Loosely based on `ProjectRefResolver.resolveOptional(Option.none())` - * (reproduced without depending on that service, so `resolveLinkedState` - * stays usable from a runtime — e.g. `status`'s — that never wires up the - * resolver) but DELIBERATELY STRICTER than it: both candidates are validated - * against `PROJECT_REF_PATTERN` here, which `resolveOptional` itself does not - * do. This is a security boundary, not just a reproduction gap — SECURITY - * (PR #6168 review): `readProjectRefFile` follows symlinks and accepts - * any non-empty content, and `status -o json`/`--output-format json` emits - * this value verbatim into machine output. A malicious/compromised worktree - * could symlink `supabase/.temp/project-ref` -> `~/.supabase/access-token` - * (or any other secret file) to exfiltrate it through CI logs or an agent's - * captured output. A candidate that does NOT match `PROJECT_REF_PATTERN` is - * therefore treated exactly as if it were absent — falling through to the - * next candidate — so non-ref-shaped content (garbage OR a symlinked - * secret) never reaches ANY output channel, text or machine, and degrades - * to `Not linked.` / `linked_project: null` instead. Also reports WHICH - * candidate won, since an env override sitting on top of an unrelated - * workdir's cache needs different trust rules than the workdir's own file. + * Soft "currently linked ref" lookup: env `SUPABASE_PROJECT_ID`, else the + * `/supabase/.temp/project-ref` file; never prompts, never fails. + * Deliberately independent of `ProjectRefResolver`, so this stays usable from + * a runtime (e.g. `status`'s) that never wires that resolver up. + * + * Both candidates are validated against `PROJECT_REF_PATTERN`: this value is + * emitted verbatim by `status -o json`/`--output-format json`, and + * `readProjectRefFile` follows symlinks, so an unvalidated candidate could + * exfiltrate a symlinked secret file's contents. A non-matching candidate is + * treated as absent rather than reaching any output channel. + * + * Reports which candidate won, since an env override and the workdir's own + * cache file carry different trust rules. */ const resolveSoftLinkedRef = Effect.fnUntraced(function* () { const cliSettings = yield* CommandSettings; @@ -76,48 +67,24 @@ const resolveSoftLinkedRef = Effect.fnUntraced(function* () { }); /** - * Resolves the current linked-state display (project or branch). Used by - * `status` to show the linked project/branch without requiring a link - * beforehand. TS-only surface (CLI-2167 follow-up, no Go counterpart). - * - * NEVER FAILS — every step degrades rather than propagating an error: + * Resolves the current linked-state display (project or branch) for + * `status`, without requiring a link. Never fails — every step degrades + * rather than propagating an error: * - * - Not linked at all → `{ linked: false }`. - * - `linked-project.json` CONFIRMS the linked ref is its own `ref` → a - * plain project link (its `name`/org fields, when known); zero API calls. - * - `linked-project.json` CONFIRMS a genuinely DIFFERENT parent, and the - * linked ref came from the `project-ref` FILE → a branch link. Always - * renders the branch-linked shape (parent ref + whatever name/org the - * cache knows), attempting the best-effort branch-name lookup - * ({@link findBranchName}) and degrading to the bare - * "assumed branch, name unknown" shape — NOT to a plain/bare project - * line — on any acquisition or API failure. This is the fix for the real - * bug this feature shipped to fix: the user must still see they're on a - * branch even when the lookup can't run (no token, offline, API error). - * The cache lifecycle invariants `link`/`resolveLinkedParentRef` - * maintain are what let a file-sourced ref trust the cache through a - * lookup failure. - * - Same cache/ref divergence, but the linked ref came from `SUPABASE_PROJECT_ID` - * (env) → the cache belongs to the WORKDIR, not necessarily to whatever - * `SUPABASE_PROJECT_ID` happens to point at (e.g. workdir linked to - * project A, `SUPABASE_PROJECT_ID=B` for an unrelated project B) — an env - * override carries none of those invariants. The lookup still runs, but - * the parent claim requires it to have POSITIVELY found `B` among `A`'s - * branches; on no-match, failure, or timeout, degrade all the way to the - * plain `{ linked: true, projectRef }` shape (no parent, no branch line, - * no cache-sourced name/org) rather than asserting "B is a branch of A" - * on nothing but the cache's mere presence (PR #6168 review). An - * env-override CI workflow that DOES link a real branch still renders - * correctly, since that lookup positively confirms. - * - No cache at all (missing/unreadable/malformed) → the plain - * `{ linked: true, projectRef }` shape, with ZERO API calls (PR #6168 - * review). `resolveLinkedParentRef`'s own env/file chain is now - * ALWAYS self-referential here (its cache candidate only ever - * participates when a link has actually completed — see its doc - * comment), so querying it could only ever match the linked ref's own - * DEFAULT branch row (misrendering an ordinary project as "a branch of - * itself") or 403 on the real platform when the linked ref genuinely is - * a branch — there is no positive-confirmation case left to attempt. + * - Not linked → `{ linked: false }`. + * - The cache confirms the linked ref is its own parent → a plain project + * link, zero API calls. + * - The cache names a different parent and the ref came from the + * `project-ref` file → a branch link. Attempts the best-effort + * {@link findBranchName} lookup, but degrades to "assumed branch, name + * unknown" rather than a plain line on any failure, so the user still sees + * they're on a branch when the lookup can't run. + * - Same divergence, but the ref came from `SUPABASE_PROJECT_ID` (env) — the + * cache belongs to the workdir, not necessarily to what the env var points + * at, so the branch claim additionally requires the lookup to positively + * confirm it; otherwise it degrades to the plain `{ linked: true, + * projectRef }` shape. + * - No cache at all → the plain shape, with zero API calls. */ export const resolveLinkedState = Effect.fnUntraced(function* () { const cliSettings = yield* CommandSettings; @@ -146,25 +113,22 @@ export const resolveLinkedState = Effect.fnUntraced(function* () { }; if (cached.value.ref === linkedRef.value) { - // The cache confirms the linked ref IS the parent — a plain project link. + // The cache confirms the linked ref is the parent — a plain project link. return { linked: true, projectRef: linkedRef.value, ...cacheFields } as const; } - // The cache names a genuinely DIFFERENT parent than the linked ref. + // The cache names a different parent than the linked ref. const parentRef = cached.value.ref; const branch = yield* findBranchName(parentRef, linkedRef.value, { spinnerLabel: "Checking linked branch...", }); if (branch === undefined && soft.source === "env") { - // An env override's lookup didn't POSITIVELY confirm a branch, and the - // cache carries none of the file-sourced trust invariants (it belongs - // to the WORKDIR, not necessarily to whatever `SUPABASE_PROJECT_ID` - // points at) — make no parent claim at all (PR #6168 review). + // An env override's lookup didn't confirm a branch, and the cache may + // belong to an unrelated project — make no parent claim at all. return { linked: true, projectRef: linkedRef.value } as const; } - // Either file-sourced (the cache's trust invariants apply) or - // env-sourced with a POSITIVE lookup confirmation — always the - // branch-linked shape, degrading only the `branch` name. + // File-sourced, or env-sourced with a confirmed lookup: always render + // the branch-linked shape, degrading only the `branch` name. return { linked: true, projectRef: linkedRef.value, @@ -174,32 +138,19 @@ export const resolveLinkedState = Effect.fnUntraced(function* () { } as const; } - // No cache at all — render the plain shape with ZERO API calls (PR #6168 - // review). With `resolveLinkedParentRef`'s own fix (its cache - // candidate only participates when a link actually completed), its - // env/file chain is now ALWAYS self-referential here — it's the exact same - // soft linked-ref chain `resolveSoftLinkedRef` above just read — so - // querying it could only ever (a) match the linked ref's own DEFAULT - // branch row when it's an ordinary parent, misrendering the project as "a - // branch of itself", or (b) 403 on the real platform when the linked ref - // genuinely is a branch (branches endpoints are parent-scoped). There is - // no positive-confirmation case left to attempt. + // No cache at all: render the plain shape with zero API calls. Querying + // `resolveLinkedParentRef` here would only match the ref's own default + // branch (misrendering it as "a branch of itself") or 403 when it + // genuinely is a branch — there's no positive-confirmation case to attempt. return { linked: true, projectRef: linkedRef.value } as const; }); -// Every string in this human-text block is untrusted display data — not just -// API-derived names/org slug/id, but the refs themselves. `projectRef` is now -// pattern-validated upstream (`resolveSoftLinkedRef`, PR #6168 review — -// closes the `.temp/project-ref` symlink/token-exfiltration vector), but -// `parentRef` (`linked-project.json`'s `ref`, via `parseCachedLinkedProject`) -// is still only validated as a non-empty string — a malicious/corrupted cache -// file could inject ANSI/OSC/newline controls into `supabase status` stdout -// via the parent ref itself, not just a branch/org name. Sanitize every -// rendered value regardless — defense-in-depth for `projectRef` too, since a -// display-layer guarantee shouldn't depend on remembering every upstream -// validation site. Machine payloads (`-o`/`--output-format`) stay -// data-faithful — JSON/YAML/TOML/env encoding already neutralizes control -// chars there; this sanitization is for the human text block only. +// Every rendered string here is untrusted display data, including the refs +// themselves (`parentRef` from `linked-project.json` is only validated as +// non-empty, so a corrupted cache file could inject terminal control +// sequences). Sanitize unconditionally rather than relying on upstream +// validation. Machine payloads (`-o`/`--output-format`) skip this — their own +// encoders already neutralize control chars. function formatOrgLabel(slug: string | undefined, id: string | undefined): string { if (slug !== undefined && id !== undefined) { return slug === id @@ -210,9 +161,8 @@ function formatOrgLabel(slug: string | undefined, id: string | undefined): strin } /** - * Pure formatter for `LinkedState` — the full multi-line block, - * including its trailing newline. Not linked stays a single plain line (no - * header block, unchanged from the prior single-line format): + * Pure formatter for `LinkedState` — the full multi-line block, including its + * trailing newline. Not linked renders a single plain line: * * ``` * Not linked. @@ -252,19 +202,13 @@ export function formatLinkedStateBlock(state: LinkedState): string { } /** - * Additive flat snake_case keys for a Go machine-format payload - * (`-o env|json|yaml|toml`) — merge into the format's own key/value map - * AFTER its existing keys so their order is undisturbed (irrelevant for - * `-o env`/`-o json`, which sort keys anyway; preserved for `-o yaml`/`-o toml`, - * which don't). `encodeEnv`'s `toEnvKey` upper-cases these unchanged, so - * `linked_project_ref` becomes `LINKED_PROJECT_REF`, etc. Empty when not - * linked — absence of every key IS "not linked" for these formats, not a - * `linked: false`/empty-string entry. Degraded branch-linked state still - * emits every field it knows (`linked_project_ref`, `linked_parent_project_ref`, - * `linked_project_name`, org fields) — only `linked_branch` is absent. TS-only - * QoL (CLI-2167 follow-up, no Go counterpart), letting an agent driving a - * machine format discover the linked project/branch without a separate - * `link`/`branches` call. + * Additive flat snake_case keys for the `-o env|json|yaml|toml` machine + * format, merged into the format's own key/value map after its existing keys + * (order matters for `-o yaml`/`-o toml`; env/json sort keys anyway). + * `encodeEnv` upper-cases these unchanged (`linked_project_ref` → + * `LINKED_PROJECT_REF`). Empty when not linked — absence of every key is + * what signals "not linked" for these formats. A degraded branch-linked + * state still emits every field it knows; only `linked_branch` is absent. */ export function linkedStateGoFields(state: LinkedState): Readonly> { if (!state.linked) return {}; @@ -278,9 +222,11 @@ export function linkedStateGoFields(state: LinkedState): Readonly Date: Thu, 10 Sep 2026 11:57:28 +0100 Subject: [PATCH 03/56] chore(repo): trim comments, shard 06 (1740 -> 1192 comment lines) --- .../db-bootstrap/health-check.unit.test.ts | 24 +-- .../db-bootstrap/image-prepull.ts | 39 ++--- .../db-bootstrap/image-prepull.unit.test.ts | 6 +- .../db-bootstrap/internal-db-connection.ts | 71 ++------- .../db-bootstrap/local-container-inputs.ts | 136 ++++------------ .../db-bootstrap/local-db-running.ts | 48 ++---- .../command-internal/db-bootstrap/messages.ts | 29 +--- .../db-bootstrap/pgdata-snapshot.ts | 64 +++----- .../db-bootstrap/pgdata-snapshot.unit.test.ts | 7 +- .../db-bootstrap/pinned-image.ts | 18 +-- .../db-bootstrap/postgres.service.ts | 113 ++++--------- .../db-bootstrap/realtime-env.ts | 47 ++---- .../db-bootstrap/recreate-local-database.ts | 149 ++++-------------- .../recreate-local-database.unit.test.ts | 14 +- .../db-bootstrap/reset-local-database.ts | 49 ++---- .../db-bootstrap/restart-services.ts | 37 ++--- .../restart-services.unit.test.ts | 9 +- .../command-internal/db-bootstrap/rollback.ts | 91 ++--------- .../db-bootstrap/rollback.unit.test.ts | 17 +- .../db-bootstrap/shadow-cache.e2e.test.ts | 65 +++----- .../shadow-cache.integration.test.ts | 39 ++--- 21 files changed, 260 insertions(+), 812 deletions(-) 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 5f73afd7e2..3cb447e96d 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 @@ -15,20 +15,14 @@ import { type HealthCheckPostgrestGateway, } from "./health-check.ts"; -/** - * Ends a scripted `logs` script, failing the stream after the chunks before it - * have already been emitted — the real "daemon dropped the pipe mid-dump" case. - */ +/** Ends a scripted `logs` script, failing the stream after already-emitted chunks — the real "daemon dropped the pipe mid-dump" case. */ const LOG_STREAM_FAILS = Symbol("log stream fails"); /** - * A spawner that answers `docker container inspect`/`docker logs` calls. - * `inspectResponse` is called once per `(containerId, callIndex)` pair — the - * `callIndex` (0-based, per container) lets a test script a container's - * health across successive polling rounds. `docker logs` calls (the - * timeout-path debug dump) succeed with whatever `logs` scripts for that - * container — an array so a test can script chunk boundaries, optionally - * terminated by {@link LOG_STREAM_FAILS} — and with empty output otherwise. + * A spawner that answers `docker container inspect`/`docker logs` calls. `inspectResponse` is + * called once per `(containerId, callIndex)` pair, so a test can script a container's health + * across successive polling rounds. `docker logs` calls succeed with whatever `logs` scripts for + * that container, optionally terminated by {@link LOG_STREAM_FAILS}, and empty output otherwise. */ type LogChunks = ReadonlyArray; @@ -781,11 +775,9 @@ const shadowConnConfig: PgConnInput = { }; /** - * A `DbConnection` whose `connect` fails the first `failTimes` calls - * (`Number.POSITIVE_INFINITY` never succeeds), recording every dialled config - * and how many probe sessions were released. `closedSessions` is what proves - * the readiness probe hands nothing back to the caller: the downstream code - * opens its own connection through `connectShadowDatabase` afterwards. + * A `DbConnection` whose `connect` fails the first `failTimes` calls, recording every dialled + * config and how many probe sessions were released. `closedSessions` proves the readiness probe + * hands nothing back to the caller. */ function mockShadowDbConnection( opts: { readonly failTimes?: number; readonly connectMillis?: number } = {}, 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 9bd5e55de5..c54f0d8b19 100644 --- a/apps/cli/src/command-internal/db-bootstrap/image-prepull.ts +++ b/apps/cli/src/command-internal/db-bootstrap/image-prepull.ts @@ -1,20 +1,7 @@ /** - * Port of Go's `ensureImagesCached` (`apps/cli-go/internal/start/start.go:225-262`, - * deleted in CLI-1966; last present at commit a253ccba2): - * guarantees every image `supabase start` needs is resolved/pulled into the - * local Docker cache BEFORE any container is created, using the same - * multi-registry fallback as the per-container start path - * (`makeDockerImageResolver`). - * - * Go's caller (`internal/start/start.go:264-291`, `run`) also runs a - * best-effort `docker-compose`-based pre-pull first - * (`pullImagesUsingCompose`) and treats `ensureImagesCached` as the hard-failing - * backstop that catches whatever the compose pre-pull's `IgnoreFailures` step - * skipped. This port does not implement docker-compose integration anywhere - * (an intentional architecture decision for this port), so - * {@link ensureImagesCached} is the ONLY image pre-pull step here, not a - * backstop for a separate best-effort pass — every image must resolve through - * this call before any container starts. + * Resolves and pulls every image `supabase start` needs into the local Docker cache before any + * container is created, using the same multi-registry fallback as the per-container start path. + * This is the only pre-pull step; every image must resolve through it before a container starts. */ import { Data, Effect, Result } from "effect"; @@ -31,10 +18,8 @@ import { SUGGEST_DOCKER_INSTALL, isDockerDaemonUnreachable } from "../docker-sug type Spawner = ChildProcessSpawner["Service"]; /** - * One or more images failed to resolve/pull from every registry candidate. - * Mirrors Go's `errors.Join(result...)` (`start.go:257`): the message - * aggregates every failed image's own error rather than surfacing only the - * first failure, so a caller can see every broken image in one report. + * One or more images failed to resolve/pull from every registry candidate. The message + * aggregates every failed image's own error rather than surfacing only the first failure. */ export class ImagePrepullError extends Data.TaggedError("ImagePrepullError")<{ readonly message: string; @@ -53,17 +38,11 @@ export class ImagePrepullError extends Data.TaggedError("ImagePrepullError")<{ } /** - * Resolves every image in `images` concurrently (Go's `utils.WaitAll` — - * unbounded goroutines) via the shared registry-fallback resolver, and returns - * a map from the ORIGINAL image reference to the resolved image URL a caller - * must use to reference that image afterward (e.g. as - * `StartContainerSpec.image`) — resolving the same image twice would be - * wasteful and could, in theory, land on a different registry candidate on a - * second, independent call. + * Resolves every image in `images` concurrently through the shared registry-fallback resolver. * - * `images` is deduped here (Go's `seen := map[string]struct{}{}`, - * `start.go:238-249`) — callers are not expected to have already deduped their - * service image list. + * Returns a map from each original image reference to the resolved image URL callers must use + * to reference that image afterward — re-resolving the same image could land on a different + * registry candidate on a second call. `images` is deduped internally. */ export function ensureImagesCached( spawner: Spawner, diff --git a/apps/cli/src/command-internal/db-bootstrap/image-prepull.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/image-prepull.unit.test.ts index b845ed17bd..6bbf3420ef 100644 --- a/apps/cli/src/command-internal/db-bootstrap/image-prepull.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/image-prepull.unit.test.ts @@ -4,7 +4,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { ImagePrepullError, ensureImagesCached } from "./image-prepull.ts"; -/** Matches the standing `mockSpawner` shape in `docker-lifecycle.unit.test.ts`, generalized to a per-call handler so each argv can respond differently (needed for "some images cached, others not"). */ +/** Per-call variant of `docker-lifecycle.unit.test.ts`'s `mockSpawner`, so each argv can respond differently. */ function mockSpawner( handler: (args: ReadonlyArray) => { exitCode: number; stdout?: string; stderr?: string }, ) { @@ -56,9 +56,7 @@ describe("ensureImagesCached", () => { const cached = image === "public.ecr.aws/supabase/postgres:15" || image === "public.ecr.aws/supabase/kong:3"; - // A confirmed "no such image" (not merely a non-zero exit) is what tells - // `hasLocalImage` this candidate is a genuine cache miss rather than some other - // inspect failure, which now fails fast instead of falling through to a pull. + // This stderr text is what `hasLocalImage` treats as a confirmed cache miss. return cached ? { exitCode: 0 } : { exitCode: 1, stderr: `Error response from daemon: No such image: ${image}` }; diff --git a/apps/cli/src/command-internal/db-bootstrap/internal-db-connection.ts b/apps/cli/src/command-internal/db-bootstrap/internal-db-connection.ts index 16db27e35a..2e0bdd4a77 100644 --- a/apps/cli/src/command-internal/db-bootstrap/internal-db-connection.ts +++ b/apps/cli/src/command-internal/db-bootstrap/internal-db-connection.ts @@ -1,56 +1,26 @@ /** - * The fixed, in-Docker-network Postgres address every `start`-created service - * container (Realtime, PostgREST, Storage) reaches Postgres at, ported from - * Go's `dbConfig := pgconn.Config{Host: utils.DbId, Port: 5432, User: - * "postgres", Password: utils.Config.Db.Password, Database: "postgres"}` - * (`apps/cli-go/internal/start/start.go:66-72`, deleted in CLI-1966; last - * present at commit a253ccba2 — threaded through `run()` as `dbConfig`). + * The fixed, in-Docker-network Postgres address every service container (Realtime, PostgREST, + * Storage) reaches Postgres at. * - * This is deliberately NOT the same value as - * `LocalConfigValues.dbUrl` (`local-config-values.ts`): that - * field is `postgresql://postgres:@:/postgres` - * — the HOST-facing address `status`/`stop` print for a user's own `psql` - * client, reachable through Docker's published port mapping. Every - * container-to-container env var in `start.go` instead reaches Postgres via - * `utils.DbId` (the `db` container's own Docker name) on its unpublished, - * always-5432 internal port — the two never share a value except by - * coincidence (`db.port` happening to equal 5432). - * - * Hoisted here (`command-internal/db-bootstrap/`) per `apps/cli/CLAUDE.md`'s - * "Hoist Before You Duplicate" rule: Realtime, PostgREST, and Storage's own - * container-spec builders (`start/services/*.service.ts`) all need this - * exact host/port/password derivation, and `db start` (a different command - * family, `commands/db/start/`) needs it too since CLI-1954. + * This differs from `LocalConfigValues.dbUrl` (`local-config-values.ts`), the host-facing + * address `status`/`stop` print for a user's own `psql` client via Docker's published port + * mapping. Containers instead reach Postgres via its Docker service name on the unpublished, + * always-5432 internal port; the two share a value only by coincidence. */ -/** Go's `dbConfig.Port` literal (`start.go:68`) — always 5432, never the configurable `db.port`. */ +/** Always 5432 — not the user-configurable `db.port`. */ export const START_INTERNAL_DB_PORT = 5432; -/** Go's `dbConfig.Database` literal (`start.go:71`) — always `"postgres"`, never configurable. */ +/** Always `"postgres"`, never configurable. */ export const START_INTERNAL_DB_NAME = "postgres"; /** - * Extracts `dbConfig.Password` (Go's `utils.Config.Db.Password`, `db.go:88` — - * `toml:"-"`, never configurable, always the `"postgres"` literal default, - * `pkg/config/config.go:459`) from the already-resolved - * `LocalConfigValues.dbUrl` rather than re-deriving that default a - * second time — `dbUrl`'s shape - * (`postgresql://postgres:@:/postgres`, - * `resolveLocalConfigValues`) always embeds the exact same password - * value `dbConfig.Password` does, since both are sourced from the same - * `config.Db.Password` field. + * Extracts the raw database password from the already-resolved `LocalConfigValues.dbUrl`. * - * Returns the RAW password, not the URI-encoded userinfo octets: `new URL(...) - * .password` preserves percent-encoding (`new URL("postgresql://u:p%40s@h:5/d") - * .password === "p%40s"`), but consumers split into plain-env uses that need the - * decoded value (Realtime's `DB_PASSWORD`, `realtime-env.ts`) and URI re-embedders - * that re-encode themselves ({@link startInternalDbUrl}). The shadow database - * path (`shadow-source.ts`) builds its `dbUrl` via `toPostgresURL`, - * which `encodeURIComponent`s a config-derived password, so decoding here is what - * keeps a special-character password working against a container initialized with - * the raw value. For `start`'s constant `"postgres"` both forms are identical. The - * fallback covers a `dbUrl` whose userinfo was never percent-encoded (a raw `%` - * would throw `URIError`) — the undecoded octets are the best available value there. + * Returns the raw, decoded password rather than the URI's percent-encoded userinfo octets: + * plain-env consumers (Realtime's `DB_PASSWORD`) need the decoded value, while URI re-embedders + * ({@link startInternalDbUrl}) re-encode it themselves. Falls back to the undecoded value if the + * userinfo was never percent-encoded (a raw `%` would throw). */ export function startInternalDbPassword(dbUrl: string): string { const encoded = new URL(dbUrl).password; @@ -62,18 +32,11 @@ export function startInternalDbPassword(dbUrl: string): string { } /** - * `:@:5432/postgres` — the shape of every - * container-to-container Postgres URI `start.go` builds for a specific role - * (PostgREST's `PGRST_DB_URI` as `authenticator`, Storage's `DATABASE_URL` as - * `supabase_storage_admin`, Storage's vector-bucket default `VECTOR_DATABASE_URL` - * as `postgres` — see `appendStorageVectorEnv`, `start.go:1487-1501`). + * `:@:5432/postgres` — the shape of every container-to-container + * Postgres URI built for a specific role. * - * `dbPassword` is the RAW password ({@link startInternalDbPassword}); it is - * percent-encoded here so the consuming service's URI parser decodes it back to - * the same value. Go interpolates the raw string, but its only possible value is - * the reserved-character-free `"postgres"` literal, for which the two are - * byte-identical — encoding only diverges for the shadow path's config-derived - * password, where the raw form would produce an unparseable URI. + * `dbPassword` must be the raw password ({@link startInternalDbPassword}); it is percent-encoded + * here so the consuming service's URI parser decodes it back to the same value. */ export function startInternalDbUrl(role: string, dbHost: string, dbPassword: string): string { return `postgresql://${role}:${encodeURIComponent(dbPassword)}@${dbHost}:${START_INTERNAL_DB_PORT}/${START_INTERNAL_DB_NAME}`; diff --git a/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts b/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts index 1f2639e9dd..43c9c1162e 100644 --- a/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts +++ b/apps/cli/src/command-internal/db-bootstrap/local-container-inputs.ts @@ -1,32 +1,11 @@ /** - * The local-container-bring-up prelude shared by `db start` - * (`commands/db/start/start.handler.ts`), `db reset` - * (`commands/db/reset/reset.handler.ts`, via `reset-local-database.ts`), and - * `db diff`/`db pull`'s shadow-database provisioning (CLI-1956): load the local - * project context, resolve config values + the `DbBootstrapConfig` - * derivation, the container's network id/opts/id, the Postgres container-spec - * fields common to every caller, the lazy image-resolve `Effect`, and the - * `FreshDbSetupInput` `setup` object `runFreshDbSetup` needs. Hoisted - * here (CLI-1955 review follow-up) — `db start`/`db reset` used to each run an - * independently-typed ~130-line copy of this exact sequence, with no test - * comparing them. + * Shared local-container bring-up prelude for `db start`, `db reset`, and the `db diff`/`db pull` + * shadow database: resolves project context, config values, network/container ids, the common + * Postgres container-spec fields, and the `FreshDbSetupInput` `setup` object. * - * Deliberately does NOT include the callers' genuinely divergent parts, which stay at each - * call site instead of being forced into this shared shape: - * - `db start`'s `fromBackup` (spliced into its OWN `postgresSpec` on top of - * {@link LocalDbContainerInputs.postgresSpecBase}) and its `isFreshVolume`/`filterValue` - * rollback tracking — `db reset` has neither concept at all (a reset never rolls back, and its - * volume is always fresh, having just been removed). - * - `db reset`'s resolved `version`/`seedFlags` (passed straight to `recreateLocalDatabase`, - * not part of this prelude) and its OWN, separately-resolved `--experimental` gate: `db reset` - * must resolve `--experimental` BEFORE this prelude ever runs (it gates the remote-target - * Go-delegation decision too, reached before `cfg.isLocal` is even known), via the Go-parity - * nested-env walk (`loadProjectEnv`/`resolveExperimentalWithProjectEnv`) — a - * deliberately different mechanism than the `@supabase/config`-backed - * `context.projectEnvValues` this function resolves `experimental` from (see - * {@link LocalDbContainerInputs.experimental}'s own doc comment). `db reset`'s caller - * overrides `setup.experimental` with its own earlier-resolved value rather than using this - * function's, to preserve that pre-existing behavior exactly. + * Callers keep their own divergent parts instead of forcing them into this shared shape: `db + * start`'s `fromBackup` splice and rollback tracking, and `db reset`'s version/seed flags and + * separately-resolved `--experimental` gate (needed earlier, before `cfg.isLocal` is known). */ import { Effect, FileSystem, Option, Path } from "effect"; @@ -61,45 +40,33 @@ export interface LocalDbContainerInputs { readonly context: LocalProjectContext; readonly values: LocalConfigValues; readonly bootstrapConfig: DbBootstrapConfig; - /** Go's `DockerStart`-forced `--network-id`, or the generated `supabase_network_` fallback. */ + /** The forced `--network-id`, or the generated `supabase_network_` fallback. */ readonly networkId: string; readonly containerOpts: ContainerOpts; - /** `localDbContainerId(projectId)` — also this project's volume name and the internal Docker network name. */ + /** Also this project's volume name and internal Docker network name. */ readonly dbContainerId: string; /** - * The Postgres container-spec fields common to BOTH callers — `db start` splices its own - * `fromBackup` on top; `db reset` (which has no `fromBackup` concept) passes this straight - * through as its whole `postgresSpec`. + * Postgres container-spec fields common to both callers: `db start` splices its own + * `fromBackup` on top; `db reset` passes this straight through as its whole `postgresSpec`. */ readonly postgresSpecBase: Omit; - /** Lazy — evaluated right where Go's `DockerStart` would resolve the `db` container's own image. */ + /** Lazy: only resolved when the returned Effect actually runs. */ readonly resolvePostgresImage: Effect.Effect; readonly dbHealthTimeoutSeconds: number; /** - * `--experimental`/`SUPABASE_EXPERIMENTAL`, resolved from THIS prelude's own - * {@link LocalProjectContext.projectEnvValues} (the `@supabase/config`-backed reader) — - * matches `db start`'s own need exactly (it has no earlier use for this gate). `db reset` - * already resolves its own `experimental` earlier, from the Go-parity nested-env walk - * (`loadProjectEnv`), because it needs the gate before this prelude ever runs (to decide - * Go-delegation for the remote target too) — its caller overrides {@link - * FreshDbSetupInput.experimental} with that earlier value instead of using this field, to - * preserve that pre-existing divergence exactly. See this module's own header. + * `--experimental`, resolved from this prelude's own project env values. `db reset` resolves + * its own `experimental` earlier — before this prelude runs — and overrides {@link + * FreshDbSetupInput.experimental} with that value instead of using this field. */ readonly experimental: boolean; readonly setup: FreshDbSetupInput; } /** - * Builds {@link LocalDbContainerInputs} — see this module's header for the full call - * order and for which parts are deliberately excluded (kept at each call site instead). + * Builds {@link LocalDbContainerInputs}; see this module's header for callers' divergent parts. * - * Loads its own {@link LocalProjectContext} via {@link loadLocalProjectContext} - * UNLESS the caller passes {@link preloadedContext} — see that parameter's own doc comment for - * why `db start`'s handler must pass one (a double-print bug: `@supabase/config`'s - * `loadCliConfig` unconditionally prints deprecated-config-section WARN lines to stderr, - * and `db start` already loads a context of its own, eagerly, ahead of this function, matching - * Go's single `flags.LoadConfig` call in `start.Run` (`apps/cli-go/internal/db/start/ - * start.go:45`)). + * Loads its own {@link LocalProjectContext} unless {@link preloadedContext} is passed — see that + * parameter's doc comment for why `db start` must pass one. */ export const buildLocalDbContainerInputs = ( spawner: Spawner, @@ -107,46 +74,19 @@ export const buildLocalDbContainerInputs = ( networkIdFlag: Option.Option, platform: string, debug: boolean, - // The resolved `--linked` ref, when the caller already has one (`db diff`/`db pull` — - // CLI-1956) — threaded straight through to `loadLocalProjectContext` so the shadow's - // OWN container-spec fields (image, `db.major_version`, JWT secret, root key, - // `db.settings`, service enabled-for-setup flags) reflect the matching `[remotes.]` - // override, the same way `readDbToml(..., ref)` already does for those commands' - // other config read. `db start`/`db reset` never pass this — see that function's own doc - // comment. + // The resolved `--linked` ref for the `db diff`/`db pull` shadow database, threaded through + // so the shadow's container-spec fields reflect the matching `[remotes.]` override. + // `db start`/`db reset` never pass this. projectRef?: string, - // The `remoteOverrideKeys` the caller's OWN, separate `readDbToml(..., ref)` read - // already computed for the SAME matched `[remotes.]` block (`@supabase/config`'s - // `loadCliConfig`, used by `loadLocalProjectContext` just above, merges the - // remote block's VALUES but tracks none of which keys it set) — threaded into - // `resolveDbBootstrapConfig`/`resolveDbSettingsEnvOverrides` AND - // `resolveLocalConfigValues` below so a remote-set field (e.g. `db.major_version`, - // `auth.jwt_secret`, `db.root_key`) isn't re-overridden by a conflicting `SUPABASE_*` env - // var (review: PRRT_kwDOErm0O86W2LL4, PRRT_kwDOErm0O86W2tRi). Go's `mergeRemoteConfig` - // installs remote leaves at viper's OVERRIDE tier, above `AutomaticEnv` - // (`apps/cli-go/pkg/config/config.go:718-730`). - // `db start`/`db reset` never pass a `projectRef` above, so they never need this either. + // Which keys the caller's own `readDbToml(..., ref)` read set from the matched remote block, + // so a remote-set field isn't overridden again by a conflicting `SUPABASE_*` env var. + // `db start`/`db reset` never pass a `projectRef`, so they never pass this either. remoteOverrideKeys?: ReadonlySet, - // `db start`'s handler — the only real caller that already has a - // {@link LocalProjectContext} loaded in scope BEFORE calling this function, since it - // must eagerly load+validate config ahead of its own "is Postgres already running" - // short-circuit (matching Go's single `flags.LoadConfig` call, `start.go:45`, which also runs - // ahead of `AssertSupabaseDbIsRunning`, `start.go:45-47`). When provided, this function uses - // it AS-IS instead of calling `loadLocalProjectContext(workdir, mapError, projectRef)` - // again — the call is skipped entirely, not just its result discarded, because that reload is - // the one genuinely observable side effect this function would otherwise repeat: - // `@supabase/config`'s `loadCliConfig` unconditionally prints deprecated-`[inbucket]`/ - // deprecated-`auth.external.{linkedin,slack}` WARN lines to stderr (`packages/config/src/ - // io.ts:705-710,792-797`), so reloading would print each warning TWICE for one `db start` - // invocation where Postgres isn't already running, instead of once like Go — whose entire - // config load is a single package-level-singleton pass, with no second `Config.Load` call - // anywhere in `db start`'s call graph to double the print. - // - // PRECONDITION (not enforced here — see this module's header for why `db start`/`db reset` - // never pass a `projectRef` above, so there is nothing to reconcile): the preloaded context - // must correspond to the SAME `workdir`/`projectRef` this call would otherwise have passed to - // `loadLocalProjectContext` itself. That's only ever true today for a caller — `db - // start` — that never passes `projectRef` at all. + // `db start`'s handler already loads a {@link LocalProjectContext} before calling this + // function (to validate config ahead of its own "already running" short-circuit). When + // provided, this function skips its own reload — `@supabase/config`'s `loadCliConfig` prints + // deprecated-config WARN lines to stderr, so reloading would print each warning twice. Must + // correspond to the same `workdir`/`projectRef` this call would otherwise use. preloadedContext?: LocalProjectContext, ): Effect.Effect< LocalDbContainerInputs, @@ -161,9 +101,6 @@ export const buildLocalDbContainerInputs = ( const context = preloadedContext ?? (yield* loadLocalProjectContext(workdir, mapError, projectRef)); const { config, projectEnvValues, loaded, hostname, projectId } = context; - // Go's `viper.GetBool("EXPERIMENTAL")` (`internal/migration/apply/apply.go:19`), read deep - // inside `runFreshDbSetup`'s fresh-volume setup pipeline — see this field's own doc - // comment for why `db reset`'s caller overrides it instead of using it directly. const experimental = yield* resolveExperimentalWithProjectEnv(projectEnvValues); const values = yield* Effect.try({ @@ -186,22 +123,13 @@ export const buildLocalDbContainerInputs = ( mapError, ); - // Go's `DockerStart` forces every container's network mode (and the network it creates) to - // `--network-id` when set, ahead of the generated `supabase_network_` fallback - // (`docker.go:379-383`) — and `--network-id` falls back to the `SUPABASE_NETWORK_ID` - // shell/project-dotenv env var ONLY when the flag was never passed, via the same - // `viper`/`AutomaticEnv` mechanism as `SUPABASE_YES`/`SUPABASE_EXPERIMENTAL` (review: - // PRRT_kwDOErm0O86VlqIL; unlike `utils.Config.Hostname`, viper re-reads the dotenv-merged - // env fresh at `DockerStart`'s own call site, not at package init). See - // {@link resolveDockerNetworkMode}'s doc comment for the full 3-way flag/env precedence. + // See {@link resolveDockerNetworkMode} for the full flag/env/fallback precedence. const networkId = resolveDockerNetworkMode({ explicit: Option.getOrUndefined(networkIdFlag), envOverride: viperEnvStringWithProjectFallback("SUPABASE_NETWORK_ID", projectEnvValues), projectId, }); - // Go's `DockerStart` unconditionally appends the Linux-only `host.docker.internal:host-gateway` - // extra host for every container it starts (`docker_linux.go`; empty on darwin/windows, where - // Docker Desktop already resolves that hostname). + // Only needed on Linux; Docker Desktop already resolves `host.docker.internal` elsewhere. const extraHosts = platform === "linux" ? ["host.docker.internal:host-gateway"] : []; const containerOpts: ContainerOpts = { projectId, @@ -271,9 +199,7 @@ export const buildLocalDbContainerInputs = ( }, dbUrl: values.dbUrl, jwtSecret: values.jwtSecret, - // Go's `initSchema15`'s realtime job resolves JWKS itself, LOCALLY, gated on - // `Realtime.Enabled` (`internal/db/start/start.go:337-341`) — `runFreshDbSetup` only - // evaluates this Effect when reached AND `realtimeEnabledForSetup`. + // Lazy: only evaluated when `runFreshDbSetup` reaches realtime setup and it's enabled. jwks: Effect.tryPromise({ try: () => resolveLocalJwks(config, workdir, values.jwtSecret, projectEnvValues, remoteOverrideKeys), diff --git a/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts b/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts index f2421a1498..12a7afe548 100644 --- a/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts +++ b/apps/cli/src/command-internal/db-bootstrap/local-db-running.ts @@ -18,7 +18,7 @@ export class LocalDbRunningError extends Data.TaggedError("LocalDbRunningError") readonly message: string; /** Classified at the container-runtime boundary; never inferred from `message` by telemetry. */ readonly daemonDown?: boolean; - /** Set when the failure is a daemon-connection error, mirroring `utils.CmdSuggestion`. */ + /** Set when the failure is a daemon-connection error. */ readonly suggestion?: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -41,29 +41,10 @@ const decodeChunks = (chunks: ReadonlyArray): string => { }; /** - * Port of Go's `utils.AssertSupabaseDbIsRunning` (`internal/utils/misc.go:144`): - * inspect the local Postgres container. Resolves `true` when it exists (the - * stack is up) and `false` when the container-CLI reports a missing container — - * Docker's "No such container"/"No such object" or Podman's own "no container with - * name or ID ... found" wording, via the shared `isContainerNotFoundMessage` - * matcher (`../container-cli.ts`) — Go's `ErrNotRunning`. Any other inspect - * failure (e.g. the Docker daemon is - * unreachable) fails with {@link LocalDbRunningError} instead of being - * treated as "not running", matching Go, which returns the wrapped inspect - * error rather than silently treating the database as stopped. - * - * Shared by `db start` (`commands/db/start/start.handler.ts`) and `db reset` - * (`commands/db/reset/reset.handler.ts`) — hoisted out of the `db __db-bootstrap` - * Go seam by CLI-1954, since this check was already a native TS `docker container - * inspect`, not a Go subprocess call. CLI-1955 later removed the rest of that seam - * too (`db reset`'s container-recreate + storage-health-gate primitives are now - * native — `recreate-local-database.ts`/`await-storage-ready.ts`), so the seam - * itself no longer exists at all. - * - * `resolveDbToml` mirrors the seam's own best-effort read: the caller has - * already run Go's `LoadConfig` validation before reaching this check, so here - * we only want the resolved `projectId` and tolerate falling back to the - * workdir basename on an unreadable `.env` rather than re-throwing. + * Inspects the local Postgres container. Resolves `true` when it exists and `false` when the + * container-CLI reports the container is missing (recognizing both Docker's and Podman's + * not-found wording). Any other inspect failure (e.g. an unreachable Docker daemon) fails with + * {@link LocalDbRunningError} rather than being treated as "not running". */ export function isLocalDbRunning( spawner: Spawner, @@ -74,11 +55,8 @@ export function isLocalDbRunning( ): Effect.Effect { return Effect.scoped( Effect.gen(function* () { - // `warnOnUnresolvedEnv: false` — this doc comment's own `resolveDbToml` note: - // the caller has already run Go's `LoadConfig` validation (and, if the config - // has an OrioleDB project with an unresolved S3 `env(VAR)`, already printed - // Go's single `assertEnvLoaded` WARN) before reaching this probe. Re-printing - // it here would diverge from Go's exactly-once `flags.LoadConfig` call. + // Config was already validated (and any unresolved-env WARN already printed) by the + // caller; only the resolved projectId matters here. const tomlProjectId = yield* readDbToml(fs, path, workdir, undefined, { validate: false, warnOnUnresolvedEnv: false, @@ -120,19 +98,11 @@ export function isLocalDbRunning( Effect.map(Number), Effect.mapError(() => new LocalDbRunningError({ message: "failed to inspect service" })), ); - if (inspectExit === 0) return true; // container exists ⇒ running + if (inspectExit === 0) return true; const stderr = decodeChunks(stderrChunks).trim(); - // Only a missing container means "not running". Any other inspect - // failure propagates, matching Go's `AssertSupabaseDbIsRunning`. Uses the - // shared, Podman-aware matcher (`isContainerNotFoundMessage`) rather than a - // Docker-only substring check, since `spawnContainerCli` above falls back to Podman - // on Docker-less hosts, and Podman's inspect-miss wording ("no container with name - // or ID ... found: no such container") differs from Docker's. if (!isContainerNotFoundMessage(stderr)) { - // Go's `AssertServiceIsRunning` sets `CmdSuggestion = suggestDockerInstall` - // on a daemon-connection failure (`misc.go:148-154`), so a down daemon - // still surfaces the actionable Docker Desktop hint, not just raw stderr. + // Surface the Docker install hint instead of raw stderr when the daemon is unreachable. const daemonDown = isDockerDaemonUnreachable(stderr); return yield* Effect.fail( new LocalDbRunningError({ diff --git a/apps/cli/src/command-internal/db-bootstrap/messages.ts b/apps/cli/src/command-internal/db-bootstrap/messages.ts index 8913ff3653..5da0dc466e 100644 --- a/apps/cli/src/command-internal/db-bootstrap/messages.ts +++ b/apps/cli/src/command-internal/db-bootstrap/messages.ts @@ -1,31 +1,14 @@ /** - * Pure text formatters for the Postgres container bring-up's stderr progress lines — shared by - * `supabase start` and `db start`'s native container bootstrap. Hoisted here (was defined in - * `commands/start/start.format.ts`, which still holds every OTHER `start`-only progress/status - * message) once `db start`'s own bootstrap became a second caller — see - * `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" rule. + * Stderr progress messages for the Postgres container bootstrap, shared by `supabase start` and + * `db start`. */ -/** - * Go's `fmt.Fprintln(w, "Starting database...")` - * (`apps/cli-go/internal/db/start/start.go:165-175`) — printed right before - * the Postgres container itself is created/started, when the pre-create - * volume-existence check finds no existing volume (a brand-new, first-ever - * start). - */ +/** Printed when no existing volume is found (a brand-new, first-ever start). */ export const START_STARTING_DATABASE_MESSAGE = "Starting database...\n"; /** - * Go's `fmt.Fprintln(w, "Starting database from backup...")` - * (`apps/cli-go/internal/db/start/start.go:165-175`) — printed instead of - * {@link START_STARTING_DATABASE_MESSAGE} when the pre-create - * volume-existence check finds an EXISTING volume (a restart reusing the - * already-persisted Postgres data). Despite the wording, this has nothing to - * do with any `--from-backup` file-restore flag — Go's own `fromBackup` - * parameter is always empty for a plain `supabase start`, so this is the only - * branch that command path ever reaches; `db start` DOES have a real - * `--from-backup` flag, but this message is still only about the - * volume-already-exists case, not that flag (see `db/start/start.handler.ts`'s - * own message-selection logic). + * Printed instead of {@link START_STARTING_DATABASE_MESSAGE} when an existing volume is reused. + * Despite the wording, this is unrelated to the `--from-backup` flag; it only reflects that the + * volume already exists. */ export const START_STARTING_DATABASE_FROM_BACKUP_MESSAGE = "Starting database from backup...\n"; diff --git a/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts b/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts index e800aa83f8..e97345490d 100644 --- a/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts +++ b/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts @@ -1,31 +1,17 @@ /** - * Generic PGDATA snapshot/restore primitives — container-agnostic on purpose. `shadow-cache.ts` - * is the only caller today, but nothing here assumes "shadow": these are the building blocks for - * savepointing ANY local Postgres container and restoring it into a fresh one — the shadow's - * disk-level baseline cache today, a future save/restore for the long-running - * `supabase_db_` stack container tomorrow. + * Generic PGDATA snapshot/restore primitives, container-agnostic: `shadow-cache.ts` is the only + * caller today, but these are reusable building blocks for savepointing any local Postgres + * container. * - * **Coherence contract:** the container must be STOPPED before {@link exportPgDataTar} - * runs — a snapshot of a running Postgres's data directory is not a consistent thing to copy. - * Callers own the stop/start around the export; this module only moves bytes. + * The container must be stopped before {@link exportPgDataTar} runs — a snapshot of a running + * Postgres's data directory is not a consistent thing to copy. Callers own the stop/start. * - * TODO(hot-save): the STOPPED contract could generalize to a consistency MODE. `frozen` — - * `docker pause` → copy → `docker unpause` — yields a crash-consistent copy (connections stall - * ~1s instead of dropping; restore boots through normal WAL recovery). `online` — - * `pg_backup_start()` → fuzzy copy → `pg_backup_stop()`, writing the returned `backup_label` - * into the artifact — is the zero-stall, Postgres-native form, and the ONLY one that also works - * for a future NATIVE (non-container) Postgres process, where no freezer exists and recovery - * replays the backup-labeled WAL range instead. Neither is worth the surface for the shadow - * cache (its export runs once per key on an already-cold path); implement when a live-stack - * savepoint feature needs to export without downtime. + * TODO(hot-save): support `frozen` (`docker pause`/copy/unpause) and `online` + * (`pg_backup_start`/`pg_backup_stop`) consistency modes for a live-stack savepoint feature that + * can't afford downtime. * - * **Ownership caveat:** the restore side ({@link pgDataRestoreArchive}) MUST be delivered as - * a tar stream unpacked via `docker cp - :`, never a directory copy — a directory copy - * resets ownership to the extracting user (root) and Postgres refuses to start on a data directory - * it does not own, whereas the tar-stream form preserves each member's uid/gid verbatim. - * - * The artifact is a plain file, so it fits a future NATIVE (non-Docker) Postgres service just as - * well — nothing about the format is container-specific. + * {@link pgDataRestoreArchive} must be delivered as a tar stream via `docker cp -`, never a + * directory copy — a directory copy resets file ownership and Postgres refuses to start. */ import { Effect, Option, Stream, type FileSystem } from "effect"; @@ -38,39 +24,29 @@ import type { StartContainerSpec } from "./docker-create-args.ts"; type Spawner = ChildProcessSpawner["Service"]; /** - * `PGDATA` in every `supabase/postgres` image — the directory {@link exportPgDataTar} - * exports and {@link pgDataRestoreArchive} restores. Hardcoded rather than read from the - * container's own `PGDATA` env: the entrypoint scripts this codebase generates - * (`postgres.service.ts`) never override it, and the value is part of the tar's own layout, so a - * mismatch has to be a deliberate change here. + * PGDATA path inside every `supabase/postgres` image. Hardcoded rather than read from the + * container's own `PGDATA` env, since the tar layout depends on this exact value. */ export const PGDATA_PATH = "/var/lib/postgresql/data"; /** - * `docker cp - :` unpacks the archive's members RELATIVE to `dest`, and - * {@link PGDATA_PATH}'s export tar has `data/` as its own top-level member, so the restore - * target is PGDATA's parent. A POSIX constant, not `path.dirname` — this is a container path and - * must not follow the host's separator. + * `docker cp` unpacks an archive's members relative to `dest`, and the export tar has `data/` as + * its own top-level member, so the restore target must be PGDATA's parent. A POSIX constant, not + * `path.dirname`, since this is a container path. */ export const PGDATA_PARENT_PATH = "/var/lib/postgresql"; -// --------------------------------------------------------------------------- -// What a valid snapshot must contain -// --------------------------------------------------------------------------- - /** * PGDATA's own directory name — `docker cp : -` names its members after the source - * BASENAME, so `data/` is the export tar's top-level entry (see {@link PGDATA_PARENT_PATH}). + * basename, so `data/` is the export tar's top-level entry. */ const PGDATA_DIR_NAME = PGDATA_PATH.slice(PGDATA_PARENT_PATH.length + 1); /** - * The entry whose presence proves an archive really carries an exported cluster: every Postgres - * data directory has a `PG_VERSION` file at its root, and `initdb` writes it first. An archive that - * unpacks cleanly but lacks it is the corruption a restore cannot otherwise notice. - * - * On its own it proves only "*a* PostgreSQL cluster", which is strictly weaker than what a cache - * key promises — hence {@link PGDATA_BASELINE_MARKER_ENTRY}. + * Proves an archive carries an exported cluster: every Postgres data directory has a + * `PG_VERSION` file at its root, written first by `initdb`. On its own this only proves "a + * PostgreSQL cluster", weaker than what a cache key promises — see + * {@link PGDATA_BASELINE_MARKER_ENTRY}. */ export const PGDATA_CLUSTER_ENTRY = `${PGDATA_DIR_NAME}/PG_VERSION`; diff --git a/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.unit.test.ts index 076d6c49f6..dc8792cc32 100644 --- a/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.unit.test.ts @@ -1,8 +1,7 @@ /** - * The pure tar-header walk behind {@link validatePgDataArchive}'s pre-restore check. - * Unit tests rather than integration ones because the interesting cases are all in the format - * handling: chunk boundaries landing mid-header (or mid-MARKER-CONTENT), content that must be - * stepped over rather than parsed, and bytes that are not a tar at all. + * The pure tar-header walk behind {@link validatePgDataArchive}'s pre-restore check. Unit + * tests, since the interesting cases are format handling: chunk boundaries landing mid-header or + * mid-marker-content, content stepped over rather than parsed, and non-tar bytes. */ import { describe, expect, it } from "@effect/vitest"; diff --git a/apps/cli/src/command-internal/db-bootstrap/pinned-image.ts b/apps/cli/src/command-internal/db-bootstrap/pinned-image.ts index 78b6fdcef9..521c67a6fe 100644 --- a/apps/cli/src/command-internal/db-bootstrap/pinned-image.ts +++ b/apps/cli/src/command-internal/db-bootstrap/pinned-image.ts @@ -6,21 +6,11 @@ import type { } from "../../shared/services/services.shared.ts"; /** - * The embedded Dockerfile default image for `alias`, with its tag replaced by - * `serviceVersions`' pin for `localServiceName` when one is present — Go's - * `Config.Load` rewriting `c.Auth.Image`/etc. from `supabase/.temp/*-version` - * (`pkg/config/config.go:827-863`) before `start`/`db start` ever read them. - * Reused by `start.gates.ts`'s own `resolveStartImagePlan` AND by both - * `supabase start` and `db start`'s fresh-DB one-shot setup jobs - * (`realtime`/`storage`/`auth`), which Go runs regardless of `--exclude` and - * therefore can't go through `resolveStartImagePlan`'s gate-filtered - * plan — hoisted here (was defined directly in `start.gates.ts`) once `db - * start`'s own native container bootstrap became a second caller across the - * `start`/`db` family boundary, see `apps/cli/CLAUDE.md`'s "Hoist Before You - * Duplicate" rule. + * Resolves the image for `alias`, replacing its tag with `serviceVersions`' pin for + * `localServiceName` when one is present. * - * Slim-translate only the current Dockerfile pin. A historical `.temp` pin - * stays on docker.io — those slim tags are not published. + * Only the current Dockerfile pin is slim-translated; a historical `.temp` pin stays on + * docker.io since those slim tags aren't published. */ export function resolvePinnedImage( alias: string, diff --git a/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts b/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts index 83a62268a5..9b19fa3793 100644 --- a/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts +++ b/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts @@ -1,23 +1,11 @@ /** - * Port of Go's `NewContainerConfig`/`NewHostConfig` - * (`apps/cli-go/internal/db/start/start.go:63-131`), plus `StartDatabase`'s - * `fromBackup` entrypoint/bind override (`start.go:143-164`): builds the - * {@link StartContainerSpec} for both `supabase start`'s Postgres - * container (always `fromBackup: undefined`, matching `apps/cli-go/internal/ - * start/start.go:295`'s always-empty `fromBackup` call) and `db start`'s own - * native container bootstrap, which is the only real caller of the - * `fromBackup` branch. Below, a bare `start.go:NNN` means `internal/db/start/ - * start.go` (still live) unless prefixed `internal/start/`, which is the - * deleted, unreachable `internal/start/start.go` (CLI-1966; last present at - * commit a253ccba25c21356ccd33044c4474aecb77d1ae4) -- see `../SIDE_EFFECTS.md`. + * Builds the {@link StartContainerSpec} for the Postgres container both `supabase start` and + * `db start`'s native container bootstrap use, including `db start`'s `fromBackup` + * entrypoint/bind override. * - * Deliberately out of scope, per the approved start-port plan: - * - `SetupLocalDatabase` (initial schema bootstrap, `start.go:184-187`) — an - * explicit follow-up, not container construction. - * - Actually creating/starting the container and waiting for it to become - * healthy — that's {@link createContainer} (`./container-lifecycle.ts`) - * and {@link waitForHealthyServices} (`./health-check.ts`), wired - * up by each caller's own handler. + * Out of scope: initial schema bootstrap, and actually creating/starting the container and + * waiting for it to become healthy — see {@link createContainer} and + * {@link waitForHealthyServices}, wired up by each caller's own handler. */ import type { CliConfig } from "@supabase/config"; @@ -32,21 +20,15 @@ import { START_DB_SCHEMA_SQL } from "./templates/db-schema.sql.ts"; import { START_DB_SUPABASE_SQL } from "./templates/db-supabase.sql.ts"; import { START_DB_WEBHOOK_SQL } from "./templates/db-webhook.sql.ts"; -/** Go's `Db.Password` default (`pkg/config/config.go:459`). In Go this is the only - * value the field can ever hold on a db path: viper decodes with the `json` tag - * (`config.go:749-750`), and `json:"-"` (`pkg/config/db.go:88`) both blocks the - * `SUPABASE_DB_PASSWORD` env binding and makes a literal `[db] password` key a fatal - * `UnmarshalExact` error (`'db' has invalid keys: password`). The TS port honors the - * toml key as a deliberate extension — see `buildShadowPostgresContainerSpec`'s - * `password` field below. Matches `DEFAULT_DB_PASSWORD` in - * `local-config-values.ts`, not imported from there since that constant - * isn't exported and status/stop's resolver is otherwise unrelated to this module. */ +/** + * The default database password. `[db] password` in config.toml is a TS-only extension — see + * `buildShadowPostgresContainerSpec`'s `password` field below. + */ const POSTGRES_PASSWORD = "postgres"; /** - * The exact in-container path Go's PG >= 15 entrypoint heredocs the pgsodium - * root key to (`start.go:96`) — now a `secretFiles` `docker cp` target instead - * (see {@link buildPostgresStartContainerSpec}), not a heredoc. + * In-container path for the pgsodium root key, delivered via + * {@link buildPostgresStartContainerSpec}'s `secretFiles` docker cp rather than a heredoc. */ const POSTGRES_PGSODIUM_ROOT_KEY_PATH = "/etc/postgresql-custom/pgsodium_root.key"; @@ -58,7 +40,7 @@ const POSTGRES_PGSODIUM_ROOT_KEY_PATH = "/etc/postgresql-custom/pgsodium_root.ke */ const POSTGRES_SCHEMA_SQL_PATH = "/etc/postgresql.schema.sql"; -/** Go's `container.HealthConfig` literals (`apps/cli-go/internal/db/start/start.go:85-90`). */ +/** Healthcheck timing constants. */ const POSTGRES_HEALTHCHECK_INTERVAL_SECONDS = 10; const POSTGRES_HEALTHCHECK_TIMEOUT_SECONDS = 2; const POSTGRES_HEALTHCHECK_RETRIES = 3; @@ -75,84 +57,53 @@ const POSTGRES_HEALTHCHECK_TEST: ReadonlyArray = [ "5432", ]; -/** Go's `utils.DbAliases` (`apps/cli-go/internal/utils/config.go:36`). */ +/** Docker network aliases for the Postgres container. */ const POSTGRES_NETWORK_ALIASES: ReadonlyArray = ["db", "db.supabase.internal"]; -/** Go's version-compare threshold (`apps/cli-go/internal/db/start/start.go:79`). */ +/** Version threshold below which `POSTGRES_INITDB_ARGS` gets a `--lc-collate=C.UTF-8` override. */ const POSTGRES_INITDB_VERSION_THRESHOLD = "15.8.1.005"; const POSTGRES_CONFIG_HEADER = "\n# supabase [db.settings] configuration\n"; export interface PostgresStartServiceInput { - /** Decoded `[db]` section — every field this builder needs (`port`, `major_version`, `settings`) lives here. */ + /** Decoded `[db]` section: `port`, `major_version`, and `settings`. */ readonly db: CliConfig["db"]; /** Decoded `[experimental]` section — only the OrioleDB/S3 fields are read. */ readonly experimental: CliConfig["experimental"]; - /** Already-resolved (default-or-configured, decrypted) `auth.jwt_secret` — same shape `resolveLocalConfigValues` produces. */ + /** Resolved `auth.jwt_secret`, as produced by `resolveLocalConfigValues`. */ readonly jwtSecret: string; /** `config.auth.jwt_expiry`. */ readonly jwtExpiry: number; - /** Go's `Config.ProjectId`, already sanitized — see `serviceContainerName`'s doc comment. */ + /** Already sanitized — see `serviceContainerName`'s doc comment. */ readonly projectId: string; - /** `utils.NetId` — the local stack's docker network id. */ + /** The local stack's Docker network id. */ readonly networkId: string; - /** `utils.Config.Db.Image`, already resolved/pulled (see `./image-prepull.ts`) — the container's own image. */ + /** Already resolved/pulled (see `./image-prepull.ts`) — the container's own image. */ readonly image: string; /** - * `utils.Config.Db.Image` BEFORE registry resolution — Go's - * `POSTGRES_INITDB_ARGS` version-tag comparison (`start.go:79`) always runs - * against this un-rewritten value; the registry candidate only ever - * overwrites the container's `Image` field, afterward, inside `DockerStart` - * (`docker.go:365,371`). Passed separately from {@link image} because a - * `SUPABASE_INTERNAL_IMAGE_REGISTRY` override containing a port (e.g. - * `localhost:5000`) would otherwise inject an extra colon that breaks - * {@link postgresImageVersionTag}'s first-colon tag split. + * `image` before registry resolution: the version-tag comparison in + * {@link postgresImageVersionTag} always runs against this un-rewritten value, since a + * `SUPABASE_INTERNAL_IMAGE_REGISTRY` override containing a port would otherwise inject an + * extra colon that breaks the tag split. */ readonly configImage: string; - /** Already-resolved `db.root_key` value. Defaults to {@link POSTGRES_DEFAULT_ROOT_KEY} when omitted — see that constant's doc comment for why. */ + /** Already-resolved `db.root_key`. Defaults to {@link POSTGRES_DEFAULT_ROOT_KEY} when omitted. */ readonly rootKey?: string; /** * Absolute host path to a `--from-backup` logical-dump file, already resolved against the - * caller's cwd (Go's `filepath.Join(utils.CurrentDirAbs, fromBackup)`, `start.go:160-161`) — - * `db start`'s ONLY caller. When set, switches to a THIRD entrypoint variant - * ({@link postgresEntrypointScriptRestore}) regardless of `db.major_version` (Go's - * `StartDatabase` override applies unconditionally, `start.go:143-159`) and appends the - * `:/etc/backup.sql:ro` bind Go's own `StartDatabase` appends - * (`start.go:163`, via `utils.ToDockerPath` — {@link toDockerMountPath} here). `undefined` for - * `supabase start`, which always calls `StartDatabase` with an empty `fromBackup` - * (`apps/cli-go/internal/start/start.go:295`, deleted in CLI-1966; last present at - * commit a253ccba2). + * caller's cwd. `db start`'s only caller. When set, switches to + * {@link postgresEntrypointScriptRestore} regardless of `db.major_version` and appends a + * `:/etc/backup.sql:ro` bind. `undefined` for `supabase start`. */ readonly fromBackup?: string; } /** - * Port of Go's `(a *settings) ToPostgresConfig()` - * (`apps/cli-go/pkg/config/db.go:181-190`): serializes `db.settings` as TOML — - * only the fields actually set, matching Go's nil-pointer fields never being - * written — replaces every `"` with `'`, and prepends the fixed header - * comment. - * - * Reuses the shared {@link encodeToml} (`go-output.encoders.ts`, backed - * by `smol-toml`) for the actual line rendering: `smol-toml`'s `stringifyTable` - * already skips `undefined`/`null` values exactly like Go's TOML encoder - * (`github.com/BurntSushi/toml`'s `eStruct`) skips nil pointers — verified - * against that library's source, which omits a nil field unconditionally, with - * no `omitempty` tag required — and its integer/string/boolean formatting - * already matches Go's (unquoted numbers/bools, double-quoted strings, single- - * quoted here afterward). The one divergence: `smol-toml`'s `stringify` always - * appends a trailing `\n`, even for an empty object (`stringify({})` → - * `"\n"`), whereas Go's `ToTomlBytes` of an all-nil-pointer struct returns the - * empty string (`TestSettingsToPostgresConfig`'s "Empty settings should - * result in empty string" case) — so the empty-settings case is special-cased - * below instead of delegated to `encodeToml`. + * Serializes `db.settings` as TOML: only the fields actually set, with `"` replaced by `'`, and + * the fixed header comment prepended. * - * `settings` itself is typed optional (`CliConfig["db"]["settings"]` - * includes `undefined`) because `db.ts` wraps the whole `[db.settings]` table - * in `Schema.optionalKey` — in practice the schema's own `withDecodingDefaultKey` - * always fills in `{}` when the section is absent, but this stays defensive - * against the static type either way, matching Go's `settings` being a plain - * (never-nil) struct value. + * The empty-settings case is special-cased rather than delegated to {@link encodeToml}, since + * `encodeToml` always appends a trailing newline even for an empty object. */ export function postgresSettingsToPostgresConfig(settings: CliConfig["db"]["settings"]): string { const defined = Object.fromEntries( diff --git a/apps/cli/src/command-internal/db-bootstrap/realtime-env.ts b/apps/cli/src/command-internal/db-bootstrap/realtime-env.ts index 2f8b148a9b..03b7b13a9f 100644 --- a/apps/cli/src/command-internal/db-bootstrap/realtime-env.ts +++ b/apps/cli/src/command-internal/db-bootstrap/realtime-env.ts @@ -1,64 +1,41 @@ /** - * Realtime's env-var literal (Go's `start.go:909-929` `Env` slice) — shared by TWO Go call - * sites, and therefore two TS ports: the long-running Realtime container - * (`commands/start/services/realtime.service.ts`'s `buildRealtimeContainerSpec`) and the - * PG15+ fresh-DB one-shot `initRealtimeJob` (`db-setup.ts`'s `startInitSchema15`, run by - * BOTH `supabase start` and `db start`). Hoisted here (was defined directly in - * `realtime.service.ts`, which still re-exports {@link REALTIME_TENANT_ID}/ - * {@link buildRealtimeEnv} for its own existing callers) once `db-setup.ts` itself moved - * to `command-internal/` and needed this reachable across the `start`/`db` family boundary — see - * `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" rule. + * Realtime's environment variables, shared by the long-running Realtime container + * (`buildRealtimeContainerSpec`) and the fresh-DB one-shot init job run by both + * `supabase start` and `db start`. */ import type { CliConfig } from "@supabase/config"; import { START_INTERNAL_DB_NAME, START_INTERNAL_DB_PORT } from "./internal-db-connection.ts"; -/** - * Go's `utils.SUPERUSER_ROLE` (`apps/cli-go/internal/utils/connect.go:338`) — - * Realtime's fixed `DB_USER` (`start.go:913`). Unrelated to the per-service - * role each OTHER container's own DB connection string uses (PostgREST's - * `authenticator`, Storage's `supabase_storage_admin`), so it is not hoisted - * alongside `startInternalDbUrl`. - */ +/** Realtime's fixed `DB_USER`; other containers connect under a different, per-service role. */ export const REALTIME_DB_USER = "supabase_admin"; /** - * Go's `realtime.TenantId` default (`pkg/config/config.go:481`) — `toml:"-"` - * (`config.go:254`), so never configurable via `config.toml` or a - * `SUPABASE_*` override; always this literal. Exported: `kong.service.ts`'s - * `kong.yml` template needs this exact same value for its `RealtimeId` field - * (Go's `Config.Realtime.TenantId`, `start.go:492` — NOT Realtime's own - * container name/id, see that module's `realtimeTenantId` doc comment). + * Fixed tenant id, never configurable. Also used by `kong.service.ts`'s `kong.yml` template + * as `RealtimeId`. */ export const REALTIME_TENANT_ID = "realtime-dev"; -/** Go's `realtime.EncryptionKey` default (`pkg/config/config.go:482`) — `toml:"-"`, never configurable. */ +/** Fixed default, never configurable. */ export const REALTIME_ENCRYPTION_KEY = "supabaserealtime"; -/** Go's `realtime.SecretKeyBase` default (`pkg/config/config.go:483`) — `toml:"-"`, never configurable. */ +/** Fixed default, never configurable. */ const REALTIME_SECRET_KEY_BASE = "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG"; export interface RealtimeEnvInput { - /** `config.realtime.ip_version` — feeds `utils.ToRealtimeEnv` (`utils/config.go:209-214`). */ readonly ipVersion: CliConfig["realtime"]["ip_version"]; - /** `config.realtime.max_header_length`. */ readonly maxHeaderLength: CliConfig["realtime"]["max_header_length"]; - /** The `db` container's own Docker name (`serviceContainerName("db", projectId)`). */ + /** The `db` container's own Docker name. */ readonly dbHost: string; /** See {@link startInternalDbPassword}. */ readonly dbPassword: string; - /** `LocalConfigValues.jwtSecret` — feeds both `API_JWT_SECRET` and `METRICS_JWT_SECRET`. */ + /** Feeds both `API_JWT_SECRET` and `METRICS_JWT_SECRET`. */ readonly jwtSecret: string; - /** `resolveLocalJwks`'s resolved JWKS JSON string — feeds `API_JWT_JWKS`. */ + /** The resolved JWKS JSON string; feeds `API_JWT_JWKS`. */ readonly jwks: string; } -/** - * Pure env-var builder, split out from `buildRealtimeContainerSpec` - * so the full Go `Env` literal (`start.go:909-929`) is unit-testable without - * constructing a whole container spec. - */ export function buildRealtimeEnv(input: RealtimeEnvInput): Record { return { PORT: "4000", @@ -75,7 +52,7 @@ export function buildRealtimeEnv(input: RealtimeEnvInput): Record= 15** (`resetDatabase15`, `reset.go:114-142`): - * 1. `docker container rm -f ` — NOT tolerant of "not found" (a genuine - * remove failure is a hard `failed to remove container`), unlike most other - * container lookups in this codebase. - * 2. `docker volume rm -f ` — tolerant of "not found" via the `-f` flag - * ITSELF (verified against a real Docker daemon: `force` makes a missing - * volume's removal a no-op), so no special-casing is needed here either. - * 3. `ensureNetwork` (Go's `DockerStart` always ensures the network - * exists, on every call — this is hoisted out of `createContainer` for - * the SAME reason `start-database.ts` hoists it). - * 4. `Recreating database...\n` to stderr (NOT `Starting database...`). - * 5. Build + create + start the Postgres container (byte-identical inputs to - * `startDatabase`'s own — there is no `fromBackup` variant on this - * path, reset has no restore concept) via the same - * `buildPostgresStartContainerSpec`/`createContainer`. - * 6. Health wait — NEVER swallowed (no `--from-backup`-equivalent gate here at - * all). - * 7. `startSetupLocalDatabase` — UNCONDITIONALLY (no fresh-volume gate: a - * reset just removed the volume, so it's always fresh) and with the - * RESOLVED reset `version`/`seedFlags` (not `""` like `db start`'s own call) - * — see `db-setup.ts`'s own header for this one genuine parameter - * difference between the shared function's two real Go callers. - * 8. `Restarting containers...\n` to stderr, then - * {@link restartServicesAndReloadKong} (`./restart-services.ts`). - * - * **PG <= 14** (`resetDatabase14`, `reset.go:96-112`): - * 1. `recreateDatabase` (`reset.go:157-176`) — connect as `supabase_admin` to - * `template1`, `DisconnectClients`, then four UNWRAPPED (no `BEGIN`/`COMMIT`) - * statements: `DROP`/`CREATE DATABASE postgres`, `DROP`/`CREATE DATABASE - * _supabase`. Go batches these via a pgconn protocol trick that has no TS - * equivalent — not needed here: none of the four can ever run inside a - * transaction anyway, so plain sequential `session.exec` calls, relying on - * Effect's own short-circuit-on-failure, reproduce Go's "stop at first - * error" behavior exactly (verified empirically against real Postgres 14/15 - * with the pinned pgconn/pgx versions — the batching trick is real, but its - * OBSERVABLE effect is identical to sequential execution for this - * particular statement set). - * 2. `initDatabase` (`reset.go:144-154`) — connect as `supabase_admin` to the - * default `postgres` database, then Go's EXPORTED `InitSchema14` (schema SQL - * ONLY, deliberately WITHOUT globals.sql — see `initSchema14`'s - * own doc comment for why this is NOT the same as `db start`'s PG<=14 path) - * + `ApplyApiPrivileges` (the exact same exported function `SetupDatabase` - * also calls), then pg_net activation when Database Webhooks is enabled. - * 3. `RestartDatabase` (`reset.go:214-225`) — `Restarting containers...\n` - * FIRST, then a REAL `docker restart` of the `db` container itself (NOT - * tolerant of "not found" — pg_cron must restart after - * `pg_terminate_backend`, Go's own comment), health wait (not swallowed), - * then {@link restartServicesAndReloadKong} — so Kong reload happens on - * the PG14 path too, after the db container restart. - * 4. Final connect as `postgres`/`postgres` → `apply.MigrateAndSeed` with the - * resolved reset `version`/`seedFlags` — the same seed-override logic PG15's - * `startSetupLocalDatabase` call applies. - * - * Deliberately absent, matching Go exactly: no volume-existence probe (a reset - * just removed the volume, so there is nothing to probe), no `NoBackupVolume`/ - * fresh-volume concept, no `fromBackup` handling at all, `initCurrentBranch` is - * NEVER called (Go's `resetDatabase`/`resetDatabase14`/`resetDatabase15` never - * call it), and no rollback on failure (Go's `cmd/db.go` only wraps `--mode - * start` in a `DockerRemoveAll` cleanup — the recreate dispatch has none). + * `db reset --local`'s container-recreate composition — not a thin wrapper over + * {@link startDatabase}, since the shared `StartDatabase`-equivalent primitive is never called + * from a reset either. Reuses the same underlying primitives (`ensureNetwork`, + * `buildPostgresStartContainerSpec`, `createContainer`, `waitForHealthyServices`, + * `startSetupLocalDatabase`) with a distinct sequence per major version: PG >= 15 recreates the + * container/volume/network and re-runs setup; PG <= 14 recreates the two databases in place and + * reinitializes the schema. Neither path probes for an existing volume, supports + * `--from-backup`, or rolls back on failure. */ import { Data, Effect, Result, Schedule, type FileSystem, type Path } from "effect"; @@ -138,13 +76,10 @@ const errMessage = (e: unknown): string => : String(e); /** - * One or more replication slots are still active (retryable — the WAL sender - * that owns the slot may still be tearing down), OR counting them failed - * outright (permanent — Go's `backoff.PermanentError`, `reset.go:204-206`: - * a query-execution failure is never retried, only "count > 0" is). Exported - * only so the exhaustive actionability guard can inspect its - * declaration; runtime callers discriminate it through the - * {@link RecreateLocalDatabaseError} union's `_tag`. + * One or more replication slots are still active (retryable — the WAL sender that owns the slot + * may still be tearing down), or counting them failed outright (permanent). Exported only so the + * exhaustive actionability guard can inspect its declaration; runtime callers discriminate it + * through the {@link RecreateLocalDatabaseError} union's `_tag`. */ export class ResetReplicationSlotsError extends Data.TaggedError("ResetReplicationSlotsError")<{ readonly message: string; @@ -159,7 +94,7 @@ export class ResetReplicationSlotsError extends Data.TaggedError("ResetReplicati /** Every failure the PG14/PG15 `db reset` recreate composition can produce. */ export type RecreateLocalDatabaseError = - // PG15 (`resetDatabase15`) + // PG15 | NetworkCreateError | ContainerRemoveError | VolumeRemoveError @@ -167,7 +102,7 @@ export type RecreateLocalDatabaseError = | ImagePrepullError | HealthCheckTimeoutError | StartSetupLocalDatabaseError - // PG14 (`resetDatabase14`) + // PG14 | DbConnectError | DbExecError | ResetReplicationSlotsError @@ -186,48 +121,37 @@ export interface RecreateLocalDatabaseInput { readonly projectId: string; readonly networkId: string; readonly hostname: string; - /** `localDbContainerId(projectId)` — also this composition's own volume name (Go: `utils.DbId` names both). */ + /** Also this composition's own volume name. */ readonly dbContainerId: string; readonly dbPort: number; readonly containerOpts: ContainerOpts; /** Fed straight to `buildPostgresStartContainerSpec` — reset has no `fromBackup` concept at all. */ readonly postgresSpec: Omit; - /** Lazy — evaluated right where Go's `DockerStart` would resolve it (PG15 path only). */ + /** Lazy: only resolved on the PG15 path. */ readonly resolvePostgresImage: Effect.Effect; readonly dbHealthTimeoutSeconds: number; /** The resolved reset migration version (`""` for every pending migration). */ readonly version: string; /** `db reset`'s `--no-seed`/`--sql-paths` — see {@link resolveResetSeedConfig}. */ readonly seedFlags: { readonly noSeed: boolean; readonly sqlPaths: ReadonlyArray }; - /** The exact same shape `start-database.ts`'s `StartDatabaseInput.setup` uses, since Go's `resetDatabase15` calls the SAME `SetupLocalDatabase` `db start` does — hoisted to {@link FreshDbSetupInput}. */ + /** The same shape `start-database.ts`'s `StartDatabaseInput.setup` uses — hoisted to {@link FreshDbSetupInput}. */ readonly setup: FreshDbSetupInput; } -/** Go's `pgerrcode.InvalidCatalogName` (`3D000`) — "database doesn't exist yet" on a first-ever reset. */ +/** Postgres SQLSTATE for "database doesn't exist yet", expected on a first-ever reset. */ const PG_INVALID_CATALOG_NAME = "3D000"; /** - * Port of Go's `DisconnectClients` (`reset.go:183-212`): disable new connections - * to `postgres`/`_supabase`, terminate existing backends, then wait for WAL - * senders to drop their replication slots (constant 1-second backoff, 10 - * retries max — Go's `NewBackoffPolicy(ctx, 10*time.Second)`). + * Disables new connections to `postgres`/`_supabase`, terminates existing backends, then waits + * for WAL senders to drop their replication slots (1-second backoff, 10 retries max). * - * Exported ONLY so `recreate-local-database.unit.test.ts` can pin the retry - * schedule's exact 10-retry boundary against a plain mocked {@link - * DbSession} (no real filesystem/Docker I/O), using the same `TestClock` - * + `Effect.forkChild` pattern as `db-bootstrap/await-storage-ready.unit.test.ts` — - * driving the full `dbReset` composite effect through a fake clock isn't - * reliable (its many REAL filesystem awaits race unpredictably against a - * virtual-time nudge issued from the outside), so this narrower, no-real-I/O - * entry point is the one actually worth pinning this way; the full retry-count - * behavior stays covered end-to-end by `reset.integration.test.ts`'s own - * `it.live` tests. Not otherwise used outside this module. + * Exported only so `recreate-local-database.unit.test.ts` can pin the retry schedule's exact + * boundary against a mocked {@link DbSession} with no real filesystem/Docker I/O; end-to-end + * retry behavior stays covered by `reset.integration.test.ts`'s `it.live` tests. */ export const resetDisconnectClients = Effect.fnUntraced(function* (session: DbSession) { - // Must be executed separately because looping in a transaction is unsupported - // (Go's own comment, `reset.go:184-185`) — sequential, unwrapped execs, relying on - // Effect's short-circuit-on-failure to stop at the first bad statement, exactly - // like pgconn's own batch-pipeline semantics would. + // Must run sequentially, unwrapped: looping these in a transaction is unsupported, and + // Effect's short-circuit-on-failure stops at the first bad statement. const disconnectResult = yield* Effect.forEach( [ "ALTER DATABASE postgres ALLOW_CONNECTIONS false", @@ -239,15 +163,10 @@ export const resetDisconnectClients = Effect.fnUntraced(function* (session: DbSe ).pipe(Effect.result); if (Result.isFailure(disconnectResult)) { const failure = disconnectResult.failure; - // Go: `if errors.As(err, &pgErr) && pgErr.Code != pgerrcode.InvalidCatalogName { return wrapped }` - // — surfaced ONLY for a genuine PgError whose code isn't 3D000. `failure.code` is NOT reliably - // only-ever-set-for-a-real-ErrorResponse: the driver layer's exec-error mapping - // (`toExecError`) falls back to `extractSqlState`, which can surface a bare node - // system errno (`ECONNRESET`, `ETIMEDOUT`, …) as `code` too — those are NOT SQLSTATEs, and - // `errors.As(err, &pgErr)` never matches a socket error in Go, so this must check - // `isSqlState` before treating `code` as a genuine PgError code. A non-PgError failure - // (network blip, no `code`, or a `code` that isn't a real SQLSTATE) AND a 3D000 PgError are - // BOTH silently swallowed. + // `failure.code` isn't reliably PgError-only: the driver's exec-error mapping falls back to + // a bare OS errno (`ECONNRESET`, `ETIMEDOUT`, ...) as `code` too, so `isSqlState` must gate + // treating it as a real Postgres error code. A non-PgError failure and a 3D000 PgError are + // both silently swallowed. if ( failure.code !== undefined && isSqlState(failure.code) && @@ -397,12 +316,8 @@ const recreateLocalDatabase15 = ( }); /** - * Port of Go's `resetDatabase14` (`reset.go:96-112`) — see this module's own - * header for the full sequence and citations. Loads `config.toml` once, ahead of - * `initDatabase` (needs `api.auto_expose_new_tables`) and the final - * `MigrateAndSeed` (needs `db.migrations.enabled`/`[db.seed]`/pg-delta gate) — - * the same "each caller re-loads its own config" duplication `db start`'s own - * handler and `startSetupLocalDatabase` both already take independently. + * Loads `config.toml` once, ahead of `initDatabase` (needs `api.auto_expose_new_tables`) and the + * final `MigrateAndSeed` (needs `db.migrations.enabled`/`[db.seed]`/pg-delta gate). */ const recreateLocalDatabase14 = ( spawner: Spawner, @@ -431,7 +346,6 @@ const recreateLocalDatabase14 = ( { isLocal: true, dnsResolver: "native" }, ); - // recreateDatabase: connect as `supabase_admin` to `template1`. yield* Effect.scoped( Effect.gen(function* () { const session = yield* connectAs("supabase_admin", "template1"); @@ -439,7 +353,6 @@ const recreateLocalDatabase14 = ( }), ); - // initDatabase: connect as `supabase_admin` to the default `postgres` database. yield* Effect.scoped( Effect.gen(function* () { const session = yield* connectAs("supabase_admin", "postgres"); diff --git a/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.unit.test.ts index e32bdd2c0b..bc1313b5eb 100644 --- a/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/recreate-local-database.unit.test.ts @@ -10,13 +10,9 @@ const COUNT_REPLICATION_SLOTS = "SELECT COUNT(*) FROM pg_replication_slots WHERE database IN ('postgres', '_supabase')"; /** - * A minimal {@link DbSession} mock built entirely from `Effect.succeed`/ - * `Effect.suspend` — no real filesystem/Docker I/O anywhere in the chain, unlike - * driving the full `dbReset` composite effect through `TestClock`. That's - * what makes the boundary tests below reliable: `resetDisconnectClients` - * reaches its retry schedule's sleep on the very first synchronous pass, so a - * single `TestClock.adjust` per round always lands exactly where expected — - * see `resetDisconnectClients`'s own doc comment. + * A minimal {@link DbSession} mock with no real filesystem/Docker I/O, so + * `resetDisconnectClients` reaches its retry schedule's sleep on the first synchronous pass — + * a single `TestClock.adjust` per round always lands exactly where expected. */ function mockSession(opts: { readonly counts?: ReadonlyArray; @@ -79,10 +75,9 @@ describe("resetDisconnectClients", () => { for (let i = 0; i < 9; i++) { yield* TestClock.adjust("1 seconds"); } - // Not yet exhausted — 9 retries is one short of Go's hardcoded 10-retry cap. + // One retry short of the cap. expect(fiber.pollUnsafe()).toBeUndefined(); - // The 10th one-second backoff crosses the boundary. yield* TestClock.adjust("1 seconds"); const exit = yield* Fiber.await(fiber); expect(Exit.isFailure(exit)).toBe(true); @@ -97,7 +92,6 @@ describe("resetDisconnectClients", () => { const mock = mockSession({ queryFails: true }); const exit = yield* resetDisconnectClients(mock.session).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - // A single attempt — the permanent (non-retryable) failure never retries. expect(mock.queries.filter((sql) => sql === COUNT_REPLICATION_SLOTS)).toHaveLength(1); }), ); diff --git a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts index 059675db08..2b686343b2 100644 --- a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts @@ -1,39 +1,12 @@ /** - * A plain, full local-database reset — Go's `reset.Run(ctx, "", 0, flags.DbConfig, fsys)` - * called against the local target (`internal/db/reset/reset.go:57-77`), with an EMPTY - * version and NO `--last` filtering. Hoisted out of `commands/db/reset/reset.handler.ts`'s - * own `cfg.isLocal` branch (CLI-1955) so it is callable in-process by any Effect context - * that provides the services below (CLI-2062) — the two `db schema declarative` - * call sites (`declarative.smart-target.ts`'s local-reset prompt, - * `sync.handler.ts`'s failed-apply recovery reset) used to shell out to a SEPARATE - * `supabase-go` child process for this (`DeclarativeSeam.execInherit`), which is - * itself a divergence from real Go: Go's `db schema declarative`/`sync` call - * `reset.Run` as a plain in-process function, sharing the outer command's own - * `PersistentPostRun` (telemetry flush / linked-project-cache write) rather than firing - * a second, independent one from a child process's own `Execute()`. Calling this - * function in-process collapses back to that single-firing behavior. - * + * Resets the local database in-process — shared by `db reset`'s handler and the `db schema + * declarative`/`sync` local-reset paths, so neither needs to shell out to a separate process. * `db reset`'s own handler is the only caller that ever passes a non-empty - * `version`/`seedFlags` override (`--version`/`--last`/`--no-seed`/`--sql-paths`) — the - * declarative callers always want the plain full reset and call with no arguments. - * - * Resolves every service it needs (`DebugFlag`, `NetworkIdFlag`, - * `RuntimeInfo`, `ChildProcessSpawner`, `FileSystem`, `Path`, `CommandSettings`, the - * project `.env` + `resolveExperimentalWithProjectEnv` gate) itself via `yield*`, - * exactly like `dbReset` did inline before this extraction — so it is - * self-contained and does not need `DbResetFlags`/`CliArgs`/ - * `resolveDbTargetFlags` (the top-level `db reset` command's own flag-parsing - * concerns, which stay in `reset.handler.ts`). + * `version`/`seedFlags` override; the declarative callers always want the plain full reset. * - * Emits the exact same two stderr lines the removed `execInherit` subprocess used to - * produce via the Go child's inherited stdio (`Resetting local database...` / - * `Finished supabase db reset on branch .`) — always via `output.raw`, - * regardless of `output.format`, matching a child process's inherited stdio, which - * never receives `-o`/`--output-format` and always prints Go-native text. Deliberately - * does NOT emit the JSON `output.success(...)` envelope: that belongs to a real - * top-level `db reset` invocation only (`reset.handler.ts` emits it itself, after - * calling this function) — neither Go's in-process `reset.Run` nor the removed - * `execInherit` subprocess ever produced a machine-JSON envelope for this nested call. + * Always prints its own two stderr lines via `output.raw`, regardless of `output.format`, but + * never the JSON `output.success(...)` envelope — that belongs to a top-level `db reset` + * invocation only, emitted by its own handler after calling this function. */ import { Data, Effect, FileSystem, Option, Path } from "effect"; @@ -63,12 +36,8 @@ import { isLocalDbRunning } from "./local-db-running.ts"; import { recreateLocalDatabase } from "./recreate-local-database.ts"; /** - * The local database container is not running. Byte-matches Go's - * `utils.ErrNotRunning` (`internal/utils/misc.go:116`), `"supabase start - * is not running."`, returned by `AssertSupabaseDbIsRunning` before the local - * reset (`internal/db/reset/reset.go:57`). Exported only so the exhaustive - * actionability guard can inspect its declaration; runtime callers consume the - * enclosing effect rather than importing this class. + * The local database container is not running. Exported only so the exhaustive actionability + * guard can inspect its declaration; runtime callers consume the enclosing effect instead. */ class ResetLocalDbNotRunningError extends Data.TaggedError("ResetLocalDbNotRunningError")<{ readonly message: string; @@ -78,7 +47,7 @@ class ResetLocalDbNotRunningError extends Data.TaggedError("ResetLocalDbNotRunni } } -/** Go's `toLogMessage` (`internal/db/reset/reset.go:88-91`). */ +/** ` to version: X`, or `...` when resetting to the latest migration. */ const toLogMessage = (version: string): string => version.length > 0 ? ` to version: ${version}` : "..."; 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 f9c2085615..3571c0db88 100644 --- a/apps/cli/src/command-internal/db-bootstrap/restart-services.ts +++ b/apps/cli/src/command-internal/db-bootstrap/restart-services.ts @@ -1,12 +1,9 @@ /** - * Post-recreate satellite-container restart + Kong reload, shared by both PG14's - * `RestartDatabase` and PG15's `resetDatabase15` (`apps/cli-go/internal/db/reset/ - * reset.go:214-288`) — the ONLY two Go call sites of `restartServices`. Neither `db - * start` nor `supabase start` calls any of this: it exists purely to bring the - * satellite containers (storage/auth/realtime/pooler) back in sync with a `db` - * container that was just recreated or force-restarted out from under them, and to - * reload Kong's nginx so its cached upstream addresses (which may have changed if a - * satellite container came back on a different one) stop 502ing. + * Post-recreate satellite-container restart + Kong reload, shared by both PG14's and PG15's + * `db reset` paths. Neither `db start` nor `supabase start` calls this: it exists purely to + * bring the satellite containers (storage/auth/realtime/pooler) back in sync with a `db` + * container that was just recreated or force-restarted out from under them, and to reload Kong's + * nginx so its cached upstream addresses (which may have changed) stop 502ing. */ import { Data, Effect, Option, Result } from "effect"; @@ -40,13 +37,9 @@ export class ContainerRestartError extends Data.TaggedError("ContainerRestartErr } /** - * Port of Go's `Docker.ContainerRestart(ctx, utils.DbId, container.StopOptions{})` - * (`apps/cli-go/internal/db/reset/reset.go:218-220`), used ONLY by PG14's - * `RestartDatabase` to restart the `db` container itself after `pg_terminate_backend` - * (pg_cron must restart, per Go's own comment). Unlike the satellite restarts below, - * this one does NOT tolerate "not found" — Go's own `RestartDatabase` has no - * `errdefs.IsNotFound` guard on this call at all, so ANY failure is a hard - * `failed to restart container: %w`. + * Restarts the `db` container itself, used only by PG14's reset path after + * `pg_terminate_backend` (pg_cron must restart). Unlike the satellite restarts below, this does + * not tolerate "not found" — any failure is a hard error. */ export function restartContainer( spawner: Spawner, @@ -61,12 +54,10 @@ export function restartContainer( } /** - * One satellite service's restart, tolerant of "not found" (Go's `!errdefs.IsNotFound(err)` - * guard, `reset.go:231`) — a service excluded from the stack (e.g. `[realtime] enabled = - * false`) has no container to restart, and that's not an error. Never fails the surrounding - * `Effect.all` itself: resolves `Option.some(message)` on a genuine failure so the caller - * can join every service's outcome the way Go's `errors.Join(result...)` does, and - * `Option.none()` on success OR a tolerated not-found. + * One satellite service's restart, tolerant of "not found" — a service excluded from the stack + * has no container to restart, and that's not an error. Never fails the surrounding `Effect.all` + * itself: resolves `Option.some(message)` on a genuine failure so the caller can join every + * service's outcome, and `Option.none()` on success or a tolerated not-found. */ const restartSatelliteService = ( spawner: Spawner, @@ -153,7 +144,7 @@ function kongRecoverySuggestion(kongId: string): string { ); } -/** Kong could not be reloaded — fails the WHOLE command (unlike `functions serve`'s best-effort reload). */ +/** Kong could not be reloaded — fails the whole command (unlike `functions serve`'s best-effort reload). */ export class KongReloadError extends Data.TaggedError("KongReloadError")<{ readonly message: string; readonly suggestion: string; @@ -163,7 +154,7 @@ export class KongReloadError extends Data.TaggedError("KongReloadError")<{ } } -/** `docker exec `, combined stdout+stderr into one buffer — mirrors Go's shared `io.Writer` in `DockerExecOnceWithStream(ctx, KongId, "", nil, cmd, &out, &out)`. Never fails the Effect itself: a spawn failure (no docker/podman) folds into `exitCode: 1`. */ +/** `docker exec `, combined stdout+stderr into one buffer. Never fails the Effect itself: a spawn failure (no docker/podman) folds into `exitCode: 1`. */ function execCaptureCombined( spawner: Spawner, containerId: string, diff --git a/apps/cli/src/command-internal/db-bootstrap/restart-services.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/restart-services.unit.test.ts index 6f16b190f5..6507701372 100644 --- a/apps/cli/src/command-internal/db-bootstrap/restart-services.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/restart-services.unit.test.ts @@ -127,12 +127,9 @@ describe("restartServicesAndReloadKong", () => { restarted.push(args[1] ?? ""); inFlight++; if (inFlight === 4) yield* Deferred.succeed(barrier, undefined); - // Every one of the four restarts blocks here until ALL FOUR are in flight - // simultaneously (Go's `utils.WaitAll`, a goroutine per service — reset.go:259-271). - // If `restartSatelliteServices` ever regressed to a sequential restart (e.g. - // `concurrency: 1`), the second restart would never even be DISPATCHED until the - // first resolves, so `inFlight` would never reach 4 and this `await` would hang - // forever, timing out the test instead of silently passing. + // Each restart blocks here until all four are in flight simultaneously. A + // sequential restart would never dispatch the second call until the first resolves, + // so `inFlight` would never reach 4 and this await would hang forever. yield* Deferred.await(barrier); } else if (args[0] === "container" && args[1] === "inspect" && args[2] === KONG_ID) { // Kong excluded from the stack — skips the reload, keeping this test focused on diff --git a/apps/cli/src/command-internal/db-bootstrap/rollback.ts b/apps/cli/src/command-internal/db-bootstrap/rollback.ts index cfa63348c5..ecdd260287 100644 --- a/apps/cli/src/command-internal/db-bootstrap/rollback.ts +++ b/apps/cli/src/command-internal/db-bootstrap/rollback.ts @@ -8,90 +8,21 @@ import { HealthCheckTimeoutError } from "./health-check.ts"; type Spawner = ChildProcessSpawner["Service"]; -// Below, a bare `start.go:NNN` means the deleted, unreachable -// `internal/start/start.go` (CLI-1966; last present at commit -// a253ccba25c21356ccd33044c4474aecb77d1ae4) unless prefixed `internal/db/start/`, -// which is still live -- see `SIDE_EFFECTS.md`. - /** - * Port of Go's `start.IsUnhealthyError` (`apps/cli-go/internal/db/start/ - * start.go:227-231`): Go tests whether the failure unwraps as a joined - * multi-error (`Unwrap() []error`) — the shape `WaitForHealthyService` - * produces on timeout. This port's equivalent health-check-timeout failure is - * {@link HealthCheckTimeoutError} (`./health-check.ts`), so the - * classification collapses to an `instanceof` check against that one class — - * the caller (`start.handler.ts`) uses this to decide whether - * `--ignore-health-check` should downgrade a failure to a warning instead of - * triggering rollback + a hard exit. - * - * INTENTIONAL DIVERGENCE (CLI-1987, ruled 2026-07-30): Go applies - * `IsUnhealthyError` ONCE, at the outer `Run()` boundary, to whatever `run()` - * returns (`internal/start/start.go:74-75`) — and the shape-based check - * accidentally also matches `ensureImagesCached`'s `errors.Join(result...)` - * (`internal/start/start.go:257-260`). Under `--ignore-health-check`, Go - * therefore swallows a total image-pull failure (or a Docker daemon that - * becomes unreachable during the pre-pull): it prints the error, skips - * rollback, prints `Started supabase local development setup.` + the status - * table + the security notice, and exits 0 with no container running. That is - * an unintended quirk of the shape check, not designed behaviour — Go's own - * comment on `IsUnhealthyError` reads "Health check always returns a - * joinError". Per the CLI-1987 ruling, this port deliberately does NOT - * reproduce the quirk — and what enforces that is control flow, not this - * matcher: the port has no outer classifier check. `start.handler.ts` - * consults this function only inside its two health-wait failure branches, - * and the pre-pull (`ensureImagesCached`) runs before bring-up, so a - * `ImagePrepullError` (`./image-prepull.ts`) propagates straight out - * and always fails the command with exit 1, with or without the flag. - * Widening this match to accept `ImagePrepullError` would be a dead - * no-op — no pre-pull failure ever reaches a call site. The observable delta - * vs Go's swallowed path is exit code + success banner + status table + - * security notice (Go prints all of the latter three unconditionally at - * `Run()`'s tail, `start.go:84-87`; this port's failure exits before any of - * them). Rollback is not part of the divergence — the pre-pull runs before - * any container/network is created, so there is nothing to roll back in - * either CLI. Do not "fix" this toward Go by gating the pre-pull on - * `--ignore-health-check` or by reintroducing an outer shape check on the - * whole handler result. + * Matches only {@link HealthCheckTimeoutError}, not any pre-pull failure. The caller consults + * this only inside its health-wait failure branches, so `--ignore-health-check` never downgrades + * an `ImagePrepullError` — pre-pull failures always exit 1. Do not widen this to a general + * unhealthy-shape check or gate the pre-pull on the flag. */ export function isUnhealthyStartError(error: unknown): error is HealthCheckTimeoutError { return error instanceof HealthCheckTimeoutError; } /** - * Port of Go's rollback-on-failure step in `start.Run` - * (`apps/cli-go/internal/start/start.go:76-81`, deleted in CLI-1966; last - * present at commit a253ccba2): - * - * ```go - * if err := utils.DockerRemoveAll(context.Background(), os.Stderr, utils.Config.ProjectId); err != nil { - * fmt.Fprintln(os.Stderr, err) - * } - * ``` - * - * Tears down every container/volume/network the failed `start` run created - * (by project label) via {@link dockerRemoveAll}. A rollback failure is - * itself only logged to stderr, never propagated — it must never mask the - * original failure that triggered the rollback in the first place, matching - * Go's `fmt.Fprintln(os.Stderr, err)` swallow exactly. `deleteVolumes` mirrors - * Go's `utils.NoBackupVolume` global (set elsewhere, based on whether the - * Postgres volume already existed before this run) — the caller decides its - * value, this wrapper only forwards it to {@link dockerRemoveAll}. - * - * Also reclaims any {@link cleanupStartSecrets} staged-secret - * directories for the containers this run created — a TS-port-only hygiene - * step with no Go equivalent (see that function's doc comment). The matching - * containers come from {@link dockerRemoveAll}'s own - * `onContainersRemoved` hook, which fires only once `container prune` has - * CONFIRMED they're actually gone — not merely listed, and not before the - * stop/prune stages have run — from that function's single internal `docker - * ps` listing, never a second, separately listed call, which would cost an - * extra real Docker Engine API request Go never makes (see that function's - * doc comment for the parity rationale). `workdir` (this run's own - * `CommandSettings.workdir`) is passed through only as - * {@link cleanupStartSecrets}'s fallback — every container this same - * `start` run just created carries its own matching `CLI_WORKDIR_LABEL` - * (see `container-lifecycle.ts`), so the fallback path is only ever exercised - * by a container created before that label existed. + * Tears down every container/volume/network the failed run created (by project label) via + * {@link dockerRemoveAll}, and reclaims this run's staged-secret directories via + * {@link cleanupStartSecrets}. A rollback failure is only logged to stderr, never propagated — + * it must not mask the original failure that triggered the rollback. */ export const rollbackStart = ( spawner: Spawner, @@ -101,11 +32,7 @@ export const rollbackStart = ( debug: boolean, ): Effect.Effect => Effect.gen(function* () { - // Go's `DockerRemoveAll` prints "Stopping containers..." to the writer its - // caller passes (`internal/utils/docker.go:97`); the start-failure path - // passes `os.Stderr` (`internal/start/start.go:77`). The TS port moved that - // line out of `dockerRemoveAll` and into each caller (`stop` prints - // it to its own status writer), so the rollback path prints it here. + // `dockerRemoveAll` doesn't print this; each caller owns its own status writer. yield* Effect.sync(() => { globalThis.process.stderr.write("Stopping containers...\n"); }); 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 48ac206eee..0ec8a4c969 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 @@ -14,12 +14,8 @@ afterEach(() => { }); /** - * Mirrors `docker-lifecycle.unit.test.ts`'s `mockSpawner`: every - * command spawned (list/stop/prune) answers with the same fixed - * `exitCode`/`stdout`/`stderr`, which is enough to drive - * `dockerRemoveAll` through either its success path (empty container - * list, every prune exits 0) or its very first failure branch (a non-zero - * `docker ps` exit). + * Every spawned command answers with the same fixed `exitCode`/`stdout`/`stderr`, enough to + * drive `dockerRemoveAll` through either its success path or its first failure branch. */ function mockSpawner( opts: { @@ -75,15 +71,8 @@ describe("rollbackStart", () => { "/tmp/rollback-unit-test-workdir", false, ); - // Go's start-failure path prints "Stopping containers..." to stderr - // before tearing down (`docker.go:97` with `w == os.Stderr`, - // `start.go:77`); no other stderr output on success without --debug. expect(stderr).toHaveBeenCalledTimes(1); expect(stderr).toHaveBeenCalledWith("Stopping containers...\n"); - // dockerRemoveAll's own list (its `onContainersListed` hook feeds - // cleanupStartSecrets the same container names, no second `ps` - // call) -> container prune -> network prune; no stop calls (empty list) - // and no volume prune (deleteVolumes: false). expect(mock.spawned.map((args) => args[0])).toEqual(["ps", "container", "network"]); }); }); @@ -112,8 +101,6 @@ describe("rollbackStart", () => { const mock = mockSpawner({ exitCode: 1, stderr: "permission denied" }); const stderr = captureStderr(); return Effect.gen(function* () { - // Never fails — Effect.Effect — a rollback failure is - // logged, never propagated (Go's `fmt.Fprintln(os.Stderr, err)` swallow). yield* rollbackStart( mock.spawner, "com.supabase.cli.project=my-app", 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 a4fcf21953..a634a8bc2f 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 @@ -1,25 +1,19 @@ /** - * The shadow baseline cache's ONE subprocess scenario (golden path only, per the repo's e2e-scope - * policy): the SAME `db diff` invocation run TWICE against a real local stack must cold-publish a + * The shadow baseline cache's one subprocess scenario (golden path only): running the same + * `db diff` invocation twice against a real local stack must cold-publish a * `shadow-baseline-.tar` on the first run, warm-restore that exact tar on the second, and * produce byte-identical diff output either way. * - * A black-box `runSupabase` subprocess test, like the other local Docker-stack `*.e2e.test.ts` - * suites: the facts it is here to prove are the ones only the real wiring can — that `db diff` - * actually routes through `acquireShadowDatabase`, that the cache engages with - * `SUPABASE_SHADOW_CACHE` genuinely UNSET (the shipped default — the run removes the harness's - * isolation pin rather than opting in) and that - * `${SUPABASE_HOME}/cache/shadow-baseline` survives a real process boundary, that the cache key is STABLE across two - * separate CLI processes (an in-process test computes it once), and that a warm-restored cluster - * yields the same migration SQL as a cold-provisioned one. It replaces an earlier in-process - * version of this file that called `acquireShadowDatabase` directly with a synthetic layer - * graph — that shape could stay green while the `db diff` wiring, the env propagation, or the cache - * enablement was broken. + * Proves what only real process wiring can: that `db diff` actually routes through + * `acquireShadowDatabase`, that the cache engages with `SUPABASE_SHADOW_CACHE` genuinely unset + * (the shipped default), that the cache directory survives a real process boundary, that the + * cache key is stable across two separate CLI processes, and that a warm-restored cluster yields + * the same migration SQL as a cold-provisioned one. * - * The acquire/export/restore MECHANICS (cold export, warm restore, tar validation and rejection, + * The acquire/export/restore mechanics (cold export, warm restore, tar validation and rejection, * retention/LRU, cache-off and bypass paths) are covered exhaustively by - * `shadow-cache.integration.test.ts` against its in-test Docker model plus a real filesystem, and - * the pure key/retention logic by `shadow-cache.unit.test.ts`. Nothing branch-shaped belongs here. + * `shadow-cache.integration.test.ts`, and the pure key/retention logic by + * `shadow-cache.unit.test.ts`. Nothing branch-shaped belongs here. */ import { mkdtemp, readdir, rm, stat } from "node:fs/promises"; @@ -33,29 +27,18 @@ const CLEANUP_TIMEOUT_MS = 120_000; const START_TIMEOUT_MS = 280_000; const DIFF_TIMEOUT_MS = 180_000; -// One full `start` plus the cold/warm `db diff` pair, with lifecycle overhead for `init`, the -// filesystem inspection between runs, and the fast-failing port-conflict retries below (a -// conflicting publish fails in `docker create`/`start`, i.e. seconds, never a whole -// `DIFF_TIMEOUT_MS`) — same "budget each subprocess separately" shape as `diff.declarative.e2e.test.ts`. +// One full `start` plus the cold/warm `db diff` pair, with lifecycle overhead for `init`, +// filesystem inspection, and fast-failing port-conflict retries. const LIFECYCLE_OVERHEAD_MS = 90_000; /** - * `db diff`'s shadow port, published on the host by the shadow container. Docker itself has to - * bind it, so a test CANNOT truly reserve it up front: binding a listener and releasing it proves - * nothing about the window between the release and the container's own bind. The honest mitigation - * is therefore two-part — pick ports far from the `[db] shadow_port` default (54320) that a stray - * local stack or a neighbouring suite would be holding, and retry the scenario on the next - * candidate when the CLI reports a real bind conflict. + * `db diff`'s shadow port. Docker has to bind it, so a test cannot truly reserve it up front — + * retry on the next candidate (derived from this process's pid, so concurrent runs don't race for + * one shared port) when the CLI reports a real bind conflict. * - * Fed through `SUPABASE_DB_SHADOW_PORT` (`db-config.toml-read.ts`'s `envOverride`) rather - * than by rewriting the generated `config.toml`, so the `init` template stays exactly as a user's - * would be. The port is deliberately NOT part of the cache key (see `shadowCacheKey`), so - * retrying on a different one cannot change which tar the run looks for. - * - * The candidate sequence is derived from this process's own pid, so two independently concurrent - * runs of this suite start from different bases instead of racing for one shared pair, and a - * locally-occupied port only costs one retry step. The base stays inside the IANA dynamic range - * (49152-65535) with room for every candidate below its ceiling. + * Fed through `SUPABASE_DB_SHADOW_PORT` rather than rewriting the generated `config.toml`, so the + * `init` template stays exactly as a user's would be. Not part of the cache key, so retrying on a + * different port cannot change which tar the run looks for. */ const SHADOW_PORT_CANDIDATE_COUNT = 8; const SHADOW_PORT_BASE = 49152 + ((process.pid * 37) % (16384 - SHADOW_PORT_CANDIDATE_COUNT)); @@ -69,11 +52,7 @@ const DIFF_ARGS = ["db", "diff", "--local", "--use-pg-delta"] as const; /** `shadow-cache.ts`'s published artifact name — `shadow-baseline-<16 hex key>.tar`. */ const BASELINE_TAR_PATTERN = /^shadow-baseline-[0-9a-f]{16}\.tar$/u; -/** - * Docker's own bind-conflict wording, as it reaches stderr through the shadow's - * `docker create`/`docker start` failure. Only used to decide whether to retry on another - * candidate port — never asserted on. - */ +/** Docker's own bind-conflict wording on stderr; only used to decide whether to retry. */ function isShadowPortConflict(stderr: string): boolean { return /port is already allocated|address already in use|Bind for \S+ failed/iu.test(stderr); } @@ -109,10 +88,8 @@ describe("shadow baseline cache (e2e, local Docker stack)", () => { { timeout: START_TIMEOUT_MS + 2 * DIFF_TIMEOUT_MS + LIFECYCLE_OVERHEAD_MS }, async () => { projectDir = await mkdtemp(path.join(tmpdir(), "sb-shadow-cache-e2e-")); - // One temp `SUPABASE_HOME` for every run in this test, so the two `db diff` processes share - // the global `${SUPABASE_HOME}/cache/shadow-baseline` directory the cache publishes into — - // `runSupabase` otherwise mints (and disposes) a fresh home per invocation, which would make - // every run a cold one. + // 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"); 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 4f56c54766..f5f41df03d 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 @@ -1,13 +1,11 @@ /** * The shadow baseline cache's acquire/export/restore flow, driven end to end against a tiny - * in-test Docker model (create/start/stop/rm all mutate the same container table, and `docker cp` - * really moves bytes in and out of it) plus the REAL filesystem under a per-test temp workdir, so - * the tar artifact, its atomic publish, and its retention rule are exercised for real. + * in-test Docker model plus the real filesystem under a per-test temp workdir, so the tar + * artifact, its atomic publish, and its retention rule are exercised for real. * - * Scenario-oriented on purpose: every test is a sequence of real acquires and releases, and the - * assertions are on the resulting Docker state, the tar on disk, and what the caller was told - * about the baseline — not on internal call ordering, except where the ordering IS the contract - * (the export must stop the container before copying and start it again afterwards). + * Scenario-oriented: assertions are on the resulting Docker state, the tar on disk, and what the + * caller was told about the baseline — not on internal call ordering, except where ordering is + * the contract (the export must stop the container before copying and start it again after). */ import { accessSync, chmodSync, constants } from "node:fs"; @@ -70,10 +68,6 @@ const withShadowCacheHome = ( withShadowCacheEnv(value, body), ); -// --------------------------------------------------------------------------- -// A fake Postgres the readiness probe can connect to -// --------------------------------------------------------------------------- - function fakeCluster(opts: { readonly failConnect?: boolean } = {}) { const connected: Array = []; const layer = Layer.succeed(DbConnection, { @@ -95,10 +89,6 @@ function fakeCluster(opts: { readonly failConnect?: boolean } = {}) { return { layer, connected }; } -// --------------------------------------------------------------------------- -// Inputs -// --------------------------------------------------------------------------- - const shadowSetup = (): ShadowDbSetupInput => ({ majorVersion: 17, config: defaultConfig, @@ -161,9 +151,8 @@ const soleTarName = Effect.fnUntraced(function* (fs: FileSystem.FileSystem, path const keyOf = (tarName: string) => tarName.slice("shadow-baseline-".length, -".tar".length); /** - * What a correct cold export publishes under `tarName`: the fake PGDATA archive carrying the - * baseline marker stamped with THAT filename's own key. Derived from the name rather than hardcoded - * so the assertion fails if the export ever stamps a different key than it publishes under. + * What a correct cold export publishes under `tarName`: the fake PGDATA archive with the baseline + * marker stamped with that filename's own key. */ const expectedTarFor = (tarName: string) => fakePgDataTar(pgDataBaselineMarkerContent(keyOf(tarName))); @@ -195,8 +184,8 @@ describe("acquireShadowDatabase", () => { const handle = yield* acquireShadowDatabase(docker.spawner, input); expect(handle.baselinePresent).toBe(false); - // `--rm` intact, no PGDATA copies either way, and the snapshot step is a no-op. The one - // `cp-secret` is the pgsodium root key every shadow has always been given. + // `--rm` intact, no PGDATA copies, and the snapshot step is a no-op. The one `cp-secret` + // is the pgsodium root key every shadow gets. expect(docker.calls("create")[0] ?? []).toContain("--rm"); yield* handle.snapshotBaseline; expect(docker.steps()).toEqual(["create", "cp-secret", "start"]); @@ -383,15 +372,15 @@ describe("acquireShadowDatabase", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - // Recursive mkdir on an EXISTING directory creates nothing and succeeds regardless of + // Recursive mkdir on an existing directory creates nothing and succeeds regardless of // permission, so the acquire's probe must check write access explicitly — otherwise a // 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); // chmod cannot revoke write access from a privileged user (root ignores permission - // bits), so mirror the workers-push suite's guard: assert the degrade only when the - // denial is real for the CURRENT user; otherwise the cached path proceeding is correct. + // 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); @@ -455,8 +444,8 @@ describe("acquireShadowDatabase", () => { yield* handle.snapshotBaseline; - // Ordering IS the contract here: stop before the copy (a live PGDATA is not coherent to - // copy), the baseline marker stamped in between (it must be the LAST thing written to + // Ordering is the contract here: stop before the copy (a live PGDATA is not coherent to + // copy), the baseline marker stamped in between (it must be the last thing written to // PGDATA, so nothing after the platform baseline can be missing from what it vouches // for), start plus a readiness probe after it (the caller is about to reconnect). expect(docker.steps()).toEqual([ From 12f1da9f1327121e7b261a9a3dac84d3cb9ad3bc Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 10 Sep 2026 12:01:21 +0100 Subject: [PATCH 04/56] chore(repo): trim comments, shard 10 (1144 -> 582 comment lines) --- apps/cli/src/command-internal/docker-ids.ts | 107 ++++----------- .../command-internal/docker-ids.unit.test.ts | 12 -- .../command-internal/docker-image-resolve.ts | 129 +++++------------- .../docker-image-resolve.unit.test.ts | 64 +-------- .../src/command-internal/docker-lifecycle.ts | 106 ++++---------- .../docker-lifecycle.unit.test.ts | 15 -- apps/cli/src/command-internal/docker-path.ts | 13 +- .../src/command-internal/docker-registry.ts | 37 ++--- .../docker-registry.unit.test.ts | 13 +- .../src/command-internal/docker-remove-all.ts | 94 ++++--------- .../src/command-internal/docker-run.args.ts | 33 ++--- .../src/command-internal/docker-run.layer.ts | 39 ++---- .../command-internal/docker-run.service.ts | 85 ++++-------- .../src/command-internal/docker-suggest.ts | 43 +----- .../docker-suggest.unit.test.ts | 11 -- apps/cli/src/command-internal/dotenv.ts | 80 +++++------ apps/cli/src/command-internal/drop-objects.ts | 14 +- .../command-internal/edge-runtime-image.ts | 27 ++-- .../edge-runtime-script.errors.ts | 10 +- ...e-runtime-script.layer.integration.test.ts | 23 +--- .../edge-runtime-script.layer.ts | 95 +++++-------- .../edge-runtime-script.service.ts | 53 +++---- apps/cli/src/command-internal/ensure-login.ts | 43 +++--- .../cli/src/command-internal/error-message.ts | 22 +-- .../src/command-internal/experimental-gate.ts | 29 +--- .../experimental-gate.unit.test.ts | 16 --- apps/cli/src/command-internal/fail-on.ts | 30 ++-- .../command-internal/functions-go-config.ts | 50 +++---- apps/cli/src/command-internal/get-api-keys.ts | 20 ++- 29 files changed, 375 insertions(+), 938 deletions(-) diff --git a/apps/cli/src/command-internal/docker-ids.ts b/apps/cli/src/command-internal/docker-ids.ts index f7bb8a8d63..d13c9ffdf9 100644 --- a/apps/cli/src/command-internal/docker-ids.ts +++ b/apps/cli/src/command-internal/docker-ids.ts @@ -1,24 +1,16 @@ /** - * Local Docker resource id derivation, ported from `utils.GetId` / - * `utils.NetId` / `utils.DbId`. Hoisted - * to `command-internal` so both `gen types` and the declarative seam derive the same - * `supabase_db_` / `supabase_network_` names when checking - * whether the local stack is running. + * Local Docker resource id derivation shared by `gen types` and the declarative seam, so both + * agree on `supabase_db_` / `supabase_network_` names when checking whether + * the local stack is running. */ import { basename } from "node:path"; /** - * Resolve the project id Go feeds into `utils.DbId`/`utils.NetId`. viper sets - * `Config.ProjectId` from config.toml's `project_id`, then `AutomaticEnv` overrides it - * with `SUPABASE_PROJECT_ID`; when both are absent Go falls back to the working - * directory basename (`utils.Config.ProjectId` default) — UNLESS a `--project-ref` - * was resolved for this invocation, in which case `flags.LoadConfig` pre-sets - * `Config.ProjectId = ProjectRef` before ever merging the file, - * so `Eject` only reaches the basename fallback - * when that default is itself empty. `projectRefDefault` is `undefined` for - * `start`/`stop`/`status`, which have no such flag. So the full precedence is - * `SUPABASE_PROJECT_ID` → config.toml `project_id` → `--project-ref` → workdir basename. + * Resolves the local project id used to derive Docker resource names. + * + * Precedence: `SUPABASE_PROJECT_ID` env var, then config.toml's `project_id`, then + * `--project-ref` (when the command accepts one), then the working directory's basename. */ export function resolveLocalProjectId( envProjectId: string | undefined, @@ -40,80 +32,47 @@ function truncateText(text: string, maxLength: number) { } /** - * `GetId` sanitisation: replace invalid runs with `_`, strip leading - * `_.-`, and cap at 40 chars. + * Sanitizes a project id for use in container/network names: replaces invalid character runs + * with `_`, strips leading `_.-`, and caps the result at 40 characters. * - * Exported because it is not only a container-*naming* concern: config - * validation rewrites the resolved project id - * to this same sanitized form **in place, once, at config-load time** (every - * `flags.LoadConfig` call ends in `Load` -> `Validate`), and every later use - * of that project id — including the Docker LABEL value written by `start` - * (`config.Labels[CliProjectLabel] = - * Config.ProjectId`) — reads that already-sanitized singleton. `GetId` itself - * performs no sanitisation of its own; it just reads the pre-sanitized value. - * So on the config/env-derived (non-`--project-id`) path, callers building a - * Docker label FILTER must sanitize too, or a `project_id` like `"my app"` - * filters on the raw string while `start` labeled the sanitized one and never - * matches anything (see `cliProjectFilterValue`'s doc comment). + * Exported separately from {@link serviceContainerName} because a Docker label filter built + * from a project id must be sanitized the same way, or a `project_id` like `"my app"` won't + * match the label `start` wrote (see {@link cliProjectFilterValue}). */ export function sanitizeProjectId(src: string) { const sanitized = src.replaceAll(INVALID_PROJECT_ID, "_").replace(/^[_.-]+/, ""); return truncateText(sanitized, MAX_PROJECT_ID_LENGTH); } -/** - * `supabase__` — the naming scheme shared by every - * local Docker resource (`utils.GetId`). - * Exported so callers building a single service's container name (e.g. a - * future `service-catalog.ts` consumer) don't need to go through - * {@link serviceContainerIds}'s fixed 13-element array. - */ +/** `supabase__` — the naming scheme for local Docker resources. */ export function serviceContainerName(suffix: string, projectId: string): string { return `supabase_${suffix}_${sanitizeProjectId(projectId)}`; } -/** `utils.DbId` — the local Postgres container name. */ +/** The local Postgres container name. */ export function localDbContainerId(projectId: string) { return serviceContainerName("db", projectId); } -/** `utils.NetId` fallback — the default generated docker network name. */ +/** The default generated Docker network name. */ export function localNetworkId(projectId: string) { return serviceContainerName("network", projectId); } -// `utils.NetId`/`DockerStart`'s network-mode resolution has ONE home: -// `resolveDockerNetworkMode` (`shared/functions/functions-docker.ts`). An -// earlier `resolveNetworkId` here fell through to `SUPABASE_NETWORK_ID` -// on an explicit-but-empty `--network-id=`, which viper's `find()` never does -// (a `Changed` pflag resolves BEFORE the env branch — see the shared helper's -// doc comment); `start`/`db start` now call the shared helper directly -// (review round on CLI-1963). - -/** `utils.CliProjectLabel` — the - * Docker label every container/volume/network created by `supabase start` carries. */ +/** The Docker label every container/volume/network created by `supabase start` carries. */ export const CLI_PROJECT_LABEL = "com.supabase.cli.project"; /** - * TS-port-only Docker label (no Go equivalent — Go never stages secrets on host disk in - * the first place, see `start-secrets-cleanup.ts`'s doc comment) recording the - * absolute `CommandSettings.workdir` a container was created under, set on every - * container `start` creates (`container-lifecycle.ts`'s `createContainer`). - * - * Read back by `listContainerIdsAndNames` (`docker-lifecycle.ts`) so a later - * `stop`/`rollbackStart` can reclaim `cleanupStartSecrets`'s staged-secret - * directory using the CONTAINER's OWN workdir, rather than the caller's own cwd/ - * `--workdir` — those can differ when tearing down another project's containers (e.g. - * `stop --all`/`stop --project-id `), which would otherwise look in the wrong - * directory and orphan that project's staged secret files on disk forever. + * Docker label recording the absolute workdir a container was created under, set when `start` + * creates it. `stop`/rollback read this label — rather than the caller's own cwd — to reclaim + * that project's staged secrets, since the two can differ when tearing down another project's + * stack. */ export const CLI_WORKDIR_LABEL = "com.supabase.cli.workdir"; /** - * `utils.GetDockerIds()` — the - * 13 service container ids (excludes `db`, `network`, and the `differ` shadow - * container, which are not part of the "expected running services" set). Order and - * alias-name strings are taken verbatim from the established naming scheme. + * The 13 service container ids checked as "running", excluding `db`, `network`, and the + * `differ` shadow container. */ export function serviceContainerIds(projectId: string): ReadonlyArray { return [ @@ -134,24 +93,12 @@ export function serviceContainerIds(projectId: string): ReadonlyArray { } /** - * `utils.CliProjectFilter` — - * the value that follows `--filter label=` on the `docker`/`podman` CLI. An empty - * `projectId` (`--all` path) filters on the bare label across every project. + * The value for `--filter label=` on a `docker`/`podman` invocation. An empty `projectId` + * filters on the bare label across every project. * - * This function itself does not sanitize — by design, it's a pure pass-through. - * The caller is responsible for sanitizing `projectId` with - * {@link sanitizeProjectId} on the config/env-derived (default) path - * BEFORE calling this, matching `Config.Validate` sanitizing the - * `Config.ProjectId` singleton once at config-load time so every later - * reader — including the Docker LABEL `start` writes — sees the same - * sanitized string. An explicit `--project-id ` (where one exists, - * e.g. `stop`) is the one exception: it assigns straight to - * the project id without going through validation, so that path must stay raw/ - * unsanitized to match. There is also no injection risk either way: this - * value is always passed as a single argv element to a spawned process - * (never through a shell), so a malformed value can only make Docker's own - * filter parsing reject it or match nothing — it cannot break out into - * another command. + * Does not sanitize `projectId` — the config/env-derived path must call + * {@link sanitizeProjectId} first to match the label `start` wrote, while an explicit + * `--project-id` value stays raw on purpose. */ export function cliProjectFilterValue(projectId: string): string { if (projectId.length === 0) return CLI_PROJECT_LABEL; diff --git a/apps/cli/src/command-internal/docker-ids.unit.test.ts b/apps/cli/src/command-internal/docker-ids.unit.test.ts index 59fdc9f3ee..701e0740a6 100644 --- a/apps/cli/src/command-internal/docker-ids.unit.test.ts +++ b/apps/cli/src/command-internal/docker-ids.unit.test.ts @@ -14,7 +14,6 @@ import { viperEnvStringWithProjectFallback } from "./viper-env.ts"; describe("resolveLocalProjectId", () => { it("prefers SUPABASE_PROJECT_ID (env) over config.toml and the basename", () => { - // Go applies SUPABASE_PROJECT_ID to Config.ProjectId (AutomaticEnv) before DbId. expect(resolveLocalProjectId("env-id", "toml-id", "/work/proj")).toBe("env-id"); }); @@ -36,8 +35,6 @@ describe("resolveLocalProjectId", () => { describe("serviceContainerIds", () => { it("returns the 13 service container ids in Go's GetDockerIds() order", () => { - // kong, auth, inbucket, realtime, - // rest, storage, imgproxy, pg_meta, studio, edge_runtime, analytics, vector, pooler. expect(serviceContainerIds("my-app")).toEqual([ "supabase_kong_my-app", "supabase_auth_my-app", @@ -71,11 +68,6 @@ describe("cliProjectFilterValue", () => { }); it("must be sanitized by the caller for the label to match what start wrote", () => { - // This function is a pure pass-through by design (see its doc comment) — a - // dirty config/env-derived id must be sanitized by the caller BEFORE being - // passed here, matching Go's Config.Validate sanitizing Config.ProjectId - // once at config-load time so every reader (including the Docker label - // `start` writes) sees the same string. const dirty = "My App!!"; expect(cliProjectFilterValue(dirty)).toBe(`${CLI_PROJECT_LABEL}=My App!!`); expect(cliProjectFilterValue(sanitizeProjectId(dirty))).toBe(`${CLI_PROJECT_LABEL}=My_App_`); @@ -89,10 +81,6 @@ describe("resolveDockerNetworkMode composed with viperEnvStringWithProjectFallba delete process.env[KEY]; }); - // `start`/`db start` resolve the network exactly like the `functions` - // Docker paths: the shared 3-way resolver fed by the viper-shaped - // shell/project-dotenv env read — one home, per the review round on - // CLI-1963 that deleted `resolveNetworkId`'s divergent copy. function resolve(flagValue: string | undefined, projectEnv: Record) { return resolveDockerNetworkMode({ explicit: flagValue, diff --git a/apps/cli/src/command-internal/docker-image-resolve.ts b/apps/cli/src/command-internal/docker-image-resolve.ts index 400336ed5a..a0d3da8583 100644 --- a/apps/cli/src/command-internal/docker-image-resolve.ts +++ b/apps/cli/src/command-internal/docker-image-resolve.ts @@ -10,17 +10,14 @@ type Spawner = ChildProcessSpawner["Service"]; const DOCKER_PULL_RETRY_DELAYS_MS = [4_000, 8_000] as const; /** - * Distinguishes a deadline-bounded pull attempt expiring from a spawn failure: - * the loop below treats a failed `pullImage` EFFECT as "docker itself is - * broken" and aborts every remaining candidate, which is exactly wrong for a - * timeout — a slow registry is the one failure the next candidate can fix. + * Marks a pull attempt that timed out rather than failed, so the retry loop moves to the next + * candidate instead of treating it like a broken Docker install and aborting every remaining one. */ const PULL_TIMED_OUT = Symbol("PULL_TIMED_OUT"); const spawnError = () => - // Never embed the spawn error verbatim: it can leak the full argv and - // environment of the failed exec (CWE-214/209). Emit a fixed, - // credential-free message that still points at the likely cause. + // The raw spawn error can leak the failed exec's full argv and environment, so emit a fixed, + // credential-free message instead. new DockerRunError({ message: `failed to run docker. ${SUGGEST_DOCKER_INSTALL}`, reason: "spawn", @@ -31,15 +28,9 @@ const runtimeNotFound = (cause: ContainerRuntimeNotFoundError) => new DockerRunError({ message: cause.message, reason: "spawn", daemonDown: false }); /** - * Docker's/Podman's "image not found" stderr shape for `image inspect` — the subprocess - * equivalent of `errdefs.IsNotFound`, mirroring `db-bootstrap/container-lifecycle.ts`'s - * (private) `isVolumeNotFoundMessage` for `volume inspect`. Confirmed empirically: `docker image - * inspect ` prints `Error response from daemon: No such image: ` and exits 1; - * an uncached Podman inspect exits non-zero with the differently worded `: image not - * known` instead (Podman's `libimage` not-found error, independent of Docker's daemon-response - * wording — see e.g. containers/podman-compose#358's `failed to find image …: image not known` - * trace). Both must resolve to "not cached" or the pull loop below never runs on a Podman-only - * host, matching the same Docker/Podman fallback `spawnContainerCli` already builds in. + * Detects a confirmed "image not found" `image inspect` failure across Docker (`No such image`) + * and Podman (`image not known`) — any other inspect error is unexpected and must not fall + * through to the pull loop. */ const isImageNotFoundMessage = (message: string): boolean => /no such image/iu.test(message) || /image not known/iu.test(message); @@ -56,34 +47,11 @@ const concat = (chunks: ReadonlyArray): Uint8Array => { }; /** - * Builds a Docker image resolver bound to `spawner`: given an image, finds the - * best registry candidate (already-local first, then pulled with retry), - * mirroring `DockerResolveImageIfNotCached` / - * `DockerImagePullWithRetry`. - * The local-image check fails fast (never reaching the pull loop at all) when - * the daemon itself is unreachable — Go's own inspect call distinguishes a - * typed `errdefs.IsNotFound` from any other inspect error and returns the - * latter immediately; see `hasLocalImage` below for how - * this port makes the same distinction from a CLI subprocess's stderr instead - * of a typed Engine API error. Once past that gate, a pull failure IS retried - * unconditionally — Go retries on any non-nil error as long as the context - * wasn't canceled, with no message-pattern gating — up to 2 times per - * candidate (3 total attempts) with an escalating 4s/8s backoff - * (`DOCKER_PULL_RETRY_DELAYS_MS`), matching `2<<(i+1)` seconds for `i` in - * `0,1`. A spawn failure (the Docker/Podman binary itself couldn't be run) is - * a different, non-retryable case — see `runtimeNotFound` below. Used by both the - * foreground `db dump`-style run-to-completion containers - * (`docker-run.layer.ts`) and `start`'s detached, long-running service - * containers — the resolve/pull/retry algorithm is identical for both, only - * the caller's process lifecycle differs. - * - * `projectEnvValues` is optional (see `docker-registry.ts`'s doc - * comment) — `start` and the `functions` Docker paths (`deploy`, `download`, - * `serve`, via `resolveFunctionsDockerImage`) all thread it through, since - * each already has the project's dotenv-merged values in scope by the time - * it resolves an image; `docker-run.layer.ts` is a statically-composed - * `Layer` built before any `projectEnvValues` is known, so its own callers - * stay ambient-only for now. + * Builds an image resolver bound to `spawner`: returns the first registry candidate already + * cached locally, or pulls candidates in order with the escalating backoff in + * {@link DOCKER_PULL_RETRY_DELAYS_MS}. A local-image check failure or a failure to spawn + * Docker/Podman at all skips retries and fails immediately. `projectEnvValues` is optional — + * only callers that already have the project's dotenv-merged values in scope thread it through. */ export function makeDockerImageResolver( spawner: Spawner, @@ -91,13 +59,9 @@ export function makeDockerImageResolver( ): (image: string, deadline?: number) => Effect.Effect { const hasLocalImage = (image: string): Effect.Effect => Effect.gen(function* () { - // `stdout: "ignore"`: `docker image inspect` writes the full image JSON - // to stdout on a cache hit, which can exceed the OS pipe buffer and - // deadlock the child against an unread default `"pipe"` stdio — see - // `docker-remove-all.ts`'s fuller explanation of this same - // hazard. `stderr` IS captured (unlike a plain exit-code check) so a - // "daemon unreachable" response can be told apart from a genuine - // "image not found" — see this function's own doc comment above. + // `stdout: "ignore"`: a cache hit writes the full image JSON to stdout, which can exceed + // the pipe buffer and deadlock the child if left unread. `stderr` is captured so a + // daemon-unreachable response can be told apart from a genuine "image not found". const handle = yield* spawnContainerCli(spawner, ["image", "inspect", image], { stdin: "ignore", stdout: "ignore", @@ -115,15 +79,10 @@ export function makeDockerImageResolver( ); if (exitCode === 0) return true; const stderr = new TextDecoder().decode(concat(stderrChunks)).trim(); - // `DockerResolveImageIfNotCached` proceeds to the pull loop only when the inspect - // error is a confirmed `errdefs.IsNotFound` — any OTHER inspect error (daemon unreachable, - // an auth-plugin denial, an invalid reference, an API error, ...) returns immediately - // instead. Defaulting anything-but-daemon-unreachable - // to "not cached" (as this used to) would instead send every OTHER inspect failure through - // the multi-registry pull loop too — performing unauthorized network operations, delaying - // the failure by each retry backoff, and replacing the real inspect error with a pull - // aggregate. So this must default to failing, and only treat a confirmed not-found as a - // cache miss — the inverse of the daemon-unreachable-only gate this replaced. + // Only a confirmed "not found" is a cache miss. Any other inspect failure (daemon + // unreachable, auth-plugin denial, invalid reference, ...) must fail immediately, not fall + // through to the pull loop and attempt an unauthorized network operation behind a retry + // backoff. if (isImageNotFoundMessage(stderr)) return false; const daemonDown = isDockerDaemonUnreachable(stderr); const hint = daemonDown ? `\n\n${SUGGEST_DOCKER_INSTALL}` : ""; @@ -150,17 +109,10 @@ export function makeDockerImageResolver( detached: false, extendEnv: true, }).pipe(Effect.mapError(runtimeNotFound)); - // Tee pull progress to the parent terminal in real time so a large, - // uncached pull does not look frozen — Go streams the same progress via - // `jsonmessage.DisplayJSONMessagesToStream`. Progress goes to stderr so - // it never corrupts the captured stdout of the `db dump` run path. The - // buffered copies are kept only to report the error message on a - // non-zero exit. Decode each stream separately so a multi-byte UTF-8 - // sequence is never split across interleaved chunks. - // `endedWithNewline` records whether the last byte teed to the parent's - // stderr was `\n` (both streams share it — last write wins, which is - // what the terminal shows), so the retry loop can start its banner on a - // fresh line when the child's final output wasn't newline-terminated. + // Tee pull progress to the parent's stderr in real time, so a slow uncached pull doesn't + // look frozen; using stderr keeps the captured `db dump` stdout stream clean. The buffered + // copies are kept only to report the error message on a non-zero exit, decoded separately + // per stream so an interleaved multi-byte UTF-8 sequence is never split. const stdoutChunks: Array = []; const stderrChunks: Array = []; let endedWithNewline = true; @@ -207,13 +159,9 @@ export function makeDockerImageResolver( const failures: Array = []; for (const [candidateIndex, candidate] of candidates.entries()) { - // `deadline` (epoch ms) is opt-in and no production caller passes one: - // a human watching `supabase start` can Ctrl-C a slow pull, CI cannot, - // so only the e2e helper (`tests/helpers/docker-image.ts`) bounds the - // resolve. Remaining time is split across the candidates still to run — - // recomputed per candidate so a fast failure carries its unused - // share forward and the last candidate gets all remaining time — and - // a stalled registry can never starve the fallbacks behind it. + // `deadline` is opt-in — only the e2e helper passes one. Remaining time is re-split + // across the candidates left after each one, so a fast failure's unused share carries + // forward and a stalled registry can't starve the fallbacks behind it. let candidateShareMs: number | undefined; let candidateDeadline: number | undefined; if (deadline !== undefined) { @@ -223,9 +171,6 @@ export function makeDockerImageResolver( ); candidateDeadline = Math.min(Date.now() + candidateShareMs, deadline); } - // Whether the most recent failed attempt's teed output ended with a - // newline — read by the retry banner below, which runs outside the - // block the pull result is scoped to. let lastPullEndedWithNewline = true; for ( let attemptIndex = 0; @@ -269,13 +214,9 @@ export function makeDockerImageResolver( break; } } else { - // A failed effect (rather than a non-zero exit, which returns a - // value) means the container runtime could not be spawned at all — - // a down daemon is caught earlier, by `hasLocalImage`'s own - // fail-fast check above, and never reaches this loop. No registry - // candidate can fix a missing Docker/Podman binary, so stop here - // and surface that failure instead of an opaque, repeated spawn - // error across every candidate. + // A failed effect (vs a non-zero exit) means Docker/Podman itself couldn't be + // spawned — no registry candidate can fix that, so stop here instead of repeating + // the same spawn error. return yield* Effect.failCause(result.cause); } @@ -288,16 +229,8 @@ export function makeDockerImageResolver( if (candidateDeadline !== undefined && Date.now() + delay >= candidateDeadline) { break; } - // Go prints a per-retry banner before sleeping: - // `fmt.Fprintf(os.Stderr, "Retrying after %v: %s\n", period, image)` — - // `%v` of the 4s/8s backoff `time.Duration` renders as `4s`/`8s`. - // Go also `Fprintln`s the failed attempt's error just before the - // banner; here the `docker pull` child's own - // stderr — already teed live to the parent's stderr above — plays - // that role. `Fprintln` always newline-terminates, so when the - // child's final output didn't, add the `\n` ourselves — otherwise - // the banner would glue onto the error text where Go prints two - // lines. + // Add a newline before the banner only when the child's own output didn't end with + // one, so the two don't glue together. yield* Effect.sync(() => { if (!lastPullEndedWithNewline) { globalThis.process.stderr.write("\n"); diff --git a/apps/cli/src/command-internal/docker-image-resolve.unit.test.ts b/apps/cli/src/command-internal/docker-image-resolve.unit.test.ts index f594c73465..4748bc20ff 100644 --- a/apps/cli/src/command-internal/docker-image-resolve.unit.test.ts +++ b/apps/cli/src/command-internal/docker-image-resolve.unit.test.ts @@ -13,12 +13,8 @@ const REGISTRY_ENV = "SUPABASE_INTERNAL_IMAGE_REGISTRY"; function mockSpawner( pullResults: ReadonlyArray<{ readonly exitCode: number; readonly stderr?: string }>, - // Defaults to a confirmed "not found" inspect response, which forces every - // candidate through the pull path instead of the already-cached shortcut — - // the behavior both existing pull-retry tests below rely on. A test - // covering `hasLocalImage`'s own fail-fast behavior overrides this to - // simulate a daemon-down (or other non-not-found) `image inspect` response - // instead. + // Defaults to a confirmed "not found" inspect response so every candidate goes through the + // pull path; tests covering the fail-fast inspect behavior override this. imageInspectResult: { readonly exitCode: number; readonly stderr?: string } = { exitCode: 1, stderr: "Error response from daemon: No such image: placeholder", @@ -90,12 +86,7 @@ function mockSpawner( }; } -/** - * Joins everything written to stderr — the tee's `Uint8Array` chunks and the - * resolver's own `string` writes — into one transcript, so tests can assert - * on the byte sequence a terminal would actually display (e.g. that a retry - * banner starts on a fresh line after an unterminated child error). - */ +/** Joins tee'd `Uint8Array` chunks and the resolver's own `string` writes into one transcript. */ function stderrTranscript(chunks: ReadonlyArray): string { const decoder = new TextDecoder(); return chunks @@ -112,17 +103,10 @@ describe("makeDockerImageResolver", () => { "retries a pull failure unconditionally through messages that wouldn't have matched the old retryable-pattern allowlist, giving up after 3 total attempts", () => Effect.gen(function* () { - // Pins the resolver to a single registry candidate (the image - // unchanged) so the assertions below cover exactly one candidate's - // attempt count, rather than the full ECR/GHCR/Docker Hub fallback - // list built by `getRegistryImageUrlCandidates`. + // Pins the resolver to a single registry candidate so the assertions below cover + // exactly one candidate's attempt count. const previousRegistry = process.env[REGISTRY_ENV]; process.env[REGISTRY_ENV] = "docker.io"; - // Records every chunk written to stderr, including the `docker pull` child's own - // stdout/stderr, which `pullImage` tees live to the parent's stderr as `Uint8Array` - // chunks — only the `Retrying after …` banner (and its fresh-line `"\n"` separator) - // is ever written as a plain `string`, so filtering by `startsWith("Retrying after")` - // isolates the banner from the tee below. const stderrChunks: Array = []; const originalWrite = globalThis.process.stderr.write.bind(globalThis.process.stderr); globalThis.process.stderr.write = ((chunk: unknown) => { @@ -131,11 +115,6 @@ describe("makeDockerImageResolver", () => { }) as typeof globalThis.process.stderr.write; try { - // Mirrors Go's own `docker_test.go` "throws error on failure to pull - // image" case: a bare, non-pattern-matching message (no - // "toomanyrequests"/"rate exceeded"/etc.) still exhausts every - // retry, because `DockerImagePullWithRetry` retries on any - // non-nil error, with no message classification at all. const mock = mockSpawner([ { exitCode: 1, stderr: "no space left on device" }, { exitCode: 1, stderr: "no space left on device" }, @@ -146,8 +125,6 @@ describe("makeDockerImageResolver", () => { Effect.forkChild({ startImmediately: true }), ); - // 2 retries after the initial attempt, with Go's escalating 4s/8s - // backoff (`2<<(i+1)` seconds for i=0,1) between them. yield* TestClock.adjust("4 seconds"); yield* TestClock.adjust("8 seconds"); const error = yield* Fiber.join(fiber).pipe(Effect.flip); @@ -155,18 +132,10 @@ describe("makeDockerImageResolver", () => { expect(mock.pulls).toHaveLength(3); expect(error.message).toContain("no space left on device"); expect(error.message).toContain("attempt 3"); - // `image inspect`'s stdout must be fully ignored — the default - // `"pipe"` stdio risks a write-buffer deadlock on a cache hit (see - // the doc comment in `docker-image-resolve.ts`) — but - // stderr IS piped so a daemon-unreachable response can be told - // apart from a genuine cache miss. expect(mock.imageInspectOptions.length).toBeGreaterThan(0); for (const options of mock.imageInspectOptions) { expect(options).toMatchObject({ stdin: "ignore", stdout: "ignore", stderr: "pipe" }); } - // Go's per-retry banner: `Fprintf(os.Stderr, "Retrying after %v: %s\n", …)` — - // one banner before each of the 2 retries, escalating 4s then 8s, naming the exact - // candidate this resolver pinned to (see the comment above on `REGISTRY_ENV`). const retryBanners = stderrChunks.filter( (chunk): chunk is string => typeof chunk === "string" && chunk.startsWith("Retrying after"), @@ -175,10 +144,6 @@ describe("makeDockerImageResolver", () => { "Retrying after 4s: supabase/postgres:17.6.1.138\n", "Retrying after 8s: supabase/postgres:17.6.1.138\n", ]); - // Go `Fprintln`s the failed error before the banner, - // so the banner always starts on a fresh line. The child's error here - // has no trailing newline, so the resolver must add one — never the - // glued `…deviceRetrying after …`. const transcript = stderrTranscript(stderrChunks); expect(transcript).toContain( "no space left on device\nRetrying after 4s: supabase/postgres:17.6.1.138\n", @@ -220,9 +185,6 @@ describe("makeDockerImageResolver", () => { expect(mock.pulls).toHaveLength(2); expect(image).toBe("supabase/postgres:17.6.1.138"); - // Only the first candidate's failed attempt sleeps through a retry banner — the - // second attempt succeeds immediately, so the 8s banner (and a third pull) must - // never happen. const retryBanners = stderrChunks.filter( (chunk): chunk is string => typeof chunk === "string" && chunk.startsWith("Retrying after"), @@ -263,9 +225,6 @@ describe("makeDockerImageResolver", () => { const image = yield* Fiber.join(fiber); expect(image).toBe("supabase/postgres:17.6.1.138"); - // The child already terminated its own line — `Fprintln` - // output shape is exactly one newline between error and banner, so - // the resolver must not add a second one. const transcript = stderrTranscript(stderrChunks); expect(transcript).toContain( "no space left on device\nRetrying after 4s: supabase/postgres:17.6.1.138\n", @@ -280,10 +239,8 @@ describe("makeDockerImageResolver", () => { ); it.live("gives every registry candidate its share when a deadline is passed", () => { - // Fail-fast pulls with a small budget: each candidate still gets a turn - // (unused share carries forward), and the guarded backoff never sleeps a - // 4s retry into the next candidate's time — the test finishing in - // milliseconds rather than seconds is itself the assertion. + // Finishing within the test timeout is itself the assertion that the guarded backoff never + // overruns the deadline. const mock = mockSpawner([ { exitCode: 1, stderr: "denied" }, { exitCode: 1, stderr: "denied" }, @@ -376,10 +333,6 @@ describe("makeDockerImageResolver", () => { process.env[REGISTRY_ENV] = "docker.io"; try { - // `DockerResolveImageIfNotCached` treats ONLY a confirmed `errdefs.IsNotFound` - // as a cache miss; every other inspect error — this is neither a "no such image" nor - // a daemon-unreachable message — returns immediately instead of falling through to - // the pull loop. const authPluginDenialStderr = "Error response from daemon: authorization denied by plugin AuthZPlugin: no policy matched"; const mock = mockSpawner([], { exitCode: 1, stderr: authPluginDenialStderr }); @@ -406,9 +359,6 @@ describe("makeDockerImageResolver", () => { process.env[REGISTRY_ENV] = "docker.io"; try { - // An uncached `podman image inspect ` exits non-zero with `image not - // known` rather than Docker's `No such image` — see `isImageNotFoundMessage`'s - // doc comment. Both wordings must reach the pull loop identically. const mock = mockSpawner([{ exitCode: 0 }], { exitCode: 1, stderr: "supabase/postgres:17.6.1.138: image not known", diff --git a/apps/cli/src/command-internal/docker-lifecycle.ts b/apps/cli/src/command-internal/docker-lifecycle.ts index 695e354487..8976f0560f 100644 --- a/apps/cli/src/command-internal/docker-lifecycle.ts +++ b/apps/cli/src/command-internal/docker-lifecycle.ts @@ -16,39 +16,27 @@ import { CLI_WORKDIR_LABEL } from "./docker-ids.ts"; type Spawner = ChildProcessSpawner["Service"]; -/** - * Listing containers or volumes by Docker label failed. Wraps the established - * `Docker.ContainerList`/`Docker.VolumeList` errors (see - * `checkServiceHealth`/`DockerRemoveAll`), which - * wrap as `"failed to list containers: %w"` / equivalent. - */ +/** Listing containers or volumes by Docker label failed. */ export class DockerLifecycleListError extends Data.TaggedError("DockerLifecycleListError")<{ readonly message: string; }> { - // `docker ps`/`docker volume ls` never fail because nothing matches the - // label filter — an empty match is a successful, empty result. Every real - // failure here is therefore a container-runtime problem: neither - // docker/podman could be spawned, or the daemon itself rejected the call. + // An empty match from `docker ps`/`docker volume ls` is a successful empty result, not a + // failure — so every real failure here means the runtime itself is broken. get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return actionability.dockerNotRunning; } } /** - * Inspecting a single container's state failed for a reason other than "not - * found" — except `assertContainerHealthy` (and this port, matching it, - * see `status.handler.ts`) never special-cases a missing container either: an - * absent container is just another non-zero `docker container inspect` exit, - * which is by far the dominant real trigger of this error (the user hasn't - * run `supabase start` yet) — same fix as the other "stack isn't running" - * errors elsewhere in this codebase. + * Inspecting a single container's state failed. A missing container isn't special-cased — an + * absent container is just another non-zero `docker container inspect` exit, and by far the most + * common cause is that `supabase start` hasn't been run yet. */ export class DockerLifecycleInspectError extends Data.TaggedError("DockerLifecycleInspectError")<{ readonly message: string; /** - * Set at the container boundary when neither runtime can be spawned or the - * daemon is unreachable. Every other inspect failure (the dominant "stack - * isn't running yet" case) keeps the `startStack` classification. + * True when neither runtime could be spawned or the daemon is unreachable; every other inspect + * failure keeps the `startStack` classification. */ readonly daemonDown?: boolean; }> { @@ -77,17 +65,9 @@ function splitNonEmptyLines(text: string): ReadonlyArray { } /** - * Shared `docker ps --filter label= [--filter label=…] [--all] --format - * ` spawn: one Docker CLI invocation is one underlying `GET - * /containers/json` Docker Engine API request regardless of `--format` - * (`--format` only controls how the CLI renders the already-returned JSON - * response client-side) — every exported listing function below funnels - * through here so two differently-formatted needs never accidentally cost - * two real requests. See {@link listContainerIdsAndNames}'s doc - * comment for why that distinction matters for Go-parity request-log tests. - * - * Multiple `labelFilters` are ANDed by Docker itself (repeating `--filter label=` narrows the - * match), so scoping a listing to a second label costs no extra request either. + * Shared `docker ps` spawn behind every listing function below, so two differently-formatted + * needs (`{{.ID}}` vs `{{.Names}}`) never cost two separate invocations. Multiple `labelFilters` + * are ANDed by Docker itself, so scoping to a second label adds no extra cost either. */ function spawnDockerPsLines( spawner: Spawner, @@ -118,11 +98,10 @@ function spawnDockerPsLines( }), ), ); - // Concurrency is required, not cosmetic: sequential `Effect.all` would - // await `exitCode` (resolved by Node's "exit" event) before subscribing - // to `stdout`/`stderr` at all. Node's "exit" can fire before a fast - // process's stdio pipes are drained, so a late subscriber sees an - // already-ended, empty stream instead of the buffered bytes. + // Concurrency is required, not cosmetic: sequential `Effect.all` would await `exitCode` + // before subscribing to `stdout`/`stderr`, and Node's "exit" event can fire before a fast + // process's stdio pipes are drained — a late subscriber would see an already-ended, empty + // stream. const [exitCode, stdout, stderr] = yield* Effect.all( [ child.exitCode.pipe(Effect.map(Number)), @@ -152,10 +131,9 @@ function spawnDockerPsLines( } /** - * `Docker.ContainerList(ctx, container.ListOptions{All, Filters})` - * via `docker ps --filter - * label=`. `all: false` mirrors `status`'s running-only list; - * `all: true` mirrors `stop`'s "every container regardless of state" list. + * Lists containers by project label via `docker ps --filter label=`. `all: false` + * matches `status`'s running-only list; `all: true` matches `stop`'s "every container regardless + * of state" list. */ export const listContainersByLabel = ( spawner: Spawner, @@ -174,10 +152,8 @@ export const listContainersByLabel = ( /** * A single `docker ps` result row's id, name, and staging workdir together. * - * `workdir` is `CLI_WORKDIR_LABEL`'s value read straight off the container (see - * that constant's doc comment) — empty when the container carries no such label, which - * `cleanupStartSecrets` treats as "fall back to the caller's own workdir" (a - * container `start` created before this label existed, or created by a Go binary). + * `workdir` is empty when the container carries no {@link CLI_WORKDIR_LABEL} — + * `cleanupStartSecrets` treats that as "fall back to the caller's own workdir". */ export interface ContainerIdName { readonly id: string; @@ -186,20 +162,9 @@ export interface ContainerIdName { } /** - * Combined-format sibling of {@link listContainersByLabel}: fetches a - * container's id, name, AND staging workdir from a SINGLE `docker ps --format - * "{{.ID}}\t{{.Names}}\t{{.Label \"com.supabase.cli.workdir\"}}"` invocation, - * rather than one call per field. Go's SDK-based `Docker.ContainerList` gets - * all of this (and every other field) from the one Engine API response it - * already makes; two separately-`--format`ted CLI calls here would silently - * double the real Docker request count relative to Go even though each call's - * own output is individually correct — exactly the bug the cli-e2e-ci - * request-log parity harness caught for `stop` (an extra `GET /containers/json` - * versus Go's single call). Used by {@link dockerRemoveAll}, which needs - * ids to stop containers, for callers (`stop`, `start`'s rollback) that ALSO - * need names and workdirs for {@link cleanupStartSecrets} — see that - * function's doc comment and {@link dockerRemoveAll}'s - * `onContainersRemoved` parameter. + * Combined-format sibling of {@link listContainersByLabel}: fetches a container's id, name, and + * staging workdir from a single `docker ps` invocation instead of one call per field, since each + * `docker ps` is a real Docker Engine API request. */ export const listContainerIdsAndNames = ( spawner: Spawner, @@ -222,12 +187,9 @@ export const listContainerIdsAndNames = ( ); /** - * `Docker.ContainerInspect(ctx, containerId)` via `docker container inspect --format - * {{json .State}}`. `assertContainerHealthy` does not special-case a - * missing container — it wraps whatever error `ContainerInspect` returns, - * so every non-zero exit, including "no such - * container", propagates as `DockerLifecycleInspectError` carrying the - * real Docker stderr text. + * Inspects a container's state via `docker container inspect --format {{json .State}}`. + * A missing container isn't special-cased — every non-zero exit, including "no such container", + * propagates as {@link DockerLifecycleInspectError} carrying the real Docker stderr text. */ export const inspectContainerState = (spawner: Spawner, containerId: string) => Effect.scoped( @@ -297,13 +259,9 @@ function parseContainerState(stdout: string): { parsed = {}; } const state = isJsonRecord(parsed) ? parsed : {}; - // `assertContainerHealthy` gates - // on the boolean `resp.State.Running`, not the status string — Docker's - // inspect `State` struct exposes both independently, and a paused or - // restarting container reports `Running: true` alongside a non-"running" - // `Status` (`"paused"`/`"restarting"`). `status` is kept as-is for the - // "container is not running: " message text, - // which still reads the string, but the gate itself must read the boolean. + // A paused or restarting container reports `Running: true` in Docker's inspect `State` + // alongside a non-"running" `Status` string, so callers must gate on the boolean, not the + // status text; `status` is kept only for error message text. const status = typeof state["Status"] === "string" ? state["Status"] : ""; const running = state["Running"] === true; const health = state["Health"]; @@ -318,11 +276,7 @@ function isJsonRecord(value: unknown): value is { readonly [key: string]: unknow return typeof value === "object" && value !== null; } -/** - * `Docker.VolumeList(ctx, volume.ListOptions{Filters})` - * (`docker.go` — used by the `stop` post-run volume-suggestion check) via - * `docker volume ls --filter label=`. - */ +/** Lists volumes by project label via `docker volume ls --filter label=`. */ export const listVolumesByLabel = (spawner: Spawner, projectIdFilter: string) => Effect.scoped( Effect.gen(function* () { diff --git a/apps/cli/src/command-internal/docker-lifecycle.unit.test.ts b/apps/cli/src/command-internal/docker-lifecycle.unit.test.ts index 31c4372f37..67592a1ea3 100644 --- a/apps/cli/src/command-internal/docker-lifecycle.unit.test.ts +++ b/apps/cli/src/command-internal/docker-lifecycle.unit.test.ts @@ -201,10 +201,6 @@ describe("listContainerIdsAndNames", () => { it.live( "resolves an empty workdir for a container carrying no com.supabase.cli.workdir label", () => { - // Docker's `{{.Label "key"}}` resolves to an empty string when the container has no such - // label — a container `start` created before this label existed, or one a Go binary - // created. `cleanupStartSecrets` treats this empty string as "fall back to the - // caller's own workdir" (see that function's doc comment). const mock = mockSpawner({ stdout: "abc123\tsupabase_kong_demo\t\n" }); return listContainerIdsAndNames(mock.spawner, { projectIdFilter: "com.supabase.cli.project=demo", @@ -261,10 +257,6 @@ describe("inspectContainerState", () => { it.live( "treats a paused/restarting container as running, matching Go's boolean-based gate", () => { - // `assertContainerHealthy` checks `resp.State.Running`, - // not `resp.State.Status` — a paused or restarting container reports - // `Running: true` alongside a non-"running" status string, and Go - // continues past the not-running branch in that case. const mock = mockSpawner({ stdout: JSON.stringify({ Status: "paused", Running: true }) }); return inspectContainerState(mock.spawner, "supabase_db_my-app").pipe( Effect.map((state) => { @@ -277,9 +269,6 @@ describe("inspectContainerState", () => { it.live( "fails with DockerLifecycleInspectError, preserving the real stderr, when the container does not exist", () => { - // `assertContainerHealthy` never special-cases "not found" — it - // wraps whatever `ContainerInspect` returns, so a - // missing container is just another non-zero exit here too. const mock = mockSpawner({ exitCode: 1, stderr: "Error response from daemon: No such container: supabase_db_my-app\n", @@ -291,8 +280,6 @@ describe("inspectContainerState", () => { expect(error.message).toBe( "failed to inspect container health: Error response from daemon: No such container: supabase_db_my-app", ); - // The dominant "stack isn't running yet" case: not daemon-down, so it - // keeps the start-stack classification. expect(error.daemonDown).toBeFalsy(); expect(classifyCliErrorActionability(error).error_category).toBe("invalid_config"); }), @@ -309,8 +296,6 @@ describe("inspectContainerState", () => { expect(error.message).toBe( "failed to inspect container health: Cannot connect to the Docker daemon", ); - // A daemon-down stderr flips the discriminant so the failure classifies - // as docker-not-running instead of a broken running stack. expect(error.daemonDown).toBe(true); const result = classifyCliErrorActionability(error); expect(result.error_category).toBe("docker_not_running"); diff --git a/apps/cli/src/command-internal/docker-path.ts b/apps/cli/src/command-internal/docker-path.ts index a55b89a8d0..92936f3892 100644 --- a/apps/cli/src/command-internal/docker-path.ts +++ b/apps/cli/src/command-internal/docker-path.ts @@ -1,15 +1,6 @@ /** - * Translate an absolute host path to its in-container mount path. Mirrors Go's - * `utils.ToDockerPath` (`apps/cli-go/internal/utils/deno.go:268-272`): strip a - * Windows volume name (`C:`) and convert backslashes to forward slashes. - * - * Hoisted out of `test db`'s `db.pg-prove-args.ts` (its original, single-command - * home, now `command-internal/test-db.pg-prove-args.ts` — CLI-1962 moved the - * whole `test db` implementation to `command-internal/` so its hidden alias - * `db test` could reuse it without a cross-command-family import) once `start`'s - * pg-meta/Studio service builders needed the same translation for their - * bind-mount paths — see `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" - * rule ("used by ≥2 commands across families"). + * Translates an absolute host path to its in-container mount path: strips a Windows volume name + * (`C:`) and converts backslashes to forward slashes. */ export function toDockerMountPath(absHostPath: string): string { const slashed = absHostPath.replaceAll("\\", "/"); diff --git a/apps/cli/src/command-internal/docker-registry.ts b/apps/cli/src/command-internal/docker-registry.ts index 8790eeab59..434ee6f3a6 100644 --- a/apps/cli/src/command-internal/docker-registry.ts +++ b/apps/cli/src/command-internal/docker-registry.ts @@ -1,24 +1,10 @@ /** - * Resolve a Docker image through the configured registry, a 1:1 port of Go's - * `utils.GetRegistryImageUrl` / `GetRegistry`. - * - * `SUPABASE_INTERNAL_IMAGE_REGISTRY` (Go's viper `INTERNAL_IMAGE_REGISTRY`) - * overrides the registry; an unset value uses the default ECR mirror. A value of - * `docker.io` returns the image unchanged (pull from Docker Hub); any other - * registry rewrites the image to `/supabase/` so - * restricted/rate-limited environments pull from their configured mirror instead - * of Docker Hub. - * - * When no registry override is configured, callers that can retry pulls should - * use `getRegistryImageUrlCandidates`: ECR stays the fast default, with - * GHCR and the source image as fallbacks for transient registry throttling. - * - * Slim images (`isSlimImageRef`) skip every rewrite below and pull from where - * they exist: both helpers key their rewrite on an image's LAST path segment, - * which would turn `ghcr.io/supabase/cli/postgres:…` into the unrelated - * non-slim `…/supabase/postgres:…` mirror. There is no mirror to redirect - * slim refs to, hence `SUPABASE_INTERNAL_IMAGE_REGISTRY` does not apply to - * them either. + * Resolves a Docker image through the configured registry. `SUPABASE_INTERNAL_IMAGE_REGISTRY` + * overrides the default ECR mirror; `docker.io` returns the image unchanged, and any other value + * rewrites it to `/supabase/`. Callers that can retry pulls should + * use {@link getRegistryImageUrlCandidates} instead, which falls back through GHCR and the + * source image. Slim images ({@link isSlimImageRef}) skip every rewrite — there's no mirror to + * redirect them to. */ import { isSlimImageRef } from "../shared/services/slim-images.ts"; @@ -39,14 +25,9 @@ function getLastImageSegment(imageName: string): string { } /** - * `projectEnvValues` (dotenv-merged env, ambient-wins) is optional and - * additive — every existing caller that omits it keeps today's ambient-only - * behavior unchanged. Only callers that already have a project's dotenv - * values in scope (currently `start`, via `getRegistryImageUrlCandidates`) - * need to pass it so a `SUPABASE_INTERNAL_IMAGE_REGISTRY` set only in - * `supabase/.env`/project-root dotenv (not the ambient shell) is honored, - * matching the project dotenv files being loaded into the process env - * before the registry override is ever read. + * `projectEnvValues` is optional: passing a project's dotenv-merged env lets a + * `SUPABASE_INTERNAL_IMAGE_REGISTRY` set only in `supabase/.env` (not the ambient shell) take + * effect; omitting it keeps ambient-only behavior. */ function getRegistryOverride( projectEnvValues?: Readonly>, diff --git a/apps/cli/src/command-internal/docker-registry.unit.test.ts b/apps/cli/src/command-internal/docker-registry.unit.test.ts index d6114ca80d..8f1eb60782 100644 --- a/apps/cli/src/command-internal/docker-registry.unit.test.ts +++ b/apps/cli/src/command-internal/docker-registry.unit.test.ts @@ -82,9 +82,6 @@ describe("getRegistryImageUrl", () => { ).toEqual(["my.mirror.example/supabase/postgres:17.6.1.138"]); }); - // `Config.Load` runs `loadNestedEnv`/`godotenv.Load` - // before any image resolution, so a project-dotenv-only `SUPABASE_INTERNAL_IMAGE_REGISTRY` - // (never set in the ambient shell) still reaches `GetRegistry()`. it("honors a projectEnvValues (dotenv)-only registry override, matching Go's post-Load os.Getenv", () => { expect( withRegistry(undefined, () => @@ -102,12 +99,6 @@ describe("getRegistryImageUrl", () => { ).toEqual(["my.mirror.example/supabase/postgres:17.6.1.138"]); }); - // `projectEnvValues` is the caller's own dotenv+ambient MERGED view (ambient - // wins ties during that merge, matching `godotenv.Load`'s "don't override - // already-set" semantics — see `envOrDefault`'s doc comment for the - // same precedent), so checking it first is equivalent to checking the - // already-correctly-merged value first; falling back to bare `process.env` - // only covers a caller with no project-env context at all. it("prefers projectEnvValues over a bare process.env read when both are set", () => { expect( withRegistry("ambient.example", () => @@ -118,9 +109,7 @@ describe("getRegistryImageUrl", () => { ).toBe("merged.example/supabase/pg_prove:3.36"); }); - // Slim images are published only under `ghcr.io/supabase/cli`. Rewriting them - // by last path segment would silently pull the unrelated non-slim mirror, and - // no mirror of them exists for a registry override to point at. + // Published only under ghcr.io/supabase/cli; rewriting by last path segment would misroute it. const SLIM_IMAGE = "ghcr.io/supabase/cli/postgres:17.6.1.165"; it("leaves a slim image unrewritten, whatever the registry override says", () => { diff --git a/apps/cli/src/command-internal/docker-remove-all.ts b/apps/cli/src/command-internal/docker-remove-all.ts index d2d20c37d6..f1116300cd 100644 --- a/apps/cli/src/command-internal/docker-remove-all.ts +++ b/apps/cli/src/command-internal/docker-remove-all.ts @@ -17,12 +17,9 @@ import { listContainerIdsAndNames, type ContainerIdName } from "./docker-lifecyc type Spawner = ChildProcessSpawner["Service"]; /** - * Failure taxonomy for {@link dockerRemoveAll}. Each variant is a neutral, stage-tagged - * cause carrying only a `.message` — same generalization pattern as `docker-lifecycle.ts`'s - * `DockerLifecycleListError`/`DockerLifecycleInspectError`. Callers (`stop.handler.ts` - * via `Effect.catchTags`; `command-internal/db-bootstrap/rollback.ts` via a blanket swallow) discriminate/consume these by - * their string `_tag`, never by importing the classes themselves. The classes - * are exported so the exhaustive telemetry guard can verify their declarations. + * Failure taxonomy for {@link dockerRemoveAll}. Callers discriminate these by their string + * `_tag` rather than importing the classes; they're exported only so the exhaustive telemetry + * guard can verify each declaration. */ class DockerRemoveAllListError extends Data.TaggedError("DockerRemoveAllListError")<{ readonly message: string; @@ -85,10 +82,8 @@ function parsePrunedNames(stdout: string): ReadonlyArray { } /** - * `--debug` prune reports: `fmt.Fprintln(os.Stderr, - * "Pruned containers:", report.ContainersDeleted)` and siblings — the `[]string` - * renders as `[a b c]` (empty: `[]`), always on stderr regardless of the writer - * the caller passed, and only when `viper.GetBool("DEBUG")` is set. + * Prints a `--debug`-only report of pruned names to stderr, formatted as a bracketed + * space-separated list (e.g. `[a b c]`, empty: `[]`). */ const reportPruned = (debug: boolean, label: string, stdout: string) => Effect.sync(() => { @@ -105,32 +100,11 @@ export type DockerRemoveAllError = | DockerRemoveAllNetworkPruneError; /** - * Port of `DockerRemoveAll`: list every - * container matching `filterValue` regardless of state -> stop them all concurrently, joining - * every failure rather than short-circuiting on the first one (`WaitAll`) -> `container - * prune --force --filter label=` -> when `deleteVolumes`, `volume prune --force - * [--all] --filter label=` (the `--all` flag itself gated on - * {@link dockerSupportsVolumePruneAllFlag}, Docker API >= 1.42) -> `network prune --force - * --filter label=`. - * - * `onContainersRemoved`, if given, fires synchronously once `container prune` (the actual - * removal step below) has EXITED SUCCESSFULLY — not at the initial listing, and not before - * containers are even stopped — with the exact containers that listing found, id/name/workdir - * together. A TS-port-only hook with no Go equivalent, for callers (`stop.handler.ts`, - * `command-internal/db-bootstrap/rollback.ts`) that need those same containers for {@link cleanupStartSecrets} - * (Go itself doesn't stage host-disk secrets, so it has no reason to know them). It exists so - * those callers get this data from THIS function's own single `docker ps` listing instead of - * issuing a second, separately-formatted `docker ps` call, which would double the real Docker - * Engine API request count relative to Go and fail the cli-e2e-ci request-log parity check. - * - * Firing AFTER `container prune` succeeds (rather than at listing time) matters: the listing - * only snapshots what MIGHT be torn down, before the stop/prune stages have actually run — firing - * there let a caller reclaim a container's staged-secret directory even when the stop stage fails - * outright (so `container prune` never even runs and nothing was actually removed) or a - * still-running container survives. Firing here instead means a caller only ever reclaims - * secrets for containers `container prune` has ACTUALLY removed. It still fires even if a LATER - * stage (volume/network prune) then fails, so those confirmed-removed containers' secrets are - * still reclaimed on that partial failure — matching the fix landed in `983eab92`. + * Lists every container matching `filterValue` (any state), stops them all concurrently — + * joining every failure rather than stopping at the first — then prunes containers, volumes + * (when `deleteVolumes`), and networks scoped to the same label. `onContainersRemoved`, when + * given, fires once `container prune` exits successfully with the containers the initial listing + * found, so callers don't have to re-list; it still fires even if a later prune stage fails. */ export const dockerRemoveAll = ( spawner: Spawner, @@ -146,16 +120,13 @@ export const dockerRemoveAll = ( }).pipe(Effect.mapError((cause) => new DockerRemoveAllListError({ message: cause.message }))); const containerIds = containers.map((container) => container.id); - // Go stops containers concurrently via `WaitAll`, joining every failure rather than - // short-circuiting on the first one. + // Stop every container concurrently, joining every failure rather than short-circuiting on + // the first one. // - // `stdout`/`stderr: "ignore"` on the exit-code-only `stop` calls below: they never read the - // child's own output, and the default `"pipe"` stdio otherwise leaves an OS pipe unread — - // once `docker`/`podman` write enough to it, the child blocks on write() and this hangs. - // Matches the existing `stdio: "ignore"` precedent for the same "exit-code-only" shape in - // `pgdelta.seam.layer.ts`. The prune calls further down instead COLLECT stdout (via - // `containerCliExitCodeAndStdout`, which reads the pipe, equally avoiding the hang) - // because their deleted-ID reports back `--debug` `Pruned …:` stderr lines. + // `stdout`/`stderr: "ignore"`: these exit-code-only calls never read the child's output, and + // the default `"pipe"` stdio would leave an unread OS pipe — once docker/podman writes enough + // to it, the child blocks on write() and this hangs. The prune calls below instead collect + // stdout (avoiding the hang the same way) because they need it for the `--debug` report. const stopResults = yield* Effect.all( containerIds.map((id) => containerCliExitCode(spawner, ["stop", id], { @@ -181,10 +152,8 @@ export const dockerRemoveAll = ( ); } - // The prune calls collect stdout (the CLI's deleted-ID report) instead of - // ignoring it — reading the pipe equally avoids the unread-pipe hang the - // exit-code-only calls above dodge with `stdout: "ignore"`, and the report - // backs `--debug` `Pruned …:` stderr lines. + // Collects stdout instead of ignoring it — reading the pipe avoids the same hang, and the + // report backs the `--debug` output below. const containerPrune = yield* containerCliExitCodeAndStdout(spawner, [ "container", "prune", @@ -205,26 +174,15 @@ export const dockerRemoveAll = ( ); } yield* reportPruned(debug, "Pruned containers:", containerPrune.stdout); - // Containers are now CONFIRMED removed — see `onContainersRemoved`'s doc comment for why this - // must fire here rather than at the listing above, and why it still must fire even if a later - // stage (volume/network prune, below) goes on to fail. + // Containers are confirmed removed only now — see {@link dockerRemoveAll}'s doc comment. onContainersRemoved?.(containers); if (deleteVolumes) { - // The `--all` filter arg is gated on Docker API >= 1.42: Docker CLI's own `volume - // prune --all` flag is annotated `version: "1.42"` and enforced by - // its own arg validator before the command runs — - // on an older daemon, passing `--all` unconditionally - // would hard-fail this whole call and prune nothing, not just prune a narrower set. There's - // no persistent Engine API client here to ask the negotiated version directly, so - // {@link dockerSupportsVolumePruneAllFlag} asks the `docker` CLI itself via `docker - // version` and applies the same gate. - // - // Podman is a Docker-CLI-compatible fallback — but `--all` isn't a real flag on any - // released Podman `volume prune` (only `--filter`/`--force`/`--help`, checked v4.3 through - // the current v5.7; `--all` only exists in unreleased dev docs), so it hard-fails on a real - // Podman-only host. Podman already prunes every unused volume by default, so omitting - // `--all` on the Podman fallback is a lossless fix. + // `--all` requires Docker API >= 1.42 and is validated by the Docker CLI itself, so passing + // it unconditionally would hard-fail on an older daemon; ask `docker version` via + // {@link dockerSupportsVolumePruneAllFlag} instead of negotiating the API version directly. + // Podman has no `--all` flag on `volume prune` at all, but already prunes every unused + // volume by default, so omitting `--all` there is a lossless fallback. const dockerSupportsAll = yield* dockerSupportsVolumePruneAllFlag(spawner); const volumePrune = yield* containerCliExitCodeAndStdout( spawner, @@ -250,8 +208,6 @@ export const dockerRemoveAll = ( new DockerRemoveAllVolumePruneError({ message: "failed to prune volumes" }), ); } - // Inside the `deleteVolumes` branch, like Go's report inside the - // `NoBackupVolume` block. yield* reportPruned(debug, "Pruned volumes:", volumePrune.stdout); } @@ -274,6 +230,6 @@ export const dockerRemoveAll = ( new DockerRemoveAllNetworkPruneError({ message: "failed to prune networks" }), ); } - // Go: singular "network", unlike the other two reports. + // Established output text: singular "network", unlike the container/volume reports. yield* reportPruned(debug, "Pruned network:", networkPrune.stdout); }); diff --git a/apps/cli/src/command-internal/docker-run.args.ts b/apps/cli/src/command-internal/docker-run.args.ts index 675aeb6e3f..b76539517b 100644 --- a/apps/cli/src/command-internal/docker-run.args.ts +++ b/apps/cli/src/command-internal/docker-run.args.ts @@ -20,29 +20,19 @@ export function buildDockerArgs(opts: DockerRunOpts): ReadonlyArray { "run", "--rm", ...networkArgs, - // `HostConfig.ExtraHosts` (DockerStart) → docker CLI `--add-host`. ...extraHosts.flatMap((h) => ["--add-host", h]), ...binds.flatMap((b) => ["-v", b]), - // Emit the key-only `-e KEY` form so values (e.g. PGPASSWORD) never appear - // in the host process argv (`ps aux` / `/proc//cmdline`). Docker reads - // each value from the spawning process's environment instead — the layer - // merges `env` into the docker child's environment before spawning. Go avoids - // this exposure entirely by passing `container.Config.Env` over the Docker - // socket API; this is the CLI-shell equivalent (CWE-214). + // Emit the key-only `-e KEY` form so values (e.g. PGPASSWORD) never appear in the host + // process argv (`ps aux`/`/proc//cmdline`, CWE-214); Docker reads each value from the + // spawning process's own environment, which the layer merges `env` into before spawning. ...Object.keys(env).flatMap((k) => ["-e", k]), ...securityOpt.flatMap((s) => ["--security-opt", s]), ...(Option.isSome(workingDir) ? ["-w", workingDir.value] : []), - // `DockerStart` unconditionally sets `com.supabase.cli.project` and - // `com.docker.compose.project` on `config.Labels` for EVERY container it - // starts, one-shot jobs included — `supabase stop` and this shell's own rollback both discover - // orphaned containers by that project-label filter - // (`docker-remove-all.ts`), so a one-shot job left running after a - // client interruption/daemon disconnect must carry the same labels to be - // found. Empty unless the caller opts in (review: Codex, PR #6022). + // A one-shot job left running after a client interruption or daemon disconnect must still + // carry the project labels `stop`/rollback use to discover orphaned containers by label + // filter (see `docker-remove-all.ts`); empty unless the caller opts in. ...Object.entries(labels).flatMap(([k, v]) => ["--label", `${k}=${v}`]), - // `--entrypoint` must precede the image (it is a `docker run` flag); the - // remaining `cmd` tokens become the entrypoint's args, mirroring Go's - // `Entrypoint: [value, ...cmd]`. + // `--entrypoint` must precede the image; the remaining `cmd` tokens become its arguments. ...(Option.isSome(entrypoint) ? ["--entrypoint", entrypoint.value] : []), image, ...cmd, @@ -50,12 +40,9 @@ export function buildDockerArgs(opts: DockerRunOpts): ReadonlyArray { } /** - * Mirror `DockerStart` Bitbucket Pipelines handling: - * when `BITBUCKET_CLONE_DIR` is set, - * that runner disallows named volumes and `--security-opt`, so Go drops named-volume - * binds and clears `SecurityOpt` before starting any container. Applied globally to - * every docker run (matching Go's placement) — e.g. the pg-delta Deno-cache - * named volume is dropped while the `:/workspace` bind mount is kept. + * Bitbucket Pipelines' Docker-in-Docker runner disallows named volumes and `--security-opt`, so + * drop named-volume binds and clear `securityOpt` when `BITBUCKET_CLONE_DIR` is set — e.g. the + * pg-delta Deno-cache named volume is dropped while a `:/workspace` bind mount is kept. */ export function applyBitbucketDockerFilter( opts: DockerRunOpts, diff --git a/apps/cli/src/command-internal/docker-run.layer.ts b/apps/cli/src/command-internal/docker-run.layer.ts index 5a363a0179..752c02307d 100644 --- a/apps/cli/src/command-internal/docker-run.layer.ts +++ b/apps/cli/src/command-internal/docker-run.layer.ts @@ -17,9 +17,8 @@ export const dockerRunLayer: Layer.Layer - // Never embed the spawn error verbatim: it can leak the full argv and - // environment of the failed exec (CWE-214/209). Emit a fixed, - // credential-free message that still points at the likely cause. + // The raw spawn error can leak the failed exec's full argv and environment, so emit a + // fixed, credential-free message instead. new DockerRunError({ message: `failed to run docker. ${SUGGEST_DOCKER_INSTALL}`, reason: "spawn", @@ -56,10 +55,8 @@ export const dockerRunLayer: Layer.Layer Effect.sync(() => { stderrChunks.push(chunk); - // Tee container stderr to the parent terminal in real time only - // when the caller opts in — `db dump` mirrors Go's - // `io.MultiWriter(os.Stderr, errBuf)`, while the edge-runtime / - // pg-delta path keeps stderr buffered (Go passes a bare - // `bytes.Buffer`) and surfaces it only on failure. + // Tee container stderr to the parent terminal in real time only when the + // caller opts in; otherwise it's buffered and surfaced only on failure. if (teeStderr) globalThis.process.stderr.write(chunk); }), ), @@ -123,10 +117,9 @@ export const dockerRunLayer: Layer.Layer = []; - // Stream stdout to the caller's sink in arrival order while draining - // stderr concurrently — reading one pipe to completion before the other - // would deadlock once the unread pipe's OS buffer fills. Go does the same - // via `stdcopy.StdCopy(stdout, stderr, logs)`. + // Stream stdout to the caller's sink in arrival order while draining stderr + // concurrently — reading one pipe to completion before the other would deadlock + // once the unread pipe's OS buffer fills. yield* Effect.all( [ // Map the stdout pipe's own read errors to a docker error while letting @@ -159,15 +152,11 @@ export const dockerRunLayer: Layer.Layer/cmdline`. - // `extendEnv: true` keeps the rest of process.env (PATH, DOCKER_HOST, - // …) so the docker invocation behaves like the parent shell's. - // Never embed the spawn error verbatim: it can leak the full argv and - // environment of the failed exec (CWE-214/209). Emit a fixed, - // credential-free message that still points at the likely cause. + // Pass run env (incl. PGPASSWORD) through the docker child's own environment, not + // the argv — `buildDockerArgs` emits the key-only `-e KEY` form, so docker inherits + // each value from here. `extendEnv: true` keeps the rest of process.env (PATH, + // DOCKER_HOST, …) so the invocation behaves like the parent shell's. The spawn error + // below omits the raw argv/environment for the same reason as `spawnError` above. const exitCode = yield* containerCliExitCode(spawner, args, { stdin: "inherit", stdout: "inherit", diff --git a/apps/cli/src/command-internal/docker-run.service.ts b/apps/cli/src/command-internal/docker-run.service.ts index 06ab8bfa73..2dc7236cb0 100644 --- a/apps/cli/src/command-internal/docker-run.service.ts +++ b/apps/cli/src/command-internal/docker-run.service.ts @@ -14,54 +14,37 @@ export interface DockerRunOpts { readonly workingDir: Option.Option; readonly securityOpt: ReadonlyArray; /** - * Overrides the image's `ENTRYPOINT` (docker CLI `--entrypoint`). Go sets - * `container.Config.Entrypoint` directly when it must replace an image's own - * entrypoint — e.g. `RunEdgeRuntimeScript` runs `sh -c ` instead of - * the edge-runtime image's default `edge-runtime` entrypoint - * (`apps/cli-go/internal/utils/edgeruntime.go`). Omitted (or `None`) keeps the - * image's entrypoint, matching the pg_dump / pg_prove containers. + * Overrides the image's `ENTRYPOINT` (docker CLI `--entrypoint`), e.g. running `sh -c ` + * instead of the edge-runtime image's default entrypoint. Omitted (or `None`) keeps the image's + * own entrypoint. */ readonly entrypoint?: Option.Option; /** - * Extra `host:ip` mappings (`--add-host`). Go populates `HostConfig.ExtraHosts` - * in `DockerStart` with `host.docker.internal:host-gateway` on Linux - * (`apps/cli-go/internal/utils/docker_linux.go`); empty on macOS/Windows. + * Extra `host:ip` mappings (`--add-host`): `host.docker.internal:host-gateway` on Linux, empty + * on macOS/Windows. */ readonly extraHosts: ReadonlyArray; readonly network: DockerNetwork; /** - * Docker labels (`--label k=v`) applied to the container. `DockerStart` - * unconditionally sets `com.supabase.cli.project`/`com.docker.compose.project` - * on every container it starts, - * including one-shot jobs — omitted here (defaults to none) for callers whose - * container is always synchronously reaped by the SAME process before it could - * ever need project-label-based discovery (e.g. `db dump`/`pg_prove`/edge-runtime - * one-shot runs); set by callers whose container can outlive an interrupted - * process (e.g. `start`'s fresh-volume PG15+ realtime/storage/auth migrate jobs) - * so `supabase stop`/rollback's project-label filter - * (`docker-remove-all.ts`) can still find and remove it if orphaned. + * Docker labels (`--label k=v`) applied to the container. Omitted (the default) for callers + * whose container is always synchronously reaped by this same process; set by callers whose + * container can outlive an interrupted process, so `stop`/rollback's project-label filter (see + * `docker-remove-all.ts`) can still find and remove it if orphaned. */ readonly labels?: Readonly>; /** - * Skips this layer's own image resolution (`makeDockerImageResolver`) when the - * caller already resolved `image` itself through a `projectEnvValues`-aware path (e.g. - * `start`'s one-shot fresh-DB setup jobs, resolved via `ensureImagesCached` before - * `startSetupLocalDatabase` ever calls this service). This layer's own resolver is - * built once, statically, with no `projectEnvValues` in scope (see - * `docker-run.layer.ts`'s header) — re-resolving an ALREADY-resolved image (e.g. - * `registry.example.com/supabase/gotrue:v2.192.0`) would treat it as a fresh, unresolved - * image reference and rebuild registry candidates from it using the DEFAULT registry, - * silently discarding a project-dotenv-only `SUPABASE_INTERNAL_IMAGE_REGISTRY` override. - * Defaults to `false` (existing ambient-only resolution) for every other caller. + * Skips this layer's own image resolution when the caller already resolved `image` through a + * `projectEnvValues`-aware path. This layer's own resolver has no `projectEnvValues` in scope, + * so re-resolving an already-resolved image would rebuild registry candidates using the default + * registry, discarding a project-dotenv-only registry override. Defaults to `false`. */ readonly skipImageResolve?: boolean; } /** - * The result of a captured `docker run`: the container's exit code, its full - * stdout as raw bytes (so binary-safe SQL dumps survive intact), and its stderr - * decoded as text for failure classification. Mirrors `dockerExec`, which - * streams stdout to the caller's writer and tees stderr into a buffer. + * The result of a captured `docker run`: the container's exit code, its full stdout as raw bytes + * (so binary-safe output survives intact), and its stderr decoded as text for failure + * classification. */ interface DockerRunCaptureResult { readonly exitCode: number; @@ -73,37 +56,27 @@ interface DockerRunShape { /** Runs `docker run --rm ...`, inheriting stdio, returns the container's exit code. */ readonly run: (opts: DockerRunOpts) => Effect.Effect; /** - * Runs `docker run --rm ...` capturing the full stdout into a buffer (instead of - * inheriting it) and collecting stderr for classification. Used by the declarative - * edge-runtime / pg-delta export, which must parse the whole stdout payload as JSON. - * (`db dump` streams instead — see {@link runStream}.) + * Runs `docker run --rm ...`, capturing the full stdout into a buffer (instead of inheriting + * it) and collecting stderr for classification. Used by callers that must parse the whole + * stdout payload (e.g. as JSON) rather than stream it (`db dump` streams instead — see + * {@link runStream}). * - * `teeStderr` controls whether container stderr is also written to the parent - * terminal in real time. The edge-runtime / pg-delta path leaves it off (Go passes - * a plain `bytes.Buffer`, surfacing stderr only on failure — - * `apps/cli-go/internal/utils/edgeruntime.go:79-113`). + * `teeStderr` controls whether container stderr is also written to the parent terminal in real + * time. */ readonly runCapture: ( opts: DockerRunOpts, captureOpts?: { readonly teeStderr?: boolean }, ) => Effect.Effect; /** - * Runs `docker run --rm ...` streaming container stdout to `onStdout` chunk-by-chunk - * as it arrives (instead of buffering), while collecting stderr for classification. - * Mirrors `DockerStreamLogs` → `stdcopy.StdCopy(stdout, stderr, logs)` with - * `Follow:true`: the destination is - * the real sink, so a large `db dump` streams to `--file`/stdout at constant memory - * and a piped consumer sees output incrementally. + * Runs `docker run --rm ...`, streaming container stdout to `onStdout` chunk-by-chunk as it + * arrives (instead of buffering), while collecting stderr for classification. `onStdout` chunks + * are delivered in arrival order; its failure aborts the run and propagates as `E`. Returns the + * exit code and captured stderr; stdout bytes are not retained. * - * `onStdout` chunks are delivered in arrival order; its failure aborts the run and - * propagates as `E`. `teeStderr` mirrors `runCapture` (Go's - * `io.MultiWriter(os.Stderr, errBuf)`). Returns the exit code + captured stderr; the - * stdout bytes are not retained. - * - * `captureStderr` (default `true`) buffers stderr for that returned string. Callers - * that only tee it and never read the result should pass `false` — retaining it - * grows with the container's total stderr, which the inherited-stdio `run` never did - * (`test db`, whose pgTAP suites can emit unbounded psql notices). + * `teeStderr` mirrors {@link runCapture}. `captureStderr` (default `true`) buffers stderr for + * the returned string — pass `false` when the caller only tees it, since a container with + * unbounded stderr (e.g. `test db`'s pgTAP notices) would otherwise grow without bound. */ readonly runStream: ( opts: DockerRunOpts, diff --git a/apps/cli/src/command-internal/docker-suggest.ts b/apps/cli/src/command-internal/docker-suggest.ts index b3f71b10a4..c879d030de 100644 --- a/apps/cli/src/command-internal/docker-suggest.ts +++ b/apps/cli/src/command-internal/docker-suggest.ts @@ -1,46 +1,15 @@ import { containerRuntimeNotFoundMessage } from "./container-cli.ts"; -/** - * Go's Docker prerequisite hint (`apps/cli-go/internal/utils/docker.go:350`, - * `suggestDockerInstall`). Go sets it as `CmdSuggestion` — rendered as a separate - * "Suggestion:" line — whenever a container-runtime call fails because the daemon - * is unreachable (`client.IsErrConnectionFailed`, `misc.go:148-154`). - */ +/** Prerequisite hint shown whenever a container-runtime call fails because the daemon is unreachable. */ export const SUGGEST_DOCKER_INSTALL = "Docker Desktop is a prerequisite for local development. Follow the official docs to install: https://docs.docker.com/desktop"; /** - * Whether a container-CLI stderr indicates the daemon is unreachable — the - * subprocess-stderr equivalent of Go's `client.IsErrConnectionFailed` (which - * inspects the Docker API client error). The docker / podman CLIs print - * "Cannot connect to the Docker daemon …" / "Cannot connect to Podman …" (often - * followed by "Is the docker daemon running?") when the socket is down, and - * "permission denied while trying to connect to the Docker daemon socket …" - * when the socket exists but the user can't open it — the pinned Docker SDK - * classifies that permission error as a connection failure too - * (`client/request.go:144-152`, docker/docker v28.5.2: `os.IsPermission` → - * `errConnectionFailed`), so Go surfaces the install hint for it as well. - * - * Also matches `spawnContainerCli`'s runtime-not-found message - * (`containerRuntimeNotFoundMessage`, imported from - * `container-cli.ts` rather than duplicated here so the producer and - * this classifier can't drift apart): a missing container-CLI binary is the - * shell-out equivalent of Go's missing daemon socket — on a machine with no - * Docker installed, Go's socket dial fails, `client.IsErrConnectionFailed` - * fires, and the install hint is exactly the guidance that case needs - * (`misc.go:155-166`). - * - * "error during connect" is the pinned SDK's uniform outer wrap for every - * transport-level failure (`client/request.go:175-185`, docker/docker - * v28.5.2) and therefore the closest 1:1 stderr equivalent of - * `errConnectionFailed`'s breadth. It notably covers Windows daemon-down, - * where a failed npipe open is wrapped as "error during connect: this error - * may indicate that the docker daemon is not running: …" (elevated, - * `request.go:181`) or "… the docker client must be run with elevated - * privileges …" (non-elevated, `request.go:178`) — word orders none of the - * Unix-socket phrases match. The inner OS text ("The system cannot find the - * file specified") is localized per the SDK's own comment - * (`request.go:172-174`), so it is deliberately not matched. + * Whether a container-CLI stderr indicates the daemon is unreachable. Matches the docker/podman + * "cannot connect"/"is the docker daemon running" messages, a socket permission-denied message, + * the generic "error during connect" wrapper (which also covers Windows npipe daemon-down text), + * and {@link containerRuntimeNotFoundMessage} — a missing CLI binary gets the same install hint + * as a missing daemon. */ export function isDockerDaemonUnreachable(stderr: string): boolean { return ( diff --git a/apps/cli/src/command-internal/docker-suggest.unit.test.ts b/apps/cli/src/command-internal/docker-suggest.unit.test.ts index b29eac2c24..eb857679d8 100644 --- a/apps/cli/src/command-internal/docker-suggest.unit.test.ts +++ b/apps/cli/src/command-internal/docker-suggest.unit.test.ts @@ -10,25 +10,14 @@ describe("isDockerDaemonUnreachable", () => { "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", ), ).toBe(true); - // Case-insensitive + the podman phrasing. expect(isDockerDaemonUnreachable("cannot connect to podman")).toBe(true); expect(isDockerDaemonUnreachable("Is the docker daemon running?")).toBe(true); - // Socket permission errors are connection failures in the pinned Docker - // SDK (`client/request.go:144-152`, v28.5.2: `os.IsPermission` → - // `errConnectionFailed`), so Go attaches the install hint for them too. expect( isDockerDaemonUnreachable( "permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock", ), ).toBe(true); - // No container runtime installed at all (`spawnContainerCli`'s - // runtime-not-found message) — the shell-out equivalent of Go's missing - // daemon socket, which `IsErrConnectionFailed` also classifies as a - // connection failure, so the install hint applies. expect(isDockerDaemonUnreachable(containerRuntimeNotFoundMessage)).toBe(true); - // Windows daemon-down: a failed npipe open is wrapped "error during - // connect" by the pinned SDK (`client/request.go:175-185`, v28.5.2) — - // both the elevated and the non-elevated variants. expect( isDockerDaemonUnreachable( 'error during connect: this error may indicate that the docker daemon is not running: Get "http://%2F%2F.%2Fpipe%2Fdocker_engine/v1.51/containers/supabase_db_test/json": open //./pipe/docker_engine: The system cannot find the file specified.', diff --git a/apps/cli/src/command-internal/dotenv.ts b/apps/cli/src/command-internal/dotenv.ts index 5b6b524cca..2fb5fbc016 100644 --- a/apps/cli/src/command-internal/dotenv.ts +++ b/apps/cli/src/command-internal/dotenv.ts @@ -1,29 +1,21 @@ -// godotenv.Parse-compatible parser: `KEY=VALUE` / `KEY="VALUE"` lines, `#` -// comments, blank lines, and an optional `export ` prefix. A line with an empty -// or invalid variable name throws (`godotenv.Parse` surfaces -// `unexpected character ... in variable name`). +// godotenv-compatible: `KEY=VALUE`/`KEY="VALUE"` lines, `#` comments, blank lines, and an +// optional `export ` prefix. An empty or invalid variable name throws. const EXPORT_PREFIX = /^\s*export\s+/; /** - * Minimal godotenv parser for project `.env` files. Returns the parsed key/value - * map. Throws an `Error` whose message mirrors Go's parser for a malformed - * variable name so callers can surface the same failure (`"!="` → unexpected - * character). - * - * Shared by `bootstrap` (`.env.example` merge) and the db-config reader's nested - * `.env` loader (`readDbToml`), so it lives in `command-internal/`. + * Minimal godotenv-compatible parser for project `.env` files. Throws when a variable name is + * empty or contains an invalid character. */ export function parseDotEnv(contents: string): Record { const result: Record = {}; - // godotenv normalizes CRLF→LF and scans the **whole buffer** with a cursor rather - // than line-by-line, so a quoted value may span newlines (a PEM block / private - // key). `parseBytes`. + // Normalize CRLF→LF and scan the whole buffer with a cursor (not line-by-line), so a quoted + // value can span newlines (e.g. a PEM block). let src = contents.replaceAll("\r\n", "\n"); for (;;) { src = skipToStatementStart(src); if (src.length === 0) break; - // `export ` prefix, then the key up to the first `=`/`:` (YAML-style). Key chars - // must be `[A-Za-z0-9_.]`; a stray char throws Go's "unexpected character". + // Strip an optional `export ` prefix, then read the key up to the first `=`/`:`. Key chars + // must be `[A-Za-z0-9_.]`; anything else throws. src = src.replace(EXPORT_PREFIX, ""); let sep = -1; for (let i = 0; i < src.length; i++) { @@ -44,8 +36,8 @@ export function parseDotEnv(contents: string): Record { `unexpected character "${src[0] ?? ""}" in variable name near "${firstLine(src)}"`, ); } - // godotenv expands `$VAR`/`${VAR}` references against variables defined **earlier - // in the same file** (the in-progress map), so assign in file order. + // `$VAR`/`${VAR}` references expand against variables defined earlier in the same file, so + // assign in file order. const { value, rest } = extractVarValue(src.slice(sep + 1), result); result[key] = value; src = rest; @@ -60,9 +52,8 @@ function firstLine(src: string): string { } /** - * Advance past blank lines and whole `#` comment lines to the next statement start, - * mirroring godotenv's `getStatementStart`. Comments are skipped - * here (before value scanning), so an apostrophe inside a comment never opens a quote. + * Advances past blank lines and whole `#` comment lines to the next statement start. Comments + * are skipped before value scanning, so an apostrophe inside a comment never opens a quote. */ function skipToStatementStart(src: string): string { let i = 0; @@ -83,18 +74,15 @@ function skipToStatementStart(src: string): string { return src.slice(i); } -// godotenv's `expandVarRegex`: an optional -// leading backslash, `$`, an optional `(`, an optional `{`, an optional -// `[A-Z0-9_]+` name, and an optional `}`. +// Optional leading backslash, `$`, optional `(`, optional `{`, an optional `[A-Z0-9_]+` name, +// and an optional closing `}`. const EXPAND_VAR_REGEX = /(\\)?(\$)(\()?\{?([A-Z0-9_]+)?\}?/g; /** - * Expand `$VAR`/`${VAR}` references, a 1:1 port of godotenv's `expandVariables`: - * a leading backslash (`\$VAR`) or a `$(`-form is returned - * with its first character dropped (no expansion / no command substitution); a - * matched `[A-Z0-9_]+` name expands to `vars[name]` (an undefined reference - * becomes the empty string); a bare `$` with no name is left unchanged. Only - * uppercase/digit/underscore names are recognized, matching the Go regex. + * Expands `$VAR`/`${VAR}` references. A leading backslash (`\$VAR`) or a `$(`-form is returned + * with its first character dropped (no expansion / no command substitution); a matched + * `[A-Z0-9_]+` name expands to `vars[name]` (undefined becomes empty string); a bare `$` with no + * name is left unchanged. */ function expandVariables(value: string, vars: Record): string { return value.replace(EXPAND_VAR_REGEX, (match, backslash, _dollar, paren, name) => { @@ -109,24 +97,19 @@ function expandVariables(value: string, vars: Record): string { } /** - * Extract a single dotenv value starting just after the `=`/`:`, matching godotenv's - * `extractVarValue`. Returns the parsed - * value and the remaining buffer (`rest`) so the caller can continue scanning. + * Extracts a single dotenv value starting just after the `=`/`:`. Returns the parsed value and + * the remaining buffer so the caller can continue scanning. * - * A quoted value (single or double) runs to its matching unescaped closing quote - * **across newlines** — a PEM/private key spanning lines parses as one value, and an - * "unterminated quoted value" error only fires after scanning to end-of-input. - * Anything after the closing quote on that line (e.g. a trailing comment) is - * discarded. An unquoted value runs to the end of its line, strips the inline - * comment, and is trimmed. Double-quoted values expand `\n`/`\r` escapes and then - * `$VAR` references (real embedded newlines pass through verbatim); single-quoted - * values are literal. + * A quoted value runs to its matching unescaped closing quote across newlines (so a multiline + * PEM key parses as one value), discarding anything after the closing quote on that line. An + * unquoted value runs to end of line, stripping the inline comment. Double-quoted values expand + * `\n`/`\r` escapes then `$VAR` references; single-quoted values are literal. */ function extractVarValue( raw: string, vars: Record, ): { value: string; rest: string } { - // godotenv left-trims spaces/tabs after `=` (not newlines) before the value. + // Left-trim spaces/tabs after `=` (not newlines) before the value. const value = raw.replace(/^[ \t]+/, ""); const quote = value[0]; if (quote === '"' || quote === "'") { @@ -149,10 +132,9 @@ function extractVarValue( const nl = afterQuote.indexOf("\n"); const rest = nl === -1 ? "" : afterQuote.slice(nl); if (quote === '"') { - // Double-quoted: expand escapes first, then variable references (godotenv: - // `expandVariables(expandEscapes(value), vars)`). `\n`/`\r` become real - // newlines, a backslash before any other char (except `$`) is dropped — so - // `\$` survives to suppress expansion. Real embedded newlines pass through. + // Double-quoted: expand escapes first, then variable references. `\n`/`\r` become real + // newlines; a backslash before any other char (except `$`) is dropped, so `\$` survives + // to suppress expansion. const escaped = inner .replaceAll("\\n", "\n") .replaceAll("\\r", "\r") @@ -171,9 +153,9 @@ function extractVarValue( } /** - * Strip an unquoted inline comment, matching godotenv: scanning from the right, - * a `#` preceded by whitespace begins a comment (`54323 # local` → `54323`), - * while a `#` with no leading whitespace is part of the value (`foo#bar`). + * Strips an unquoted inline comment: a `#` preceded by whitespace begins a comment + * (`54323 # local` → `54323`), while a `#` with no leading whitespace is part of the value + * (`foo#bar`). */ function stripInlineComment(value: string): string { for (let i = value.length - 1; i > 0; i--) { diff --git a/apps/cli/src/command-internal/drop-objects.ts b/apps/cli/src/command-internal/drop-objects.ts index 18a1881ca0..dc08d6f928 100644 --- a/apps/cli/src/command-internal/drop-objects.ts +++ b/apps/cli/src/command-internal/drop-objects.ts @@ -17,14 +17,12 @@ export class MigrationDropError extends Data.TaggedError("MigrationDropError")<{ } /** - * A single PL/pgSQL `DO` block that drops user schemas, non-managed - * extensions, and `public`-schema objects, drops every RLS policy and every - * non-Supabase publication database-wide (not just in `public`), then - * truncates the managed `auth` / `supabase_functions` / `supabase_migrations` - * tables. The schema loop anti-joins `pg_depend` scoped by `classid` to - * `pg_namespace` rows so an oid collision with another catalog cannot hide a - * user schema (supabase/cli#6375). Shared by `migration down` and `db reset` - * (`drop-schemas.ts`). + * A single PL/pgSQL `DO` block that drops user schemas, non-managed extensions, and + * `public`-schema objects, drops every RLS policy and every non-Supabase publication + * database-wide (not just in `public`), then truncates the managed + * `auth`/`supabase_functions`/`supabase_migrations` tables. The schema loop anti-joins + * `pg_depend` scoped by `classid` to `pg_namespace` rows so an oid collision with another + * catalog cannot hide a user schema. */ export const dropObjectsSql = `do $$ declare rec record; diff --git a/apps/cli/src/command-internal/edge-runtime-image.ts b/apps/cli/src/command-internal/edge-runtime-image.ts index f7f7cd0dfe..3f602bdc0b 100644 --- a/apps/cli/src/command-internal/edge-runtime-image.ts +++ b/apps/cli/src/command-internal/edge-runtime-image.ts @@ -7,31 +7,24 @@ import { import { slimImageForCurrentPin } from "../shared/services/slim-images.ts"; /** - * Resolves the edge-runtime Docker image the way Go's `config.Load` does - * (`apps/cli-go/pkg/config/config.go:445,682-683,999-1007`), for the - * declarative pg-delta scripts that run inside the edge-runtime container. - * - * The default tag is baked into the Go binary via the embedded Dockerfile. A - * pinned tag in `supabase/.temp/edge-runtime-version` overrides it (written by - * `supabase start`). `edge_runtime.deno_version = 1` selects the legacy `deno1` - * image instead (default `deno_version = 2` keeps the Dockerfile image). + * Resolves the edge-runtime Docker image: the default tag comes from the Dockerfile image, a + * pinned tag in `supabase/.temp/edge-runtime-version` overrides it, and + * `edge_runtime.deno_version = 1` selects the legacy `deno1` image instead. */ // Read per call, not captured at import time, so `SUPABASE_USE_SLIM_IMAGES` is // observed by the resolver (and by tests that stub the env). export const edgeRuntimeDockerfileImage = () => dockerfileServiceImage("edgeruntime"); -// `deno1` (`pkg/config/constants.go:15`) — used when `deno_version = 1`. No slim -// build exists for it, so it stays on docker.io regardless of the flag — the -// same exception `edgeRuntimeImage` (`shared/functions/functions.shared.ts`) -// applies for the functions Docker paths reading the SAME pin file. +// Used when `deno_version = 1`. No slim build exists for it, so it stays on docker.io regardless +// of the flag — the same exception `edgeRuntimeImage` (`shared/functions/functions.shared.ts`) +// applies for the functions Docker paths reading the same pin file. const EDGE_RUNTIME_DENO1_IMAGE = `supabase/edge-runtime:${DENO1_EDGE_RUNTIME_VERSION}`; /** - * Resolve the edge-runtime image, honoring the pinned tag in - * `supabase/.temp/edge-runtime-version` and the `deno_version` selector - * (default 2 → Dockerfile image; 1 → `deno1`). The version pin is applied first, - * then `deno_version = 1` overrides to `deno1`. Historical pins stay on - * docker.io — those slim tags are not published. + * Resolves the edge-runtime image, honoring the pinned tag in + * `supabase/.temp/edge-runtime-version` and the `deno_version` selector (default 2 → Dockerfile + * image; 1 → `deno1`). The version pin is applied first, then `deno_version = 1` overrides to + * `deno1`. Historical pins stay on docker.io — those slim tags are not published. */ export const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, diff --git a/apps/cli/src/command-internal/edge-runtime-script.errors.ts b/apps/cli/src/command-internal/edge-runtime-script.errors.ts index 07e0642f88..3dbb9225f8 100644 --- a/apps/cli/src/command-internal/edge-runtime-script.errors.ts +++ b/apps/cli/src/command-internal/edge-runtime-script.errors.ts @@ -6,12 +6,10 @@ import { } from "../shared/telemetry/error-actionability.ts"; /** - * Running a TypeScript program inside the edge-runtime container failed (non-zero - * exit whose stderr does not contain `"main worker has been destroyed"`, which - * Go intentionally swallows). Byte-matches Go's wrapping - * `errors.Errorf("%s: %w:\n%s", errPrefix, err, stderr)` in `RunEdgeRuntimeScript` - * (`apps/cli-go/internal/utils/edgeruntime.go`), where `errPrefix` is supplied by - * the caller (e.g. `"error diffing schema"`). + * Running a TypeScript program inside the edge-runtime container failed: a non-zero exit whose + * stderr does not contain `"main worker has been destroyed"` (which is otherwise swallowed). The + * message is `: :\n`, where `errPrefix` is supplied by the caller (e.g. + * `"error diffing schema"`). */ export class EdgeRuntimeScriptError extends Data.TaggedError("EdgeRuntimeScriptError")<{ readonly message: string; diff --git a/apps/cli/src/command-internal/edge-runtime-script.layer.integration.test.ts b/apps/cli/src/command-internal/edge-runtime-script.layer.integration.test.ts index 37e5a1a238..8757f24e13 100644 --- a/apps/cli/src/command-internal/edge-runtime-script.layer.integration.test.ts +++ b/apps/cli/src/command-internal/edge-runtime-script.layer.integration.test.ts @@ -14,10 +14,8 @@ import { DockerRun, type DockerRunOpts } from "./docker-run.service.ts"; import { edgeRuntimeScriptLayer } from "./edge-runtime-script.layer.ts"; import { EdgeRuntimeScript } from "./edge-runtime-script.service.ts"; -// Fakes a `docker run --rm` capture: the pg-delta scripts throw to force the -// worker to exit, so a real diff always comes back with a non-zero exit and -// "main worker has been destroyed" in stderr — the crash path only differs by -// the sentinel line the templates' catch blocks add. +// Fakes a `docker run --rm` capture. A real diff always exits non-zero with "main worker has +// been destroyed" in stderr; the crash path only differs by the added sentinel line. function fakeDocker(result: { exitCode: number; stdout?: string; stderr?: string }) { let lastOpts: DockerRunOpts | undefined; return { @@ -40,8 +38,8 @@ function fakeDocker(result: { exitCode: number; stdout?: string; stderr?: string }; } -// `workdir` points at a directory without `supabase/.temp/edge-runtime-version`, -// so the image resolver falls back to the default tag (the read is orElseSucceed). +// Points at a directory with no `supabase/.temp/edge-runtime-version`, so the image resolver +// falls back to the default tag. function makeCliSettings(workdir = "/nonexistent-workdir") { return Layer.succeed(CommandSettings, { profile: "supabase", @@ -99,8 +97,6 @@ describe("edgeRuntimeScriptLayer sentinel handling", () => { it.effect( "fails with the real error when the script crashes behind the worker-destroyed message", () => { - // The catch block emits the real error, the sentinel, and the worker is torn - // down — all with a non-zero exit. This must surface, not read as an empty diff. const stderr = "error: permission denied for table pg_user_mapping\n" + "PGDELTA_SCRIPT_ERROR\n" + @@ -115,7 +111,6 @@ describe("edgeRuntimeScriptLayer sentinel handling", () => { : undefined; const message = (error as { message: string } | undefined)?.message ?? ""; expect(message).toContain("error diffing schema: error running script:"); - // The actionable permission error must reach the user. expect(message).toContain("permission denied for table pg_user_mapping"); }), ), @@ -125,8 +120,6 @@ describe("edgeRuntimeScriptLayer sentinel handling", () => { ); it.effect("still succeeds on a worker-destroyed exit when no sentinel is present", () => { - // Success path: the template forces the worker to exit after writing output, so - // the exit is non-zero with "main worker has been destroyed" but no sentinel. return runScript().pipe( Effect.tap((res) => Effect.sync(() => { @@ -146,12 +139,6 @@ describe("edgeRuntimeScriptLayer sentinel handling", () => { it.effect( "resolves the image pin from `opts.workdir`, overriding the layer's own `cliSettings.workdir`", () => { - // Regression coverage (review thread on CLI-1953): `bootstrap` targets a - // directory other than the invocation directory, and `CommandSettings` is - // built once, before that `process.chdir` runs — so its `workdir` never - // reflects bootstrap's real target. `opts.workdir` (threaded from - // `PgDeltaContext.cwd` by every pg-delta/migra caller) must win the - // pin-file lookup instead. const configWorkdir = mkdtempSync(join(tmpdir(), "edge-runtime-config-")); const callerWorkdir = mkdtempSync(join(tmpdir(), "edge-runtime-caller-")); mkdirSync(join(configWorkdir, "supabase", ".temp"), { recursive: true }); @@ -217,8 +204,6 @@ describe("edgeRuntimeScriptLayer sentinel handling", () => { it.effect( "disables SELinux label separation so the container can read CLI-written workspace files", () => { - // Without this the pg-delta CA bundle written under `supabase/.temp/pgdelta/` - // is unreadable through the `/workspace` bind on SELinux hosts (supabase/cli#5989). const { layer, docker } = setup({ exitCode: 1, stdout: "{}", diff --git a/apps/cli/src/command-internal/edge-runtime-script.layer.ts b/apps/cli/src/command-internal/edge-runtime-script.layer.ts index e123e61207..a5423f8128 100644 --- a/apps/cli/src/command-internal/edge-runtime-script.layer.ts +++ b/apps/cli/src/command-internal/edge-runtime-script.layer.ts @@ -17,9 +17,8 @@ import { } from "./edge-runtime-script.service.ts"; /** - * Asks the OS for an unused TCP port on 127.0.0.1, like `getFreeHostPort`. - * On failure the caller drops the `--port` flag (Go preserves prior behaviour), - * so this resolves to `None` rather than failing the whole run. + * Asks the OS for an unused TCP port on 127.0.0.1. Resolves to `None` on failure so the caller + * can drop the `--port` flag instead of failing the whole run. */ const allocateFreeHostPort = Effect.callback>((resume) => { const server = Net.createServer(); @@ -32,15 +31,13 @@ const allocateFreeHostPort = Effect.callback>((resume) => }); /** - * Real `EdgeRuntimeScript`: runs the Deno program in the edge-runtime - * container via `DockerRun.runCapture`, overriding the image entrypoint - * with `sh -c ` (`RunEdgeRuntimeScript`). The image (from the - * caller's effective `deno_version`) and a fresh free port are resolved per run, - * so layer construction reads no config (it would validate base config before a - * linked command resolves its ref). + * Real `EdgeRuntimeScript`: runs the Deno program in the edge-runtime container via + * `DockerRun.runCapture`, overriding the image entrypoint with `sh -c `. The image + * (from the caller's effective `deno_version`) and a fresh free port are resolved per run, so + * layer construction reads no config. * - * NOTE: the non-zero-exit message string is approximated from the docker exit - * code and should be golden-verified against the Go binary. + * NOTE: the non-zero-exit message string is approximated from the docker exit code and may not + * exactly match real edge-runtime output. */ export const edgeRuntimeScriptLayer = Layer.effect( EdgeRuntimeScript, @@ -52,20 +49,15 @@ export const edgeRuntimeScriptLayer = Layer.effect( const debug = yield* DebugFlag; const networkIdFlag = yield* NetworkIdFlag; const runtimeInfo = yield* RuntimeInfo; - // `DockerStart` appends `host.docker.internal:host-gateway` to every - // container's ExtraHosts on Linux only (build-tag `extraHosts` in - // `apps/cli-go/internal/utils/docker_linux.go:8`; the append at `docker.go:266` - // is unconditional but the slice is empty on macOS/Windows). The pg-delta - // container needs it so a `host.docker.internal` local DB host (from - // SUPABASE_SERVICES_HOSTNAME) resolves inside the container on Linux/dev-container. + // The pg-delta container needs `host.docker.internal:host-gateway` on Linux only, so a + // `host.docker.internal` local DB host (from SUPABASE_SERVICES_HOSTNAME) resolves inside the + // container on Linux/dev-container; Docker Desktop already provides this on macOS/Windows. const extraHosts = runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; - // Go requests host networking for the edge-runtime container, but `DockerStart` - // overrides any network mode (host included) with `--network-id` when set. - // Mirror the sibling pattern in - // `db dump` / `gen types` / `test db` so declarative pg-delta runs reach the - // local stack on custom networks. + // Host networking is the default so pg-delta reaches the local stack directly, but an + // explicit `--network-id` overrides it — matching the same pattern used by + // `db dump`/`gen types`/`test db`. const networkId = Option.getOrUndefined(networkIdFlag); const network = networkId !== undefined && networkId.length > 0 @@ -75,24 +67,14 @@ export const edgeRuntimeScriptLayer = Layer.effect( return EdgeRuntimeScript.of({ run: (opts) => Effect.gen(function* () { - // Resolve the image per-run from the caller's effective `deno_version` — - // the remote-merged value the handler resolved AFTER the linked ref. The - // config read happens here, not at layer acquisition, so merely composing - // the db diff/pull runtime never validates the base config before the - // linked ref is known (Go validates the `[remotes.]`-merged config, - // and even `db diff --use-pgadmin --linked` — a native path since CLI-1968, - // reading config directly rather than exec'ing a Go child, and never calling - // this layer's `run` at all — must not fail at layer build). Every pg-delta/ - // migra caller passes `opts.denoVersion`, so the base read is a defensive - // fallback that does not run for them. + // Resolved per-run, not at layer acquisition, so merely composing this runtime never + // validates the base config before a linked ref is known. Every pg-delta/migra caller + // passes `opts.denoVersion`, so this read only runs as a defensive fallback. // - // Same per-run override for `workdir`: `cliSettings.workdir` is fixed at - // layer-build time, before a command's own `process.chdir` (bootstrap's - // real target directory only exists once its handler runs — see - // `bootstrap.handler.ts`), so every pg-delta/migra caller passes its own - // `ctx.cwd` here too, keeping the image-pin lookup and the base-config - // fallback read consistent with the workdir the rest of the run actually - // targets. + // `workdir` follows the same pattern: `cliSettings.workdir` is fixed at layer-build + // time, before a command's own `process.chdir` runs, so callers pass their own + // directory to keep the image-pin lookup and config fallback consistent with the rest + // of the run. const workdir = opts.workdir ?? cliSettings.workdir; const denoVersion = opts.denoVersion ?? @@ -116,20 +98,18 @@ export const edgeRuntimeScriptLayer = Layer.effect( env, binds: opts.binds, workingDir: Option.none(), - // SELinux-enforcing hosts (e.g. Fedora + rootless Podman) block the - // container from reading CLI-generated files under the `/workspace` - // bind, like the pg-delta CA bundle (supabase/cli#5989). Disable label - // separation for this helper container instead of relabeling the - // user's project files — same as `db test`'s pg_prove run; Bitbucket CI clears it - // via `applyBitbucketDockerFilter`. + // SELinux-enforcing hosts (e.g. Fedora + rootless Podman) block the container from + // reading CLI-generated files under the `/workspace` bind, like the pg-delta CA + // bundle. Disable label separation for this helper container instead of relabeling + // the user's project files — same as `db test`'s pg_prove run; Bitbucket CI clears + // it via `applyBitbucketDockerFilter`. securityOpt: ["label:disable"], extraHosts, network, }) - // A spawn failure (e.g. Docker not installed) carries no container - // stderr; wrap it with the caller's prefix like `%s: %w`. - // Thread the docker discriminant so a daemon-down / registry-pull - // failure at the docker boundary is not misclassified as user SQL. + // A spawn failure (e.g. Docker not installed) carries no container stderr, so wrap + // it with the caller's prefix. Threading the docker discriminant keeps a + // daemon-down/registry-pull failure from being misclassified as user SQL. .pipe( Effect.mapError( (cause) => @@ -145,9 +125,9 @@ export const edgeRuntimeScriptLayer = Layer.effect( ), ); - // Go ignores the error when stderr reports the runtime tore down its - // worker after the script completed (the script's output is still - // valid). Any other non-zero exit is a real failure. + // A non-zero exit is ignored when stderr reports the runtime tore down its worker + // after the script completed — the script's output is still valid. Any other + // non-zero exit is real. if (result.exitCode !== 0 && !result.stderr.includes("main worker has been destroyed")) { return yield* Effect.fail( new EdgeRuntimeScriptError({ @@ -156,13 +136,10 @@ export const edgeRuntimeScriptLayer = Layer.effect( ); } - // The pg-delta templates force the worker to exit by throwing, so a - // script crash is masked by the "main worker has been destroyed" - // suppression above. The sentinel — printed only by the templates' - // catch blocks — marks a real failure so the collected stderr (which - // holds the real error) reaches the user instead of looking like an - // empty diff. Byte-for-byte port of Go's check in - // apps/cli-go/internal/utils/edgeruntime.go. + // A script crash is otherwise masked by the "main worker has been destroyed" + // suppression above, since the templates force the worker to exit by throwing. The + // sentinel — printed only by the templates' catch blocks — marks that real failure so + // the collected stderr reaches the user instead of looking like an empty diff. if (result.stderr.includes(EDGE_RUNTIME_SCRIPT_ERROR_SENTINEL)) { return yield* Effect.fail( new EdgeRuntimeScriptError({ diff --git a/apps/cli/src/command-internal/edge-runtime-script.service.ts b/apps/cli/src/command-internal/edge-runtime-script.service.ts index 47c7e08486..08b643db5f 100644 --- a/apps/cli/src/command-internal/edge-runtime-script.service.ts +++ b/apps/cli/src/command-internal/edge-runtime-script.service.ts @@ -3,15 +3,12 @@ import { Context, type Effect, Option } from "effect"; import type { EdgeRuntimeScriptError } from "./edge-runtime-script.errors.ts"; /** - * Printed to stderr by the pg-delta Deno templates when their body throws (see - * the `catch` blocks in `apps/cli-go/internal/db/diff/templates/*.ts`). The - * templates force the edge-runtime worker to exit by throwing on both the - * success and failure paths, and that non-zero exit is otherwise suppressed when - * stderr contains `"main worker has been destroyed"`. Without a distinct marker - * a crashed script is indistinguishable from a successful empty diff, so - * `db pull` reports "No schema changes found" while the real error is swallowed. - * Byte-for-byte mirror of `EdgeRuntimeScriptErrorSentinel` - * (`apps/cli-go/internal/utils/edgeruntime.go`). See supabase/cli#5826. + * Printed to stderr when a pg-delta Deno template's body throws. The templates force the + * edge-runtime worker to exit by throwing on both the success and failure paths, and that + * non-zero exit is otherwise suppressed whenever stderr contains `"main worker has been + * destroyed"`. Without this marker, a crashed script is indistinguishable from a successful + * empty diff, so `db pull` would report "No schema changes found" while swallowing the real + * error. */ export const EDGE_RUNTIME_SCRIPT_ERROR_SENTINEL = "PGDELTA_SCRIPT_ERROR"; @@ -28,22 +25,20 @@ export interface EdgeRuntimeRunOpts { readonly env: Readonly>; /** Volume binds (e.g. the Deno cache volume + `cwd:/workspace`). */ readonly binds: ReadonlyArray; - /** Prefix for the failure message, matching `errPrefix`. */ + /** Prefix for the failure message. */ readonly errPrefix: string; /** - * Effective `edge_runtime.deno_version` for this run, used to pick the image tag - * (`1` → the `deno1` image). Lets a caller that has the remote-merged config (e.g. - * `--linked` declarative generate) override the layer's base-config default so - * pg-delta runs under the configured Deno version. Absent → the base-config value. + * Effective `edge_runtime.deno_version` for this run, used to pick the image tag (`1` → the + * `deno1` image). Lets a caller with the remote-merged config (e.g. `--linked` declarative + * generate) override the layer's base-config default. Absent → the base-config value. */ readonly denoVersion?: number; /** - * The caller's authoritative target directory (e.g. `PgDeltaContext.cwd`), - * used to resolve the `supabase/.temp/edge-runtime-version` image pin (and, when - * `denoVersion` is absent, the base-config fallback read). Overrides the layer's - * own `CommandSettings.workdir` — needed because that layer is built once, before - * a command's own `process.chdir` (e.g. `bootstrap`, whose real target directory - * only exists after its handler runs). Absent → the layer's `CommandSettings.workdir`. + * The caller's authoritative target directory (e.g. `PgDeltaContext.cwd`), used to resolve the + * `supabase/.temp/edge-runtime-version` image pin and, when `denoVersion` is absent, the + * base-config fallback. Overrides the layer's own `CommandSettings.workdir`, needed because + * that layer is built once, before a command's own `process.chdir` (e.g. `bootstrap`) runs. + * Absent → the layer's `CommandSettings.workdir`. */ readonly workdir?: string; } @@ -55,12 +50,10 @@ interface EdgeRuntimeRunResult { interface EdgeRuntimeScriptShape { /** - * Runs a Deno program in the edge-runtime container and returns its captured - * stdout/stderr. Mirrors `RunEdgeRuntimeScript` - * (`apps/cli-go/internal/utils/edgeruntime.go`): writes the files via a - * here-document entrypoint, starts `edge-runtime start --main-service=.` on a - * free host port over the host network, and ignores a non-zero exit whose - * stderr contains `"main worker has been destroyed"`. + * Runs a Deno program in the edge-runtime container and returns its captured stdout/stderr: + * writes the files via a here-document entrypoint, starts `edge-runtime start + * --main-service=.` on a free host port over the host network, and ignores a non-zero exit + * whose stderr contains `"main worker has been destroyed"`. */ readonly run: ( opts: EdgeRuntimeRunOpts, @@ -72,11 +65,9 @@ export class EdgeRuntimeScript extends Context.Service; diff --git a/apps/cli/src/command-internal/ensure-login.ts b/apps/cli/src/command-internal/ensure-login.ts index f9dbe44d3a..e6c453bfe4 100644 --- a/apps/cli/src/command-internal/ensure-login.ts +++ b/apps/cli/src/command-internal/ensure-login.ts @@ -17,20 +17,18 @@ import { LoginFailedError, type LoginVerificationError } from "../commands/login import { dashboardUrl } from "./profile.ts"; import { resolveAccessToken } from "./resolve-token.ts"; -// `maxRetries`: the initial probe plus 2 retries (3 total). +// Initial probe plus 2 retries (3 total). const MAX_LOGIN_RETRIES = 2; export const LOGGED_IN_MSG = "You are now logged in. Happy coding!\n"; /** - * Mirrors `handleTelemetryAfterLogin`: fetch the gotrue - * id (best-effort), stitch or clear the telemetry identity, then always capture - * `cli_login_completed`. The capture rides the just-stitched identity so PostHog - * attributes it to the user. + * Fetches the gotrue id (best-effort), stitches or clears the telemetry identity, then always + * captures `cli_login_completed`, riding the just-stitched identity so PostHog attributes it to + * the user. * - * NOTE: `StitchLogin` only *aliases* — it does NOT call `identify`. Do not add - * `analytics.identify` here; it would emit an event the established telemetry - * never sends. Shared by the token path (`login`) and the browser flow. + * `stitchLogin` only aliases — it does not call `identify`. Do not add `analytics.identify` here; + * it would emit an event the established telemetry never sends. */ export const postLoginTelemetry = Effect.fnUntraced(function* (token: string) { const loginApi = yield* LoginApi; @@ -58,12 +56,11 @@ export interface BrowserLoginOptions { } /** - * The interactive browser login flow, extracted from `login`'s handler so - * `bootstrap` can reuse it: generate an ECDH keypair, surface the dashboard - * login link (optionally opening the browser), poll for the verification code - * with Go's retry/notify cadence, decrypt + persist the token, then run the - * post-login telemetry and print the success banners. Owns the single - * `cli_login_completed` capture for this path. + * The interactive browser login flow, shared by `login` and `bootstrap`: generates an ECDH + * keypair, surfaces the dashboard login link (optionally opening the browser), polls for the + * verification code with a retry/notify cadence, decrypts and persists the token, then runs the + * post-login telemetry and prints the success banners. Owns the single `cli_login_completed` + * capture for this path. */ export const browserLogin = Effect.fnUntraced(function* (opts: BrowserLoginOptions) { const output = yield* Output; @@ -83,7 +80,7 @@ export const browserLogin = Effect.fnUntraced(function* (opts: BrowserLoginOptio ? opts.tokenName.value : yield* crypto.defaultTokenName; - // Go concatenates the query string without URL-encoding. + // Established behavior: the query string is concatenated without URL-encoding. const loginUrl = `${dashboardUrl(cliSettings.profile)}/cli/login` + `?session_id=${sessionId}&token_name=${tokenName}&public_key=${publicKeyHex}`; @@ -111,9 +108,8 @@ export const browserLogin = Effect.fnUntraced(function* (opts: BrowserLoginOptio yield* output.raw(`Here is your login link, open it in the browser ${loginUrl}\n\n`, "stdout"); } - // Verify + retry, mirroring `pollForAccessToken` backoff: - // the notifier prints `\nRetry (n/2): ` after the - // first 2 failures; the 3rd failure gives up without a notice. + // Verify with retry: prints `\nRetry (n/2): ` after each of the first 2 failures; the 3rd + // failure gives up without a notice. const verifyWithRetries = ( failuresSoFar: number, ): Effect.Effect< @@ -153,8 +149,8 @@ export const browserLogin = Effect.fnUntraced(function* (opts: BrowserLoginOptio publicKey: session.public_key, nonce: session.nonce, }); - // Go returns the raw save error here — not the - // "cannot save provided token" wrapper used on the token path. + // Returns the raw save error here, not the "cannot save provided token" wrapper used on the + // token path. yield* credentials.saveAccessToken(token); yield* postLoginTelemetry(token); @@ -168,10 +164,9 @@ export const browserLogin = Effect.fnUntraced(function* (opts: BrowserLoginOptio }); /** - * Ensures a Management API access token exists. Mirrors `bootstrap` login - * step: if a token is already resolvable (env / keyring / - * file) it is a no-op; otherwise the browser login flow runs and fires - * `cli_login_completed` once. + * Ensures a Management API access token exists: a no-op if a token is already resolvable + * (env/keyring/file), otherwise runs the browser login flow and fires `cli_login_completed` + * once. */ export const ensureLogin = Effect.fnUntraced(function* (opts: { openBrowser: boolean }) { const existing = yield* resolveAccessToken; diff --git a/apps/cli/src/command-internal/error-message.ts b/apps/cli/src/command-internal/error-message.ts index 84e67a6f80..711da7dccf 100644 --- a/apps/cli/src/command-internal/error-message.ts +++ b/apps/cli/src/command-internal/error-message.ts @@ -1,9 +1,6 @@ /** - * Best-effort extraction of a human-readable message from an unknown thrown/failed - * value — an Effect `PlatformError`, a driver error, a plain `Error`, or anything else. - * Shared by every command module that wraps a raw Effect/driver failure into Go-style - * error text (Go's own `err.Error()` equivalent), so wording stays consistent across - * call sites instead of each one re-deriving its own fallback. + * Best-effort extraction of a human-readable message from an unknown thrown/failed value — an + * Effect `PlatformError`, a driver error, a plain `Error`, or anything else. */ export const errorMessage = (e: unknown): string => typeof e === "object" && e !== null && "message" in e && typeof e.message === "string" @@ -11,17 +8,10 @@ export const errorMessage = (e: unknown): string => : String(e); /** - * Substitutes an absolute path a real syscall needed back to its Go-equivalent - * display path inside an already-rendered error message. Go's own `fsys` is always a - * real `afero.OsFs` with the process cwd already `chdir`'ed into the workdir - * (`ChangeWorkDir`, `cmd/root.go`), so every Go error message embeds the - * workdir-relative (or verbatim-absolute) path it was actually called with. This shell - * deliberately never `process.chdir`s, so its own syscalls need a real absolute path to - * work — but the wrapped message must still report the Go-equivalent path, not the - * local temp/workdir absolute path the syscall needed, or it leaks a path Go would - * never show. Shared by every command module that wraps a raw filesystem failure this - * way (`sql-files-glob.ts`'s matched-file/matched-directory warnings, - * `migration-apply.ts`'s migration-file read errors). + * Substitutes an absolute path a real syscall needed back to the display path used in error + * messages. This shell never `process.chdir`s, so its own syscalls need a real absolute path, + * but the wrapped message must still report the workdir-relative display path — otherwise it + * leaks the local temp/workdir absolute path. */ export const relativizeErrorMessage = ( rawMessage: string, diff --git a/apps/cli/src/command-internal/experimental-gate.ts b/apps/cli/src/command-internal/experimental-gate.ts index a3d0c5b886..3b1ccffa95 100644 --- a/apps/cli/src/command-internal/experimental-gate.ts +++ b/apps/cli/src/command-internal/experimental-gate.ts @@ -8,29 +8,12 @@ import { import { resolveExperimental } from "./global-flags.ts"; /** - * The Effect-CLI replacement for Go's root-level experimental gate - * (`apps/cli-go/cmd/root.go:91-96`): - * - * ```go - * if IsExperimental(cmd) && !viper.GetBool("EXPERIMENTAL") { - * return errors.New("must set the --experimental flag to run this command") - * } - * ``` - * - * `IsExperimental` is true for the commands registered in the `experimental` - * slice and their direct children (`root.go:56-74`), which includes `storageCmd`. - * Go enforces this in `PersistentPreRunE`, which cobra runs BEFORE - * `ValidateFlagGroups()` (mutual-exclusivity checks) and `RunE`/`PersistentPostRun` - * (`cobra@v1.10.2/command.go:985,1010,1014`) — so a closed gate must NOT run - * mutual-exclusivity checks, emit `cli_command_executed`, or write the - * telemetry/linked-project files. (Cobra's positional-argument count/type - * validation, `ValidateArgs`, runs even earlier, at `command.go:968` — the gate - * does not preempt that.) Each native experimental leaf therefore calls this in - * its `.command.ts` before any mutual-exclusivity check and before - * `withCommandTelemetry`. - * - * The message byte-matches Go's `errors.New(...)`; the value is resolved with the - * `SUPABASE_EXPERIMENTAL` viper fallback (see {@link resolveExperimental}). + * Gates access to experimental commands and their children. Must run before any + * mutual-exclusivity check, `withCommandTelemetry`, and any telemetry/linked-project file write — + * a closed gate must produce none of those side effects. Each native experimental leaf calls + * {@link requireExperimental} first in its `.command.ts` for this reason. The message text and + * the `SUPABASE_EXPERIMENTAL` env fallback (see {@link resolveExperimental}) are established + * behavior and must not change casually. */ export class ExperimentalRequiredError extends Data.TaggedError("ExperimentalRequiredError")<{ readonly message: string; diff --git a/apps/cli/src/command-internal/experimental-gate.unit.test.ts b/apps/cli/src/command-internal/experimental-gate.unit.test.ts index 6415f01436..c0cdc0f60d 100644 --- a/apps/cli/src/command-internal/experimental-gate.unit.test.ts +++ b/apps/cli/src/command-internal/experimental-gate.unit.test.ts @@ -41,9 +41,6 @@ describe("requireExperimental", () => { "fails even with SUPABASE_EXPERIMENTAL=1 when --experimental=false is explicit (viper Changed wins)", () => Effect.gen(function* () { - // viper's bound-pflag lookup returns the flag value whenever Changed is true — - // BEFORE falling back to AutomaticEnv — so an - // explicit --experimental=false must win over SUPABASE_EXPERIMENTAL=1. const saved = process.env[ENV]; process.env[ENV] = "1"; const error = yield* requireExperimental.pipe( @@ -60,14 +57,6 @@ describe("requireExperimental", () => { "passes with SUPABASE_EXPERIMENTAL=1 when --experimental=false is a positional operand after --", () => Effect.gen(function* () { - // Both pflag/cobra (a value placed after -- - // never sets cmd.Flags().Changed(...)) and this CLI's own lexer - // (effect/unstable/cli/internal/lexer.ts, `argv.indexOf("--")`) stop parsing - // flags at the first bare `--`. A positional operand that merely LOOKS like a - // flag — e.g. a migration name literally called `--experimental=false` passed - // as `db pull -- --experimental=false` — must not be mistaken for an explicit - // `--experimental=false` and must not suppress the SUPABASE_EXPERIMENTAL=1 - // AutomaticEnv fallback. const saved = process.env[ENV]; process.env[ENV] = "1"; const exit = yield* requireExperimental.pipe( @@ -84,11 +73,6 @@ describe("requireExperimental", () => { "a repeated --experimental=false --experimental=true keeps the LAST occurrence (viper Set() wins)", () => Effect.gen(function* () { - // pflag/viper bind ONE variable per flag, so repeated occurrences collapse to - // whichever Set() call happened last — verified empirically against the pinned - // cobra@v1.10.2/pflag@v1.0.10/viper@v1.21.0 versions. A scan that - // merely checks "does any pre-terminator token say false" gets this ordering - // backwards and would incorrectly fail open here. const exit = yield* requireExperimental.pipe( Effect.provide( withFlag(false, ["db", "pull", "--experimental=false", "--experimental=true"]), diff --git a/apps/cli/src/command-internal/fail-on.ts b/apps/cli/src/command-internal/fail-on.ts index b6ab407b6e..79f2b41c7a 100644 --- a/apps/cli/src/command-internal/fail-on.ts +++ b/apps/cli/src/command-internal/fail-on.ts @@ -1,21 +1,18 @@ /** - * Shared `--fail-on` / `--level` machinery for `db lint` and `db advisors`. + * Shared `--fail-on`/`--level` machinery for `db lint` and `db advisors`. * - * Both commands map a textual issue level to an ordinal so a minimum-level - * filter (`--level`) and a fail-on threshold (`--fail-on`) become integer - * comparisons, exactly like Go's `toEnum` (`internal/db/lint/lint.go:33-40`, - * `internal/db/advisors/advisors.go:38-48`). The two commands differ only in how - * a level string maps to an ordinal: + * Both commands map a textual issue level to an ordinal so a minimum-level filter (`--level`) + * and a fail-on threshold (`--fail-on`) become integer comparisons. The two commands differ only + * in how a level string maps to an ordinal: * - * - **lint** uses `strings.HasPrefix(level, allowed[i])` over - * `["warning", "error"]`, so `"warning extra"` still resolves to `warning`. - * - **advisors** uses an exact, case-insensitive switch over - * `["info", "warn", "error"]` matching only the lower- or upper-case form - * (`"info"`/`"INFO"`), so a mixed-case `"Info"` resolves to `-1`. + * - **lint** uses a prefix match over `["warning", "error"]`, so `"warning extra"` still + * resolves to `warning`. + * - **advisors** uses an exact, case-insensitive match over `["info", "warn", "error"]` + * matching only the lower- or upper-case form (`"info"`/`"INFO"`), so a mixed-case `"Info"` + * resolves to `-1`. * - * An unmatched level returns `-1` in both, which is below every real level — so - * a `--fail-on` of `-1` (i.e. `none`) never triggers, and a `--level` of `-1` - * keeps everything. + * An unmatched level returns `-1` in both, which is below every real level — so a `--fail-on` of + * `-1` (i.e. `none`) never triggers, and a `--level` of `-1` keeps everything. */ /** How a level string maps to its ordinal — see module docs. */ @@ -53,9 +50,8 @@ export function makeLevelEnum(allowed: ReadonlyArray, matcher: LevelMatc } /** - * Whether any item's level meets or exceeds the `--fail-on` threshold, porting - * the shared tail of Go's `Run` / `outputAndCheck` (`lint.go:67-76`, - * `advisors.go:253-260`). A `failOnLevel` below 0 (`none`) never triggers. + * Whether any item's level meets or exceeds the `--fail-on` threshold. A `failOnLevel` below 0 + * (`none`) never triggers. * * The caller flattens to the right granularity — lint checks every issue across * every result, advisors checks each lint — and supplies the fail message, diff --git a/apps/cli/src/command-internal/functions-go-config.ts b/apps/cli/src/command-internal/functions-go-config.ts index 1b628bbe56..d107110529 100644 --- a/apps/cli/src/command-internal/functions-go-config.ts +++ b/apps/cli/src/command-internal/functions-go-config.ts @@ -8,19 +8,14 @@ function toError(cause: unknown): Error { } /** - * Go-parity config resolution for the native `functions` Docker paths - * (`deploy`/`download`/`serve`), injected into `functions-config.ts`'s - * `loadFunctionsCliConfig` so `shared/functions/` never imports - * the command tree's validation directly (same isolation rationale as - * `styleEmphasis`/`styleAqua`). + * Config resolution for the native `functions` Docker paths (`deploy`/`download`/`serve`), + * injected into `functions-config.ts`'s `loadFunctionsCliConfig` so `shared/functions/` never + * imports the command tree's validation directly. * - * Delegates entirely to the SAME two functions `start`/`stop`/`status` - * already share — `loadLocalProjectContext` (dotenv + config load) and - * `resolveLocalConfigValues` (`Config.Validate`, one home per - * `apps/cli/CLAUDE.md`) — rather than re-implementing either. Their - * derived local-dev values (JWTs, URLs) are discarded here; only - * `projectId`/`edgeRuntimeDenoVersion` and the validation side effect - * (throws on the first Go-parity failure) matter to these three commands. + * Delegates to the same `loadLocalProjectContext`/`resolveLocalConfigValues` pair + * `start`/`stop`/`status` already use, rather than re-implementing either. Their derived + * local-dev values (JWTs, URLs) are discarded here; only `projectId`/`edgeRuntimeDenoVersion` and + * the validation side effect (throws on the first failure) matter to these three commands. */ export const functionsGoConfigCompat: FunctionsGoConfigCompat = { load: ({ projectRoot, projectRef }) => @@ -34,16 +29,12 @@ export const functionsGoConfigCompat: FunctionsGoConfigCompat = { projectRoot, context.projectEnvValues, context.loaded?.document, - // No `[remotes.]` override-tier gating (empty set, the - // parameter default): the remote block itself already merged over - // the base config at file level via `loadLocalProjectContext`'s - // `projectRef` threading above. Known narrow divergence: without - // the key set, an ambient `SUPABASE_EDGE_RUNTIME_DENO_VERSION` - // still beats a matched remote block's own `deno_version`, where - // Go's OVERRIDE-tier `v.Set` would win — computing the keys here - // needs `db-config.toml-read.ts`'s remote-resolution - // pipeline, which this `loadCliConfig`-based path doesn't run - // (review round on CLI-1963). + // No `[remotes.]` override-tier gating (empty set, the default): the remote + // block already merged over the base config at file level via + // `loadLocalProjectContext`'s `projectRef` threading above. Known narrow divergence: + // an ambient `SUPABASE_EDGE_RUNTIME_DENO_VERSION` still beats a matched remote + // block's own `deno_version` here, since computing the override keys needs + // `db-config.toml-read.ts`'s remote-resolution pipeline, which this path doesn't run. undefined, projectRef, ), @@ -52,15 +43,12 @@ export const functionsGoConfigCompat: FunctionsGoConfigCompat = { return { loaded: context.loaded, projectEnvValues: context.projectEnvValues, - // `context.projectId`, NOT `validated.projectId`: the context's id is - // the one built for Docker naming/labels — sanitized, `--project-ref` - // defaulted, and `SUPABASE_PROJECT_ID`-gated when a `[remotes.]` - // block matched (Go installs the remote's own `project_id` at viper's - // OVERRIDE tier, above `AutomaticEnv` — `pkg/config/config.go:718-724`; - // see `local-project-context.ts`'s gate, review - // PRRT_kwDOErm0O86XHGDL). `validated.projectId` exists only to feed - // `validateResolvedConfig`'s emptiness check and deliberately - // skips that gate — see its own doc comment. + // `context.projectId`, not `validated.projectId`: the context's id is the one built for + // Docker naming/labels — sanitized, `--project-ref` defaulted, and + // `SUPABASE_PROJECT_ID`-gated when a `[remotes.]` block matched (see + // `local-project-context.ts`'s gate). `validated.projectId` exists only to feed + // `validateResolvedConfig`'s emptiness check and skips that gate — see its own doc + // comment. projectId: context.projectId, denoVersion: validated.edgeRuntimeDenoVersion, }; diff --git a/apps/cli/src/command-internal/get-api-keys.ts b/apps/cli/src/command-internal/get-api-keys.ts index b303664e13..9f3889a440 100644 --- a/apps/cli/src/command-internal/get-api-keys.ts +++ b/apps/cli/src/command-internal/get-api-keys.ts @@ -18,21 +18,17 @@ const mapApiKeysError = mapHttpError({ }); /** - * Ports Go's `apiKeys.RunGetApiKeys` (`apps/cli-go/internal/projects/apiKeys/api_keys.go:41-49`, - * deleted in CLI-1970; last present at commit 7b469f5b3): - * `GET /v1/projects/{ref}/api-keys`, mapping transport / non-200 failures to the same - * `failed to get api keys` / `unexpected get api keys status` errors Go raises. Shared by + * Fetches `GET /v1/projects/{ref}/api-keys`, mapping transport/non-200 failures to + * `failed to get api keys`/`unexpected get api keys status` errors. Shared by * `projects api-keys` (display) and `bootstrap` (which derives the `.env` keys). * - * When `reveal` is `true`, the `reveal=true` query param is sent so the Management API - * returns the full secret keys (prefix `sb_secret_`) in `api_key` instead of `null` - * (issue #4775). The param is omitted entirely when `reveal` is `false` to keep the - * default request byte-identical to Go's (`bootstrap` only consumes the never-redacted - * anon key, so it stays on the default path). + * When `reveal` is `true`, the `reveal=true` query param is sent so the Management API returns + * the full secret keys (prefix `sb_secret_`) in `api_key` instead of `null`. The param is omitted + * entirely when `reveal` is `false` to keep the default request payload unchanged. * - * Resolves the client lazily via `CommandPlatformApiFactory.make` so callers on the local - * path (no `--linked`) never trigger Management API auth. The factory is memoised, so - * repeated calls in the same command invocation reuse the same client. + * Resolves the client lazily via `CommandPlatformApiFactory.make` so callers on the local path + * (no `--linked`) never trigger Management API auth. The factory is memoised, so repeated calls + * in the same command invocation reuse the same client. */ export const getProjectApiKeys = Effect.fnUntraced(function* (ref: string, reveal = false) { const api = yield* (yield* CommandPlatformApiFactory).make; From 5ff1eeefad40f1cf89dc572fde00146cc5ff3fa6 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 10 Sep 2026 12:01:35 +0100 Subject: [PATCH 05/56] chore(repo): trim comments, shard 08 (1021 -> 518 comment lines) --- .../command-internal/db-config.toml-read.ts | 1099 +++++------------ 1 file changed, 298 insertions(+), 801 deletions(-) diff --git a/apps/cli/src/command-internal/db-config.toml-read.ts b/apps/cli/src/command-internal/db-config.toml-read.ts index 9cb638dc8a..a53802313b 100644 --- a/apps/cli/src/command-internal/db-config.toml-read.ts +++ b/apps/cli/src/command-internal/db-config.toml-read.ts @@ -32,20 +32,15 @@ type EnvLookup = (name: string) => string | undefined; /** * Subset of `supabase/config.toml` (plus the linked pooler URL) the db-config - * resolver needs. - * - * A **missing** config file yields the default config values, but a - * **malformed** file is a hard error (aborts the command rather than running - * against the default local database). + * resolver needs. A missing config file yields defaults; a malformed one aborts + * the command instead of running against the default local database. */ export interface DbTomlValues { readonly projectEnv: Readonly>; /** - * Resolves a `SUPABASE_*` env var with Go's precedence: shell env (non-empty) - * wins, then the loaded project `.env*` files (non-empty), else undefined. - * Go writes project `.env` into the process env before viper's `AutomaticEnv` - * reads these, so handlers must consult both - * rather than `process.env` alone (e.g. `SUPABASE_EXPERIMENTAL_PG_DELTA`). + * Resolves a `SUPABASE_*` env var: shell env (non-empty) wins, then the + * loaded project `.env*` files (non-empty), else `undefined`. Handlers must + * call this rather than reading `process.env` directly. */ readonly envLookup: (name: string) => string | undefined; readonly apiSchemas: ReadonlyArray; @@ -56,10 +51,9 @@ export interface DbTomlValues { /** `[db] password`, runtime default `"postgres"` (not in the config schema). */ readonly password: string; /** - * Linked connection pooler URL, used by the `--linked` pooler fallback. Written - * by `supabase link` to `supabase/.temp/pooler-url`, not stored in config.toml - * (the field is excluded from the config schema; it's populated - * programmatically after config load). + * Linked pooler connection string, used by the `--linked` pooler fallback. + * Read from `supabase/.temp/pooler-url` (written by `supabase link`); not + * part of the config schema. */ readonly poolerConnectionString: Option.Option; /** top-level `project_id`, used to name the local docker network. */ @@ -67,9 +61,8 @@ export interface DbTomlValues { /** `[db] major_version`, default 17. */ readonly majorVersion: number; /** - * `[experimental] orioledb_version` (env-expanded). When set on a 15/17 project, - * `config.Validate` rewrites the Postgres image to the OrioleDB tag; - * `None` for a vanilla project. + * `[experimental] orioledb_version` (env-expanded). Set on a 15/17 project to + * rewrite the Postgres image to the OrioleDB tag; `None` for a vanilla project. */ readonly orioledbVersion: Option.Option; /** @@ -79,7 +72,7 @@ export interface DbTomlValues { readonly denoVersion: number; /** * `[experimental.pgdelta]` config, consumed by the declarative-schema commands - * (`db schema declarative generate` / `sync`). Mirrors `PgDeltaConfig`. + * (`db schema declarative generate` / `sync`). */ readonly pgDelta: PgDeltaTomlConfig; /** Effective `[experimental.webhooks].enabled`; false when the section is absent. */ @@ -93,27 +86,18 @@ export interface DbTomlValues { /** `[db.migrations] enabled` (default true) — gates `up`/`down` migration apply. */ readonly migrationsEnabled: boolean; /** - * `[db.migrations] schema_paths`, default `[]` — resolved (supabase-prefixed when - * relative, `path.Join`/`path.Clean`) and `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` - * env-overridable exactly like `seed.sqlPaths` below, resolved unconditionally (not - * gated on `db.migrations.enabled`). Feeds `apply.MigrateAndSeed`'s EXPERIMENTAL - * declarative branch (`applySchemaFiles`) — consumed by `migrateAndSeed` - * (`start`'s fresh-volume setup, `migration down`) and by `db reset`'s own - * `--experimental` remote path. + * `[db.migrations] schema_paths`, default `[]` — resolved (supabase-prefixed + * when relative) and overridable via `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS`, + * same as {@link DbSeedTomlConfig.sqlPaths}. Resolved unconditionally, not + * gated on `migrationsEnabled`. */ readonly schemaPaths: ReadonlyArray; /** - * `[db.migrations] schema_paths`, RAW patterns — the SAME env/remote-override - * resolution as {@link schemaPaths} above (`SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS`, - * remote-override tiering), but WITHOUT the `supabase/`-prefix + `path.Join`/`path.Clean` - * step — `utils.Config.Db.Migrations.SchemaPaths` pre-that- - * resolution form. `prepareShadowSource`'s `schemaPaths` input (`db diff`/`db pull`'s - * shadow-provisioning prelude) does that join itself (`resolveSeedSqlPath`), so it - * needs THIS raw form — passing {@link schemaPaths} there would double-join a relative - * pattern (`supabase/supabase/...`). The `@supabase/config`-backed - * `context.config.db.migrations.schema_paths` these two callers used before is a DIFFERENT - * raw form: correct patterns, but never `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS`-overridden - * (`@supabase/config` has no viper-`AutomaticEnv` equivalent) — review: PRRT_kwDOErm0O86XDr4S. + * `[db.migrations] schema_paths`, in raw (non-prefixed) form — the same + * env/remote-override resolution as {@link schemaPaths}, but without the + * `supabase/`-prefix join. Callers that join the prefix themselves (e.g. + * shadow-provisioning) need this form to avoid double-joining a relative + * pattern. */ readonly schemaPathPatterns: ReadonlyArray; /** `[db.seed]` enabled + supabase-prefixed `sql_paths` globs — used by `down`. */ @@ -126,14 +110,10 @@ export interface DbTomlValues { */ readonly appliedRemote: string | undefined; /** - * The config keys the matched remote block contributed at viper's OVERRIDE tier — see - * {@link RemoteOverride.remoteOverrideKeys}'s own doc comment for the full - * precedence rationale. Exposed here (in addition to being used internally, above) so a - * caller resolving a SEPARATE config read for the same linked ref — `buildLocalDbContainerInputs`, - * whose `@supabase/config`-backed loader merges the same remote block's VALUES but - * tracks none of which keys it set — can preserve the identical remote-over-env - * precedence for the shadow's own bootstrap fields (`db diff --linked`/`db pull`, - * CLI-1956), without re-deriving this set a third time. Empty when no remote matched. + * The config keys the matched remote block contributed at override tier — + * see {@link RemoteOverride.remoteOverrideKeys}. Exposed so a separate config + * read for the same linked ref can apply the identical remote-over-env + * precedence without re-deriving this set. Empty when no remote matched. */ readonly remoteOverrideKeys: ReadonlySet; } @@ -141,17 +121,14 @@ export interface DbTomlValues { /** `[db.seed]` config surfaced for `migration down`'s seed step. */ interface DbSeedTomlConfig { readonly enabled: boolean; - /** Glob patterns, each supabase-prefixed when relative (`config.resolve`). */ + /** Glob patterns, each supabase-prefixed when relative. */ readonly sqlPaths: ReadonlyArray; } /** - * A `[db.vault]` secret. `value` is the resolved plaintext: env-expanded and, for - * a dotenvx `encrypted:` ciphertext, decrypted. `resolved` mirrors Go's - * `len(SHA256) > 0` gate (true once the value resolved to a non-empty, non-`env(...)` - * string — including a successful decrypt). The HMAC itself is not reproduced; - * `UpsertVaultSecrets` only uses it as a resolved/unresolved flag, and `resolved` - * stands in for it. + * A `[db.vault]` secret. `value` is the resolved plaintext (env-expanded, and + * decrypted if it was a dotenvx `encrypted:` ciphertext). `resolved` is true + * once the value is a non-empty, non-`env(...)` string. */ interface DbVaultSecretToml { readonly name: string; @@ -159,12 +136,7 @@ interface DbVaultSecretToml { readonly resolved: boolean; } -/** - * Cache-key inputs from `[auth]`/`[storage]`/`[realtime]`/`[api]`/`[db.vault]`. - * Exported so callers that build this cache-key subset directly reference this - * shape instead of re-declaring it inline, making field drift a compile error - * rather than a silent cache-key gap. - */ +/** Cache-key inputs from `[auth]`/`[storage]`/`[realtime]`/`[api]`/`[db.vault]`. */ interface BaselineTomlConfig { /** `[auth] enabled`, default true. Gates `initSchema`'s auth service migration. */ readonly authEnabled: boolean; @@ -173,18 +145,18 @@ interface BaselineTomlConfig { /** `[realtime] enabled`, default true. */ readonly realtimeEnabled: boolean; /** - * `[api] auto_expose_new_tables` (tri-state `*bool`). `None` when unset. Drives - * `applyApiPrivileges`; the cache key folds in the *effective* bool (unset and - * `true` both mean grants-kept). + * `[api] auto_expose_new_tables`, tri-state (`None` when unset). The cache + * key folds in the effective bool — unset and `true` both mean grants are + * kept. */ readonly apiAutoExposeNewTables: Option.Option; - /** `[db.vault]` secret names (sorted), created during setup by `UpsertVaultSecrets`. */ + /** `[db.vault]` secret names (sorted), created during setup. */ readonly vaultNames: ReadonlyArray; } /** The `[experimental.pgdelta]` subtree. */ export interface PgDeltaTomlConfig { - /** `[experimental.pgdelta] enabled`, default false. `IsPgDeltaEnabled`. */ + /** `[experimental.pgdelta] enabled`, default false. */ readonly enabled: boolean; /** * `[experimental.pgdelta] declarative_schema_path`, resolved to a @@ -245,23 +217,19 @@ interface RemoteOverride { */ readonly appliedRemote?: string; /** - * The config keys the matched remote block contributed at override tier. Each - * explicitly-set remote key — plus the forced `db.seed.enabled` default - * injected when the block omits it — must outrank the matching `SUPABASE_*` - * env override (a plain TOML value elsewhere is still env-overridable). Holds - * every key in `ENV_OVERRIDABLE_KEYS` the matched block supplies, plus - * `db.seed.enabled` (always). + * The config keys the matched remote block contributed at override tier; + * each must outrank the matching `SUPABASE_*` env override. Holds every key + * in {@link ENV_OVERRIDABLE_KEYS} the block supplies, plus `db.seed.enabled` + * (always forced). */ readonly remoteOverrideKeys: ReadonlySet; } /** - * The `project_id` of a `[remotes.]` block as used for block-matching and - * duplicate detection: `SUPABASE_REMOTES__PROJECT_ID` wins when - * non-empty; an empty env value is dropped (never overrides an empty shell - * var), falling back to the RAW TOML literal — unexpanded, so a TOML `env(...)` - * form is NOT resolved here. Validation reads the decoded (expanded) field - * instead — see `resolveValidatedRemoteProjectId`. + * The `project_id` of a `[remotes.]` block for matching/duplicate + * detection: `SUPABASE_REMOTES__PROJECT_ID` wins when non-empty, else + * the raw (unexpanded) TOML literal. Validation instead uses the expanded + * value — see {@link resolveValidatedRemoteProjectId}. */ function resolveRemoteProjectId( name: string, @@ -275,11 +243,9 @@ function resolveRemoteProjectId( } /** - * The `project_id` of a `[remotes.]` block as used for validation: the - * decoded value, with `env(...)` already expanded (an unset `env(...)` stays - * literal and fails the ref pattern). The `SUPABASE_REMOTES__PROJECT_ID` - * env override still wins when non-empty, same precedence as the - * block-matching lookup above. + * The `project_id` of a `[remotes.]` block for validation: the same + * override precedence as {@link resolveRemoteProjectId}, but with `env(...)` + * expanded (an unset `env(...)` stays literal and fails the ref pattern). */ function resolveValidatedRemoteProjectId( name: string, @@ -298,14 +264,6 @@ function resolveValidatedRemoteProjectId( * must beat the matching env override. */ const ENV_OVERRIDABLE_KEYS = [ - // The matched `[remotes.]` block's own `project_id` field is what selected it in the - // first place (`applyRemoteOverride` above matches on exactly this key) — same override-tier - // reasoning as every other key in this array. NOT guaranteed present, though: a block can also - // match purely via its `SUPABASE_REMOTES__PROJECT_ID` env override with no literal - // `project_id` line in the block's own TOML table, in which case this reader's own - // `blockProvidesKey` check below correctly finds the key absent from the block, so - // `remoteOverrideKeys` omits it and the env override still applies for that (nonexistent) - // literal key (review: PRRT_kwDOErm0O86XHGDL). "project_id", "api.schemas", "db.port", @@ -316,13 +274,6 @@ const ENV_OVERRIDABLE_KEYS = [ "db.seed.enabled", "db.seed.sql_paths", "auth.enabled", - // Not read by THIS reader's own resolved fields (nor by `apiUrl`'s own `api.port`/ - // `api.tls.enabled`/`api.external_url` inputs, unlike those three) — tracked purely because - // `resolveLocalConfigValues`'s `envOverrideBool("SUPABASE_API_ENABLED", ...)` - // call THROWS on a malformed override, which would abort resolution of the caller-needed - // fields it computes afterward (`apiPort`/`apiUrl`/`dbPort`/`rootKey`/etc.) — same - // "throws before a value the caller needs is resolved" rationale as `auth.enabled` above and - // `analytics.enabled`/`edge_runtime.deno_version` below (review: PRRT_kwDOErm0O86W5UlV). "api.enabled", "edge_runtime.deno_version", "experimental.webhooks.enabled", @@ -335,11 +286,6 @@ const ENV_OVERRIDABLE_KEYS = [ "analytics.gcp_project_id", "analytics.gcp_project_number", "analytics.gcp_jwt_path", - // Not read by THIS reader's own resolved fields — tracked so `remoteOverrideKeys` (exposed - // on this module's return value, see its own doc comment) also covers every field - // `resolveDbBootstrapConfig`/`resolveDbSettingsEnvOverrides` - // (`command-internal/db-bootstrap/`) resolve for the shadow's own container spec on the - // `db diff --linked`/`db pull` native-provisioning path (CLI-1956). "experimental.orioledb_version", "experimental.s3_host", "experimental.s3_region", @@ -375,22 +321,9 @@ const ENV_OVERRIDABLE_KEYS = [ "db.settings.wal_sender_timeout", "db.settings.work_mem", "db.network_restrictions.enabled", - // Not read by `resolveDbBootstrapConfig`/`resolveDbSettingsEnvOverrides` above — - // these feed `resolveLocalConfigValues`'s OWN fields instead (`apiUrl`/`dbUrl`/ - // `dbPort`/`rootKey`/`jwtSecret`/`authSiteUrl`/`authJwtExpiry`/`anonKey`/`serviceRoleKey`), - // which the shadow's container spec/fresh-DB setup input also consume on the same - // `db diff --linked`/`db pull` path (review: PRRT_kwDOErm0O86W2tRi) — same override-tier - // gap as the block above, just for that resolver's reachable subset instead of this one's. "db.root_key", "api.port", "api.tls.enabled", - // Not read by `resolveDbBootstrapConfig`/`resolveDbSettingsEnvOverrides` above, - // same as `api.tls.enabled`/`api.port` — these feed `resolveLocalConfigValues`'s own - // `readApiTlsFiles` gate (`apiEnabled && apiTlsEnabled`), which the shadow's own - // `db diff --linked`/`db pull` setup input also consumes on the same path. Without this, - // a matched remote's override-tier `api.tls.cert_path`/`key_path` could still lose to a - // stale/missing ambient `SUPABASE_API_TLS_CERT_PATH`/`SUPABASE_API_TLS_KEY_PATH` (review: - // PRRT_kwDOErm0O86W8ZYk). "api.tls.cert_path", "api.tls.key_path", "api.external_url", @@ -399,11 +332,6 @@ const ENV_OVERRIDABLE_KEYS = [ "auth.site_url", "auth.anon_key", "auth.service_role_key", - // Not read by ANY of the resolvers above — these feed `resolveLocalJwks`'s/ - // `resolveAuthExternalUrl`'s/`resolveConfiguredSigningKeys`'s own fields - // instead, which the shadow's PG15+ one-shot auth-migration job also consumes on the - // same `db diff --linked`/`db pull` path (review: PRRT_kwDOErm0O86W3Ox_) — same - // override-tier gap as the two blocks above, just for THOSE resolvers' reachable subset. "auth.signing_keys_path", "auth.external_url", "auth.third_party.firebase.enabled", @@ -418,25 +346,8 @@ const ENV_OVERRIDABLE_KEYS = [ "auth.third_party.clerk.domain", "auth.third_party.workos.enabled", "auth.third_party.workos.issuer_url", - // `auth.jwt_issuer`/`auth.additional_redirect_urls` are plain, non-throwing - // `envOverride`/comma-split string reads in `resolveLocalConfigValues` — same - // "non-throwing read is still a precedence bug" reasoning as `auth.external.*`'s - // `client_id`/`url`/`redirect_uri` above: a matched remote's own value must beat a stale - // ambient `SUPABASE_AUTH_JWT_ISSUER`/`SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS`. "auth.jwt_issuer", "auth.additional_redirect_urls", - // Same "throws before a value the caller needs is resolved" bug class as `api.enabled`/ - // `auth.enabled`/`analytics.*`/`edge_runtime.deno_version` above, just for a much larger set of - // fields the doc comment on `resolveLocalConfigValues`'s `remoteOverrideKeys` parameter - // used to claim were safe to leave ungated. That claim rested on "their own `envOverride*` - // calls cannot throw before a value the caller needs has already been resolved" — which doesn't - // actually hold: `resolveLocalConfigValues` is a single synchronous function that either - // returns its whole object or throws, so ANY unconditional throw anywhere in its body (not just - // ones textually positioned before a caller-needed field) aborts the entire call and denies the - // shadow every field, including the ones already computed as local variables earlier in the - // function. Every dotted key below resolves through `envOverrideBool`/`envOverrideUint`/ - // `envOverrideAuthPasswordRequirements`, all of which throw on a malformed override — same - // as `api.enabled`'s own reasoning, just generalized (review: PRRT_kwDOErm0O86W6R-G). "studio.enabled", "studio.port", "local_smtp.enabled", @@ -449,9 +360,6 @@ const ENV_OVERRIDABLE_KEYS = [ "auth.minimum_password_length", "auth.password_requirements", "auth.passkey.enabled", - // `auth.webauthn.rp_id`/`.rp_origins` are the same shape of plain, non-throwing string/slice - // reads `resolveLocalConfigValues` resolves for its `passkey` validation input — same - // "non-throwing read is still a precedence bug" reasoning as `auth.jwt_issuer` above. "auth.webauthn.rp_id", "auth.webauthn.rp_origins", "auth.hook.mfa_verification_attempt.enabled", @@ -480,74 +388,25 @@ const ENV_OVERRIDABLE_KEYS = [ "auth.mfa.web_authn.enroll_enabled", "auth.mfa.web_authn.verify_enabled", "auth.mfa.max_enrolled_factors", - // `auth.mfa.phone.template`/`.max_frequency` are plain, non-throwing `envOverride` string - // reads in `resolveAuthMfa` — same "non-throwing read is still a precedence bug" - // reasoning as `auth.jwt_issuer`/`auth.webauthn.rp_id` above. "auth.mfa.phone.template", "auth.mfa.phone.max_frequency", "auth.captcha.enabled", - // `auth.captcha.provider` can't throw on its own (`envOverride` is a plain string read), - // but `validateResolvedConfig`'s enum check (`config-validate.ts`) rejects any - // value other than `hcaptcha`/`turnstile` — same "non-throwing read, throwing downstream - // consumer" class as `studio.api_url` below. A matched remote's own valid `provider` must beat - // a stale/unsupported ambient `SUPABASE_AUTH_CAPTCHA_PROVIDER`, or - // `validateResolvedConfig` aborts the whole synchronous `resolveLocalConfigValues` - // call (and the shadow it feeds) on a value the override tier (above the ambient env tier) - // never lets win. "auth.captcha.provider", - // `auth.captcha.secret` is a `config.Secret`, decrypted the same - // way `auth.email.smtp.pass` below is — `resolveAuthCaptcha`'s ungated `envOverride` - // call let a malformed ambient `SUPABASE_AUTH_CAPTCHA_SECRET` outrank a matched remote's own - // valid `secret` and throw during decryption, aborting the whole synchronous - // `resolveLocalConfigValues` call (and the shadow it feeds) on a value `v.Set` - // (override tier, above `AutomaticEnv`) silently ignores — same bug class as `.pass` below - // (review: PRRT_kwDOErm0O86XJ4HR). "auth.captcha.secret", "auth.email.smtp.enabled", "auth.email.smtp.port", - // `auth.email.smtp.pass` is a `config.Secret`, decrypted uniformly - // by `DecryptSecretHookFunc` decode hook regardless of which viper tier supplied the - // raw value — so when a matched remote block sets it, `v.Set` (override tier) wins over - // `AutomaticEnv` and the decode hook decrypts the REMOTE's value; an ambient malformed - // `SUPABASE_AUTH_EMAIL_SMTP_PASS` never reaches decryption at all. `resolveAuthEmailSmtp` - // previously ran `envOverride` unconditionally before decrypting, so that same malformed - // env value could win over a matched remote's valid `pass` and throw, aborting the whole - // synchronous `resolveLocalConfigValues` call — same bug class as `.enabled`/`.port` - // above, just for this Secret-typed leaf (review: PRRT_kwDOErm0O86XJYol). "auth.email.smtp.pass", - // `auth.email.smtp.host`/`.user`/`.admin_email`/`.sender_name` are plain, non-throwing - // `envOverride` string reads in `resolveAuthEmailSmtp` — unlike `.enabled`/`.port`/ - // `.pass` above, none of these can throw, but leaving them ungated is still a precedence bug, - // same reasoning as `auth.email.template.*`'s `subject`/`content` below. "auth.email.smtp.host", "auth.email.smtp.user", "auth.email.smtp.admin_email", "auth.email.smtp.sender_name", - // Not read by THIS reader's own resolved fields — tracked so `resolveAuthEmail` - // (`local-config-values.ts`) also gates its own throw-capable - // `envOverrideBool`/`envOverrideUint` calls for these `auth.email.*` scalars, - // same "throws before a value the caller needs is resolved" bug class as - // `auth.email.smtp.enabled`/`.port` above (review: PRRT_kwDOErm0O86XHvYh). "auth.email.enable_signup", "auth.email.double_confirm_changes", "auth.email.enable_confirmations", "auth.email.secure_password_change", "auth.email.otp_length", "auth.email.otp_expiry", - // `auth.email.max_frequency` is a plain, non-throwing `envOverride` string read in - // `resolveAuthEmail` — same "non-throwing read is still a precedence bug" reasoning as - // `auth.email.smtp.host` above. "auth.email.max_frequency", - // `auth.sms.*` (`resolveAuthSms`) has the identical "throws before a value the caller - // needs is resolved" bug class as every other group above: `enable_signup`/`enable_confirmations` - // and each provider's `enabled` run an UNGATED `envOverrideBool`, and each provider's - // Secret-typed field (`auth_token`/`access_key`/`api_key`/`api_secret`) runs an UNGATED - // `decryptAuthSecret` — either can throw on a malformed - // ambient `SUPABASE_AUTH_SMS_*` override even when a matched remote block already set that field - // at viper's OVERRIDE tier, aborting the whole `resolveLocalConfigValues` call (and the - // shadow it feeds via `buildLocalDbContainerInputs`) — reachable via `validateAuthSmsProviders`, - // called unconditionally whenever `authEnabled` (review: PRRT_kwDOErm0O86XFmjZ — the prior - // "unreachable from the shadow path" rejection missed this call site). "auth.sms.enable_signup", "auth.sms.enable_confirmations", "auth.sms.twilio.enabled", @@ -560,10 +419,6 @@ const ENV_OVERRIDABLE_KEYS = [ "auth.sms.textlocal.api_key", "auth.sms.vonage.enabled", "auth.sms.vonage.api_secret", - // The remaining `auth.sms..*` fields (`resolveField` in `resolveAuthSms`) are - // plain, non-throwing `envOverride` string reads — `vonage.api_key` sitting right next to - // the already-gated `vonage.api_secret` was the clearest tell that these were missed. Same - // "non-throwing read is still a precedence bug" reasoning as `auth.email.smtp.host` above. "auth.sms.twilio.account_sid", "auth.sms.twilio.message_service_sid", "auth.sms.twilio_verify.account_sid", @@ -572,37 +427,18 @@ const ENV_OVERRIDABLE_KEYS = [ "auth.sms.textlocal.sender", "auth.sms.vonage.from", "auth.sms.vonage.api_key", - // `auth.sms.template`/`.max_frequency` are the same shape, sibling to `auth.email.max_frequency` - // above. "auth.sms.template", "auth.sms.max_frequency", - // `auth.publishable_key`/`auth.secret_key` and - // `studio.openai_api_key` are `config.Secret`-typed exactly like - // `auth.email.smtp.pass`/`auth.captcha.secret` above, decrypted via the same throw-capable - // `decryptAuthSecret` — but were never added to this allowlist when `anon_key`/ - // `service_role_key` (their sibling API-key pair, right next to them in - // `resolveLocalConfigValues`'s return block) were gated. Same bug class: an ungated - // malformed ambient override can throw during decryption even when a matched remote block - // already set the field, aborting the whole call. "auth.publishable_key", "auth.secret_key", "studio.openai_api_key", - // `studio.api_url` is validated with `goUrlParse` inside `validateResolvedConfig` - // (gated on `studio.enabled`, matching `studio.port` above) — a plain, non-throwing - // `envOverride` read here can still flip that downstream validate() outcome, same - // "non-throwing read, throwing downstream consumer" class as the third_party required fields - // above. "studio.api_url", ] as const; /** - * `auth.external.` is a genuine map keyed by arbitrary provider name — not just the ~19 - * known ids `@supabase/config`'s schema recognizes, but any custom/unmodeled name a user's - * `[auth.external.]` table declares (`resolveAuthExternalProviders`'s own doc - * comment). A fixed `ENV_OVERRIDABLE_KEYS` entry per provider can't cover every possible - * name a `[remotes.]` block might set, so these per-provider leaves are tracked dynamically - * in {@link applyRemoteOverride} instead (flattening whichever provider names the matched block - * actually supplies) rather than enumerated here. + * `auth.external.` is a map keyed by arbitrary provider name, so these + * per-provider leaves can't be enumerated in {@link ENV_OVERRIDABLE_KEYS} — + * {@link applyRemoteOverride} tracks them dynamically instead. */ const AUTH_EXTERNAL_PROVIDER_FIELDS = [ "enabled", @@ -615,16 +451,9 @@ const AUTH_EXTERNAL_PROVIDER_FIELDS = [ ] as const; /** - * `auth.email.template.`/`auth.email.notification.` are the same shape of genuine, - * arbitrarily-keyed map as `auth.external.` above — a fixed `ENV_OVERRIDABLE_KEYS` - * entry per template/notification name can't cover every name a `[remotes.]` block might - * set, so these are also tracked dynamically in {@link applyRemoteOverride}. `content_path` is - * the field that can actually abort resolution (a matched remote's own valid path losing to a - * stale/missing ambient `_CONTENT_PATH` env var makes {@link resolveAuthEmail}'s caller-side - * file read throw — same "non-throwing read, throwing downstream consumer" class as - * `auth.captcha.provider` above); `subject`/`content` can't throw the same way, but leaving them - * ungated is still a precedence bug, same reasoning as `auth.external.*`'s `client_id`/`url`/ - * `redirect_uri` above (review: PRRT_kwDOErm0O86XLAYn, PRRT_kwDOErm0O86XLAYo). + * `auth.email.template.`/`auth.email.notification.` are the same + * arbitrarily-keyed shape as `auth.external.` above, tracked dynamically + * in {@link applyRemoteOverride} rather than enumerated here. */ const AUTH_EMAIL_TEMPLATE_FIELDS = ["subject", "content_path", "content"] as const; @@ -632,15 +461,10 @@ const AUTH_EMAIL_TEMPLATE_FIELDS = ["subject", "content_path", "content"] as con const AUTH_EMAIL_NOTIFICATION_FIELDS = ["enabled", "subject", "content_path", "content"] as const; /** - * Every literal member of {@link ENV_OVERRIDABLE_KEYS}, PLUS the dotted-key patterns for - * the three genuinely dynamically-keyed families {@link applyRemoteOverride} tracks separately — - * arbitrary user-declared names (provider ids, email template/notification names), not a fixed - * list, so they can't be enumerated as literal members (see - * {@link AUTH_EXTERNAL_PROVIDER_FIELDS}/{@link AUTH_EMAIL_TEMPLATE_FIELDS}/ - * {@link AUTH_EMAIL_NOTIFICATION_FIELDS}'s own doc comments). Every - * `remoteWins(...)`/`remoteOverrideKeys.has(...)` call site across this module, - * `local-config-values.ts`, and `db-bootstrap/bootstrap-config.ts` is typed against this - * union (via {@link makeRemoteWins}), so a typo'd dotted key is a compile error instead of a + * Every literal member of {@link ENV_OVERRIDABLE_KEYS}, plus the dotted-key + * patterns for the dynamically-keyed families {@link applyRemoteOverride} + * tracks separately. Every `remoteWins(...)` call site is typed against this + * union, so a typo'd dotted key is a compile error instead of a * silently-always-false gate. */ export type RemoteOverridableKey = @@ -650,14 +474,9 @@ export type RemoteOverridableKey = | `auth.email.notification.${string}.${(typeof AUTH_EMAIL_NOTIFICATION_FIELDS)[number]}`; /** - * Hoists the `const remoteWins = (p: string): boolean => remoteOverrideKeys.has(p)` closure that - * used to be copy-pasted once per remote-gated resolver (five times in - * `local-config-values.ts`, once in `db-bootstrap/bootstrap-config.ts`) into a single - * helper. The returned function's parameter is typed as {@link RemoteOverridableKey} — - * narrower than `keys` itself, which stays the loosely-typed `ReadonlySet` every resolver - * already threads a `remoteOverrideKeys` parameter as — so every call site is checked against the - * allowlist without having to also re-type every `remoteOverrideKeys` parameter/field across the - * db-bootstrap/shadow-provisioning call graph (CLI-1956). + * Hoists the `remoteOverrideKeys.has(key)` closure duplicated across several + * resolvers into one helper, typed against {@link RemoteOverridableKey} so a + * typo'd key is a compile error. */ export function makeRemoteWins(keys: ReadonlySet): (key: RemoteOverridableKey) => boolean { return (key) => keys.has(key); @@ -684,25 +503,19 @@ function applyRemoteOverride( for (const name of Object.keys(remotes)) { const block = asRecord(remotes[name]); if (block === undefined) continue; - // Match on the project_id from the raw lookup (env override > RAW TOML literal, no - // `env(...)` expansion), so a block whose id comes from `SUPABASE_REMOTES__PROJECT_ID` - // still merges while a TOML `env(...)` literal does not (blocks are selected before the - // literal is expanded). + // Matches on the raw (env override > unexpanded TOML literal) project_id lookup, so + // blocks are selected before any `env(...)` literal is expanded. if (resolveRemoteProjectId(name, block, lookup) === ref) { const merged = deepMergeDoc(doc, block); const blockSeed = asRecord(asRecord(block["db"])?.["seed"]); - // Flatten the WHOLE matched block and apply every leaf at override tier (above the ambient - // env tier). Record every env-overridable key the block supplies — not just migrations/seed — so the - // resolution below suppresses their `SUPABASE_*` value. + // Record every env-overridable key the block supplies, so the resolution below + // suppresses the matching `SUPABASE_*` override. const remoteOverrideKeys = new Set(); for (const key of ENV_OVERRIDABLE_KEYS) { if (blockProvidesKey(block, key)) remoteOverrideKeys.add(key); } - // `auth.external.` is a genuine map (arbitrary/custom provider names — see - // `AUTH_EXTERNAL_PROVIDER_FIELDS`'s own doc comment), so flatten whichever provider - // names/fields THIS matched block actually supplies instead of relying on a fixed list — - // same per-leaf override-tier semantics as `ENV_OVERRIDABLE_KEYS` above, just - // computed dynamically for this one dynamically-keyed section. + // `auth.external.` is dynamically keyed (see AUTH_EXTERNAL_PROVIDER_FIELDS), + // so flatten whichever provider names this block actually supplies. const externalBlock = asRecord(asRecord(block["auth"])?.["external"]); if (externalBlock !== undefined) { for (const providerName of Object.keys(externalBlock)) { @@ -712,9 +525,7 @@ function applyRemoteOverride( } } } - // `auth.email.template.`/`auth.email.notification.` are the same - // arbitrarily-keyed shape as `auth.external.` above — see - // `AUTH_EMAIL_TEMPLATE_FIELDS`'s own doc comment. + // Same dynamically-keyed handling for `auth.email.template.`/`notification.`. const emailBlock = asRecord(block["auth"])?.["email"]; const emailTemplateBlock = asRecord(asRecord(emailBlock)?.["template"]); if (emailTemplateBlock !== undefined) { @@ -734,9 +545,8 @@ function applyRemoteOverride( } } } - // `db.seed.enabled` is ALWAYS override-tier for a matched block: either the block set - // it, or `mergeRemoteConfig` forces it `false` when omitted — - // so env never overrides it on a matched-remote linked run. + // `db.seed.enabled` is always override-tier: either the block set it, or it's + // forced false below when omitted — so env never overrides it on a matched-remote run. remoteOverrideKeys.add("db.seed.enabled"); if (blockSeed?.["enabled"] === undefined) { return { @@ -752,9 +562,9 @@ function applyRemoteOverride( } /** - * `config.Load` aborts when two `[remotes.*]` blocks declare the same - * `project_id`, regardless of which command runs. - * Returns the conflicting pair (current + prior block name) or `undefined`. + * Config load aborts when two `[remotes.*]` blocks declare the same + * `project_id`. Returns the conflicting pair (current + prior block name) or + * `undefined`. */ function findDuplicateRemoteProjectId( doc: RawDoc | undefined, @@ -765,8 +575,7 @@ function findDuplicateRemoteProjectId( const seen = new Map(); for (const name of Object.keys(remotes)) { const block = asRecord(remotes[name]); - // Dedupe on the project_id `v.GetString` returns (env override > RAW TOML literal, - // no `env(...)` expansion), matching Go's duplicate check. + // Same raw project_id lookup used for block matching, so dedup uses the same identity. const projectId = resolveRemoteProjectId(name, block, lookup); if (projectId === undefined) continue; const prior = seen.get(projectId); @@ -777,10 +586,10 @@ function findDuplicateRemoteProjectId( } /** - * `config.Validate` rejects any `[remotes.]` whose `project_id` is not a - * valid project ref, on every config load — so a malformed or - * missing remote `project_id` fails even local/direct commands before touching the - * database. Returns the first offending block name (object order) or `undefined`. + * Rejects any `[remotes.]` whose `project_id` is not a valid project + * ref, on every config load — so a malformed or missing remote `project_id` + * fails even local/direct commands before touching the database. Returns the + * first offending block name (object order) or `undefined`. */ function findInvalidRemoteProjectId( doc: RawDoc | undefined, @@ -790,10 +599,8 @@ function findInvalidRemoteProjectId( if (remotes === undefined) return undefined; for (const name of Object.keys(remotes)) { const block = asRecord(remotes[name]); - // Validate the DECODED project_id (env override > env-expanded TOML literal), matching - // `Validate` over the decoded `remote.ProjectId` field, which - // passed through `LoadEnvHook`. An unset `env(...)` stays literal and still fails Go's - // ref pattern. (Block matching/dedup above use the RAW literal — `v.GetString`.) + // Validates the expanded project_id; an unset `env(...)` stays literal and still + // fails the ref pattern. (Block matching/dedup above use the raw literal.) const projectId = resolveValidatedRemoteProjectId(name, block, lookup); if (typeof projectId !== "string" || !PROJECT_REF_PATTERN.test(projectId)) { return name; @@ -887,12 +694,10 @@ function resolveStringSlice( } /** - * Replicates `path.Join("supabase", pattern)` for a relative seed `sql_paths` - * entry: `path.Clean` collapses `.`/`..` segments (`../seed.sql` → `seed.sql`, - * `sub/../seed.sql` → `supabase/seed.sql`, `../../x.sql` → `../x.sql`). The - * cleaned path is the `seed_files` hash key, so a non-collapsed key would miss - * a previously recorded entry and re-run/re-record the seed. Forward-slash - * only, not the platform `filepath.Join`. + * Joins `pattern` under `supabase/`, collapsing `.`/`..` segments (e.g. + * `../seed.sql` → `seed.sql`). The cleaned, forward-slash-only path is the + * seed-tracking hash key, so an uncollapsed key would miss a previously + * recorded entry and re-run the seed. */ function joinSupabaseSeedPath(pattern: string): string { const out: Array = []; @@ -909,21 +714,9 @@ function joinSupabaseSeedPath(pattern: string): string { } /** - * `filepath.IsAbs` on Windows (`internal/filepathlite/path_windows.go`'s - * `IsAbs`/`volumeNameLen`) requires a volume name — a drive letter (`C:\`) or a UNC - * prefix (`\\server\share`) — before a path counts as absolute; a bare leading - * separator (`/schemas`, `\schemas`) has no volume name, so Go treats it as RELATIVE - * and joins it under `supabase/`. `pathSvc.isAbsolute` is backed by `node:path`, which - * selects `path.win32` on an actual Windows host, and Node's win32 `isAbsolute` treats - * a bare leading separator as rooted at the *current drive* — i.e. absolute — so it - * disagrees with Go on exactly this shape. Verified empirically: Node's - * `path.win32.isAbsolute("/schemas/*.sql")` is `true`, while `filepath.IsAbs` on - * the same input is `false` (`volumeNameLen` returns `0` — none of its drive-letter, - * UNC, or device-path cases match a path with no volume component). Only the - * seed/schema-paths resolve step below (`[db.seed].sql_paths`/ - * `[db.migrations].schema_paths`) needs this exact rule — real filesystem calls - * elsewhere in this shell still need the platform's own `isAbsolute` to - * resolve an actual path on disk. + * A bare leading separator (`/schemas`) has no Windows volume, so it's + * relative and joins under `supabase/` — unlike Node's win32 `isAbsolute`, + * which treats it as rooted at the current drive. */ const goIsAbs = (pathSvc: Path.Path, pattern: string): boolean => { if (process.platform !== "win32") { @@ -955,40 +748,17 @@ const DEFAULT_SUPABASE_ENV = "development"; /** * Keys {@link applyProjectEnv} copies from the project `.env` into - * `process.env`. Kept to an allowlist of values that are read *only* via - * `process.env` (no project-env map path) and must reflect `supabase/.env`: - * `SUPABASE_INTERNAL_IMAGE_REGISTRY` (`getRegistryImageUrl`). Go's - * `godotenv.Load` (`loadNestedEnv`) `os.Setenv`s every key from the project - * `.env`, so the reader sees a `.env`-only value there; omitting it here - * would leave that process.env-only reader blind to a project-`.env`-scoped - * override the shell never set. - * Everything else is read from {@link loadProjectEnv}'s returned map - * (`envOverride`, `resolveYesWithProjectEnv`, `resolveDbPassword`) or resolved - * eagerly from the shell before any `.env` load — Go's root globals (workdir / - * profile / `SUPABASE_ENV` / project-ref) are frozen before `loadNestedEnv`, so - * writing them here would let our lazily-built resolvers diverge from Go (retarget - * the project, switch the env-file set, or leak into the Go `--experimental` proxy). + * `process.env`: only values read directly via `process.env` rather than + * through {@link loadProjectEnv}'s returned map, e.g. + * `SUPABASE_INTERNAL_IMAGE_REGISTRY` (`getRegistryImageUrl`). */ const PROCESS_ENV_APPLY_KEYS = ["SUPABASE_INTERNAL_IMAGE_REGISTRY"] as const; /** - * Load the project's nested `.env` files into a lookup map. **Pure**: it reads the - * files and returns the merged map, with no `process.env` side effect — so the - * `SUPABASE_YES` / `SUPABASE_DB_PASSWORD` readers that call it - * (`resolveYesWithProjectEnv`, `resolveDbPassword`) never mutate the global - * environment. Commands that need an allowlisted key visible to a synchronous - * `process.env` reader (`db dump` / `db pull` → `getRegistryImageUrl`) opt - * into {@link applyProjectEnv} around the container work instead. - * - * Partially mirrors `loadNestedEnv` + `loadDefaultEnv`. - * Go walks from the `supabase/` directory up to - * the repo root and, in each directory, loads `.env..local`, `.env.local` - * (skipped when `SUPABASE_ENV=test`), `.env.`, then `.env` via `godotenv.Load`, - * which never overrides a value already set. So the shell environment wins over the - * files, the `supabase/` directory wins over the repo root, and earlier filenames - * win within a directory. A malformed `.env` — or one that exists but cannot be - * read — aborts: `loadEnvIfExists` swallows only `os.ErrNotExist` and returns - * every other error. The path is named without leaking file contents (CWE-209-safe). + * Loads the project's nested `.env` files into a lookup map without mutating + * `process.env` (first writer wins; the shell environment always wins over any + * file). Callers needing a key visible to a synchronous `process.env` reader + * instead opt into {@link applyProjectEnv} around that work. */ export const loadProjectEnv = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, @@ -999,14 +769,13 @@ export const loadProjectEnv = Effect.fnUntraced(function* ( const filenames = [`.env.${env}.local`]; if (env !== "test") filenames.push(".env.local"); filenames.push(`.env.${env}`, ".env"); - // Go walks `supabase/` first, then the repo root; first writer wins. + // `supabase/` is searched before the repo root; first writer wins. const dirs = [path.join(workdir, "supabase"), workdir]; const loaded: Record = {}; for (const dir of dirs) { for (const name of filenames) { - // Go's loadEnvIfExists ignores only os.ErrNotExist; any other read error - // aborts rather than silently skipping the file (which would hide a broken - // env-backed config). Effect surfaces "not found" as a NotFound PlatformError. + // A missing file is skipped; any other read error aborts rather than silently + // running with a broken env file. const content = yield* fs.readFileString(path.join(dir, name)).pipe( Effect.map(Option.some), Effect.catchTag("PlatformError", (error) => @@ -1029,7 +798,7 @@ export const loadProjectEnv = Effect.fnUntraced(function* ( ); } for (const [key, value] of Object.entries(parsed)) { - // godotenv.Load never overrides: the shell env and earlier files win. + // The shell env and earlier files win; never overrides an already-set key. if (process.env[key] === undefined && loaded[key] === undefined) loaded[key] = value; } } @@ -1038,20 +807,12 @@ export const loadProjectEnv = Effect.fnUntraced(function* ( }); /** - * Apply the allowlisted project-`.env` keys (see {@link PROCESS_ENV_APPLY_KEYS}) - * to `process.env` **for the duration of the current scope**, then revert. This is - * the opt-in counterpart to the pure {@link loadProjectEnv}: `bootstrap` / - * `db push` / `db pull` / `db dump` run it around their pg_dump / migration - * container work so a `SUPABASE_INTERNAL_IMAGE_REGISTRY` set - * only in `supabase/.env` still reaches `getRegistryImageUrl` (which reads - * `process.env` synchronously) — mirroring - * the `os.Setenv` half of `loadNestedEnv`. Kept out of the shared loader so - * SUPABASE_YES / db-password reads stay side-effect-free. - * - * Never overrides an existing `process.env` value (`godotenv.Load` never - * overrides; `loaded` already excludes keys present in `process.env`, and this - * re-checks). The `acquireRelease` finalizer deletes only the keys it set when the - * scope closes, so in-process test workers don't leak env between cases. + * Applies the allowlisted project-`.env` keys (see {@link PROCESS_ENV_APPLY_KEYS}) + * to `process.env` for the duration of the current scope, then reverts — + * the opt-in counterpart to the pure {@link loadProjectEnv}, kept separate so + * that loader stays side-effect-free. Never overrides an existing + * `process.env` value. The `acquireRelease` finalizer deletes only the keys it + * set, so in-process test workers don't leak env between cases. */ export const applyProjectEnv = ( loaded: Readonly>, @@ -1082,11 +843,10 @@ function nonEmptyString(value: unknown): Option.Option { } /** - * Resolve a `[section] enabled` style bool. Go decodes a TOML bool natively and a - * string (incl. an `env(VAR)` reference) via `strconv.ParseBool` — so `"1"`/`"t"`/etc. - * count as true and a malformed value aborts the load. Returns `"invalid"` for a - * malformed string so the caller can fail with Go's config error; applies the schema - * default (`auth`/`storage`/`realtime` default `true`) when the key is absent. + * Resolve a `[section] enabled` style bool: a native TOML bool, or a string + * (including an `env(VAR)` reference) accepted by {@link parseGoBool}. + * Returns `"invalid"` for a malformed string; applies `fallback` when the key + * is absent. */ function resolveBool(value: unknown, fallback: boolean, lookup: EnvLookup): boolean | "invalid" { if (typeof value === "boolean") return value; @@ -1094,25 +854,19 @@ function resolveBool(value: unknown, fallback: boolean, lookup: EnvLookup): bool const parsed = parseGoBool(expandEnv(value, lookup)); return parsed ?? "invalid"; } - // A numeric config value decodes into a bool under weak typing: `value != 0`. A TOML - // number (`enabled = 0`) is therefore an explicit false, NOT absent — it must not fall - // through to the schema default. + // A numeric value decodes as a bool (`value != 0`), so `enabled = 0` is an explicit + // false, not absent. if (typeof value === "number") return value !== 0; - // Absent → the schema default. A PRESENT non-scalar (array/inline table, e.g. - // `enabled = []`) is a decode error, so it must NOT fall through to the default — - // otherwise `db reset` could accept the prompt and drop schemas on a config that - // should have already failed validation. + // A present non-scalar (e.g. `enabled = []`) is a decode error, not absent — it must + // not fall through to the default and silently pass a config that should have failed. if (value === undefined) return fallback; return "invalid"; } /** * `resolveBool` that fails the config load on a malformed bool. `envValue` is - * the `SUPABASE_*` env override: when set it wins over the TOML value/default - * (`envOverride` already drops empty values). The override is still a - * string-kind value, so an `env(VAR)` indirection - * (`SUPABASE_DB_SEED_ENABLED=env(SEED_ON)`) is expanded before the bool - * parse. + * the `SUPABASE_*` env override, which wins over the TOML value/default when + * set; an `env(VAR)` indirection in the override is expanded before parsing. */ const resolveBoolOrFail = Effect.fnUntraced(function* ( field: string, @@ -1140,12 +894,10 @@ const resolveBoolOrFail = Effect.fnUntraced(function* ( }); /** - * Tri-state (`*bool`) sibling of `resolveBoolOrFail` for fields decoded as a - * pointer-bool (absent → `None`, never `false`). The `SUPABASE_*` env override - * wins when present; otherwise a present TOML bool/string is decoded with the - * accepted bool-spelling set (`parseGoBool`) and a malformed value - * aborts the load with a `failed to parse config` error. An absent value - * stays `None`. (`envOverride` already drops empty env values.) + * Tri-state sibling of `resolveBoolOrFail` for fields that stay `None` (never + * `false`) when absent. The `SUPABASE_*` env override wins when present; + * otherwise a present TOML bool/string is decoded with {@link parseGoBool}, + * and a malformed value aborts the load. */ const resolveOptionalBoolOrFail = Effect.fnUntraced(function* ( field: string, @@ -1163,7 +915,7 @@ const resolveOptionalBoolOrFail = Effect.fnUntraced(function* ( return Option.some(parsed); } if (typeof value === "boolean") return Option.some(value); - // Numeric `*bool` value decodes the same way under weak typing: `value != 0`. + // A numeric value decodes the same way: `value != 0`. if (typeof value === "number") return Option.some(value !== 0); if (typeof value === "string") { const parsed = parseGoBool(expandEnv(value, lookup)); @@ -1174,8 +926,8 @@ const resolveOptionalBoolOrFail = Effect.fnUntraced(function* ( } return Option.some(parsed); } - // Absent → `None` (`*bool` stays nil). A present non-scalar value is a decode - // failure, so reject it here rather than silently treating it as absent. + // A present non-scalar value is a decode failure, not absent — reject it here + // rather than silently treating it as `None`. if (value === undefined) return Option.none(); return yield* Effect.fail( new DbConfigLoadError({ message: `failed to parse config: invalid ${field}.` }), @@ -1187,16 +939,10 @@ const VAULT_SECRET_PATH = ["db", "vault", "*"] as const; /** * Dotted paths of every secret-typed config field that must be decryptable — * `*` matches any map key (`auth.external.`, `auth.hook.`, - * `db.vault.`). `[db.vault]` (a name-to-secret map) IS included — the - * db-config reader below also decrypts it directly in its own body - * ({@link readDbToml}'s `vault` loop) with the same - * fail-on-undecryptable behaviour, so for that caller this just detects the - * same failure a little earlier (no observable difference: same - * `decryptSecret` call, same `failed to parse config: ` - * message). For `config push` — the other caller of - * {@link assertDecryptableSecrets}, which has no such downstream vault - * pass — omitting `db.vault` here would let an undecryptable vault secret - * through to the API calls. Update alongside any new secret-typed field. + * `db.vault.`). `[db.vault]` is included so `config push`'s call to + * {@link assertDecryptableSecrets} (which has no downstream vault pass of its + * own) still catches an undecryptable vault secret before it reaches the API. + * Update alongside any new secret-typed field. */ const SECRET_PATHS: ReadonlyArray> = [ ["db", "root_key"], @@ -1244,40 +990,25 @@ const collectSecretStrings = ( } }; -/** Returns Go's hook-error message when a single `encrypted:` secret value cannot be decrypted. */ +/** Returns an error message when a single `encrypted:` secret value cannot be decrypted. */ const assertSecretValue = ( value: string, lookup: EnvLookup, dotenvPrivateKeys: ReadonlyArray, ): string | undefined => { const expanded = expandEnv(value, lookup); - // Unset `env(...)` and plain strings are returned verbatim by Go's hook (no error). + // An unset `env(...)` or a plain string is returned verbatim (no error). if (ENV_PATTERN.test(expanded) || !isEncryptedSecret(expanded)) return undefined; const decrypted = decryptSecret(expanded, dotenvPrivateKeys); return decrypted.ok ? undefined : `failed to parse config: ${decrypted.error}`; }; /** - * Asserts every `config.Secret`-typed `encrypted:` value in the (merged) config can be - * decrypted, mirroring Go's global `DecryptSecretHookFunc`, which aborts the load with - * `failed to parse config: ` when a secret cannot be decrypted. Only the actual - * `Secret` field paths ({@link SECRET_PATHS}) are scanned — a non-secret string that - * merely starts with `encrypted:` (e.g. an auth email-template `subject`) stays plain text - * in Go and must not block the load. Go decodes every `[remotes.]` block into the same - * struct, so the same paths are checked under each remote too. Returns Go's error message (or - * `undefined`); callers surface it as their own domain error (e.g. `Effect.fail`). - * - * Shared across command families: the db-config reader below uses it for `db push`/`db - * reset`/`migration up|down`/etc, and `config push`'s handler reuses it directly against its - * own (`@supabase/config`-decoded) document — both need the exact same "decrypt-or-abort before - * anything else runs" behaviour Go gets for free from `config.Load`. - * - * The "check every `[remotes.]` block too" part of that contract only holds when `doc` - * still has an intact `remotes` key. The db-config reader's own remote-merge keeps it (so this - * function really does check every declared remote there), but `@supabase/config`'s - * `loadCliConfig` strips `remotes` from the document once a block matches the target ref — - * so for `config push`, an undecryptable secret hiding in a *different, non-matching* remote - * block goes unchecked (see that command's SIDE_EFFECTS.md KNOWN GAPS). + * Asserts every `encrypted:` value at a {@link SECRET_PATHS} location — in the + * merged config and each `[remotes.]` block — can be decrypted, failing + * with `failed to parse config: ` if not. Ignores a non-secret string + * that merely starts with `encrypted:`. Remotes are only checked when `doc` + * still has its `remotes` key — see `config push`'s SIDE_EFFECTS.md. */ export const assertDecryptableSecrets = ( doc: unknown, @@ -1309,8 +1040,7 @@ export const assertDecryptableSecrets = ( return undefined; }; -// Go merges the template default before Validate (`templates/config.toml`), so an absent -// `auth.site_url` is non-empty; only an explicit empty string fails A1. +// An absent `auth.site_url` defaults to this value; only an explicit empty string fails. const DEFAULT_AUTH_SITE_URL = "http://127.0.0.1:3000"; /** @@ -1325,39 +1055,24 @@ const readDbTomlCore = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, path: Path.Path, workdir: string, - // When set (the explicitly-linked path only), a `[remotes.]` block whose - // `project_id` equals `ref` is merged over the base config before fields are read. - // `--local` / `--db-url` / declarative pass nothing and read the unmerged config - // (those paths never resolve a ref before config load). + // When set, the `[remotes.]` block whose `project_id` equals `ref` is merged + // over the base config before fields are read; omitted for `--local`/`--db-url`/declarative. ref?: string, - // Internal: when true the on-disk `config.toml` is treated as absent so the body - // resolves pure defaults (still honoring `SUPABASE_*` env overrides, which Go binds - // regardless of a config file). The lenient `readDbToml({ validate: false })` - // wrapper uses this as its fallback after a config-load failure, mirroring the - // best-effort behavior the container-id seam relied on before. + // Internal: when true, `config.toml` is treated as absent so the body resolves pure + // defaults (env overrides still apply). Used as the fallback after a config-load + // failure by the lenient `readDbToml({ validate: false })` wrapper. ignoreConfigFile = false, - // Internal: gates the `assertEnvLoaded` OrioleDB S3 stderr WARN below (review: - // Codex, PR #6022). This reader should run exactly once per command invocation, so - // the warning prints at most once. `start`/`db start`'s fresh-volume bootstrap calls - // this reader more than once in a single invocation — once purely for its validation - // side effect (`start.handler.ts:614`, `db/start/start.handler.ts:125`, both discard - // the result), then again internally wherever a resolved value is actually needed - // (`isLocalDbRunning`'s best-effort `projectId` probe, - // `startSetupLocalDatabase`'s own accepted duplicate config-load pass — - // see `db-bootstrap/db-setup.ts`'s header). Those internal re-reads pass - // `false` so the warning still fires exactly once per invocation instead of - // two or three times. + // Internal: gates the OrioleDB S3 `assertEnvLoaded` WARN below so it prints at most + // once per command invocation. Callers that re-read the config internally after an + // earlier same-invocation read pass `false`. warnOnUnresolvedEnv = true, resolveVaultSecrets = true, ) { const supabaseDir = path.join(workdir, "supabase"); const configPath = path.join(supabaseDir, "config.toml"); - // Distinguish "absent" (→ defaults) from "present but unreadable/malformed" (→ fail), - // matching `mergeFileConfig`: only `os.ErrNotExist` - // is swallowed, every other read error aborts rather than silently running against the - // default local database. Effect surfaces "not found" as `PlatformError` with a - // `SystemError` reason tagged `"NotFound"`. + // A missing file yields defaults; any other read error aborts rather than silently + // running against the default local database. const maybeContent = ignoreConfigFile ? Option.none() : yield* fs.readFileString(configPath).pipe( @@ -1373,15 +1088,12 @@ const readDbTomlCore = Effect.fnUntraced(function* ( ), ); - // Resolve `env(VAR)` against the shell env first, then the project `.env` files. Built - // here — before the remote-config validation/merge below — so remote and - // top-level `project_id` env() forms are expanded before they are validated or - // used to derive Docker IDs. + // Built before the remote-config validation/merge below, so remote and top-level + // `project_id` env() forms are expanded before they are validated or used to derive + // Docker IDs. const projectEnv = yield* loadProjectEnv(fs, path, workdir); const lookup: EnvLookup = (name) => process.env[name] ?? projectEnv[name]; - // dotenvx private keys for decrypting `encrypted:` secrets, from the shell + project - // env. Used by the global secret-decryptability assertion below and the `[db.vault]` - // resolution. + // dotenvx private keys for decrypting `encrypted:` secrets below. const dotenvPrivateKeys = collectDotenvPrivateKeys({ ...projectEnv, ...process.env }); let db: RawDoc | undefined; @@ -1395,13 +1107,11 @@ const readDbTomlCore = Effect.fnUntraced(function* ( let functionsRaw: RawDoc | undefined; let analyticsRaw: RawDoc | undefined; let projectId = Option.none(); - // Whether `config.toml` set a top-level `project_id` string that env-expanded to empty - // (`project_id = ""`). That empty override is kept and validation fails with - // `Missing required field in config: project_id`; tracked here so the - // check can run after the `SUPABASE_PROJECT_ID` env override below may still rescue it. + // A present `project_id` that env-expands to empty; the `SUPABASE_PROJECT_ID` + // override below may still rescue it before the check further down fails the load. let projectIdExplicitEmpty = false; - // Config keys a matched remote block contributed at override tier, so they must beat - // the matching `SUPABASE_*` env overrides below. + // Keys a matched remote block set at override tier; they must beat matching env + // overrides below. let remoteOverrideKeys: ReadonlySet = new Set(); // The matched `[remotes.]` block name, echoed as the config-override line. let appliedRemote: string | undefined; @@ -1456,10 +1166,8 @@ const readDbTomlCore = Effect.fnUntraced(function* ( edgeRuntimeRaw = asRecord(effectiveDoc?.["edge_runtime"]); functionsRaw = asRecord(effectiveDoc?.["functions"]); analyticsRaw = asRecord(effectiveDoc?.["analytics"]); - // `env(VAR)` for the top-level `project_id` must be expanded before the derived - // Docker container names are computed from it — otherwise a - // `project_id = "env(PROJECT_ID)"` would sanitize to a wrong local-stack id like - // `supabase_db_env_PROJECT_ID_`. + // Expand `env(VAR)` before deriving the Docker container name from `project_id` — + // otherwise `project_id = "env(PROJECT_ID)"` would sanitize to the literal string. const rawProjectId = effectiveDoc?.["project_id"]; projectId = nonEmptyString( typeof rawProjectId === "string" ? expandEnv(rawProjectId, lookup) : rawProjectId, @@ -1467,12 +1175,9 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // A present `project_id` string that resolves to empty is a "kept empty override". projectIdExplicitEmpty = typeof rawProjectId === "string" && Option.isNone(projectId); - // Every secret-typed field in the merged config must be decryptable, so an - // `encrypted:` secret anywhere (e.g. no DOTENV_PRIVATE_KEY) aborts the load with - // `failed to parse config: ` — before validation and before connecting. - // This covers `[db.vault]` unless the caller is - // explicitly skipping Vault sync, so the vault loop below only materializes values - // that this assertion has already proved decryptable. + // Every secret-typed field must be decryptable before validation/connecting. Skips + // `[db.vault]` when the caller opts out, so the vault loop below only materializes + // already-proven-decryptable values. const secretError = assertDecryptableSecrets(effectiveDoc, lookup, dotenvPrivateKeys, { includeVault: resolveVaultSecrets, }); @@ -1501,31 +1206,18 @@ const readDbTomlCore = Effect.fnUntraced(function* ( return fromFile !== undefined && fromFile.length > 0 ? fromFile : undefined; }; - // `SUPABASE_PROJECT_ID` overrides the top-level `project_id` before the local-stack - // container/network names (`NetId = supabase_network_`) are derived from - // it. The reader's `projectId` is exactly that Docker-naming id, so apply the - // override here (env-expanded like the TOML value, then sanitized at the consumer) — - // otherwise `test db --local` would join `supabase_network_` - // instead of honoring the env id. This is independent of the linked-ref resolver, - // which reads the env var on its own chain; the env value is bound regardless of - // whether a config file exists. UNLESS a matched `[remotes.]` block already set - // `project_id` at override tier (`remoteWins("project_id")` — NOT guaranteed whenever - // `appliedRemote` is set: a block can also match purely via its own - // `SUPABASE_REMOTES__PROJECT_ID` env override with no literal `project_id` key, - // in which case this stays `false` — see `ENV_OVERRIDABLE_KEYS`'s own doc - // comment on that key): that override-tier value, when present, outranks the ambient - // env tier, so a stale/differently-scoped `SUPABASE_PROJECT_ID` must not clobber it — - // otherwise a linked `db diff`/`db pull` mounts the wrong `supabase_edge_runtime_` - // Deno-cache volume for the matched remote (review: PRRT_kwDOErm0O86XHGDL). + // `SUPABASE_PROJECT_ID` overrides the top-level `project_id` used to name the local + // stack's Docker resources, unless a matched `[remotes.]` block already set + // `project_id` at override tier — that value must win over a stale/differently-scoped + // env var. const projectIdEnv = remoteWins("project_id") ? undefined : envOverride("SUPABASE_PROJECT_ID"); if (projectIdEnv !== undefined) { projectId = nonEmptyString(expandEnv(projectIdEnv, lookup)); } - // Validation rejects an empty top-level `project_id`. An absent field is tolerated - // here (deferred), but a present `project_id = ""` that the `SUPABASE_PROJECT_ID` - // override did not rescue is a load error, so a destructive command (e.g. remote - // `db reset`) fails fast rather than dropping schemas on a config that should have + // An absent `project_id` is tolerated here (deferred); a present `project_id = ""` + // that the env override didn't rescue fails the load, so a destructive command (e.g. + // remote `db reset`) fails fast instead of running against a config that should have // already failed validation. if (projectIdExplicitEmpty && Option.isNone(projectId)) { return yield* Effect.fail( @@ -1533,9 +1225,8 @@ const readDbTomlCore = Effect.fnUntraced(function* ( ); } - // A present-but-unmarshalable port aborts rather than defaulting, so `test db - // --local` never silently targets the default local database while hiding a broken - // `[db]` config. + // A present-but-unmarshalable port aborts rather than defaulting, so a broken `[db]` + // config never silently targets the default local database. const port = resolvePort( (remoteWins("db.port") ? undefined : envOverride("SUPABASE_DB_PORT")) ?? db?.["port"], DEFAULT_PORT, @@ -1554,28 +1245,22 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }), ); } - // Validation rejects an explicit `db.port = 0`; an absent port is defaulted before - // validation, so only a present 0 fails. `resolvePort` accepts 0 as a syntactically - // valid uint16, so the zero check lives here. No equivalent check for `shadow_port`. + // An explicit `db.port = 0` is a load error (an absent port is defaulted first); + // `resolvePort` accepts 0 as a valid uint16, so the zero check lives here. No + // equivalent check for `shadow_port`. if (port === 0) { return yield* Effect.fail( new DbConfigLoadError({ message: "Missing required field in config: db.port" }), ); } - // `db.password` isn't part of the config schema (no `SUPABASE_DB_PASSWORD` env - // binding, and the local password otherwise defaults to `"postgres"`). Honoring a - // literal `[db] password` toml key here is a deliberate TS extension (established - // for `--local` connections, `db-config.layer.ts`). `DB_PASSWORD` is read only - // by linked password resolution (`db-config.layer.ts`), so the local password - // must not source it or `db query --local` etc. would authenticate with a remote - // secret. + // `db.password` isn't part of the config schema — it's a TS-only extension for + // `--local` connections. Must not read `DB_PASSWORD` (linked-only), or `db query + // --local` etc. would authenticate with a remote secret. const passwordRaw = typeof db?.["password"] === "string" ? db["password"] : undefined; - // A quoted `env(VAR)` reference for `major_version` is expanded and then decoded - // into a `uint`, strictly rejecting a non-integer string (`17foo` is NOT truncated - // to 17), resolving `env(PG_MAJOR)` before validation. `resolveConfigInt` does - // this; `SUPABASE_DB_MAJOR_VERSION` overrides the TOML value. + // `env(VAR)` is expanded, then the result must decode as a whole integer (`17foo` is + // not truncated to 17); `SUPABASE_DB_MAJOR_VERSION` overrides the TOML value. const majorVersionRaw = (remoteWins("db.major_version") ? undefined : envOverride("SUPABASE_DB_MAJOR_VERSION")) ?? db?.["major_version"]; @@ -1593,24 +1278,19 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }), ); } - // Rejecting an unsupported major version ({13,14,15,17}) is - // `validateResolvedConfig`'s `db.major_version` switch (called once, below) — an - // absent value falls through to the default (zero-then-default) and a present one - // (including `0`) flows into `input.db.majorVersion` for that switch to check. + // An unsupported major version is rejected by the single `validateResolvedConfig` + // call below; an absent value defaults first, a present one (including 0) flows through. const majorVersion = typeof majorVersionResolved === "number" ? majorVersionResolved : DEFAULT_MAJOR_VERSION; - // `[experimental] orioledb_version`: on a 15/17 project, validation rewrites the - // Postgres image to the OrioleDB tag and `assertEnvLoaded`s the four S3 fields. - // Expand env() like every other field; the image rewrite itself is applied by - // `resolveDbImage`. + // On a 15/17 project, validation rewrites the Postgres image to the OrioleDB tag and + // checks the four S3 fields below; the image rewrite itself happens in `resolveDbImage`. const expandString = (value: unknown): Option.Option => typeof value === "string" ? nonEmptyString(expandEnv(value, lookup)) : Option.none(); const orioledbVersion = expandString(experimentalRaw?.["orioledb_version"]); if (Option.isSome(orioledbVersion) && (majorVersion === 15 || majorVersion === 17)) { - // `assertEnvLoaded` warns (does NOT fail) for any S3 value still holding an - // unexpanded `env(VAR)` after env loading. Match the - // stderr line byte-for-byte; the env var name is the `env(...)` capture. + // Warns (does not fail) when an S3 field still holds an unexpanded `env(VAR)`; + // matches the established stderr line, with the env var name from the capture. const s3Fields = ["s3_host", "s3_region", "s3_access_key", "s3_secret_key"] as const; for (const field of s3Fields) { const raw = experimentalRaw?.[field]; @@ -1623,19 +1303,15 @@ const readDbTomlCore = Effect.fnUntraced(function* ( } } - // `[edge_runtime] deno_version` (default 2). The edge-runtime image switches to the - // `deno1` tag when this is 1; the declarative pg-delta runner needs it to pick the - // matching image. `SUPABASE_EDGE_RUNTIME_DENO_VERSION` overrides the TOML before - // validation (same env-override precedence as the pg-delta env vars below), so a CI - // env override decides which edge-runtime image pg-delta runs under. + // Selects the edge-runtime image tag (`deno1` when 1, otherwise the default); pg-delta + // needs it to pick the matching image. `SUPABASE_EDGE_RUNTIME_DENO_VERSION` overrides + // the TOML value before validation. const denoVersionRaw = (remoteWins("edge_runtime.deno_version") ? undefined : envOverride("SUPABASE_EDGE_RUNTIME_DENO_VERSION")) ?? edgeRuntimeRaw?.["deno_version"]; - // `deno_version` decodes into a `uint` before validation, so a present non-integer - // string (`2foo`) or an unresolved `env(MISSING)` aborts the load rather than falling - // through to the default Deno 2 image. `resolveConfigInt` expands `env()` then requires - // a whole integer; the validation switch handles the rest. + // A present non-integer string or unresolved `env(MISSING)` aborts the load rather + // than falling through to the default Deno 2 image. const denoVersionResolved = resolveConfigInt(denoVersionRaw, lookup); if (denoVersionResolved === "invalid") { const shown = @@ -1648,37 +1324,15 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }), ); } - // Rejecting a present-but-invalid deno_version (0 → missing-required, anything other - // than 1/2 → invalid) is `validateResolvedConfig`'s `edgeRuntimeDenoVersion` - // switch (called once, below). An absent key falls through to the default - // (deno_version=2). + // An invalid deno_version is rejected by the single `validateResolvedConfig` call + // below; an absent key falls through to the default (2). const denoVersion = typeof denoVersionResolved === "number" ? denoVersionResolved : DEFAULT_DENO_VERSION; - // `[experimental.webhooks]`. `*webhooks` is a nil-unless-declared pointer sibling of - // `*PgDeltaConfig` below. The section only exists to be turned ON: ANY present - // `[experimental.webhooks]` whose `enabled` isn't explicitly `true` is rejected, - // including when the key is simply omitted (bool zero-value `false`). - // `webhooksPresent`/`webhooksEnabled` feed `validateResolvedConfig`'s existing - // `experimental.webhooks` check (`config-validate.ts:676-680`) — this D pipeline never - // populated that input pair, so the check never ran for any of D's ~15 db/migration-command - // callers (`db start`, `db reset`, `db push`, `start`, migrate-and-seed), unlike L's - // `resolveLocalConfigValues`, which already computes the identical pair from its own - // decoded config + raw document (review: PRRT_kwDOErm0O86WE42i). - // - // UNLIKE `experimental.pgdelta.enabled` below, the `SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED` - // env override is NOT presence-independent — verified empirically: loading a config with no - // `[experimental.webhooks]` section and a malformed env value succeeds and leaves - // `Experimental.Webhooks` unset, silently ignoring the override, where the equivalent pgdelta - // probe fails to load. The difference is the default-merge order: the template merged before - // the user's file declares `[experimental.pgdelta]` but has no `[experimental.webhooks]` entry - // at all — so `pgdelta.enabled` is always a "known" key (env-bindable regardless of the user's - // own file), while `webhooks.enabled` is only known, and therefore only env-overridable, when - // the section itself is declared (by the user's file or a matching `[remotes.*]` block — both - // already folded into `experimentalRaw` above). Gate the env read itself on `webhooksPresent`, - // not just the later validation check, so a bogus/irrelevant - // `SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED` in the shell or project `.env` doesn't abort a load - // that has no `[experimental.webhooks]` section to apply it to (review: this thread). + // `[experimental.webhooks]` only exists to be turned ON: any present section whose + // `enabled` isn't explicitly `true` is rejected. Unlike `pgdelta.enabled` below, + // `SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED` only applies when the section itself is + // declared — gate the env read on `webhooksPresent`, not just later validation. const webhooksRaw = asRecord(experimentalRaw?.["webhooks"]); const webhooksPresent = webhooksRaw !== undefined; const webhooksEnabledRaw = webhooksRaw?.["enabled"]; @@ -1702,8 +1356,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( } else if (typeof webhooksEnabledRaw === "boolean") { webhooksEnabled = webhooksEnabledRaw; } else if (typeof webhooksEnabledRaw === "number") { - // Go decodes the whole config under mapstructure's weak typing, so a numeric `enabled = 1` - // is true (`value != 0`) — same rule as `experimental.pgdelta.enabled` below. + // A numeric `enabled = 1` is true (`value != 0`), same as `experimental.pgdelta.enabled` below. webhooksEnabled = webhooksEnabledRaw !== 0; } else if (typeof webhooksEnabledRaw === "string") { const parsed = parseGoBool(expandEnv(webhooksEnabledRaw, lookup)); @@ -1719,25 +1372,19 @@ const readDbTomlCore = Effect.fnUntraced(function* ( webhooksEnabled = false; } - // `[experimental.pgdelta]`. `enabled` is a TOML bool (Go decodes weakly, so an - // `env(VAR)`/string "true" also counts); `declarative_schema_path` is resolved - // to a `supabase/`-prefixed path when relative (`config.resolve`). - // Go's viper `AutomaticEnv` lets `SUPABASE_EXPERIMENTAL_PGDELTA_*` override the - // TOML before validation (`config.go` `SetEnvPrefix("SUPABASE")` + `.`→`_`), so a - // CI env override decides the gate / paths. `envOverride` is the shell→project-.env - // lookup that ignores empty values, matching viper. + // `[experimental.pgdelta]`. `enabled` accepts a TOML bool or a string (including + // `env(VAR)`); `declarative_schema_path` is resolved to a `supabase/`-prefixed path + // when relative. `SUPABASE_EXPERIMENTAL_PGDELTA_*` overrides the TOML before + // validation. const enabledRaw = pgDeltaRaw?.["enabled"]; const enabledEnv = remoteWins("experimental.pgdelta.enabled") ? undefined : envOverride("SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"); - // Go decodes this bool via `strconv.ParseBool` (mapstructure weakly typed), so `"1"` - // counts as true and a malformed value (`SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED=maybe`) - // aborts the load. The env override wins (viper AutomaticEnv), then the TOML bool, then - // an `env(VAR)` string, defaulting to false when absent. + // `"1"` counts as true and a malformed value aborts the load. The env override wins, + // then the TOML bool, then an `env(VAR)` string, defaulting to false when absent. let enabled: boolean; if (enabledEnv !== undefined) { - // The AutomaticEnv override is decoded through `LoadEnvHook`, so an `env(VAR)` - // indirection is expanded before the weak `ParseBool` decode. + // An `env(VAR)` indirection in the override is expanded before the bool parse. const expandedEnabledEnv = expandEnv(enabledEnv, lookup); const parsed = parseGoBool(expandedEnabledEnv); if (parsed === undefined) { @@ -1751,8 +1398,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( } else if (typeof enabledRaw === "boolean") { enabled = enabledRaw; } else if (typeof enabledRaw === "number") { - // Go decodes the whole config under mapstructure's weak typing, so a numeric - // `enabled = 1` is true (`value != 0`) — same rule as the generic `resolveBool`. + // A numeric `enabled = 1` is true (`value != 0`), same rule as the generic `resolveBool`. enabled = enabledRaw !== 0; } else if (typeof enabledRaw === "string") { const parsed = parseGoBool(expandEnv(enabledRaw, lookup)); @@ -1769,10 +1415,8 @@ const readDbTomlCore = Effect.fnUntraced(function* ( } const declarativeSchemaPathRaw = pgDeltaRaw?.["declarative_schema_path"]; - // The AutomaticEnv override and the TOML literal both flow through `LoadEnvHook` - // under `UnmarshalExact`, so an `env(VAR)` indirection is expanded - // before the path is used — whichever source wins. Expand once over the resolved value - // (`expandEnv` is a no-op on a non-`env()` string). + // Expand `env(VAR)` in whichever source wins (override or TOML literal) before the + // path is used (`expandEnv` is a no-op on a non-`env()` string). const declarativeSchemaPathValue = expandEnv( (remoteWins("experimental.pgdelta.declarative_schema_path") ? undefined @@ -1790,8 +1434,8 @@ const readDbTomlCore = Effect.fnUntraced(function* ( } const formatOptionsRaw = pgDeltaRaw?.["format_options"]; - // Same `LoadEnvHook` path: expand the resolved value (env override or TOML literal) before - // the JSON validation below runs. + // Expand the resolved value (env override or TOML literal) before the JSON validation + // below runs. const formatOptionsExpanded = expandEnv( (remoteWins("experimental.pgdelta.format_options") ? undefined @@ -1799,22 +1443,16 @@ const readDbTomlCore = Effect.fnUntraced(function* ( (typeof formatOptionsRaw === "string" ? formatOptionsRaw : ""), lookup, ); - // Rejecting a non-empty, non-JSON `format_options` is `validateResolvedConfig`'s - // `experimental.pgdeltaFormatOptions` check (called once, below). + // A non-empty, non-JSON `format_options` is rejected by the single + // `validateResolvedConfig` call below. const formatOptions = nonEmptyString(formatOptionsExpanded); // Bucket-name/function-slug validation lives in `validateResolvedConfig` (called // once, below); only the pure extraction stays here. const bucketsRaw = asRecord(storageRaw?.["buckets"]); - // Same gap for each bucket's own `file_size_limit` — Go decodes it via the same `sizeInBytes` - // hook as the storage-level default (validated eagerly in `start.handler.ts`), unconditionally - // during `Config.Load`, for every configured bucket, before any command-specific logic runs. - // This reader only ever extracted bucket NAMES above — the per-bucket size itself was only - // parsed deep inside `seedBucketsRun`, reached only on a fresh volume with Storage - // actually started, so a malformed value on a reused-volume restart (or the already-running - // short-circuit, which never reaches bring-up at all) went completely unvalidated. An absent - // key is skipped (Go's decode hook never fires over one; the bucket inherits the storage-level - // default later). Validate-only: `seedBucketsRun`'s own call re-parses the real value. + // Validates each bucket's own `file_size_limit` eagerly, so a malformed value is + // caught even on a restart that never reaches Storage bring-up. Validate-only — + // `seedBucketsRun` re-parses the real value when it actually seeds. if (bucketsRaw !== undefined) { for (const [bucketName, bucketRaw] of Object.entries(bucketsRaw)) { const rawLimit = asRecord(bucketRaw)?.["file_size_limit"]; @@ -1833,13 +1471,8 @@ const readDbTomlCore = Effect.fnUntraced(function* ( } } - // Go's config.Validate runs the full `if c.Auth.Enabled` block after - // the bucket/function checks. Gated on `auth.enabled` (default true); Go's viper AutomaticEnv - // binds `auth.enabled` to `SUPABASE_AUTH_ENABLED` before Validate, so the - // env override decides whether the auth block is validated — UNLESS a matched `[remotes.*]` - // block supplies `auth.enabled` itself, in which case `mergeRemoteConfig`'s `v.Set` (override - // tier, above `AutomaticEnv`) wins, matching the same suppression every other - // `ENV_OVERRIDABLE_KEYS` entry gets below. + // Gated on `auth.enabled` (default true); `SUPABASE_AUTH_ENABLED` decides whether the + // auth block is validated, unless a matched remote block set `auth.enabled` itself. const authEnabled = yield* resolveBoolOrFail( "auth.enabled", authRaw?.["enabled"], @@ -1848,20 +1481,17 @@ const readDbTomlCore = Effect.fnUntraced(function* ( remoteWins("auth.enabled") ? undefined : envOverride("SUPABASE_AUTH_ENABLED"), ); - // Local helpers mirroring the deleted `validateAuthConfig`'s closures — its Go-parity - // CHECKS now live in `validateResolvedConfig`; `str`/`gate`/`fail` are still needed here - // to build that call's `AuthInput`, and by the D-only sms/external checks below (never - // part of the shared validator — see `config-validate.ts`'s module header). + // `str`/`gate`/`fail` build `AuthInput` for the shared validator call below, and back + // the sms/external checks further down that only this reader performs. const fail = (message: string) => Effect.fail(new DbConfigLoadError({ message })); - // Env-expanded string of `rec[key]` ("" when absent/non-string). An unresolved `env(VAR)` - // stays literal (non-empty), matching Go's LoadEnvHook + the Secret decode hook. + // Env-expanded string of `rec[key]` ("" when absent/non-string). An unresolved + // `env(VAR)` stays literal (non-empty). const str = (rec: RawDoc | undefined, key: string): string => { const value = rec?.[key]; return typeof value === "string" ? expandEnv(value, lookup) : ""; }; - // Weak-bool decode (Go mapstructure): boolean | nonzero number | strconv.ParseBool string. A - // malformed string ABORTS the load like Go's decode (it does NOT coerce to false). Absent / - // non-string → false (the default for every auth enable-flag). + // Accepts a boolean, nonzero number, or parseable string; a malformed string aborts + // the load rather than coercing to false. Absent/non-string → false. const gate = (rec: RawDoc | undefined, key: string, field: string) => Effect.gen(function* () { const value = rec?.[key]; @@ -1899,8 +1529,8 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }; } - // A5: signing keys file load — I/O, stays in D. A relative path - // resolves under the supabase dir; absolute is verbatim. + // A5: signing keys file load (I/O). A relative path resolves under the supabase + // dir; absolute is verbatim. const signingKeysPath = str(authRawResolved, "signing_keys_path"); if (signingKeysPath.length > 0) { const keysJson = yield* fs @@ -1928,12 +1558,9 @@ const readDbTomlCore = Effect.fnUntraced(function* ( if (passkeyRaw !== undefined && (yield* gate(passkeyRaw, "enabled", "auth.passkey.enabled"))) { const webauthnRaw = asRecord(authRawResolved["webauthn"]); const rpOriginsRaw = webauthnRaw?.["rp_origins"]; - // Go decodes `rp_origins` (a `[]string`) through the same - // `StringToSliceHookFunc(",")` mapstructure hook as every other `[]string` field — - // a raw or `env(...)`-resolved comma-separated string must be - // split, not treated as "missing" just because it isn't already a literal TOML array. - // Matches `local-config-values.ts`'s own `resolveGotruePasskeyWebauthn`/ - // `strToArr` handling of this identical field. + // `rp_origins` may be a comma-separated string (raw or `env(...)`-resolved) rather + // than a literal TOML array; split it, matching `local-config-values.ts`'s + // handling of this same field. const rpOrigins = Array.isArray(rpOriginsRaw) ? rpOriginsRaw : strToArr(str(webauthnRaw, "rp_origins")); @@ -1944,7 +1571,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }; } - // B1: hooks — each enabled hook, Go's fixed iteration order. + // B1: hooks — each enabled hook, checked in this fixed order. const hookRaw = asRecord(authRawResolved["hook"]); const hookTypes = [ "mfa_verification_attempt", @@ -1974,8 +1601,8 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }); } - // B3: email — template/notification content is I/O, stays in D. Config loading resolves - // every relative `content_path` from the project root; absolute paths remain unchanged. + // B3: email — template/notification content is I/O. Config loading resolves every + // relative `content_path` from the project root; absolute paths remain unchanged. const emailRaw = asRecord(authRawResolved["email"]); const templatesRaw = asRecord(emailRaw?.["template"]); if (templatesRaw !== undefined) { @@ -2042,18 +1669,15 @@ const readDbTomlCore = Effect.fnUntraced(function* ( ); } } - // Go defaults `auth.email.smtp.enabled = true` when the `[auth.email.smtp]` table is present - // but omits `enabled`, so a present table validates unless explicitly + // A present `[auth.email.smtp]` table defaults `enabled` to true unless explicitly // disabled. const smtpRaw = asRecord(emailRaw?.["smtp"]); let smtpInput: SmtpInput | undefined; if (smtpRaw !== undefined) { const smtpPortRaw = smtpRaw["port"]; - // The shared validator's required-field check is `port === 0` (Go decodes `port` into a - // numeric type at the config-decode step, so it can never observe a non-numeric value here). - // D reads the raw TOML/env string directly, so a non-numeric `port` (or an unresolved - // `env(VAR)`) parses to `NaN` via `Number(...)` — normalize that to `0` so it still trips - // the "missing required field" check instead of silently passing config load. + // The shared validator's required-field check is `port === 0`. A non-numeric `port` + // (or unresolved `env(VAR)`) parses to `NaN` here — normalize that to `0` so it + // still trips the check instead of silently passing. const smtpPortNumeric = typeof smtpPortRaw === "number" ? smtpPortRaw @@ -2073,8 +1697,8 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }; } - // B6: third_party — each enabled provider, Go's fixed order. Note - // `aws_cognito`'s messages say `cognito` (Go's wording). + // B6: third_party — each enabled provider, checked in this fixed order. + // `aws_cognito`'s error messages say "cognito", not "aws_cognito". const thirdPartyRaw = asRecord(authRawResolved["third_party"]); const thirdParty: Array = []; const firebaseRaw = asRecord(thirdPartyRaw?.["firebase"]); @@ -2128,17 +1752,10 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }; } - // Go's config.Validate validates `[analytics]` after the auth block. - // Computed here (after the auth block, before the single shared call) rather than pure-derived - // earlier: `analyticsEnabled` resolves through `resolveBoolOrFail`, which can itself FAIL on a - // malformed `SUPABASE_ANALYTICS_ENABLED`/`analytics.enabled` bool — positioning that failure - // point ahead of the auth block would report it before an auth-block error even for a config - // that's ALSO broken there, reversing Go's real order. Go merges the template defaults - // `enabled = true`, `backend = "postgres"` before Validate (`templates/config.toml:388-392`), so - // an absent `[analytics]` section is enabled+postgres and passes (an empty backend never equals - // `bigquery`, so the GCP block is skipped). viper AutomaticEnv binds `SUPABASE_ANALYTICS_*`; a - // matched remote block makes those keys env-immune, same as every other - // `ENV_OVERRIDABLE_KEYS` field above. + // Computed after the auth block, before the shared validator call: `analyticsEnabled` + // can itself fail on a malformed bool, and that failure must report after an + // auth-block error for a config broken in both places. An absent `[analytics]` + // section defaults to enabled+postgres. const analyticsString = ( key: "backend" | "gcp_project_id" | "gcp_project_number" | "gcp_jwt_path", envName: string, @@ -2155,8 +1772,8 @@ const readDbTomlCore = Effect.fnUntraced(function* ( lookup, remoteWins("analytics.enabled") ? undefined : envOverride("SUPABASE_ANALYTICS_ENABLED"), ); - // Each GCP value is env-expanded (Go's LoadEnvHook), so an unresolved `env(VAR)` stays - // non-empty and passes the shared validator's `length === 0` check, exactly like Go. + // Each GCP value is env-expanded, so an unresolved `env(VAR)` stays non-empty and + // passes the shared validator's `length === 0` check. const gcpProjectId = analyticsString("gcp_project_id", "SUPABASE_ANALYTICS_GCP_PROJECT_ID"); const gcpProjectNumber = analyticsString( "gcp_project_number", @@ -2164,12 +1781,9 @@ const readDbTomlCore = Effect.fnUntraced(function* ( ); const gcpJwtPath = analyticsString("gcp_jwt_path", "SUPABASE_ANALYTICS_GCP_JWT_PATH"); - // Every PURE Config.Validate check this module/config-validate.ts jointly own (db.port - // is checked earlier, above, and stays there — see the comment at that check) is deferred to - // this single call, in Go's exact relative order. D's sms/external checks (D-only, never part - // of the shared validator) run AFTER this call succeeds, still gated on `authEnabled` — see - // `config-validate.ts`'s module header for the accepted ordering tradeoff this - // introduces against third_party. + // Every check `validateResolvedConfig` owns runs through this single call (except + // `db.port`, checked earlier above). The sms/external checks below, which only this + // reader performs, run after this call succeeds, still gated on `authEnabled`. const dbInput: DbInput = { port, majorVersion }; const analyticsInput: AnalyticsInput = { enabled: analyticsEnabled, @@ -2201,9 +1815,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }); if (authEnabled) { - // B4: sms — D-only (never part of the shared validator, see - // `config-validate.ts`'s module header). Only the FIRST enabled provider is - // validated. + // B4: sms — only this reader validates it; only the first enabled provider is checked. const sms = asRecord(authRawResolved["sms"]); if (sms !== undefined) { const twilio = asRecord(sms["twilio"]); @@ -2264,8 +1876,8 @@ const readDbTomlCore = Effect.fnUntraced(function* ( } } - // B5: external providers — D-only (never part of the shared validator). linkedin/slack - // are deprecated and deleted before validation, so they are never validated here. + // B5: external providers — only this reader validates it. linkedin/slack are + // deprecated and skipped. const external = asRecord(authRawResolved["external"]); if (external !== undefined) { for (const name of Object.keys(external)) { @@ -2285,9 +1897,8 @@ const readDbTomlCore = Effect.fnUntraced(function* ( const vaultRaw = asRecord(db?.["vault"]); const vaultNames = vaultRaw === undefined ? [] : Object.keys(vaultRaw).sort(); - // `[db.migrations] enabled` — default true; overridable by - // `SUPABASE_DB_MIGRATIONS_ENABLED` — EXCEPT when the matched remote block explicitly - // set it (then the remote override-tier value wins). + // `[db.migrations] enabled` — default true, overridable by + // `SUPABASE_DB_MIGRATIONS_ENABLED` unless the matched remote block explicitly set it. const migrationsRaw = asRecord(db?.["migrations"]); const migrationsEnabled = yield* resolveBoolOrFail( "db.migrations.enabled", @@ -2296,10 +1907,9 @@ const readDbTomlCore = Effect.fnUntraced(function* ( lookup, remoteWins("db.migrations.enabled") ? undefined : envOverride("SUPABASE_DB_MIGRATIONS_ENABLED"), ); - // `[db.seed]` — Go defaults enabled true, sql_paths ["seed.sql"]; relative - // patterns are supabase-prefixed. `db.seed.enabled` is - // overridable by `SUPABASE_DB_SEED_ENABLED` via viper AutomaticEnv — EXCEPT when a - // matched remote block supplied it at the override tier (set or forced false). + // `[db.seed]` — default enabled true, sql_paths `["seed.sql"]`; relative patterns are + // supabase-prefixed. Overridable by `SUPABASE_DB_SEED_ENABLED` unless a matched remote + // block supplied it at the override tier (set or forced false). const seedRaw = asRecord(db?.["seed"]); const seedEnabled = yield* resolveBoolOrFail( "db.seed.enabled", @@ -2308,45 +1918,20 @@ const readDbTomlCore = Effect.fnUntraced(function* ( lookup, remoteWins("db.seed.enabled") ? undefined : envOverride("SUPABASE_DB_SEED_ENABLED"), ); - // `db.seed.sql_paths` decodes through the hook chain in order: `env(VAR)` expansion - // runs BEFORE the comma-split. So a STRING value — the `SUPABASE_DB_SEED_SQL_PATHS` - // env override or a TOML string — is env-expanded FIRST, then comma-split (no - // trimming; empty → `[]`). A TOML ARRAY is decoded element-by-element: each element is - // env-expanded but NOT re-split, so `["env(SEEDS)"]` stays one pattern. The env - // override (non-empty; `envOverride` drops empties) wins over the TOML value; an - // absent (or non-string/non-array) value falls back to the `["seed.sql"]` default. + // A string value (env override or TOML string) is env-expanded, then comma-split (no + // trimming; empty → `[]`). An array is decoded element-by-element: each element is + // expanded but not re-split, so `["env(SEEDS)"]` stays one pattern. The env override + // wins over the TOML value; absent/invalid falls back to the caller's default. const splitGoSeedPaths = (value: string): ReadonlyArray => { const expanded = expandEnv(value, lookup); return expanded.length === 0 ? [] : expanded.split(","); }; - // A weakly-converted float renders via `strconv.FormatFloat(v, 'f', -1, 64)` — ALWAYS - // fixed decimal notation, never scientific, regardless of magnitude. JS's - // `String(value)` agrees for ordinary magnitudes (both use the same - // shortest-round-trip digit sequence — `String()` just chooses "e" notation once - // `|value| >= 1e21` or `< 1e-6`, which `FormatFloat('f', …)` never does). Verified - // empirically: `strconv.FormatFloat(1e21, 'f', -1, 64)` returns - // `"1000000000000000000000"`, not `"1e+21"`. Expand JS's own exponential notation - // back into fixed notation instead of re-deriving the digits, since - // `Number.prototype.toString()`/`toExponential()` already computed the same shortest - // round-tripping digit sequence the Go algorithm would — only the notation differs. - // - // `strconv.FormatFloat` special-cases the three non-finite values BEFORE it ever - // looks at the format verb, so `'f'` never applies to them: verified empirically - // (a probe against a real `schema_paths = [inf, -inf, nan]` config load) — - // `+Inf`/`-Inf`/`NaN` (note the "+Inf" sign Go always prints, and the short "Inf"/"NaN" - // spelling) — never JS's own `Infinity`/`-Infinity`/`NaN` (which happens to already - // match the "NaN" case, but not the two `Infinity` ones). TOML v1.0's bare `inf`/ - // `+inf`/`-inf`/`nan` float literals (smol-toml) parse to exactly these JS values, so - // a `schema_paths`/`sql_paths` array entry can realistically hit this branch. - // - // `strconv.FormatFloat` also preserves the IEEE754 sign bit on zero: a genuine - // negative-zero float64 formats as `"-0"`, never `"0"`. Verified empirically — - // `strconv.FormatFloat(math.Copysign(0, -1), 'f', -1, 64)` returns `"-0"` — and - // end-to-end through the real decode pipeline this weak-decode mirrors - // (`BurntSushi/toml` + `mapstructure`'s weakly-typed input): a `schema_paths = - // [-0.0]` config decodes its glob entry to the literal string `"-0"`. JS's own - // `(-0).toString()` is `"0"` (the sign is dropped), by spec — `Object.is(value, -0)` - // is JS's only way to detect it, since `-0 === 0`. + /** + * Formats a float the way a weakly-decoded TOML `Glob` array element renders as a + * string: always fixed notation (never scientific), `+Inf`/`-Inf`/`NaN` spelled that + * way, and a negative zero keeps its sign — so the resolved pattern's hash key matches + * an existing recorded entry. + */ const formatGoWeakFloat = (value: number): string => { if (Number.isNaN(value)) return "NaN"; if (value === Number.POSITIVE_INFINITY) return "+Inf"; @@ -2362,46 +1947,20 @@ const readDbTomlCore = Effect.fnUntraced(function* ( if (pointPos >= digits.length) return `${sign}${digits}${"0".repeat(pointPos - digits.length)}`; return `${sign}${digits.slice(0, pointPos)}.${digits.slice(pointPos)}`; }; - // Go decodes both `[db.seed].sql_paths` and `[db.migrations].schema_paths` as - // `config.Glob` (`[]string`) through the SAME mapstructure `UnmarshalExact` call, - // whose decoder config never sets `WeaklyTypedInput: false` — - // viper's `defaultDecoderConfig` defaults it to `true` and nothing here overrides - // it. So a non-string array element isn't dropped: `decodeString` - // weakly - // converts a bool to `"1"`/`"0"` and a number to its decimal string, THEN the - // result flows through the same env-expand/resolve pipeline as a real string - // entry. Verified empirically (`schema_paths = [42]` resolves to `supabase/42`, - // `schema_paths = [true]` to `supabase/1`). + // A non-string array element isn't dropped: a bool coerces to `"1"`/`"0"` and a + // number to its decimal string, then flows through the same env-expand/resolve + // pipeline as a real string entry. const weakCoerceGlobEntry = (value: unknown): string | undefined => { if (typeof value === "string") return value; if (typeof value === "boolean") return value ? "1" : "0"; if (typeof value === "number") return formatGoWeakFloat(value); return undefined; }; - // A non-scalar element (nested array/table, e.g. `schema_paths = [[]]` or - // `[{path = "x.sql"}]`) is mapstructure's `UnconvertibleTypeError` instead of a weak - // conversion, and mapstructure reports every offending index from the SAME - // `UnmarshalExact` call together, joined by its own `Error.Error()` — which is what - // aborts the entire config load, not just that one array. Verified empirically: - // `schema_paths = [[]]` / `[{path = "x.sql"}]` both fail with - // `failed to parse config: decoding failed due to the following error(s):\n\n'db. - // migrations.schema_paths[0]' expected type 'string', got unconvertible type - // '[]interface {}'` / `'map[string]interface {}'` respectively — never silently - // dropping the element and continuing with an empty/partial glob list. - // - // A bare TOML datetime (e.g. `schema_paths = 1979-05-27T07:32:00Z`) hits this same - // `UnconvertibleTypeError` path, but as a DIFFERENT Go type per TOML datetime - // variant — `BurntSushi/toml` decodes an offset date-time to stdlib `time.Time` and - // each of the 3 zone-less "local" variants to its own `toml.Local*` wrapper type. - // Verified empirically (review CLI-1958): `schema_paths = 1979-05-27T07:32:00Z` → - // `unconvertible type - // 'time.Time'`; `= 1979-05-27T07:32:00` (no zone) → `'toml.LocalDateTime'`; - // `= 1979-05-27` → `'toml.LocalDate'`; `= 07:32:00` → `'toml.LocalTime'` — same four - // messages whether the datetime is this top-level scalar or an array element. - // `smol-toml` parses every TOML datetime to a `TomlDate` (a `Date` subclass, so - // `typeof`/`Array.isArray` alone can't tell it apart from an inline table) exposing - // exactly the `isDate`/`isTime`/`isDateTime`/`isLocal` discriminators needed to - // reproduce Go's per-variant type name. + // A non-scalar glob element (nested array/table, or a bare TOML datetime) fails with + // an "unconvertible type" error instead of being silently dropped; each datetime + // variant reports its own type name for the message. `smol-toml` parses every TOML + // datetime to a `TomlDate` (a `Date` subclass), exposing the `isDate`/`isTime`/ + // `isLocal` discriminators needed to pick the right variant name. const goTomlDateType = (value: SmolToml.TomlDate): string => { if (value.isDate()) return "toml.LocalDate"; if (value.isTime()) return "toml.LocalTime"; @@ -2415,22 +1974,9 @@ const readDbTomlCore = Effect.fnUntraced(function* ( : typeof value === "object" && value !== null ? "map[string]interface {}" : undefined; - // Pure — returns the mapstructure-style issue strings for a real `Glob` array's - // unconvertible elements WITHOUT failing. `UnmarshalExact` decodes the WHOLE - // config in a SINGLE mapstructure pass: `decodeStructFromMap`'s per-field loop - // appends each field's decode error to a shared `errs` - // slice and keeps going — it never stops at the first field's error — then - // `errors.Join(errs...)`-s everything together at the very end. - // So an invalid `db.migrations.schema_paths` does NOT - // prevent `db.seed.sql_paths` from ALSO being decoded (and erroring) in the same - // pass; both surface together in ONE combined error. Verified empirically (both - // fields containing an unconvertible entry, e.g. `sql_paths = [[]]` + - // `schema_paths = [[]]`): the single returned error - // contains BOTH lines, `db.migrations.schema_paths[0]` BEFORE `db.seed.sql_paths[0]` — - // `db` struct declares `Migrations` before `Seed`, - // and mapstructure iterates struct fields in declaration order, not alphabetically, - // so callers below must combine in that same order before failing once (see - // `failOnGlobIssues`). + // Returns the "unconvertible type" issue for each bad array element, without + // failing — both `Glob` fields' issues are combined into one error afterward (see + // `failOnGlobIssues`), with `db.migrations.schema_paths` ordered before `db.seed.sql_paths`. const globArrayIssues = ( keyPath: string, values: ReadonlyArray, @@ -2441,11 +1987,8 @@ const readDbTomlCore = Effect.fnUntraced(function* ( ? [] : [`'${keyPath}[${index}]' expected type 'string', got unconvertible type '${goType}'`]; }); - // Fails ONCE with every issue collected across BOTH `Glob` fields (see - // `globArrayIssues`'s doc comment) — never called per-field, so a config - // invalid in both `db.seed.sql_paths` and `db.migrations.schema_paths` reports both, - // matching Go's single combined `UnmarshalExact` error instead of only the first - // field checked. + // Fails once with every issue collected across both `Glob` fields, instead of + // failing on the first field checked. const failOnGlobIssues = ( issues: ReadonlyArray, ): Effect.Effect => @@ -2454,35 +1997,11 @@ const readDbTomlCore = Effect.fnUntraced(function* ( : fail( `failed to parse config: decoding failed due to the following error(s):\n\n${issues.join("\n")}`, ); - // A TOP-LEVEL raw value that is neither an array nor a string (e.g. - // `schema_paths = 42`/`true`, or a stray inline table) still reaches - // mapstructure's `decodeSlice`, which is weakly typed the same way an array - // ELEMENT is (see `weakCoerceGlobEntry` above): a zero-length map - // decodes straight to an empty slice; anything else is wrapped into a - // synthetic single-element `[]any{value}` and decoded through the exact - // same per-element rules as a real array entry — a scalar weakly coerces, - // an unconvertible value (map/array) fails with `'[0]' expected - // type 'string', got unconvertible type '...'` (mapstructure always - // reports the synthetic wrapped index, which is `0`). Verified empirically: - // `schema_paths = 42` → `["42"]`, `= true` → - // `["1"]`, `= {}` → `[]`, `[db.migrations.schema_paths]\nfoo = "bar"` → - // `failed to parse config: … 'db.migrations.schema_paths[0]' expected type - // 'string', got unconvertible type 'map[string]interface {}'`. Never - // called with `undefined` — an absent key has its own default per caller - // below, so callers guard that case before reaching here. - // - // The zero-length-map special case must NOT match a `TomlDate` (e.g. `schema_paths = - // 1979-05-27T07:32:00Z`): a `TomlDate` stores its value internally, not as an - // enumerable own property, so `Object.keys(tomlDate).length === 0` is ALSO true for - // it — but Go does not treat a bare datetime as an empty map; mapstructure reports it - // unconvertible and aborts the whole load (see `goUnconvertibleType` above). - // Without this exclusion, a `TomlDate` would silently resolve to `[]` here instead of - // falling through to the unconvertible-type issue below, turning Go's hard config-load - // failure into a silently-empty schema/seed path list (review CLI-1958). - // Pure — the TOP-LEVEL scalar fallback (see doc comment above), returning either the - // one resolved pattern or the one issue it would raise, WITHOUT failing (same reason - // as `globArrayIssues`: the caller combines issues across both `Glob` fields - // before deciding whether to fail). + // A scalar top-level value (e.g. `schema_paths = 42`) is treated like a + // single-element array: a zero-length map decodes to `[]`, anything else weakly + // coerces or reports an unconvertible-type issue. A `TomlDate` must not match the + // zero-length-map case — its value is stored internally, so `Object.keys` is empty + // too, but it should still be treated as unconvertible. const resolveScalarGlobFallback = ( keyPath: string, value: unknown, @@ -2502,14 +2021,9 @@ const readDbTomlCore = Effect.fnUntraced(function* ( return { resolved: [], issues: globArrayIssues(keyPath, [value]) }; }; /** - * Resolves ONE `Glob`-typed field (`[db.seed].sql_paths` / `[db.migrations]. - * schema_paths`) into its pre-supabase-join patterns, covering every decode branch - * in one place: override env var, real array, bare string, absent key (caller's own - * Go-matching default), or the top-level scalar fallback. Returns any issues - * alongside the best-effort patterns rather than failing here — see - * `failOnGlobIssues`'s doc comment for why the two `Glob` fields must combine - * their issues into ONE error before failing, matching Go's single `UnmarshalExact` - * pass, instead of each field failing independently on its own first bad entry. + * Resolves one `Glob`-typed field into its pre-supabase-join patterns: env override, + * real array, bare string, absent key (caller's default), or the scalar fallback + * above. Returns issues instead of failing — see {@link failOnGlobIssues}. */ const resolveGlobField = ( keyPath: string, @@ -2554,10 +2068,8 @@ const readDbTomlCore = Effect.fnUntraced(function* ( resolveSeedSqlPath(path, pattern), ); - // `[db.migrations] schema_paths` — default `[]`, resolved through the exact same - // decode + env-expand + supabase-join pipeline as `[db.seed].sql_paths` above, but - // UNCONDITIONALLY (the resolve loop for schema_paths is not gated on - // `db.migrations.enabled` the way the seed loop is gated on `db.seed.enabled`). + // `[db.migrations] schema_paths` — default `[]`, resolved the same way as + // `[db.seed].sql_paths` above, but unconditionally (not gated on `db.migrations.enabled`). const rawSchemaPaths = migrationsRaw?.["schema_paths"]; const schemaPathsOverride = remoteOverrideKeys.has("db.migrations.schema_paths") ? undefined @@ -2569,10 +2081,8 @@ const readDbTomlCore = Effect.fnUntraced(function* ( [], ); - // The whole config decodes in ONE pass (see `globArrayIssues`'s doc comment) — - // combine BOTH `Glob` fields' issues, in struct-declaration order (`Migrations` - // before `Seed`), before failing once, so a config invalid in both surfaces both - // in a single combined error instead of only the first field checked. + // Combines both `Glob` fields' issues before failing once, so a config invalid in + // both surfaces both instead of only the first field checked. yield* failOnGlobIssues([...schemaPathsResolved.issues, ...sqlPathsResolved.issues]); const schemaPaths = schemaPathsResolved.patterns.map((pattern) => @@ -2580,17 +2090,14 @@ const readDbTomlCore = Effect.fnUntraced(function* ( ); // `[db.vault]` secrets: env-expand each value, then decrypt dotenvx `encrypted:` - // ciphertext. `resolved` gates on a successful decrypt-or-passthrough (`UpsertVaultSecrets` - // upserts only resolved secrets). An `encrypted:` value that cannot be decrypted - // aborts the command with `failed to parse config: ` — it is never silently - // skipped, which an earlier port did. + // ciphertext. `resolved` is true only after a successful decrypt-or-passthrough. An + // `encrypted:` value that cannot be decrypted aborts the command, never silently skipped. const vault: Array = []; if (resolveVaultSecrets && vaultRaw !== undefined) { for (const name of Object.keys(vaultRaw).sort()) { const raw = vaultRaw[name]; const value = typeof raw === "string" ? expandEnv(raw, lookup) : ""; - // Empty or an unexpanded `env(...)` reference → unresolved (Go returns these - // verbatim from the hook without hashing, so SHA256 stays empty). + // Empty or an unexpanded `env(...)` reference is left unresolved rather than hashed. if (value.length === 0 || ENV_PATTERN.test(value)) { vault.push({ name, value, resolved: false }); continue; @@ -2609,11 +2116,9 @@ const readDbTomlCore = Effect.fnUntraced(function* ( } } - // `[api] auto_expose_new_tables` is a tri-state `*bool`: - // present → Some(bool), absent → None (never false). Go applies the - // `SUPABASE_API_AUTO_EXPOSE_NEW_TABLES` AutomaticEnv override and decodes the value - // with `strconv.ParseBool`, failing the load on a malformed value — so `1`/`TRUE`/ - // `env(...)` parse correctly and `maybe` aborts rather than silently coercing to false. + // `[api] auto_expose_new_tables` is tri-state: present → `Some(bool)`, absent → + // `None` (never false). A malformed override/value aborts the load rather than + // silently coercing to false. const apiAutoExposeNewTables = yield* resolveOptionalBoolOrFail( "api.auto_expose_new_tables", remoteWins("api.auto_expose_new_tables") @@ -2681,24 +2186,18 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }); /** - * Read + validate `config.toml` exactly like `flags.LoadConfig` → `config.Load` - * → `Validate`: an absent file yields defaults, but a present config that is - * unreadable/malformed, references an undecryptable `encrypted:` secret, or fails any - * of Go's decode/Validate checks (remote refs, `db.port`, `db.major_version`, - * `edge_runtime.deno_version`, pgdelta gate, `format_options` JSON, bucket names, - * function slugs, auth, analytics) aborts with the matching Go error. Call this at the - * point Go fails fast — before asserting the stack is running, prompting, or any - * destructive work — so a broken config never runs against the default local database. + * Reads and validates `config.toml`: an absent file yields defaults, but a present + * config that is unreadable, malformed, references an undecryptable secret, or fails + * validation aborts with a matching error. Call this before asserting the stack is + * running, prompting, or any destructive work. */ export const checkDbToml = ( fs: FileSystem.FileSystem, path: Path.Path, workdir: string, ref?: string, - // `warnOnUnresolvedEnv: false` — see `readDbTomlCore`'s own doc comment — for a - // caller known to run AFTER an earlier, same-invocation `checkDbToml`/ - // `readDbToml` call already printed the OrioleDB S3 `assertEnvLoaded` WARN - // once. Omit (default `true`) for every standalone command entry point. + // See `readDbTomlCore`'s doc comment. Pass `false` only when an earlier, + // same-invocation call already printed the OrioleDB S3 WARN once. opts?: { readonly warnOnUnresolvedEnv?: boolean; /** Skip resolving `[db.vault]` values while validating the rest of the config. */ @@ -2716,12 +2215,11 @@ export const checkDbToml = ( ); /** - * Read `config.toml`. Defaults to Go's validating behavior (identical to - * {@link checkDbToml}); pass `{ validate: false }` for a best-effort read that - * never throws on an invalid config — a config-load failure falls back to pure - * defaults (env overrides still applied), matching the tolerant behavior the - * container-id seam needs when it only wants `projectId` and the handler has already - * validated the config. The throwing default keeps every existing caller at Go parity. + * Reads `config.toml`. Defaults to the same validating behavior as + * {@link checkDbToml}; pass `{ validate: false }` for a best-effort read that never + * throws — a config-load failure falls back to pure defaults (env overrides still + * applied), for callers that only need `projectId` and don't require a fully + * validated config. */ export const readDbToml = ( fs: FileSystem.FileSystem, @@ -2750,9 +2248,8 @@ export const readDbToml = ( /** * The effective declarative schema directory: the configured * `declarative_schema_path` (already `supabase/`-prefixed when relative) or the - * default `supabase/schemas`. Mirrors `utils.GetDeclarativeDir`. - * `path` joins the segments so - * the separator matches the host platform, as `filepath.Join` does. + * default `supabase/schemas`. `path` joins the segments so the separator matches the + * host platform. */ export function resolveDeclarativeDir(path: Path.Path, pgDelta: PgDeltaTomlConfig): string { return Option.getOrElse(pgDelta.declarativeSchemaPath, () => From 4c38bd1be56ec96d2cec41099000a733ae2a041b Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 10 Sep 2026 12:06:24 +0100 Subject: [PATCH 06/56] chore(repo): trim comments, shard 22 (1572 -> 855 comment lines) --- .../commands/db/diff/diff.integration.test.ts | 459 ++++++------------ apps/cli/src/commands/db/diff/pgadmin-diff.ts | 190 ++------ .../db/diff/pgadmin-diff.unit.test.ts | 16 +- apps/cli/src/commands/db/dump/dump.command.ts | 34 +- apps/cli/src/commands/db/dump/dump.handler.ts | 123 ++--- .../commands/db/dump/dump.integration.test.ts | 94 +--- apps/cli/src/commands/db/dump/dump.layers.ts | 28 +- apps/cli/src/commands/db/lint/lint.command.ts | 9 +- apps/cli/src/commands/db/lint/lint.format.ts | 29 +- .../commands/db/lint/lint.format.unit.test.ts | 14 +- apps/cli/src/commands/db/lint/lint.handler.ts | 36 +- .../commands/db/lint/lint.integration.test.ts | 41 +- apps/cli/src/commands/db/lint/lint.layers.ts | 44 +- .../commands/db/lint/lint.layers.unit.test.ts | 31 +- apps/cli/src/commands/db/pull/pull.command.ts | 4 +- apps/cli/src/commands/db/pull/pull.errors.ts | 20 +- apps/cli/src/commands/db/pull/pull.handler.ts | 14 +- .../commands/db/pull/pull.integration.test.ts | 432 ++++++----------- .../src/commands/db/pull/pull.live.test.ts | 8 +- .../db/pull/pull.sync.integration.test.ts | 15 +- apps/cli/src/commands/db/pull/pull.sync.ts | 43 +- apps/cli/src/commands/db/push/push.command.ts | 22 +- apps/cli/src/commands/db/push/push.handler.ts | 25 +- .../commands/db/push/push.integration.test.ts | 90 ++-- apps/cli/src/commands/db/push/push.layers.ts | 12 +- 25 files changed, 557 insertions(+), 1276 deletions(-) diff --git a/apps/cli/src/commands/db/diff/diff.integration.test.ts b/apps/cli/src/commands/db/diff/diff.integration.test.ts index d3739af49b..e7ff2bb085 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -76,68 +76,51 @@ interface SetupOpts { readonly hazards?: PgDeltaHazardReport; readonly oom?: boolean; // edge-runtime OOMs; the bash fallback returns `diffSql` readonly delegateStdout?: string; // stdout returned by a captured Go-delegate run - // When set, the PGDELTA_DEBUG shadow-catalog export fails with this message - // instead of succeeding. + // Message for a failing PGDELTA_DEBUG shadow-catalog export. readonly diffFailWith?: string; - // When set, the shadow's own PG15+ one-shot platform-baseline job(s) exit - // non-zero, exercising cleanup-on-partial-failure (the shadow is still removed). + // Makes the shadow's PG15+ baseline job(s) exit non-zero; the shadow should + // still be removed. readonly failShadowSetupJob?: boolean; readonly networkId?: string; // --network-id value forwarded to docker runs // When set, the Nth `writeFileString` fails, exercising cleanup-on-failure. readonly failWriteOnCall?: number; - // When set, the first `writeFileString` whose path matches fails. Prefer this - // over `failWriteOnCall` when shadow setup writes extra SQL before the - // command's `--file` migration. + // Fails the first `writeFileString` call whose path matches; prefer over + // `failWriteOnCall` when shadow setup writes extra files first. readonly failWriteMatching?: (path: string) => boolean; - // When set, the shadow container never reports healthy — for the interrupt-during- - // health-wait regression coverage (review: PRRT_kwDOErm0O86XMrID). See - // `mockShadowContainerCliSpawner`'s own doc comment for why this is required - // (not `Effect.never`) to observe a genuinely suspended retry loop. Only the - // `--use-pgadmin` branch still gates on the Docker healthcheck; the shadow-source - // branch gates on `neverConnectableShadow` below instead. + // Makes the shadow container never report healthy; only the `--use-pgadmin` branch + // gates on this (the shadow-source branch uses `neverConnectableShadow` instead). readonly neverHealthyShadow?: boolean; - // When set, every connect to the shadow's own port is refused, so the readiness gate - // (`waitForShadowReady`) keeps polling — the shadow-source branch's equivalent of - // `neverHealthyShadow`, since that wait no longer consults the Docker healthcheck. + // Refuses every connect to the shadow's port, so `waitForShadowReady` keeps + // polling — the shadow-source branch's equivalent of `neverHealthyShadow`. readonly neverConnectableShadow?: boolean; - // `CommandSettings.projectId` (the `SUPABASE_PROJECT_ID` env-only reader). Defaults - // to `Option.some("test")`; pass `Option.none()` to exercise the - // config.toml/workdir-basename fallback `resolveLocalProjectId` provides for - // the pg-delta edge-runtime cache bind. + // `CommandSettings.projectId`; defaults to `Option.some("test")`. Pass + // `Option.none()` to exercise the config.toml/workdir-basename fallback + // (`resolveLocalProjectId`). readonly projectId?: Option.Option; - // Simulates a genuinely unlinked workdir: `loadProjectRef` fails with - // `ProjectRefNotLinkedError` absent an explicit `--project-ref` flag, - // instead of silently falling back to `opts.linkedRef ?? VALID_REF`. + // Simulates an unlinked workdir: `loadProjectRef` fails with + // `ProjectRefNotLinkedError` absent an explicit `--project-ref` flag. readonly linkedFails?: boolean; - // --- CLI-1968 (native --use-pgadmin) --- - // Per-differ-run `--json-diff` stdout, one entry per `runCapture` call to the differ - // image (index 0 = the no-`--schema` run, or the 1st `--schema` run; index 1 = the - // 2nd `--schema` run; …). Falls back to `""` (an empty/"No schema changes" diff) once - // exhausted, so a single-run test only needs a one-element array. + // Per-differ-run `--json-diff` stdout, indexed by run order; falls back to `""` + // once exhausted, so a single-run test only needs one element. readonly pgadminStdout?: ReadonlyArray; - // Per-differ-run stderr (the raw text `processPgAdminDiffProgress` filters). - // Falls back to `""` once exhausted. + // Per-differ-run stderr; falls back to `""` once exhausted. readonly pgadminStderr?: ReadonlyArray; - // Applied to every differ `runCapture` call (the failure tests below only ever drive - // a single, no-`--schema` run, so one number covers them). + // Exit code applied to every differ `runCapture` call. readonly pgadminExitCode?: number; - // Makes every differ `runCapture` call fail at the docker boundary instead of - // returning a result — `"spawn"` (daemon unreachable) or `"pull"` (registry failure). + // Fails every differ `runCapture` call at the docker boundary: `"spawn"` (daemon + // unreachable) or `"pull"` (registry failure). readonly pgadminDockerFail?: "spawn" | "pull"; - // Makes the pre-flight `docker container inspect supabase_db_` probe - // (`isLocalDbRunning`, run before `--use-pgadmin` provisions anything) report - // "container not found" — surfaces as "supabase start is not running.". + // Makes the pre-flight `isLocalDbRunning` probe report "container not found", + // surfacing as "supabase start is not running.". readonly dbNotRunning?: boolean; - // Makes that SAME probe fail with a daemon-unreachable stderr instead — the - // `daemonDown: true` classification branch. Mutually exclusive with `dbNotRunning`. + // Makes the same probe fail with a daemon-unreachable stderr instead (mutually + // exclusive with `dbNotRunning`). readonly dbInspectFailsWith?: string; - // `RuntimeInfo.platform` — drives the differ's `--add-host host.docker.internal: - // host-gateway` (Linux-only). Defaults to `"linux"` (every other test's implicit - // baseline); pass `"darwin"`/`"win32"` to exercise the no-add-host branch. + // `RuntimeInfo.platform`; defaults to `"linux"`. Pass `"darwin"`/`"win32"` to + // exercise the no-add-host branch (`--add-host` is Linux-only). readonly platform?: NodeJS.Platform; - // Swaps the stateless shadow spawner for the stateful Docker model, whose - // `stop`/`cp`/`start` really move bytes. Required by (and only by) the tests that - // enable the shadow BASELINE CACHE — see `mockDockerDaemonCliSpawner`. + // Swaps in the stateful Docker model (real `stop`/`cp`/`start`), required by the + // shadow baseline cache tests. readonly statefulDocker?: boolean; } @@ -152,13 +135,10 @@ const alwaysReadyHttpClientLayer = Layer.succeed( const SHADOW_PORT = 54320; /** - * Records every `DbConnection.connect` target's database name, and every `exec`/`query` - * SQL run against it. - * - * `neverConnectableShadow` makes every connect to the SHADOW port fail (leaving the local - * target's own connects untouched) — the shadow's readiness gate is now a direct connect probe - * (`waitForShadowReady`), so a shadow that never accepts a connection is what keeps a - * provisioning fiber genuinely suspended inside that retry loop. + * Records every `DbConnection.connect` target's database name and every `exec`/`query` run + * against it. `neverConnectableShadow` fails every connect to the shadow port, leaving local + * connects untouched — this keeps a provisioning fiber suspended in `waitForShadowReady`'s + * retry loop. */ function fakeShadowDbConnection(opts: { readonly neverConnectableShadow?: boolean } = {}) { const connectedDatabases: Array = []; @@ -192,17 +172,14 @@ function setup(workdir: string, opts: SetupOpts = {}) { const telemetry = mockTelemetryStateTracked(); const cache = mockLinkedProjectCacheTracked(); - // A real docker-spawner fake backs container create/start/health-inspect/cleanup, - // and a real (fake) Postgres session backs the shadow's own - // platform-baseline/migration/declarative setup. + // A docker-spawner fake backs container lifecycle; a fake Postgres session backs + // shadow setup. const shadowSpawner = mockShadowContainerCliSpawner({ neverHealthy: opts.neverHealthyShadow ?? false, dbNotRunning: opts.dbNotRunning ?? false, dbInspectFailsWith: opts.dbInspectFailsWith, }); - // The shadow baseline cache's cold export and warm restore only mean anything against a - // daemon that actually holds container state and carries `docker cp` bytes, so the cache - // tests below opt into the stateful model instead. + // Cache tests need the stateful Docker model since `docker cp` needs real container state. const dockerDaemon = opts.statefulDocker === true ? mockDockerDaemonCliSpawner() : undefined; const shadowDbConnection = fakeShadowDbConnection({ neverConnectableShadow: opts.neverConnectableShadow ?? false, @@ -271,10 +248,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { return Effect.fail(new EdgeRuntimeScriptError({ message: opts.diffFailWith })); } const diffSql = opts.diffSql ?? ""; - // The pg-delta diff script (uniquely identified by `renderPlanFiles`) prints a - // JSON envelope with one file per plan unit; wrap the test's raw SQL into a - // single-unit envelope so `diffPgDelta` parses it. The migra script - // returns raw SQL unchanged. + // The pg-delta script (identified by `renderPlanFiles`) prints a JSON envelope + // with one file per plan unit; the migra script returns raw SQL unchanged. const isPgDelta = runOpts.script.includes("renderPlanFiles"); const planFiles = opts.diffFiles !== undefined @@ -295,26 +270,19 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, }); - // `dockerCalls` tracks the migra OOM bash fallback's own `runCapture` calls — the - // native shadow's PG15+ one-shot setup jobs (`runStartMigrateJob`) go through - // `runStream` instead (constant-memory stdout discard), so they're tracked - // separately in `shadowSetupJobCalls` (their `env`, notably `DB_HOST`, is the one - // shadow-specific parameterization that matters to get right). + // Tracks the migra OOM bash fallback's own `runCapture` calls; the native shadow's + // PG15+ one-shot setup jobs go through `runStream` instead and are tracked + // separately in `shadowSetupJobCalls`. const dockerCalls: unknown[] = []; - // The pgAdmin differ's own `runCapture` calls, tracked separately from - // `dockerCalls` (the migra OOM bash fallback's image) so pgadmin tests never - // conflate the two — both go through the SAME `DockerRun.runCapture` seam, - // distinguished only by `image`. + // The pgAdmin differ's own `runCapture` calls, distinguished from `dockerCalls` + // by `image` (both share the same `DockerRun.runCapture` seam). const differCalls: Array = []; - // The `runCapture` SECOND (options) argument for every differ call, parallel to - // `differCalls` — pinned `undefined` below, since the differ's raw stderr is - // never teed to the parent terminal (see `pgadmin-diff.ts`'s own doc - // comment). + // The `runCapture` options argument for every differ call, parallel to + // `differCalls`; stays `undefined` since the differ's stderr is never teed to + // the parent terminal. const differCaptureOpts: Array<{ readonly teeStderr?: boolean } | undefined> = []; - // Snapshots `process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]` at the moment each - // differ `runCapture` call is made — the real `dockerRunLayer`'s own image - // resolver reads that key straight off `process.env` at call time (no - // `projectEnvValues` threaded through), so this stands in for it here. + // Snapshots `process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]` at each differ + // `runCapture` call, standing in for the real image resolver's own read of it. const differRegistryEnvAtCall: Array = []; const shadowSetupJobCalls: Array<{ readonly env: Readonly> }> = []; const docker = Layer.succeed(DockerRun, { @@ -361,10 +329,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { const resolver = Layer.succeed(DbConfigResolver, { resolve: (resolveFlags) => { resolverCalls.push(resolveFlags); - // A threaded `--project-ref` flag wins over the fixed `opts.linkedRef` test - // fixture, same top precedence a real resolver would give it — lets a test - // prove the flag (not just `opts.linkedRef`) drives the resolved ref (read - // by both the native path and explicit mode's "linked" case). + // A threaded `--project-ref` flag wins over the fixed `opts.linkedRef` fixture, + // matching real resolver precedence. const flagRef = resolveFlags.linkedProjectRef ?? Option.none(); const ref = Option.isSome(flagRef) && flagRef.value.length > 0 ? flagRef.value : opts.linkedRef; @@ -383,14 +349,9 @@ function setup(workdir: string, opts: SetupOpts = {}) { resolvePoolerFallback: () => Effect.succeed(Option.none()), }); - // The linked ref is now pre-loaded (for the config-override print, ahead of - // `resolver.resolve()`'s own network work — review: PRRT_kwDOErm0O86XHvYl) via - // `ProjectRefResolver`, mirroring the SAME ref `resolver`'s own mock embeds in - // its resolved `ref` above, so both stay consistent regardless of whether a test sets - // `opts.linkedRef` (mirrors `reset.integration.test.ts`'s identical mock). - // `loadProjectRef` gives an explicit `--project-ref` flag top precedence, same - // as Go's `flags.LoadProjectRef` — mirror that so a test can prove the flag - // (not just `opts.linkedRef`) drives the linked ref. + // Mirrors the same ref `resolver`'s own mock embeds above, and gives an explicit + // `--project-ref` flag top precedence over `opts.linkedRef` (mirrors + // `reset.integration.test.ts`'s identical mock). const projectRefResolver = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(opts.linkedRef ?? VALID_REF), resolveForLink: () => Effect.succeed(opts.linkedRef ?? VALID_REF), @@ -417,10 +378,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const baseLayer = Layer.mergeAll( - // `BunServices.layer` is listed FIRST so every fake service layer below (most - // importantly `shadowSpawner.layer`'s fake `ChildProcessSpawner`) OVERRIDES its - // real implementation — `Layer.mergeAll` is last-wins on a shared service, - // matching `start.integration.test.ts`'s own established ordering. + // Listed first so the fake service layers below (`Layer.mergeAll` is last-wins) + // override its real implementations, matching `start.integration.test.ts`. BunServices.layer, out.layer, telemetry.layer, @@ -516,8 +475,6 @@ const stderr = (out: ReturnType) => const tmp = useTempWorkdir(); useShadowCacheDisabled(); -// --- native --use-pgadmin fixtures --- - /** `DiffEntry` shape, defaulting to a kept entry. */ function pgadminEntry(overrides: Record = {}) { return { @@ -532,10 +489,8 @@ function pgadminEntry(overrides: Record = {}) { /** `processPgAdminDiffOutput`'s exact output for a single default `pgadminEntry()`. */ const PGADMIN_DIFF_SQL = `${PGADMIN_DIFF_HEADER}\n\nALTER TABLE test;\n`; -// The default `resolver`/shadow-port fixtures in `setup()` below (conn -// 127.0.0.1:54322, shadow port 54320) — `source` is the user's db (via -// `toPostgresURL`) and `target` is the shadow (a raw, hardcoded connection -// string). +// Matches `setup()`'s default resolver/shadow-port fixtures (conn 127.0.0.1:54322, +// shadow port 54320). const PGADMIN_SOURCE_URL = "postgresql://postgres:postgres@127.0.0.1:54322/postgres?connect_timeout=10"; const PGADMIN_TARGET_URL = "postgresql://postgres:postgres@127.0.0.1:54320/postgres"; @@ -545,8 +500,6 @@ describe("db diff", () => { const s = setup(tmp.current, { diffSql: "create table players ();\n" }); return Effect.gen(function* () { yield* dbDiff(flags()); - // The native shadow was created once (one `docker create`) and removed once - // (one `docker rm -f -v`) — see `mockShadowContainerCliSpawner`. expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); expect(stdout(s.out)).toBe("create table players ();\n\n"); @@ -554,15 +507,6 @@ describe("db diff", () => { expect(stderr(s.out)).toContain("Diffing schemas..."); expect(stderr(s.out)).toContain("Finished supabase db diff on branch"); expect(s.telemetry.flushed).toBe(true); - // The shadow's PG15+ one-shot platform-baseline job(s) connect to the shadow over - // Docker's embedded DNS using the shadow container's OWN 12-char short id as - // `DB_HOST` — NOT the real `db` container's name, and not some other slice length - // (a mutation from `.slice(0, 12)` to `.slice(0, 8)` must fail this). This is the - // one shadow-specific parameterization that matters - // (`buildShadowSetupDatabaseInput`'s `dbHost`). The default config enables - // realtime (and PG >= 15 by default), so this always exercises at least one - // one-shot job — Realtime's own env sets `DB_HOST` directly; Storage/Auth embed - // the same host inside a `DATABASE_URL`-style connection string instead. const expectedHost = FAKE_SHADOW_CONTAINER_ID.slice(0, 12); expect(s.shadowSetupJobCalls.length).toBeGreaterThan(0); let sawHost = false; @@ -608,8 +552,6 @@ describe("db diff", () => { connectOptions: { isLocal: true, dnsResolver: "native" }, }, }); - // pg-delta runs in-process through PgDeltaEngine; the handler never - // invokes the edge runtime for it. expect(s.edgeCalls).toEqual([]); expect(stderr(s.out)).toContain("Diffing schemas: public"); expect(stdout(s.out)).toBe("create table p ();\n\n"); @@ -659,10 +601,8 @@ describe("db diff", () => { }).pipe(Effect.provide(s.layer)); }); - // The transition warning is only true for pg-delta. Migra still routes a local - // target with declarative files through the declared-schema `contrib_regression` - // override, so schema_paths DOES still shape its output and claiming otherwise - // would be a lie. + // Migra still routes declarative files through the `contrib_regression` override, + // so schema_paths still shapes its output — only pg-delta prints this warning. const writeSchemaPathsConfig = (pgDeltaEnabled: boolean) => { mkdirSync(join(tmp.current, "supabase", "database"), { recursive: true }); writeFileSync( @@ -691,9 +631,6 @@ describe("db diff", () => { }); it.effect("PG14: provisions a shadow via the SQL-exec init path (no PG15+ one-shot jobs)", () => { - // This covers the PG14 branch of the `setupDatabase` pipeline, which execs - // SQL directly via the session instead of the three one-shot `DockerRun` - // jobs (the PG15+ short-id DNS resolution path is covered separately). mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "config.toml"), "[db]\nmajor_version = 14\n"); const s = setup(tmp.current, { diffSql: "create table pg14 ();\n" }); @@ -702,8 +639,6 @@ describe("db diff", () => { expect(stdout(s.out)).toBe("create table pg14 ();\n\n"); expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - // PG14's `startInitSchemaPre15` execs SQL over the session directly — - // no one-shot `DockerRun` jobs run for this branch. expect(s.dockerCalls).toEqual([]); expect(s.shadowExecCalls.length).toBeGreaterThan(0); }).pipe(Effect.provide(s.layer)); @@ -712,8 +647,6 @@ describe("db diff", () => { it.effect( "removes the shadow even when its own platform-baseline setup fails midway (ok-sentinel cleanup)", () => { - // Once the shadow container is created, ANY later failure (here, a PG15+ - // one-shot platform-baseline job exiting non-zero) still removes it. const s = setup(tmp.current, { diffSql: "create table x ();\n", failShadowSetupJob: true }); return Effect.gen(function* () { const exit = yield* dbDiff(flags()).pipe(Effect.exit); @@ -724,10 +657,7 @@ describe("db diff", () => { }, ); it.effect("a linked [remotes.] block enabling pg-delta selects the pg-delta engine", () => { - // The linked path merges the matching [remotes.] block before - // experimental.pgdelta.enabled is read. The default db diff target is local (no - // merge), so this only applies with --linked; base config disables pg-delta, the - // remote override enables it, so the diff must pick the pg-delta engine. + // Base config disables pg-delta; the remote override enables it. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), @@ -758,14 +688,9 @@ describe("db diff", () => { it.effect( "a linked [remotes.] db.major_version override reaches the shadow's OWN container spec, not just cfg", () => { - // Go remote-merges the WHOLE config uniformly on the linked path (`LoadConfig` seeds - // `flags.ProjectRef` before every field read) — the shadow's container spec (image, - // JWT secret, root key, db.settings, service enabled-for-setup flags) must reflect the - // matched `[remotes.]` override too, not just the `cfg`/`toml` read used for - // pg-delta/schema_paths. `major_version` is a clean, directly-observable probe: PG <= 14 - // is the ONLY branch that emits a `--tmpfs` flag on the shadow's `docker create` argv - // (`buildShadowPostgresContainerSpec`) — a base config of 17 (>= 15, no tmpfs) - // overridden by a remote block's `major_version = 14` must flip that flag on. + // The remote's own container spec must reflect the `[remotes.]` override too, not + // just the config read for pg-delta/schema_paths; `major_version` is used as a probe + // since PG <= 14 is the only branch that emits `--tmpfs` on `docker create`. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), @@ -790,16 +715,12 @@ describe("db diff", () => { yield* dbDiff(flags({ linked: Option.some(true) })); const createArgs = s.shadowSpawned.find((c) => c.args[0] === "create")?.args ?? []; expect(createArgs).toContain("--tmpfs"); - // The PG15+ one-shot platform-baseline jobs (`initSchema15`) never run for PG14 — - // it execs SQL directly over the session instead — corroborating the same override. expect(s.dockerCalls).toEqual([]); }).pipe(Effect.provide(s.layer)); }, ); it.effect("the base config (default local target) does not merge a remote block", () => { - // The default db diff target is local; Go never calls LoadProjectRef for local, - // so a [remotes.] override must be ignored and the base engine (migra) wins. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), @@ -818,7 +739,6 @@ describe("db diff", () => { const s = setup(tmp.current, { diffSql: "create table players ();\n" }); return Effect.gen(function* () { yield* dbDiff(flags()); - // The local default never merges a remote block, so the base (migra) engine wins. expect(s.edgeCalls[0]?.script).not.toContain("renderPlanFiles"); }).pipe(Effect.provide(s.layer)); }); @@ -836,8 +756,7 @@ describe("db diff", () => { }); it.effect("diffs the project given via --project-ref without a linked workdir", () => { - // The fake resolver fails as "unlinked" (`ProjectRefNotLinkedError`) - // absent the flag — only the flag can resolve a ref here. + // `linkedFails: true` simulates an unlinked workdir; only the flag can resolve a ref. const FLAG_REF = "flagflagflagflagflag"; const s = setup(tmp.current, { isLocal: false, @@ -853,8 +772,7 @@ describe("db diff", () => { it.effect("--project-ref overrides an already-linked workdir's project ref", () => { const FLAG_REF = "flagflagflagflagflag"; - // The workdir already resolves to VALID_REF (e.g. via - // .temp/project-ref) — the flag must win over it. + // A distinct `linkedRef` proves the flag, not the workdir's own linked ref, wins. const s = setup(tmp.current, { isLocal: false, linkedRef: "abcdefghijklmnopqrst", @@ -879,7 +797,6 @@ describe("db diff", () => { expect(JSON.stringify(exit)).toContain( "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", ); - // The guard fires before any connection resolution or cache write. expect(s.resolverCalls).toEqual([]); expect(s.cache.cached).toBe(false); }).pipe(Effect.provide(s.layer)); @@ -888,10 +805,8 @@ describe("db diff", () => { it.effect( "explicit --from linked --to migrations --project-ref proceeds and uses the flag ref", () => { - // The `[remotes.staging]` block's `project_id` matches the FLAG ref, not the - // resolver's own `opts.linkedRef` fallback (left unset) — the shadow only - // gets the remote's `db.major_version = 14` override (`--tmpfs` on PG<=14) - // if the flag (not a fallback) actually resolved the "linked" ref. + // `[remotes.staging]`'s `project_id` matches the flag ref, not `opts.linkedRef` + // (left unset), so the override only applies if the flag resolved it. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), @@ -929,11 +844,8 @@ describe("db diff", () => { it.effect( "explicit --from local --to migrations --linked --project-ref proceeds and applies the flag ref's remote override", () => { - // Same `[remotes.staging]` fixture as the `--from linked` case above, but here - // it's a changed `--linked` (not a "linked" ref on either side) that resolves the - // flag ref via the preflight — `preflightConnType` keys off - // `Option.isSome(flags.linked)`, so the guard must not fire and the preflight's - // resolved ref must still drive the `[remotes.]` merge below. + // Same `[remotes.staging]` fixture as the `--from linked` case above, but here a + // changed `--linked` (not a literal "linked" ref) resolves the flag ref instead. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), @@ -972,8 +884,6 @@ describe("db diff", () => { it.effect( "explicit --from local --to migrations --project-ref errors (neither side is linked)", () => { - // Neither side of the explicit cascade is the literal ref "linked", so the - // flag would go unused — the guard fires instead of silently discarding it. const FLAG_REF = "flagflagflagflagflag"; const s = setup(tmp.current); return Effect.gen(function* () { @@ -998,10 +908,8 @@ describe("db diff", () => { it.effect( "caches the linked ref even when the merged config fails to load afterward (review: PRRT_kwDOErm0O86XLe6s)", () => { - // The project ref is cached the moment it's known, and stays cached even when a - // LATER step (here, `readDbToml`'s own config-load) fails afterward. - // `db.migrations.enabled = "notabool"` fails `readDbToml`'s own bool - // parse AFTER the ref is already known, exercising exactly that gap. + // `db.migrations.enabled = "notabool"` fails config-load after the ref is + // already cached. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), @@ -1024,16 +932,15 @@ describe("db diff", () => { it.effect( "migra provisions a local-target declarative shadow and diffs against the override database", () => { - // A declarative schema file under supabase/schemas makes `loadDeclaredSchemas` - // non-empty, so the native `--target-local` branch redirects the diff target to - // a second (contrib_regression) database on the SAME shadow container. + // A declarative schema file makes `loadDeclaredSchemas` non-empty, redirecting + // the diff target to a second (contrib_regression) database on the same shadow + // container. mkdirSync(join(tmp.current, "supabase", "schemas"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "schemas", "public.sql"), "select 1;\n"); const s = setup(tmp.current, { diffSql: "create table o ();\n" }); return Effect.gen(function* () { yield* dbDiff(flags()); expect(stdout(s.out)).toBe("create table o ();\n\n"); - // The declarative-schema file was migrated into the contrib_regression override. expect(s.shadowConnectedDatabases).toContain("contrib_regression"); expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); }).pipe(Effect.provide(s.layer)); @@ -1046,20 +953,17 @@ describe("db diff", () => { const s = setup(tmp.current, { pgadminStdout: [JSON.stringify([pgadminEntry()])] }); return Effect.gen(function* () { yield* dbDiff(flags({ usePgAdmin: Option.some(true) })); - // --use-pgadmin no longer delegates to the bundled Go binary. expect(s.proxyCalls).toEqual([]); expect(s.proxyCaptureCalls).toEqual([]); expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); expect(s.differCalls).toHaveLength(1); - // Status lines go to STDOUT, not stderr. + // Status lines go to stdout, not stderr. expect(stdout(s.out)).toBe( `Creating shadow database...\nDiffing local database with current migrations...\n${PGADMIN_DIFF_SQL}\n`, ); - // Stderr still carries the SHARED shadow-setup diagnostics (revoke-api-privileges, - // roles.sql seeding — identical on every diff engine), but none of pgAdmin's own - // status lines, which are on stdout instead, and none of the migra/pg-delta-only - // "Diffing schemas..."/"Finished ... on branch" lines (`diff.Run`-only, bypassed). + // Stderr carries the shared shadow-setup diagnostics but not pgAdmin's own status + // lines (those are on stdout) or the migra/pg-delta-only status lines. const err = stderr(s.out); expect(err).not.toContain("Creating shadow database..."); expect(err).not.toContain("Diffing local database with current migrations..."); @@ -1070,10 +974,8 @@ describe("db diff", () => { ); it.effect("rejects --project-ref combined with --use-pg-schema before delegating", () => { - // The bundled Go binary's own `db diff` never registered `--project-ref`, so - // the flag can't be forwarded — fail up front instead of silently dropping it. - // (`--use-pgadmin` is native as of CLI-1968 and honors the flag — see the - // positive test below.) + // The delegated Go binary doesn't support --project-ref, so this must fail before + // forwarding rather than silently dropping the flag. const FLAG_REF = "flagflagflagflagflag"; const s = setup(tmp.current); return Effect.gen(function* () { @@ -1088,8 +990,6 @@ describe("db diff", () => { }); it.effect("--use-pgadmin --linked honors --project-ref like the other native engines", () => { - // CLI-1968 made pgadmin share the same target resolve as migra/pg-delta, so - // the flag ref must win over the workdir's own linked ref here too. const FLAG_REF = "flagflagflagflagflag"; const s = setup(tmp.current, { isLocal: false, @@ -1114,10 +1014,8 @@ describe("db diff", () => { it.effect( "--use-pgadmin --linked succeeds when only the [remotes.] override fixes an invalid base config", () => { - // pgadmin now shares the SAME target resolve as migra/pg-delta, so it validates - // the remote-merged config, prints the override line, and succeeds — unlike the - // old Go-delegate era, where the whole command (config load included) ran - // inside the delegated child. + // pgadmin shares the same target resolve as migra/pg-delta, so it validates the + // remote-merged config and succeeds. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), @@ -1150,9 +1048,8 @@ describe("db diff", () => { it.effect( "--use-pgadmin --linked's preflight probe targets the resolved LINKED project id, not the base config's", () => { - // The preflight probe's project id derives from the resolved config AFTER the - // linked remote merge, NOT the base config's own `project_id` — the matched - // `[remotes.]` block's own `project_id` must suppress it. + // The preflight probe's project id comes from the config after the linked + // remote merge, not the base config's own `project_id`. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), @@ -1171,9 +1068,8 @@ describe("db diff", () => { }); return Effect.gen(function* () { yield* dbDiff(flags({ usePgAdmin: Option.some(true), linked: Option.some(true) })); - // `mockShadowContainerCliSpawner` distinguishes this SEPARATE - // `isLocalDbRunning` preflight probe from the shadow's own (64-hex-id) - // health-check inspect by the `supabase_db_` container-name prefix. + // `mockShadowContainerCliSpawner` distinguishes this preflight probe from the + // shadow's own health-check inspect by the `supabase_db_` container-name prefix. const inspectTargets = s.shadowSpawned .filter((c) => c.args[0] === "container" && c.args[1] === "inspect") .map((c) => c.args[2]); @@ -1199,9 +1095,6 @@ describe("db diff", () => { ); it.effect("a native local diff still validates the base config", () => { - // Control for the delegate case: the local/db-url native path reads the base - // config (no remote merge), so an invalid base value (db.major_version=16) must - // still fail. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "config.toml"), "[db]\nmajor_version = 16\n"); const s = setup(tmp.current, { diffSql: "create table x ();\n" }); @@ -1214,14 +1107,9 @@ describe("db diff", () => { it.effect( "validates the shadow's own local config (api.tls cert file) BEFORE resolving the connection", () => { - // `db.major_version` above is caught by `cfg` (`readDbToml`'s "D" pipeline), - // which already runs ahead of `resolver.resolve()`. `api.tls` is "L only" — `cfg` - // only tracks its dotted keys for remote-override gating, it never reads the cert/key - // files (see `buildLocalDbContainerInputs`'s doc comment) — so this is the ONE - // config error only `buildLocalDbContainerInputs`'s own validation catches, and - // it must run strictly before `resolver.resolve()` ever runs — so `resolverCalls` - // must stay empty here, proving the shadow's config validation ran first, not just - // that the command failed. + // `api.tls`'s cert/key files are only validated by `buildLocalDbContainerInputs`, + // which must run strictly before `resolver.resolve()` — `resolverCalls` staying + // empty here proves validation ran first, not just that the command failed. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), @@ -1248,10 +1136,8 @@ describe("db diff", () => { ); it.effect("re-quotes a comma-containing schema when delegating --use-pg-schema", () => { - // flags.schema holds the single parsed value `tenant,one`; forwarding it raw - // would let the Go child's pflag StringSlice CSV-split it into two schemas, so - // it must be re-encoded as a quoted CSV field. `--use-pg-schema` is the only - // remaining delegate path. + // The parsed schema value `tenant,one` must be re-encoded as a quoted CSV field + // so the delegated Go child's pflag StringSlice doesn't split it into two schemas. const s = setup(tmp.current); return Effect.gen(function* () { yield* dbDiff(flags({ usePgSchema: Option.some(true), schema: ["tenant,one"] })); @@ -1264,9 +1150,8 @@ describe("db diff", () => { it.effect( "forwards a comma-containing --schema value to the differ raw, with no CSV re-quoting (native path)", () => { - // Unlike the --use-pg-schema delegate above, the native differ argv is never - // re-parsed by a pflag StringSlice, so the single parsed value reaches the - // container unchanged. + // Unlike the delegate above, the native differ argv isn't re-parsed by a pflag + // StringSlice, so the value reaches the container unchanged. const s = setup(tmp.current, { pgadminStdout: [JSON.stringify([pgadminEntry()])] }); return Effect.gen(function* () { yield* dbDiff(flags({ usePgAdmin: Option.some(true), schema: ["tenant,one"] })); @@ -1283,15 +1168,9 @@ describe("db diff", () => { const s = setup(tmp.current); return Effect.gen(function* () { yield* dbDiff(flags({ usePgSchema: Option.some(true) })); - // The TS wrapper prints its own deprecation notice pointing at pg-delta / - // the default migra engine, additive to (not a replacement for) the - // delegated Go child's own "experimental" warning (unchanged, printed by - // the real Go binary rather than this mocked proxy). Assert on a stable - // substring so future wording tweaks don't require touching every test site. + // Asserts on a stable substring so wording tweaks don't require touching every test site. expect(stderr(s.out)).toContain('"--use-pg-schema" is deprecated'); - // The TS wrapper must not print a second copy of the delegated child's own warning. expect(stderr(s.out)).not.toContain("--use-pg-schema flag is experimental"); - // Delegation to Go is unchanged besides the new warning. expect(s.proxyCalls[0]?.args).toEqual(["db", "diff", "--use-pg-schema"]); // The child's own telemetry is disabled so the single `cli_command_executed` // event comes from this TS command's instrumentation, not the delegated child. @@ -1328,11 +1207,7 @@ describe("db diff", () => { }); return Effect.gen(function* () { yield* dbDiff(flags({ usePgAdmin: Option.some(true) })); - // stdout stays payload-only in machine mode — no status lines leak into it. expect(stdout(s.out)).toBe(""); - // The status lines are diagnostics, not payload, so machine mode redirects - // them to stderr instead of dropping them (repo invariant: stdout is - // payload-only, diagnostics go to stderr — CLI-1546). const err = stderr(s.out); expect(err).toContain("Creating shadow database..."); expect(err).toContain("Diffing local database with current migrations..."); @@ -1389,9 +1264,7 @@ describe("db diff", () => { expect(s.proxyCaptureCalls).toHaveLength(1); const success = s.out.messages.find((m) => m.type === "success"); expect(success?.data).toMatchObject({ diff: "create table e ();\n", engine: "pg-schema" }); - // The deprecation notice is a diagnostic, so it must still reach stderr in - // machine output mode rather than being dropped or leaking into the stdout - // payload. + // Diagnostics like the deprecation notice still reach stderr in machine mode. expect(stderr(s.out)).toContain('"--use-pg-schema" is deprecated'); // The child's own telemetry is disabled here too, same as the text-mode delegate. expect(s.proxyCaptureCalls[0]?.env).toEqual({ SUPABASE_TELEMETRY_DISABLED: "1" }); @@ -1515,8 +1388,6 @@ describe("db diff", () => { }); it.effect("creates nested parent directories for a nested single-unit --file name", () => { - // `db diff -f snapshots/remote` must create the `_snapshots/` parent dir - // before writing. const s = setup(tmp.current, { diffSql: "create table g ();\n" }); return Effect.gen(function* () { yield* dbDiff(flags({ file: Option.some("snapshots/remote") })); @@ -1532,7 +1403,6 @@ describe("db diff", () => { const s = setup(tmp.current, { isLocal: false, diffSql: "create table e ();\n" }); return Effect.gen(function* () { yield* dbDiff(flags({ from: Option.some("local"), to: Option.some("linked") })); - // Explicit mode is pg-delta and never provisions a shadow. expect(s.explicitDiffCalls[0]).toMatchObject({ source: { kind: "database", @@ -1600,10 +1470,8 @@ describe("db diff", () => { it.effect( "forwards an explicit --linked=false target flag to the delegated pg-schema child", () => { - // Target flags are selectors keyed on flag.Changed in the delegated Go child; - // dropping Some(false) would make the child default to local instead of the - // linked target the native path selected. `--use-pg-schema` is the only - // remaining delegate path. + // Target flags are selectors keyed on the delegated child's flag.Changed; dropping + // `Some(false)` would default it to local instead of the linked target selected. const s = setup(tmp.current); return Effect.gen(function* () { yield* dbDiff(flags({ usePgSchema: Option.some(true), linked: Option.some(false) })); @@ -1615,9 +1483,8 @@ describe("db diff", () => { it.effect( "an empty --file value prints to stdout instead of writing a nameless migration", () => { - // The file write is gated on the value being non-empty; an empty --file - // (e.g. an unset shell var) falls through to stdout rather than writing - // `_.sql`. + // The file write is gated on non-empty; an empty --file falls through to stdout + // instead of writing `_.sql`. const s = setup(tmp.current, { diffSql: "create table y ();\n" }); return Effect.gen(function* () { yield* dbDiff(flags({ file: Option.some("") })); @@ -1631,14 +1498,13 @@ describe("db diff", () => { it.effect( "explicit --output with an empty value prints to stdout instead of writing a file", () => { - // The file write is gated on the value being non-empty; an empty value falls - // through to stdout rather than writing SQL into the project directory. + // The file write is gated on non-empty; an empty --output falls through to stdout + // instead of writing into the project directory. const s = setup(tmp.current, { diffSql: "create table z ();\n" }); return Effect.gen(function* () { yield* dbDiff( flags({ from: Option.some("local"), to: Option.some("local"), output: Option.some("") }), ); - // Reaching stdout proves it didn't try to write SQL to the resolved workdir. expect(stdout(s.out)).toBe("create table z ();\n"); }).pipe(Effect.provide(s.layer)); }, @@ -1654,8 +1520,7 @@ describe("db diff", () => { }); it.effect("explicit --from linked --to migrations passes the linked ref to the strategy", () => { - // Go resolves linked first (LoadConfig merges [remotes.]), so the later - // migrations catalog is built from the remote-merged config (explicit.go). + // Linked resolves first, so the later migrations catalog uses the remote-merged config. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), @@ -1682,17 +1547,15 @@ describe("db diff", () => { kind: "migrations", projectRef: "abcdefghijklmnopqrst", }); - // Opposite direction of the sibling "migrations --to linked" test below: linked - // resolves FIRST here, so the remote-merged config (major_version = 14) is what - // must reach the migrations shadow/catalog. + // Linked resolves first here (opposite of the sibling test below), so the + // remote-merged config reaches the catalog. expect(s.explicitDiffCalls[0]?.toml?.majorVersion).toBe(14); }).pipe(Effect.provide(s.layer)); }); it.effect("explicit --from migrations --to linked passes base config to the strategy", () => { - // Migrations is resolved BEFORE linked here, so Go's LoadConfig(ref) hasn't run - // yet — the catalog (and its shadow's own container spec) must use base config - // (no ref forwarded), matching order. + // Migrations resolves before linked here, so the catalog must use base config + // (no ref forwarded yet). mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), @@ -1706,8 +1569,8 @@ describe("db diff", () => { "[remotes.staging.db]", "major_version = 14", "", - // Set ONLY under the remote block: proves the strategy-received toml is the - // base config, not the linked-merged one (which would flip this to true). + // Set only under the remote block, so it would flip to true if the + // linked-merged config leaked in. "[remotes.staging.experimental.webhooks]", "enabled = true", "", @@ -1727,10 +1590,8 @@ describe("db diff", () => { }); it.effect("explicit --from local --to migrations --linked seeds the merged config", () => { - // A changed --linked resolves the project ref and remote-merges the config - // before the explicit refs resolve — so the migrations catalog's shadow (and - // local refs/format options) use the linked override even though neither - // explicit ref is itself `linked`. + // A changed --linked remote-merges the config before the explicit refs resolve, + // even though neither explicit ref is itself `linked`. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), @@ -1767,9 +1628,8 @@ describe("db diff", () => { }); it.effect("explicit --from local --to migrations --linked validates the merged config", () => { - // The explicit base config read is deferred until after the linked preflight, so - // a base config that's only valid after the [remotes.] merge (base - // major_version=16, override=15) does not fail before the ref is resolved. + // The base config read is deferred until after the linked preflight, so a base + // config only valid after the remote merge doesn't fail early. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), @@ -1803,12 +1663,11 @@ describe("db diff", () => { }); it.effect("empty --from/--to (shell vars) fall through to the normal diff", () => { - // Go gates explicit mode on len(diffFrom)>0 || len(diffTo)>0; `--from "" --to ""` - // is unset and runs the normal local diff, not an unknown-target error. + // `--from "" --to ""` is unset and runs the normal local diff, not an + // unknown-target error. const s = setup(tmp.current, { diffSql: "create table e ();\n" }); return Effect.gen(function* () { yield* dbDiff(flags({ from: Option.some(""), to: Option.some("") })); - // Reaching the native path proves it didn't enter explicit mode and error. expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); expect(stdout(s.out)).toBe("create table e ();\n\n"); }).pipe(Effect.provide(s.layer)); @@ -1825,10 +1684,8 @@ describe("db diff", () => { }); it.effect("explicit mode still runs the target-flag preflight on a changed --db-url", () => { - // Go runs ParseDatabaseConfig in PreRun before RunExplicit (cmd/root.go:118), - // so a changed target flag is still validated/loaded even when the explicit - // refs drive the diff. The preflight resolves the --db-url target (connType - // db-url); a real bad URL would surface the resolver's parse error. + // A changed target flag is still validated even when the explicit refs drive the + // diff; the preflight resolves --db-url (connType db-url). const s = setup(tmp.current, { diffSql: "create table p ();\n" }); return Effect.gen(function* () { yield* dbDiff( @@ -1901,7 +1758,6 @@ describe("db diff", () => { const s = setup(tmp.current, { format: "json", diffSql: "create table j ();\n" }); return Effect.gen(function* () { yield* dbDiff(flags()); - // No raw SQL on stdout in machine mode; the envelope carries it instead. expect(stdout(s.out)).toBe(""); const success = s.out.messages.find((m) => m.type === "success"); expect(success?.data).toMatchObject({ @@ -1944,8 +1800,7 @@ describe("db diff", () => { }); it.effect("the migra OOM fallback honors --network-id over host networking", () => { - // The migra OOM bash fallback routes through Docker start, which overrides the - // requested host network with --network-id when set. + // Routes through Docker start, which overrides host networking with --network-id. const s = setup(tmp.current, { oom: true, diffSql: "create table fb ();\n", @@ -1965,38 +1820,25 @@ describe("db diff", () => { it.live( "removes the shadow container on a SIGINT-style interruption during the readiness wait, without waiting for the readiness timeout", () => { - // Regression test for the acquireUseRelease restructuring (review: - // PRRT_kwDOErm0O86XMrID): an earlier shape passed the ENTIRE - // `prepareShadowSource` (create -> health-wait -> migrate -> - // declarative-apply) as `acquireUseRelease`'s `acquire`, which Effect's - // `uninterruptibleMask` (no `restore` around `acquire`) made completely - // uninterruptible — a SIGINT landing during the readiness wait (which can run - // for up to 30 real seconds, `HEALTH_CHECK_TIMEOUT_SECONDS`) was silently - // swallowed until the wait gave up on its own. `acquire` is now ONLY - // `createShadowDatabase` (container creation); the readiness wait runs - // inside the interruptible `use` phase instead, so a `Fiber.interrupt` here must - // land promptly. + // `acquire` covers only `createShadowDatabase`; the readiness wait runs in the + // interruptible `use` phase instead, so a `Fiber.interrupt` here must land + // promptly rather than waiting for the readiness timeout. const s = setup(tmp.current, { neverConnectableShadow: true }); return Effect.gen(function* () { const fiber = yield* dbDiff(flags()).pipe( Effect.provide(s.layer), Effect.forkChild({ startImmediately: true }), ); - // Wait until the shadow's readiness gate has actually refused a connect at - // least once — proving the fiber is genuinely suspended inside - // `waitForShadowReady`'s retry loop, not merely past the `create` call. + // Waits until the shadow's readiness gate has refused a connect at least once, + // proving the fiber is suspended in `waitForShadowReady`'s retry loop. while (s.shadowConnectedDatabases.length === 0) { yield* Effect.sleep("5 millis"); } - // `Fiber.interrupt` only resolves once the target fiber (and its finalizers, - // including `removeShadowDatabase`) has fully completed — if `acquire` - // still covered the readiness wait, this call would hang for up to 30 real - // seconds (or until this test's own timeout), instead of resolving as soon - // as the in-flight probe returns. + // `Fiber.interrupt` only resolves once finalizers complete; this would hang for + // up to 30s if `acquire` still covered the readiness wait. yield* Fiber.interrupt(fiber); expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - // The diff step (past the health wait) was never reached. expect(s.edgeCalls).toHaveLength(0); }); }, @@ -2092,11 +1934,9 @@ describe("db diff", () => { "invokes the differ with the exact argv, image, network, labels, and empty env/binds (no --schema)", () => { const s = setup(tmp.current, { pgadminStdout: [JSON.stringify([pgadminEntry()])] }); - // `CommandSettings.projectId` only feeds pg-delta's own project id (a - // SEPARATE mechanism); the shadow/differ's docker network+labels come from - // `loadLocalProjectContext`'s REAL resolution (no config.toml - // `project_id`/`SUPABASE_PROJECT_ID` here), which falls back to the workdir - // basename — same as the pg-delta Deno-cache-volume tests above. + // `CommandSettings.projectId` only feeds pg-delta's project id; the differ's + // network/labels come from `loadLocalProjectContext`'s own resolution, falling + // back to the workdir basename. const projectId = basename(tmp.current); return Effect.gen(function* () { yield* dbDiff(flags({ usePgAdmin: Option.some(true) })); @@ -2116,8 +1956,7 @@ describe("db diff", () => { "com.docker.compose.project": projectId, }); expect(call.extraHosts).toEqual(["host.docker.internal:host-gateway"]); - // Go never tees the differ's raw stderr to the parent terminal — the - // `runCapture` options argument must stay unset. + // The differ's stderr is never teed to the parent terminal. expect(s.differCaptureOpts[0]).toBeUndefined(); }).pipe(Effect.provide(s.layer)); }, @@ -2171,11 +2010,9 @@ describe("db diff", () => { it.effect( "a supabase/.env-only SUPABASE_INTERNAL_IMAGE_REGISTRY reaches the differ's image resolver during the run, and reverts after", () => { - // `dockerRunLayer`'s own image resolver has no `projectEnvValues` in - // scope, so it falls back to reading `process.env` directly at `runCapture` - // call time; this mock docker layer records that same read - // (`differRegistryEnvAtCall`) since it replaces the real resolver wholesale - // and can't observe an already-rewritten image. + // The image resolver reads `process.env` directly at call time (no + // `projectEnvValues` in scope); this mock records that same read since it + // replaces the resolver wholesale. const prev = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; mkdirSync(join(tmp.current, "supabase"), { recursive: true }); @@ -2187,8 +2024,6 @@ describe("db diff", () => { return Effect.gen(function* () { yield* dbDiff(flags({ usePgAdmin: Option.some(true) })); expect(s.differRegistryEnvAtCall).toEqual(["registry.example.com"]); - // Reverted once the handler's scope closes — no leak into a later command - // (or a later test) sharing this process. expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); }).pipe( Effect.ensuring( @@ -2268,12 +2103,8 @@ describe("db diff", () => { it.effect( ">=2 --schema runs each emitting a diff array succeed, aggregating every run's DDL under ONE header (CLI-1968 round 2: parsed per run, not concatenated then parsed once)", () => { - // Completes the intended shared-buffer algorithm's own purpose (see - // `pgadmin-diff.ts`'s own header comment): each run's stdout is - // parsed on its own, so >=2 `--schema` runs that each emit a full JSON - // array no longer concatenate into one buffer and fail a single - // `JSON.parse` — every run's own DESKTOP-mode NOTE prefix (`pgadmin4#24`) - // is trimmed from that run's own buffer too, not just the very first run's. + // Each run's stdout is parsed on its own (see `pgadmin-diff.ts`), including its + // own DESKTOP-mode NOTE prefix trim, not just the first run's. const s = setup(tmp.current, { pgadminStdout: [ `${PGADMIN_DESKTOP_NOTE_PREFIX}${JSON.stringify([pgadminEntry({ diff_ddl: "create table pub ();" })])}`, @@ -2283,17 +2114,14 @@ describe("db diff", () => { return Effect.gen(function* () { yield* dbDiff(flags({ usePgAdmin: Option.some(true), schema: ["public", "app"] })); const text = stdout(s.out); - // A single header, not one per run. expect(text.split(PGADMIN_DIFF_HEADER)).toHaveLength(2); expect(text).toContain( `${PGADMIN_DIFF_HEADER}\n\ncreate table pub ();\n\ncreate table app ();\n`, ); - // Per-run "Diffing schema:" ordering is preserved. const idxPublic = text.indexOf("Diffing schema: public"); const idxApp = text.indexOf("Diffing schema: app"); expect(idxPublic).toBeGreaterThanOrEqual(0); expect(idxApp).toBeGreaterThan(idxPublic); - // Neither run's raw NOTE prefix leaked into the rendered diff. expect(text).not.toContain("NOTE: Configuring authentication for DESKTOP mode."); }).pipe(Effect.provide(s.layer)); }, @@ -2316,9 +2144,6 @@ describe("db diff", () => { it.effect( "emits a failed run's captured progress statuses before the container-error surfaces", () => { - // This port batches stderr via `runCapture` instead of streaming it, so a - // run that later exits non-zero must still have its captured status lines - // processed/emitted BEFORE the exit-code check, not after returning early. const s = setup(tmp.current, { pgadminExitCode: 1, pgadminStderr: ["Comparing Tables 45%\nDiffing 100%\n"], @@ -2368,8 +2193,7 @@ describe("db diff", () => { reason: "differ", message: "error running container: exit 1", }); - // The differ's own stderr never reaches the error message (Go quirk — it - // only ever fed the progress-line filter). + // The differ's own stderr never reaches the error message. expect((error as { message: string }).message).not.toContain("some differ crash text"); expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); }).pipe(Effect.provide(s.layer)); @@ -2416,9 +2240,6 @@ describe("db diff", () => { ); expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toEqual([]); expect(s.differCalls).toEqual([]); - // The target was still resolved BEFORE the running-check failed — Go - // resolves the target in the root PersistentPreRunE, strictly before - // RunPgAdmin's AssertSupabaseDbIsRunning. expect(s.resolverCalls.length).toBeGreaterThan(0); }).pipe(Effect.provide(s.layer)); }, @@ -2525,9 +2346,9 @@ describe("db diff", () => { Effect.provide(s.layer), Effect.forkChild({ startImmediately: true }), ); - // Wait for the SHADOW's own health probe specifically (its 64-hex id) — - // the pgadmin path's separate `supabase_db_test` "is running" probe fires - // first and would otherwise satisfy a looser check immediately. + // Waits for the shadow's own health probe (its 64-hex id); the pgadmin path's + // separate `supabase_db_test` probe fires first and would satisfy a looser + // check immediately. while ( !s.shadowSpawned.some( (c) => @@ -2580,11 +2401,9 @@ describe("db diff", () => { ).pipe(Effect.as(s)); }; - // Regression: both migrate paths used to pass a hardcoded `{ webhooks: "enabled" }`, so the - // migra run's forced-`pg_net` baseline and the pg-delta run's config-following baseline keyed - // to the SAME tar and silently restored each other's cluster. The handler now forks the - // policy on `migrationMode`; `shadow-cache.integration.test.ts` covers the cache's half of - // the contract, this covers `db diff`'s call site. + // A migra baseline and a pg-delta baseline must not key to the same cache tar and + // silently restore each other's cluster; `shadow-cache.integration.test.ts` covers + // the cache's own half, this covers the call site. it.live("a migra-engine baseline is never restored into a pg-delta run", () => { mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( @@ -2600,7 +2419,7 @@ describe("db diff", () => { expect(migraTars).toHaveLength(1); // pg-delta follows the config (webhooks are off here), so it must cold-provision - // and publish its OWN baseline rather than restore the forced-on one above. + // and publish its own baseline rather than restore the forced-on one above. const pgDeltaRun = yield* runCached("pg-delta"); expect(pgDeltaRun.dockerDaemon?.stepCalls("cp-in")).toHaveLength(0); expect(pgDeltaRun.dockerDaemon?.stepCalls("cp-out")).toHaveLength(1); diff --git a/apps/cli/src/commands/db/diff/pgadmin-diff.ts b/apps/cli/src/commands/db/diff/pgadmin-diff.ts index 8dd9dde824..18358142af 100644 --- a/apps/cli/src/commands/db/diff/pgadmin-diff.ts +++ b/apps/cli/src/commands/db/diff/pgadmin-diff.ts @@ -1,32 +1,15 @@ /** - * Native pgAdmin schema-diff engine. `db diff` is the only caller, so this stays - * colocated with the command rather than under `commands/db/shared/`; move it - * there (and split the error into its own `pgadmin-diff.errors.ts`, - * mirroring `migra.ts`/`migra.errors.ts`) if a second command ever - * needs it. + * Native pgAdmin schema-diff engine used by `db diff`. * - * Covers the two pure halves — parsing (`parsePgAdminDiffEntries`) and - * rendering (`renderPgAdminDiff`), recomposed as - * `processPgAdminDiffOutput` for a single whole buffer — and the - * container-invocation loop, `diffSchemaPgAdmin`. Shadow provisioning, the - * `Creating shadow database...`/`Diffing local database with current - * migrations...` status lines, and the file write all stay in `diff.handler.ts`. + * Covers the pure halves — parsing (`parsePgAdminDiffEntries`) and rendering + * (`renderPgAdminDiff`), recomposed as `processPgAdminDiffOutput` for a single + * whole buffer — and the container-invocation loop, `diffSchemaPgAdmin`. Shadow + * provisioning, status lines, and the file write stay in `diff.handler.ts`. * - * **Deliberate divergence from the historical Go implementation:** the differ - * container's stdout was, in the original Go CLI, never actually collected — a - * struct-copy bug meant `--use-pgadmin` always reported "No schema changes found" - * (exit 0) regardless of the differ's real output, on any schema count. This port - * implements the algorithm the surrounding code clearly intended: it parses EACH - * run's own real stdout separately and aggregates the kept DDLs across runs, - * rather than gluing every run's raw bytes into one buffer and parsing that once - * (which would turn a multi-`--schema` diff whose every run individually parses - * fine into a spurious `JSON.parse` "trailing data" failure — the worst of both - * worlds). So where the historical implementation silently reported an empty diff - * no matter what the differ produced, this port produces the actual, aggregated - * diff across every run (or a real per-run JSON-parse error — see - * `parsePgAdminDiffEntries`'s own doc comment). Ruling: keep this port's - * (correct) implementation; see `SIDE_EFFECTS.md`'s "Deliberate divergence" entry - * for the user-facing framing. + * Each differ run's stdout is parsed and aggregated separately rather than + * concatenated across runs and parsed once, which would turn a multi-`--schema` + * diff, where every run parses fine on its own, into a spurious JSON + * "trailing data" failure. See `SIDE_EFFECTS.md` for the user-facing behavior. */ import { Effect, Option, Result } from "effect"; @@ -42,11 +25,9 @@ import { DbDiffPgAdminError } from "./diff.errors.ts"; const DIFFER_IMAGE = dockerfileServiceImage("differ"); /** - * Trimmed front-anchored only (not a global strip) — a real pgAdmin4 output quirk - * (`supabase/pgadmin4#24`). `parsePgAdminDiffEntries` runs once per differ - * run, so each run's OWN copy of this note is trimmed off the front of that run's - * own buffer; `processPgAdminDiffOutput`, applied to a single whole buffer, - * still only strips the very front of whatever string it's given. + * Only the front of the buffer is trimmed, not every occurrence — a real pgAdmin4 + * output quirk (`supabase/pgadmin4#24`). Each differ run's own copy is trimmed + * off that run's own buffer. */ export const PGADMIN_DESKTOP_NOTE_PREFIX = "NOTE: Configuring authentication for DESKTOP mode.\n"; @@ -68,38 +49,22 @@ const PGADMIN_DIFF_TYPES = new Set([ ]); /** - * Compiled with the `s` (dotAll) flag: JS's `.` excludes every line-terminator code - * point (`\r`, `\n`, U+2028, U+2029) unless `s` is set, and `scanLines` only - * splits on `\n`, so a line can still carry embedded `\r`s from a `\r`-driven - * progress bar (multiple updates overwriting the same terminal line) — without `s`, - * this pattern would stop matching at the first embedded `\r`. No alternation, so - * the match is greedy on the digit/percent suffix, e.g. `"Diffing 100%"` → group 1 - * `"Diffing 1"`, group 2 `"00"`. + * Uses the `s` (dotAll) flag so `.` still matches an embedded `\r` left by a + * `\r`-driven progress bar; `scanLines` only splits on `\n`, so a line can carry + * one of these mid-string. */ const PGADMIN_PROGRESS_RE = /(.*)([0-9]{2,3})%/s; -/** - * Splits `stderr` into lines: `\r\n`/`\n`-terminated lines with the trailing `\r` - * (if any) stripped, and a final, non-newline-terminated fragment still emitted as - * its own line. An empty input yields zero lines. Deliberately has no line-length - * limit, so an abnormally long differ progress line is still scanned in full. - */ +/** Splits into lines, stripping a trailing `\r` from each and any final empty line left by a trailing `\n`. */ function scanLines(text: string): ReadonlyArray { if (text.length === 0) return []; const lines = text.split("\n"); - // A trailing `\n` produces one trailing empty element from `split` that isn't a - // real line of its own — the `\n` itself already terminated the prior line. + // `split("\n")` leaves a trailing empty element after a trailing newline. const withoutTrailingNewline = text.endsWith("\n") ? lines.slice(0, -1) : lines; return withoutTrailingNewline.map((line) => (line.endsWith("\r") ? line.slice(0, -1) : line)); } -/** - * Extracts the status lines from the differ's stderr progress stream (progress - * percentages themselves are dropped). `"Starting schema diff..."` and any - * non-matching line produce nothing. This port's stdout emission - * (`diff.handler.ts`'s `emitStatus`) targets non-TTY status-line output; a TTY - * session's frame-by-frame progress rendering has no TS equivalent. - */ +/** Extracts status text from the differ's stderr progress stream, dropping percentages and non-matching lines. */ export function processPgAdminDiffProgress(stderr: string): ReadonlyArray { const statuses: Array = []; for (const line of scanLines(stderr)) { @@ -110,10 +75,7 @@ export function processPgAdminDiffProgress(stderr: string): ReadonlyArray): string { } /** - * Parse-then-render composition of the two halves above, applied to a SINGLE, - * whole buffer — kept for callers (and this file's own unit tests) that want the - * whole pipeline as one function over one buffer. `diffSchemaPgAdmin`'s run - * loop calls `parsePgAdminDiffEntries`/`renderPgAdminDiff` directly - * instead, once per run, so this function's own single-buffer semantics - * (including the multi-JSON-array "trailing data" failure on a buffer that - * concatenates >=1 complete arrays) are unchanged but no longer reachable from a - * multi-`--schema` diff. + * Parses and renders a single whole buffer in one call. `diffSchemaPgAdmin` + * calls the two halves directly, once per differ run, instead. */ export function processPgAdminDiffOutput( stdout: string, @@ -268,15 +203,6 @@ export function processPgAdminDiffOutput( return Result.map(parsePgAdminDiffEntries(stdout), renderPgAdminDiff); } -/** - * Maps `DockerRunError`'s own three-way docker-boundary discriminant onto this - * command's `reason` union — mirrors `dbSetupDockerReason` (`db-setup.ts`): a spawn - * failure or a detected daemon-down message means the daemon itself is unreachable, a failed - * image inspect is a config/registry-availability issue distinct from a pull failure, and - * everything else at this boundary is a registry-pull failure. `diffMigraBash` - * (`migra.ts`) keeps its own pre-existing two-way collapse (`inspect` folded into - * `pull`) — out of scope for this port. - */ function pgAdminDockerReason( reason: "spawn" | "inspect" | "pull", daemonDown: boolean, @@ -287,19 +213,16 @@ function pgAdminDockerReason( } export interface DiffSchemaPgAdminParams { - /** The USER'S db. */ + /** The user's database connection string. */ readonly source: string; - /** The SHADOW — a raw connection string, not built via `toPostgresURL`. */ + /** The shadow database's raw connection string (not built via `toPostgresURL`). */ readonly target: string; readonly schema: ReadonlyArray; - /** Merged onto both docker labels, matching every other container this codebase creates. */ + /** Merged onto both docker container labels. */ readonly projectId: string; /** - * Already `--network-id`/`SUPABASE_NETWORK_ID`/`supabase_network_`-resolved by - * the caller (`resolveNetworkId`, via `buildLocalDbContainerInputs`'s - * `localInputs.networkId`) — never empty, so this function does no second resolution and, - * unlike `migra.ts`'s `diffMigraBash`, never falls back to a host network: the - * differ always joins a user-defined bridge network. + * Already resolved to a network ID by the caller; never empty. The differ always + * joins this network and never falls back to the host network. */ readonly networkId: string; /** Linux-only `host.docker.internal:host-gateway`; empty elsewhere. */ @@ -309,28 +232,11 @@ export interface DiffSchemaPgAdminParams { } /** - * One differ container run when no `--schema` is given, else one run per - * `--schema` (in flag order), each preceded by its own `Diffing schema: ` - * status. `runCapture`, not `runStream`, because the differ's progress lines - * arrive on STDERR, and `DockerRun.runStream` only exposes an `onStdout` - * streaming hook — there is no `onStderr` equivalent to observe stderr - * incrementally through this service today. This means status lines aren't - * live-streamed: this function buffers each run's stderr in full via - * `runCapture` and only filters/flushes it (`processPgAdminDiffProgress` + - * `emitStatus`, below) once that run's container has already exited — so a - * multi-`--schema` diff still gets one status batch per run, but within a single - * run every one of its status lines appears together, after the fact, instead - * of as the differ actually emits them. A real fix would add an `onStderr` - * streaming hook to `runStream`, mirroring `onStdout`, and switch this function - * to it. `teeStderr` stays off regardless. The image is passed raw (not - * pre-resolved via `getRegistryImageUrl`, unlike `diffMigraBash`): - * `dockerRunLayer`'s own resolver builds the ECR→GHCR→docker.io candidate - * ladder from it — reading `SUPABASE_INTERNAL_IMAGE_REGISTRY` straight off - * `process.env` at call time (no `projectEnvValues` passed through). It is the - * caller's (`diff.handler.ts`) own `applyProjectEnv` scope, applied right - * after the config load, that makes a registry override set only in the - * project's `supabase/.env` (not the ambient shell) visible to that resolver by - * the time this function's `runCapture` call reaches it. + * Runs one differ container per `--schema` flag (or one run if none given). Uses + * `runCapture`, not `runStream`, because progress arrives on stderr, which cannot be + * streamed incrementally today — so each run's status lines print only after that + * run's container exits, not live. The image is passed unresolved so the registry + * resolver picks up any project-level registry override from the caller's env scope. */ export const diffSchemaPgAdmin = ( params: DiffSchemaPgAdminParams, @@ -373,17 +279,12 @@ export const diffSchemaPgAdmin = ( }), ), ); - // Emitted BEFORE the exit-code check below: a failed run's own status lines - // should still print ahead of the container error surfacing. Returning early - // on a nonzero exit before reaching this would silently drop that run's - // already-captured statuses. + // Emitted before the exit-code check so a failed run's status lines still print. for (const line of processPgAdminDiffProgress(result.stderr)) { yield* params.emitStatus(line); } if (result.exitCode !== 0) { - // The differ's own stderr is never surfaced beyond the progress-line - // filter above; any non-matching line is silently dropped, even under - // `--debug`. + // Non-progress stderr lines are dropped silently, even under `--debug`. return yield* Effect.fail( new DbDiffPgAdminError({ message: `error running container: exit ${result.exitCode}`, @@ -392,10 +293,7 @@ export const diffSchemaPgAdmin = ( ); } const stdout = new TextDecoder().decode(result.stdout); - // Parsed per run rather than concatenated across runs and parsed once, - // which would turn a multi-`--schema` diff whose every run individually - // parses fine into a spurious "trailing data" `JSON.parse` failure — see - // this module's own header comment. + // Parsed per run, not concatenated across runs. const parsed = parsePgAdminDiffEntries(stdout); if (Result.isFailure(parsed)) { return yield* Effect.fail( diff --git a/apps/cli/src/commands/db/diff/pgadmin-diff.unit.test.ts b/apps/cli/src/commands/db/diff/pgadmin-diff.unit.test.ts index 486fd81b3d..d9bc03de3f 100644 --- a/apps/cli/src/commands/db/diff/pgadmin-diff.unit.test.ts +++ b/apps/cli/src/commands/db/diff/pgadmin-diff.unit.test.ts @@ -26,7 +26,6 @@ const headerPlus = (ddl: string) => `${PGADMIN_DIFF_HEADER}\n\n${ddl}\n`; describe("processPgAdminDiffOutput", () => { describe("filtering rules (container_output.go:154-195)", () => { it("keeps DDL from every whitelisted entry type, joined under the exact 4-line pgAdmin header", () => { - // Go test parity: `TestProcessDiffOutput/processes valid diff entries`. const types = ["extension", "function", "mview", "table", "trigger_function", "type", "view"]; const entries = types.map((type, i) => entry({ type, diff_ddl: `DDL_${i};` })); const result = processPgAdminDiffOutput(JSON.stringify(entries)); @@ -57,9 +56,6 @@ describe("processPgAdminDiffOutput", () => { }); it('skips an entry with no type field at all, given a non-empty diff_ddl (defaults to "", outside the allow-list)', () => { - // Distinct from the `[{"unknown":1}]` acceptance-rule case below, whose empty - // `diff_ddl` short-circuits at the PRIOR `status === "Identical" || diff_ddl === ""` - // check — this covers the `type` fallback itself. const result = processPgAdminDiffOutput( JSON.stringify([ { status: "Different", diff_ddl: "ALTER TABLE test;", group_name: "public" }, @@ -90,7 +86,6 @@ describe("processPgAdminDiffOutput", () => { }); it("skips an entry whose group_name is an internal schema (auth)", () => { - // Go test parity: `TestProcessDiffOutput/filters out internal schemas`. const result = processPgAdminDiffOutput(JSON.stringify([entry({ group_name: "auth" })])); expect(result).toEqual(Result.succeed("")); }); @@ -141,8 +136,6 @@ describe("processPgAdminDiffOutput", () => { }); }); - // Go-acceptance-rules table (`json.Unmarshal` into `[]DiffEntry`, `container_output.go:127-134`), - // verified against Go 1.26 `encoding/json`. describe("Go encoding/json acceptance rules", () => { it("treats a top-level JSON null the same as Go's nil-slice no-op", () => { expect(processPgAdminDiffOutput("null")).toEqual(Result.succeed("")); @@ -292,8 +285,7 @@ describe("processPgAdminDiffProgress", () => { it.each([ ["Comparing Tables 45%", ["Comparing Tables "]], ["Diffing 100%", ["Diffing 1"]], - // `container_output.go:96`'s real regexp and JS both produce group1="10", - // group2="00" for "1000%" (verified against Go 1.26 `regexp`) — NOT ["1"]. + // Greedy digit match on "1000%" yields "10", not "1". ["1000%", ["10"]], ["5%", []], ["Starting schema diff...", []], @@ -315,12 +307,6 @@ describe("processPgAdminDiffProgress", () => { }); it("matches across embedded \\r within a single line (the `s`/dotAll flag, Go's RE2 . matches \\r)", () => { - // A `\r`-driven progress bar overwrites the same terminal line with multiple - // updates, none of them `\n`-terminated, so `scanLines` treats the whole - // thing as ONE line. With the `s` flag, `.` matches `\r` too, so the greedy - // `(.*)` consumes across every embedded `\r` and the match is anchored on the - // LAST `%`-suffixed run — not the first, which is what this pattern would - // wrongly match without `s` (JS's `.` excludes `\r` by default). const input = "Comparing 10%\rComparing 20%\rComparing 30%"; expect(processPgAdminDiffProgress(input)).toEqual(["Comparing 10%\rComparing 20%\rComparing "]); }); diff --git a/apps/cli/src/commands/db/dump/dump.command.ts b/apps/cli/src/commands/db/dump/dump.command.ts index 65abcea106..86f04648dc 100644 --- a/apps/cli/src/commands/db/dump/dump.command.ts +++ b/apps/cli/src/commands/db/dump/dump.command.ts @@ -12,13 +12,10 @@ import { dbDump } from "./dump.handler.ts"; import { dbDumpRuntimeLayer } from "./dump.layers.ts"; /** - * `db dump` streams the pg_dump SQL to stdout (or `--file`) in every output - * format — there is no `--output-format` for it, so there is no machine - * envelope. A *run* failure (non-zero container exit) would otherwise let - * `withJsonErrorHandling` append a JSON error object to stdout after the SQL - * has already been written, corrupting machine consumers. In json/stream-json - * mode send the diagnostic to stderr and exit 1 instead; text mode keeps - * normal error rendering. + * `db dump` has no `--output-format` machine envelope; it streams pg_dump SQL to + * stdout (or `--file`) in every mode. A run failure (nonzero container exit) sends the + * diagnostic to stderr and exits 1 in json/stream-json mode instead of letting + * `withJsonErrorHandling` corrupt already-written SQL with a JSON error object. */ const onRunFailure = (error: DbDumpRunError) => Effect.gen(function* () { @@ -34,13 +31,9 @@ const config = { Flag.withDescription("Prints the pg_dump script that would be executed."), Flag.withDefault(false), ), - // The boolean flags in mutually-exclusive groups (`data-only`/`role-only`/ - // `keep-comments` and the `db-url`/`linked`/`local` target group) are - // modelled as `Option` so presence tracks whether the flag was explicitly - // set: group validation and dump's target selection key off that, not the - // value, so e.g. `--data-only=false` still counts as set. Handlers read the - // value via `Option.getOrElse(..., () => false)` where the value actually - // matters. + // Mutually-exclusive-group flags (data-only/role-only/keep-comments, and the + // db-url/linked/local target group) are modelled as `Option` so presence, not + // value, drives validation — `--data-only=false` still counts as set. dataOnly: Flag.boolean("data-only").pipe( Flag.withDescription("Dumps only data records."), Flag.optional, @@ -53,8 +46,7 @@ const config = { Flag.withAlias("x"), Flag.withDescription("List of schema.tables to exclude from data-only dump."), Flag.atLeast(0), - // --exclude/-x is a CSV string-slice value; use the shared pflag-faithful - // helper so quoted commas survive and malformed CSV fails at parse time. + // CSV string-slice value; quoted commas survive, malformed CSV fails at parse time. Flag.mapTryCatch( (rawValues) => parseSchemaFlags(rawValues), (err) => (err instanceof Error ? err.message : String(err)), @@ -129,17 +121,11 @@ export const dbDumpCommand = Command.make("dump", config).pipe( linked: flags.linked, local: flags.local, "project-ref": flags.projectRef, - // `password` must never be added to `safeFlags` — it is a credential and - // must always reach telemetry as ``. + // Never add `password` to `safeFlags`; it must stay `` in telemetry. password: flags.password, schema: flags.schema, }, - // TS-only flag with no Go telemetry-safety baseline; Go's nearest - // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most - // others) are unmarked, so it stays redacted. - // Map dump's shorthand flags to their canonical names so a shorthand - // invocation (`-s`/`-x`/`-f`/`-p`) is reported in telemetry under the - // long name. + // Not on the established `--project-ref` safeFlags allowlist, so it stays redacted. aliases: { s: "schema", x: "exclude", f: "file", p: "password" }, }), Effect.catchTag("DbDumpRunError", onRunFailure), diff --git a/apps/cli/src/commands/db/dump/dump.handler.ts b/apps/cli/src/commands/db/dump/dump.handler.ts index f6c19d19b5..f0ef718103 100644 --- a/apps/cli/src/commands/db/dump/dump.handler.ts +++ b/apps/cli/src/commands/db/dump/dump.handler.ts @@ -74,30 +74,24 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { const tty = yield* Tty; const runtimeInfo = yield* RuntimeInfo; - // Resolved linked ref, captured so the post-run finalizer can cache the project - // (GET /v1/projects/{ref}) AFTER the command's own API calls. + // Captured so the post-run finalizer can cache the project after the command's + // own API calls. let linkedRefForCache: string | undefined; yield* Effect.gen(function* () { - // Make an allowlisted `supabase/.env` registry override visible to the - // synchronous `process.env` reader in `getRegistryImageUrl` (the pg_dump - // image), reverted when this scope closes. The pure `loadProjectEnv` does - // not apply the env as a side effect of `resolveDbPassword`, so `db dump` opts - // in explicitly here. + // Makes an allowlisted `supabase/.env` registry override visible to the synchronous + // `process.env` reader in `getRegistryImageUrl`; reverted when this scope closes. const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); yield* applyProjectEnv(projectEnv); - // The grouped boolean flags are modelled as `Option` (presence = explicitly - // set) for the mutex/target checks; resolve their effective values here for - // the places that consume the value (default is false). + // Resolves grouped boolean flags' effective values (default false) for code paths + // that need the value, not just presence. const dataOnly = Option.getOrElse(flags.dataOnly, () => false); const roleOnly = Option.getOrElse(flags.roleOnly, () => false); const keepComments = Option.getOrElse(flags.keepComments, () => false); - // 1. `data-only` is required when `--use-copy`/`--exclude` are set. The - // requirement is satisfied by flag PRESENCE, not the value — so - // `--use-copy --data-only=false` passes the check and runs the schema - // dump with dataOnly=false. Gate on absence, not the resolved value. + // 1. `data-only` is required when `--use-copy`/`--exclude` are set, keyed on + // presence not value — `--use-copy --data-only=false` still passes. if ((flags.useCopy || flags.exclude.length > 0) && Option.isNone(flags.dataOnly)) { return yield* Effect.fail( new DbDumpRequiresDataOnlyError({ @@ -140,22 +134,15 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { } } - // 3. Resolve the connection. dump defaults `--linked` to true (unlike the - // other db subcommands), so translate the flag surface into the - // resolver's selection: db-url > local > linked, defaulting to linked - // when neither local nor db-url is set. + // 3. Resolve the connection: db-url > local > linked, defaulting to linked when + // neither is set (unlike other db subcommands, which default to local). const useLocal = Option.isNone(flags.dbUrl) && Option.isSome(flags.local); - // `connType` selects the resolver branch (explicitly-set-first precedence): - // a `--db-url` wins, then explicit `--local`; otherwise dump defaults to - // linked (unlike the other db commands, whose unset default is local). const connType: DbConnType = Option.isSome(flags.dbUrl) ? "db-url" : useLocal ? "local" : "linked"; - // `--project-ref` never implies `--linked` and must not be silently discarded - // on a non-linked target — one-liner: see push.handler.ts's identical guard - // for the full TS-only rationale. + // `--project-ref` never implies `--linked`; see push.handler.ts's identical guard. if (Option.isSome(flags.projectRef) && connType !== "linked") { return yield* Effect.fail( new DbDumpMutuallyExclusiveFlagsError({ @@ -165,14 +152,9 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { ); } // The project ref is resolved before the connection is built, and the - // linked-project cache is refreshed unconditionally afterward — including on a - // connection-resolution failure (IPv6 / pooler / login-role). Capture the ref - // up-front for the linked path via `loadProjectRef`, which implements the same - // flag > SUPABASE_PROJECT_ID/project_id > `.temp/project-ref` file precedence as - // the resolver, now validated — it raises the same invalid-ref/not-linked errors - // `resolver.resolve()` would raise right after, so the user-visible error surface - // is unchanged, and an unvalidated raw `--project-ref` value is never stored for - // the cache finalizer to send to the Management API. + // linked-project cache is refreshed unconditionally afterward, even on a + // connection-resolution failure. `loadProjectRef` validates the ref up front so an + // unvalidated raw `--project-ref` is never stored for the cache finalizer. if (connType === "linked") { const refResolver = yield* ProjectRefResolver; linkedRefForCache = yield* refResolver.loadProjectRef(flags.projectRef); @@ -192,23 +174,17 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { // On the linked path, re-read config with the resolved ref so a matching // `[remotes.]` block overrides `db.major_version` for the pg_dump image. const linkedRef = Option.getOrUndefined(resolvedRef ?? Option.none()); - // On a successful linked resolve this is the canonical ref (it equals the - // up-front capture); guard so a `None` from a non-linked path never clobbers it. + // Guards a `None` from a non-linked path from clobbering the up-front capture. if (linkedRef !== undefined) { linkedRefForCache = linkedRef; } - // Read config (with any `[remotes.]` override applied) BEFORE the - // dry-run print. The merged config is validated even for `--dry-run`, so an - // invalid merged config (e.g. an unsupported remote `db.major_version` or a - // malformed remote `project_id`) fails rather than silently printing a script. + // Read before the dry-run print so an invalid merged config fails rather than + // silently printing a script. const tomlValues = yield* readDbToml(fs, path, cliSettings.workdir, linkedRef); - // 4. Pick the mode-specific script + env (pure builders, `pg-dump.env.ts`). - // --schema/-s and --exclude/-x are CSV string-slice values, CSV-parsed at - // the flag level via `parseSchemaFlags` (quoted commas preserved, - // malformed CSV rejected at parse time), so they arrive here already - // split — matching `gen types` / `db lint` / declarative. + // 4. Pick the mode-specific script + env. --schema/-s and --exclude/-x arrive here + // already CSV-parsed by `parseSchemaFlags`. const opt = { schema: flags.schema, keepComments, @@ -232,9 +208,8 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { } as const); const modeEnv = mode.buildEnv(conn, opt); - // Every `--file` branch keys off `len(path) > 0`, not flag presence: an - // explicit `--file ""` means stdout, with no file open and no `Dumped - // schema to …` line. + // Keys off `path.length > 0`, not flag presence: `--file ""` means stdout, no + // file opened. const fileFlag = Option.filter(flags.file, (file) => file.length > 0); // 5. Dry-run: print the env-expanded script to stdout (no container). @@ -242,10 +217,8 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { yield* output.raw("DRY RUN: *only* printing the pg_dump script to console.\n", "stderr"); yield* output.raw(`Dumping ${mode.verb} from ${db} database...\n`, "stderr"); yield* output.raw(`${expandScript(mode.script, modeEnv)}\n`); - // The file is never opened on dry-run, but `Dumped schema to .` is - // still printed when `--file` is set, with no dry-run guard. Emit the - // same stderr line here WITHOUT creating/truncating the file. Resolve - // the path like the real path (absolute, relative to the workdir). + // Still prints "Dumped schema to ." on dry-run, without creating/truncating + // the file. if (Option.isSome(fileFlag)) { const dryRunFile = path.resolve(cliSettings.workdir, fileFlag.value); yield* output.raw(`Dumped schema to ${bold(dryRunFile)}.\n`, "stderr"); @@ -253,10 +226,7 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { return; } - // Resolve the pg_dump image BEFORE opening `--file` (only needed for the - // real container path; the dry-run script above is image-independent). The - // file is never opened on dry-run, so it is created/truncated only here, - // after the dry-run early return. + // Resolved before opening `--file`; the dry-run path above never reaches here. const { image } = yield* resolveDbImage( fs, path, @@ -265,14 +235,13 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { Option.getOrUndefined(tomlValues.orioledbVersion), ); - // Resolve a relative `--file` against the workdir, so `--workdir /repo db - // dump -f out.sql` writes `/repo/out.sql`. `path.resolve` leaves absolute - // paths unchanged. + // Resolves a relative `--file` against the workdir (e.g. --workdir /repo -f + // out.sql → /repo/out.sql). const resolvedFile = Option.map(fileFlag, (file) => path.resolve(cliSettings.workdir, file)); - // PowerShell interposes a pipe for `>`/`|` and re-encodes what it reads with - // the legacy console code page, mangling multi-byte UTF-8 (#6397); TTYs, - // disk-file handles, and MSYS/mintty pipes are byte-faithful and never warn. + // PowerShell interposes a pipe for `>`/`|` and re-encodes what it reads with the + // legacy console code page, mangling multi-byte UTF-8; TTYs, disk-file handles, and + // MSYS/mintty pipes are byte-faithful and never warn. const trackNonAscii = runtimeInfo.platform === "win32" && tty.stdoutIsPipe && @@ -292,13 +261,8 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { // 6. Diagnostic to stderr (printed for both real and dry-run paths). yield* output.raw(`Dumping ${mode.verb} from ${db} database...\n`, "stderr"); - // 7. Run the pg_dump container, streaming stdout. `streamPgDump` applies - // the registry mirror + host networking (overridden by `--network-id`) and - // tees stderr. - // - // pg_dump stdout streams straight to the destination sink (the `--file` - // handle or stdout) at constant memory: write each chunk to the - // destination as it arrives instead of buffering the whole dump. + // 7. Streams pg_dump's stdout straight to the destination (file or stdout) at + // constant memory, chunk by chunk, via `streamPgDump`. const runContainer = (env: Readonly>) => Option.isSome(resolvedFile) ? // `--file`: (re)truncate then append-stream. Truncating per attempt @@ -345,14 +309,10 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { projectEnvValues: projectEnv, }); - // 7b. Container-level IPv6 → IPv4-pooler retry, shared with `db pull`. A - // linked dump can reach the direct host from the CLI process (so the - // resolver returned the direct conn) yet fail from inside the pg_dump - // container on an IPv6-only Docker network. `resolvePoolerFallback` is - // neutralised to `None` on any resolution error so the original, - // actionable pg_dump failure is surfaced at step 9 rather than a - // fallback-setup error. `db dump` re-prints the "Dumping …" line on - // the retry. + // 7b. IPv6 → IPv4-pooler retry, shared with `db pull`: a linked dump can reach the + // direct host from the CLI process yet fail inside the container on an + // IPv6-only Docker network. Falls back to `None` on any resolution error so the + // original pg_dump failure surfaces instead of a fallback-setup error. const result = yield* runWithPoolerFallback({ result: yield* runContainer(modeEnv), connType, @@ -376,16 +336,9 @@ export const dbDump = Effect.fn("db.dump")(function* (flags: DbDumpFlags) { // 8. The dump has already been streamed to the destination by `runContainer` // (to `--file` or stdout) as pg_dump produced it. - // 9. Non-zero container exit → exit 1 (the success-only PostRun step is - // skipped). The captured container stderr is classified into an - // actionable suggestion before returning: on the no-fallback path and - // the failed-retry path alike, an IPv6 connectivity failure attaches - // the IPv4 transaction-pooler guidance. `result.stderr` is the relevant - // stderr in both cases (the original when no retry ran, the retry's - // when it did), so classify it here. (Enriching the no-fallback hint - // with the project's pooler URL needs the pooler connection string - // exposed through the resolver and is left as a follow-up — the - // generic hint is restored.) + // 9. A nonzero exit classifies `result.stderr` (the retry's stderr when a retry + // ran, otherwise the original) into an actionable suggestion, e.g. IPv6 + // connectivity. if (result.exitCode !== 0) { return yield* Effect.fail( new DbDumpRunError({ diff --git a/apps/cli/src/commands/db/dump/dump.integration.test.ts b/apps/cli/src/commands/db/dump/dump.integration.test.ts index d220bdf567..85cbd4ed99 100644 --- a/apps/cli/src/commands/db/dump/dump.integration.test.ts +++ b/apps/cli/src/commands/db/dump/dump.integration.test.ts @@ -63,17 +63,15 @@ function mockResolver(opts: { const layer = Layer.succeed(DbConfigResolver, { resolve: (flags) => { calls.push(flags); - // Simulate connection resolution failing (IPv6 probe / pooler / temp - // login-role) after the ref is already loaded. + // Simulates connection resolution failing (IPv6 probe/pooler/temp login-role) + // after the ref is already loaded. if (opts.resolveFails === true) { return Effect.fail( new DbConfigConnectTempRoleError({ message: "failed to create temp role" }), ); } - // A threaded `--project-ref` flag wins over the fixed `opts.ref` test - // fixture, same top precedence a real resolver would give it — lets a - // test prove the flag (not just `opts.ref`) drives the resolved (and - // later cached) ref. + // A threaded `--project-ref` flag wins over the fixed `opts.ref` fixture, + // matching real resolver precedence. const linkedProjectRef = flags.linkedProjectRef ?? Option.none(); const ref = Option.isSome(linkedProjectRef) && linkedProjectRef.value.length > 0 @@ -104,16 +102,10 @@ function mockResolver(opts: { } /** - * Mocks `ProjectRefResolver` for the up-front `loadProjectRef` pre-capture - * (`dump.handler.ts`), mirroring push/diff's identical mock (`push.integration.test.ts`, - * `diff.integration.test.ts`): `loadProjectRef` gives an explicit `--project-ref` flag - * top precedence, same as Go's `flags.LoadProjectRef` — a real (non-empty) ref pattern - * is validated so a malformed flag surfaces `InvalidProjectRefError`, matching the - * real service. `opts.projectId` stands in for `CommandSettings.projectId` - * (`SUPABASE_PROJECT_ID`/`project_id`), which `loadProjectRef` consults before falling - * back to `opts.ref` (the SAME ref `mockResolver`'s own mock embeds in its resolved - * `ref`, so both stay consistent regardless of which fixture a test sets). - * `opts.linkedFails` simulates a genuinely unlinked workdir absent an explicit flag. + * Mocks `ProjectRefResolver` for the up-front `loadProjectRef` pre-capture, mirroring + * push/diff's identical mock: an explicit `--project-ref` flag wins, a malformed value + * fails with `InvalidProjectRefError`, and `opts.projectId` stands in for + * `CommandSettings.projectId` consulted before falling back to `opts.ref`. */ function mockProjectRefResolver(opts: { projectId: Option.Option; @@ -329,8 +321,6 @@ describe("db dump integration", () => { it.live( "allows --use-copy with an explicit --data-only=false (Go required check is presence)", () => { - // The required-flag check keys off explicit presence, so `--data-only=false` - // satisfies it; the command proceeds and runs the schema dump with dataOnly=false. const { layer } = setup({ isLocal: true, stdout: "SELECT 1;\n" }); return Effect.gen(function* () { const exit = yield* dbDump( @@ -405,8 +395,6 @@ describe("db dump integration", () => { }); it.live("rejects --linked=false --local as a target conflict (Go flag.Changed)", () => { - // The target mutex keys off explicit presence, so the explicit-false - // `--linked` still counts as set and conflicts with `--local`. const { layer } = setup(); return Effect.gen(function* () { const exit = yield* dbDump( @@ -433,8 +421,6 @@ describe("db dump integration", () => { }); it.live("treats --local=false as an explicit local target (Go ParseDatabaseConfig)", () => { - // Local is selected on explicit presence of `--local` before the linked - // default, so `--local=false` resolves the local target, not the linked one. const { layer, resolver } = setup({ isLocal: true }); return Effect.gen(function* () { yield* dbDump(flags({ local: Option.some(false), dryRun: true })); @@ -448,15 +434,12 @@ describe("db dump integration", () => { yield* dbDump(flags({ dryRun: true, local: Option.some(true) })); expect(out.stderrText).toContain("DRY RUN: *only* printing the pg_dump script to console."); expect(out.stderrText).toContain("Dumping schemas from local database..."); - // The script must have $PGHOST expanded from the resolved local connection. expect(out.stdoutText).toContain('export PGHOST="127.0.0.1"'); expect(docker.lastOpts).toBeUndefined(); }).pipe(Effect.provide(layer)); }); it.live("prints the post-run Dumped-schema message on --dry-run --file without writing", () => { - // The file is never opened on dry-run, but `Dumped schema to .` is - // still printed, with no dry-run guard and without touching the file. const filePath = join(tmp.current, "dry.sql"); const { layer, out, docker } = setup({ isLocal: true }); return Effect.gen(function* () { @@ -470,9 +453,6 @@ describe("db dump integration", () => { }); it.live("treats an explicit --file '' as stdout on --dry-run (Go: len(path) > 0)", () => { - // Every --file branch keys off len(path) > 0, not flag presence; an - // explicit empty --file means stdout, with no "Dumped schema to …" line - // and no file ever touched. const { layer, out, docker } = setup({ isLocal: true }); return Effect.gen(function* () { yield* dbDump(flags({ dryRun: true, local: Option.some(true), file: Option.some("") })); @@ -483,8 +463,6 @@ describe("db dump integration", () => { }); it.live("validates the merged config before the --dry-run print (Go root PreRun order)", () => { - // The merged config is validated before the dump runs, even for - // --dry-run, so an invalid config fails without printing. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), @@ -515,7 +493,6 @@ describe("db dump integration", () => { expect.stringContaining("pg_dump"), "--", ]); - // host networking, no security-opt expect(docker.lastOpts?.network).toEqual({ _tag: "host" }); expect(docker.lastOpts?.securityOpt).toEqual([]); expect(docker.lastOpts?.env["EXCLUDED_SCHEMAS"]).toBeDefined(); @@ -559,9 +536,8 @@ describe("db dump integration", () => { }); it.live("joins a multi-schema selection into EXTRA_FLAGS with pipes", () => { - // CSV-splitting of `--schema` happens at the flag level via - // `parseSchemaFlags`, so the handler receives the already-split - // array and the env builder pipe-joins it. + // The handler receives an already-split array (CSV-split at the flag level by + // `parseSchemaFlags`) and the env builder pipe-joins it. const { layer, docker } = setup({ isLocal: true }); return Effect.gen(function* () { yield* dbDump(flags({ schema: ["public", "auth"], local: Option.some(true) })); @@ -570,8 +546,6 @@ describe("db dump integration", () => { }); it.live("resolves a relative --file against the workdir", () => { - // A relative `--file` is resolved against the workdir, so it is written - // under the workdir, not the original cwd. const { layer } = setup({ isLocal: true, stdout: "CREATE SCHEMA public;\n", @@ -594,9 +568,8 @@ describe("db dump integration", () => { it.live( "resolves the pg_dump network via SUPABASE_NETWORK_ID from supabase/.env when neither the flag nor the ambient env is set", () => { - // Host networking is the default, but a resolved `--network-id`/`SUPABASE_NETWORK_ID` - // value overrides it whenever non-empty — a value sourced only from `supabase/.env` - // still wins over host. + // A `SUPABASE_NETWORK_ID` sourced only from `supabase/.env` still overrides host + // networking. const prev = process.env["SUPABASE_NETWORK_ID"]; delete process.env["SUPABASE_NETWORK_ID"]; mkdirSync(join(tmp.current, "supabase"), { recursive: true }); @@ -646,12 +619,8 @@ describe("db dump integration", () => { it.live( "caches the flag ref, not the workdir's own config ref, when resolution fails (regression)", () => { - // The pre-connect `linkedRefForCache` chain must check `flags.projectRef` - // FIRST — before config.toml's `project_id` and the `.temp/project-ref` - // file — so a `--project-ref` override still wins even when `resolve()` - // fails before ever returning its own `ref`. `opts.projectId` here stands - // in for the workdir's own linked ref (e.g. config.toml `project_id`); - // it must lose to the flag. + // `linkedRefForCache` must check `flags.projectRef` before config.toml's + // `project_id`, so the flag still wins even when `resolve()` fails first. const FLAG_REF = "flagflagflagflagflag"; const { layer, cache } = setup({ projectId: Option.some("abcdefghijklmnopqrst"), @@ -670,10 +639,8 @@ describe("db dump integration", () => { ); it.live("does not cache when the linked ref is unknown and resolution fails", () => { - // No config project_id and no .temp/project-ref file (workdir is a throwaway - // path), so the up-front `loadProjectRef` pre-capture itself fails "not linked" - // (linkedFails) before `resolve()` is ever reached; the cache is only written - // when a ref is known, so nothing is cached. + // No config project_id or .temp/project-ref file, so the up-front pre-capture + // itself fails "not linked" before `resolve()` is reached; nothing is cached. const { layer, cache } = setup({ resolveFails: true, linkedFails: true }); return Effect.gen(function* () { const exit = yield* dbDump(flags({ linked: Option.some(true) })).pipe(Effect.exit); @@ -696,8 +663,7 @@ describe("db dump integration", () => { }); it.live("dumps the project given via --project-ref without a linked workdir", () => { - // No fixed `opts.ref` fixture — only the flag can resolve a ref for the - // resolver call and the linked-project cache. + // No fixed `opts.ref`; only the flag can resolve a ref here. const FLAG_REF = "flagflagflagflagflag"; const { layer, cache, resolver } = setup({ conn: REMOTE_CONN, @@ -714,8 +680,7 @@ describe("db dump integration", () => { it.live("--project-ref overrides an already-linked workdir's project ref", () => { const FLAG_REF = "flagflagflagflagflag"; - // The workdir already resolves to a fixed ref (e.g. via .temp/project-ref) — - // the flag must win over it. + // A distinct fixed ref proves the flag, not the workdir's own ref, wins. const { layer, cache } = setup({ conn: REMOTE_CONN, isLocal: false, @@ -733,11 +698,8 @@ describe("db dump integration", () => { it.live( "rejects a malformed --project-ref on the linked path before resolving or caching", () => { - // The pre-capture now runs the SAME validated `loadProjectRef` the resolver - // would raise right after (codex review on dump.handler.ts:182), so a malformed - // flag value must fail fast — never reaching `resolver.resolve()` (no - // connection/API work) and never writing the linked-project cache (no - // `GET /v1/projects/*`). + // `loadProjectRef` validates before `resolver.resolve()` runs, so a malformed + // flag fails fast with no connection/API work and no cache write. const { layer, cache, resolver } = setup(); return Effect.gen(function* () { const exit = yield* dbDump( @@ -811,13 +773,10 @@ describe("db dump integration", () => { }); return Effect.gen(function* () { yield* dbDump(flags()); - // Retried once: two container runs, one fallback resolution. expect(docker.allOpts).toHaveLength(2); expect(resolver.fallbackCalls).toHaveLength(1); expect(resolver.fallbackCalls[0]).toMatchObject({ connType: "linked" }); - // The retry targeted the pooler host (PGHOST in the rebuilt env). expect(docker.allOpts[1]?.env["PGHOST"]).toBe(POOLER_CONN.host); - // The IPv6 warning was printed to stderr; only the retry's output reached stdout. expect(out.stderrText).toContain("does not support IPv6"); expect(out.stderrText).toContain("Retrying via the IPv4 connection pooler."); expect(out.stdoutText).toBe("CREATE SCHEMA x;\n"); @@ -825,8 +784,6 @@ describe("db dump integration", () => { }); it.live("linked: preserves the original dump error when the pooler fallback fails", () => { - // Any fallback-resolution error reports the original pg_dump failure — the - // optional retry must not replace it. const { layer, resolver, docker } = setup({ conn: REMOTE_CONN, isLocal: false, @@ -836,7 +793,6 @@ describe("db dump integration", () => { return Effect.gen(function* () { const exit = yield* dbDump(flags()).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - // Original container failure, NOT the fallback-resolution error. expect(failMessage(exit)).toBe("error running container: exit 1"); expect(resolver.fallbackCalls).toHaveLength(1); // attempted expect(docker.allOpts).toHaveLength(1); // no retry container ran @@ -870,11 +826,8 @@ describe("db dump integration", () => { const exit = yield* dbDump(flags()).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toBe("error running container: exit 1"); - // The fallback was attempted (classified IPv6) but returned no pooler. expect(resolver.fallbackCalls).toHaveLength(1); expect(docker.allOpts).toHaveLength(1); - // The IPv6 pooler guidance is attached on the no-fallback path; the bare - // container error must carry it. expect(failSuggestion(exit)).toContain( "Your network does not support IPv6, which is required for direct connections", ); @@ -883,8 +836,6 @@ describe("db dump integration", () => { }); it.live("linked: attaches the IPv6 suggestion when the pooler retry also fails", () => { - // The IPv6 pooler guidance is also attached to the retry's stderr when the - // pooler retry also fails; an IPv6 retry failure surfaces the same guidance. const { layer, docker } = setup({ conn: REMOTE_CONN, isLocal: false, @@ -942,9 +893,8 @@ describe("db dump integration", () => { const NON_ASCII_WARNING = "The dump contains non-ASCII characters"; const PIPED_WIN32 = { platform: "win32", stdoutIsPipe: true } as const; - // Real-runtime probe of the classification `ttyLayer` ships for - // `stdoutIsPipe`. A shell pipeline is used for the pipe case: spawnSync's - // own "pipe" stdio is a socketpair under Bun, which fstats as a socket. + // A shell pipeline gets a genuine FIFO for the pipe probe below; Bun's spawnSync + // "pipe" stdio is a socketpair, which fstats as a socket instead. const PROBE = 'process.stdout.write(String(require("node:fs").fstatSync(1).isFIFO()));'; it.skipIf(process.platform === "win32")("classifies a real piped stdout as a pipe", () => { diff --git a/apps/cli/src/commands/db/dump/dump.layers.ts b/apps/cli/src/commands/db/dump/dump.layers.ts index 1c7a027e80..d97f74d197 100644 --- a/apps/cli/src/commands/db/dump/dump.layers.ts +++ b/apps/cli/src/commands/db/dump/dump.layers.ts @@ -17,15 +17,10 @@ import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.lay /** * Runtime layer for `supabase db dump`. * - * Mirrors `test db`'s composition (`command-internal/test-db.layers.ts`): the - * bulk of the Management API stack is still built lazily inside the resolver's - * `--linked` branch. The one exception is `ProjectRefResolver`, exposed here - * (same shape as `db push`, `push.layers.ts:40-50`) so the handler's up-front - * `loadProjectRef` pre-capture can validate `--project-ref` before the - * linked-project-cache finalizer ever sees it. The dump handler reaches the - * database through a pg_dump container (`DockerRun`), never a direct - * connection, but the resolver still needs `DbConnection` for the linked - * pooler temp-role probe. + * Mirrors `test db`'s composition: most of the Management API stack builds lazily + * inside the resolver's `--linked` branch. `ProjectRefResolver` is exposed here (like + * `db push`) so the handler can validate `--project-ref` before the linked-project-cache + * finalizer sees it. */ const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); const httpClient = httpClientLayer.pipe(Layer.provide(debugLoggerLayer)); @@ -34,10 +29,9 @@ const credentials = commandCredentialsLayer.pipe( Layer.provide(debugLoggerLayer), ); -// Deliberately the **lazy** `commandPlatformApiFactoryLayer` (not the eager -// management-API runtime), so dump's auth-free `--linked --password` path never -// resolves an access token at layer-build time — same rationale as `db push` -// (`push.layers.ts:26-31`). +// The lazy `commandPlatformApiFactoryLayer` (not the eager management-API runtime) +// keeps dump's auth-free `--linked --password` path from resolving an access token +// at layer-build time, same as `db push`. const platformApiFactory = commandPlatformApiFactoryLayer.pipe( Layer.provide(credentials), Layer.provide(cliSettings), @@ -65,11 +59,9 @@ const dbConfig = dbConfigLayer.pipe( Layer.provide(cliSettings), Layer.provide(dbConnectionLayer), Layer.provide(debugLoggerLayer), - // The linked db-config resolver snapshots `IdentityStitch` (shared with - // the lazy platform-API factory + linked-project cache), so the command - // runtime must provide it or the bundled binary panics with a - // missing-service error (CLAUDE.md invariant 5). Its Analytics / TelemetryRuntime - // / FileSystem / Path deps are ambient from the root runtime. + // `IdentityStitch` is shared with the lazy platform-API factory and linked-project + // cache, so it must be provided here too or the binary panics with a + // missing-service error (CLAUDE.md invariant 5). Layer.provide(identityStitchLayer), ); diff --git a/apps/cli/src/commands/db/lint/lint.command.ts b/apps/cli/src/commands/db/lint/lint.command.ts index 2415427d64..702b739ac8 100644 --- a/apps/cli/src/commands/db/lint/lint.command.ts +++ b/apps/cli/src/commands/db/lint/lint.command.ts @@ -62,14 +62,9 @@ export const dbLintCommand = Command.make("lint", config).pipe( level: flags.level, "fail-on": flags.failOn, }, - // level/fail-on are Flag.choice and are auto-detected as safe via - // `config` below. --schema stays redacted: it's a string-slice flag, - // not a choice. --project-ref has no established telemetry-safety - // baseline either, so it stays redacted too. + // level/fail-on are auto-detected as safe (Flag.choice); --schema and + // --project-ref stay redacted (no established safelist). config, - // Telemetry reports flags under their canonical name even for the - // `-s` shorthand; map it so `db lint -s public` records the schema - // flag in telemetry. aliases: { s: "schema" }, }), withJsonErrorHandling, diff --git a/apps/cli/src/commands/db/lint/lint.format.ts b/apps/cli/src/commands/db/lint/lint.format.ts index afee0966ac..ca714772cc 100644 --- a/apps/cli/src/commands/db/lint/lint.format.ts +++ b/apps/cli/src/commands/db/lint/lint.format.ts @@ -122,29 +122,22 @@ function normalizeIssue(value: unknown): LintIssue { } /** - * Parses the `plpgsql_check_function(... format:='json')` payload for one - * function and overrides `function` with `.`. - * - * Throws on malformed JSON; the handler maps that to `DbLintMalformedJsonError`. - * - * Structurally strict: a top-level `null` decodes to the zero value, but any - * other non-object (array / string / number), a present-but-not-array - * `issues`, or a non-object issue entry throws — rather than being silently - * coerced to an empty result, which would report a malformed payload as "no - * lint errors". Missing/unknown fields stay tolerated. + * Parses the `plpgsql_check_function(... format:='json')` payload for one function and + * overrides `function` with `.`. Throws on malformed JSON (mapped to + * `DbLintMalformedJsonError` by the handler): a top-level `null` decodes to the zero + * value, but any other non-object, a present-but-not-array `issues`, or a non-object + * issue entry throws instead of being silently coerced to an empty ("no lint errors") + * result. */ export function parseLintResult(jsonText: string, functionName: string): LintResult { const parsed: unknown = JSON.parse(jsonText); - // A top-level `null` leaves the result at its zero value (no error). if (parsed === null) { return { function: functionName, issues: [] }; } - // A top-level array / string / number throws. if (typeof parsed !== "object" || Array.isArray(parsed)) { throw new TypeError("cannot unmarshal payload into lint.Result"); } const record = parsed as Record; - // `issues` missing/null → zero value; present-but-not-array → throw. const issuesField = record["issues"]; let issuesRaw: ReadonlyArray; if (issuesField === undefined || issuesField === null) { @@ -154,17 +147,15 @@ export function parseLintResult(jsonText: string, functionName: string): LintRes } else { throw new TypeError("cannot unmarshal issues into []lint.Issue"); } - // Each entry decodes into an issue; a scalar/array entry fails. A null entry - // decodes to the zero-value issue (all fields empty strings) and is included - // in the slice — normalizeIssue handles null via its record fallback. + // A null entry decodes to a zero-value issue (handled by `normalizeIssue`'s + // fallback), not skipped. for (const entry of issuesRaw) { if (entry !== null && (typeof entry !== "object" || Array.isArray(entry))) { throw new TypeError("cannot unmarshal issue into lint.Issue"); } } - // `function` is a string field, so a present non-string value throws BEFORE - // the code overrides it with `.`. Validate the type, then - // discard it for the override. + // Validates `function`'s type (throwing if non-string) before discarding it for + // the override below. requireLintString(record["function"], "function"); return { function: functionName, issues: issuesRaw.map(normalizeIssue) }; } diff --git a/apps/cli/src/commands/db/lint/lint.format.unit.test.ts b/apps/cli/src/commands/db/lint/lint.format.unit.test.ts index 47be80a7c7..b705989a3a 100644 --- a/apps/cli/src/commands/db/lint/lint.format.unit.test.ts +++ b/apps/cli/src/commands/db/lint/lint.format.unit.test.ts @@ -45,9 +45,6 @@ describe("parseLintResult", () => { }); it("throws on Go-rejected shapes (top-level array/scalar, non-array issues, scalar entry)", () => { - // These shapes throw; the handler maps the throw to - // DbLintMalformedJsonError. The old parser silently coerced them to - // an empty result (false "no lint errors"). expect(() => parseLintResult("[]", "public.f")).toThrow(); expect(() => parseLintResult("42", "public.f")).toThrow(); expect(() => parseLintResult(`{"issues":"nope"}`, "public.f")).toThrow(); @@ -56,9 +53,6 @@ describe("parseLintResult", () => { }); it("throws on issue fields with the wrong JSON type (Go UnmarshalTypeError)", () => { - // The issue/statement/query string fields reject a non-string; a present - // non-object statement/query also throws. The old parser coerced these - // via String(...). expect(() => parseLintResult(`{"issues":[{"level":123,"message":"m"}]}`, "public.f")).toThrow(); expect(() => parseLintResult(`{"issues":[{"level":"warning","message":true}]}`, "public.f"), @@ -78,8 +72,6 @@ describe("parseLintResult", () => { }); it("throws on a present non-string top-level function field, accepts string/absent", () => { - // `function` is a string field; a non-string value throws before it is - // overridden with .. expect(() => parseLintResult(`{"function":123,"issues":[]}`, "public.f")).toThrow(); expect(parseLintResult(`{"function":"x","issues":[]}`, "public.f")).toEqual({ function: "public.f", @@ -92,7 +84,6 @@ describe("parseLintResult", () => { }); it("tolerates Go-accepted shapes (null, missing issues, unknown fields)", () => { - // The result stays at zero on a top-level null, and unknown fields stay tolerated. expect(parseLintResult("null", "public.f")).toEqual({ function: "public.f", issues: [] }); expect(parseLintResult("{}", "public.f")).toEqual({ function: "public.f", issues: [] }); expect(parseLintResult(`{"issues":null}`, "public.f")).toEqual({ @@ -106,9 +97,8 @@ describe("parseLintResult", () => { }); it("decodes a null array element to the zero-value Issue{} (Go encoding/json behavior)", () => { - // A null element in the issues array decodes as the zero-value issue - // (level: "", message: ""). It is included in the slice and later - // filtered out by filterLintResult since toEnum("") returns -1. + // Included in the slice, not skipped; `filterLintResult` later drops it since + // `toEnum("")` returns -1. const result = parseLintResult(`{"issues":[null]}`, "public.f"); expect(result.issues).toEqual([{ level: "", message: "" }]); }); diff --git a/apps/cli/src/commands/db/lint/lint.handler.ts b/apps/cli/src/commands/db/lint/lint.handler.ts index 433a406b5c..4ab2a22650 100644 --- a/apps/cli/src/commands/db/lint/lint.handler.ts +++ b/apps/cli/src/commands/db/lint/lint.handler.ts @@ -107,8 +107,8 @@ const runLint = Effect.fnUntraced(function* ( const dbConn = yield* DbConnection; const processControl = yield* ProcessControl; - // Mutually-exclusive db-url/linked/local group, keyed off the - // explicitly-set flags, not the `--local` default value. + // Mutually-exclusive db-url/linked/local group, keyed off explicitly-set flags, + // not `--local`'s default value. const setFlags = target.setFlags; if (setFlags.length > 1) { return yield* Effect.fail( @@ -118,9 +118,7 @@ const runLint = Effect.fnUntraced(function* ( ); } - // `--project-ref` never implies `--linked` and must not be silently - // discarded on a non-linked target — see push.handler.ts's identical guard - // for the full TS-only rationale. + // `--project-ref` never implies `--linked`; see push.handler.ts's identical guard. if (Option.isSome(flags.projectRef) && target.connType !== "linked") { return yield* Effect.fail( new DbLintMutuallyExclusiveFlagsError({ @@ -133,15 +131,13 @@ const runLint = Effect.fnUntraced(function* ( const level = Option.getOrElse(flags.level, () => "warning"); const failOn = Option.getOrElse(flags.failOn, () => "none"); - // `--schema` is a CSV string-slice value, split at parse time. The command - // definition applies `Flag.mapTryCatch(parseSchemaFlags)` so - // `flags.schema` is already the fully CSV-parsed and validated schema list. + // `flags.schema` is already CSV-parsed and validated by + // `Flag.mapTryCatch(parseSchemaFlags)` at the command definition. const schemaFlags = flags.schema; const lintBody = Effect.gen(function* () { - // The resolver applies the established precedence (db-url > linked > - // local-default), so the connType passes straight through — `--local`'s - // default is handled by the resolver's fall-through to the local branch. + // connType passes straight through; the resolver applies db-url > linked > + // local precedence and handles `--local`'s default. const cfg = yield* resolver.resolve({ dbUrl: flags.dbUrl, connType: target.connType ?? "local", @@ -176,10 +172,8 @@ const runLint = Effect.fnUntraced(function* ( }), ); - // "\nNo schema errors found" is printed to stderr when the RAW result is - // empty (before level filtering), and nothing is emitted on stdout. The - // diagnostic goes to stderr in every mode (stdout stays payload-only); - // machine modes additionally emit the empty result envelope. + // Printed when the raw result (before level filtering) is empty; stdout stays + // payload-only, so machine modes additionally emit the empty result envelope. if (results.length === 0) { yield* output.raw("\nNo schema errors found\n", "stderr"); if (output.format !== "text") { @@ -215,12 +209,9 @@ const runLint = Effect.fnUntraced(function* ( } }); - // For `--linked`, the project ref is resolved and the linked-project cache - // is refreshed afterward, writing supabase/.temp/linked-project.json so - // telemetry carries the project/org grouping. Resolve the ref up front - // (non-prompting) and write the cache on success and failure. `--local` / - // `--db-url` leave the ref empty, so its cache write no-ops — we match that - // by caching only on the linked branch. + // For `--linked`, the ref is resolved up front (non-prompting) and the + // linked-project cache is refreshed on both success and failure; `--local`/`--db-url` + // never write it since caching only runs on the linked branch. if (target.connType === "linked") { const projectRef = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; @@ -235,7 +226,6 @@ export const dbLint = Effect.fn("db.lint")(function* (flags: DbLintFlags) { const telemetryState = yield* TelemetryState; const cliArgs = yield* CliArgs; const target = resolveDbTargetFlags(cliArgs.args); - // Flush telemetry on success and failure. Command-level instrumentation / - // JSON error handling are applied by `lint.command.ts` (the codebase convention). + // Command-level instrumentation/JSON error handling are applied by `lint.command.ts`. yield* runLint(flags, dnsResolver, target).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/commands/db/lint/lint.integration.test.ts b/apps/cli/src/commands/db/lint/lint.integration.test.ts index ed018dd51c..b266e49675 100644 --- a/apps/cli/src/commands/db/lint/lint.integration.test.ts +++ b/apps/cli/src/commands/db/lint/lint.integration.test.ts @@ -81,8 +81,8 @@ function mockConnection(opts: { extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), - // Record at run-time (inside the effect), not call-time, so a finalizer - // built with `session.exec("rollback")` is logged only when it runs. + // Recorded at run-time (inside `Effect.suspend`), so a finalizer's + // `exec("rollback")` is only logged when it actually runs. exec: (sql: string) => Effect.suspend(() => { execs.push(sql); @@ -117,8 +117,7 @@ function mockConnection(opts: { } return Effect.succeed([]); }), - // A migration file's statements arrive as one batch; replay them through - // `exec`/`query` so this suite's recordings and failure injection still apply. + // Replays a batch through exec/query so recordings and failure injection apply. execBatch: (statements) => sequentialExecBatch(session)(statements), }; return Effect.succeed(session); @@ -147,9 +146,8 @@ function mockProjectRef() { resolve: () => Effect.succeed(VALID_REF), resolveForLink: () => Effect.succeed(VALID_REF), resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), - // Gives an explicit `--project-ref` flag top precedence, same as Go's - // `flags.LoadProjectRef` — mirrors the real resolver so a test can prove the - // flag (not just the hardcoded fallback) drives the linked ref. + // An explicit `--project-ref` flag takes top precedence, mirroring the real + // resolver. loadProjectRef: (flagValue: Option.Option) => Effect.sync(() => { calls.push("loadProjectRef"); @@ -231,7 +229,6 @@ describe("db lint", () => { expect(out.stdoutText).toBe(expected); expect(out.stderrText).toContain("Connecting to local database..."); expect(out.stderrText).toContain("Linting schema: public"); - // Begin / enable extension / rollback all ran on the session. expect(connection.execs).toEqual(["begin", ENABLE_PGSQL_CHECK, "rollback"]); }).pipe(Effect.provide(layer)); }); @@ -243,7 +240,6 @@ describe("db lint", () => { }); return Effect.gen(function* () { yield* dbLint(flags()); - // ListUserSchemas ran with the managed-schemas array bound as $1. expect(Array.isArray(connection.listParams?.[0])).toBe(true); expect(connection.linted).toEqual(["public", "private"]); expect(out.stderrText).toContain("Linting schema: private"); @@ -329,7 +325,6 @@ describe("db lint", () => { ); } } - // The result is still printed to stdout before the non-zero exit. expect(out.stdoutText).toContain("never read variable"); }).pipe(Effect.provide(layer)); }); @@ -356,8 +351,6 @@ describe("db lint", () => { }); it.live("does not trigger --fail-on warning when --level error filters the warning out", () => { - // The --level filter runs before the fail-on check, so a warning removed - // by --level error cannot trigger --fail-on warning. const { layer, out } = setup({ checkRows: { public: [checkRow("f1", [WARNING_ISSUE])] } }); return Effect.gen(function* () { const exit = yield* Effect.exit( @@ -375,7 +368,6 @@ describe("db lint", () => { }); it.live("rejects --db-url together with --linked (via args Changed detection)", () => { - // Both flags present in args → mutual exclusion error (sorted set [db-url linked]). const { layer } = setup({ args: ["--db-url=postgres://x", "--linked"] }); return Effect.gen(function* () { const exit = yield* Effect.exit(dbLint(flags({ dbUrl: Option.some("postgres://x") }))); @@ -456,22 +448,18 @@ describe("db lint", () => { }); it.live("lints multiple pre-parsed schemas from a comma-separated --schema value", () => { - // CSV parsing of `public,private` into ["public", "private"] now happens at - // Flag.mapTryCatch parse time (before the handler). The handler receives the - // already-split list and uses it directly. + // CSV parsing happens at `Flag.mapTryCatch` parse time; the handler receives the + // already-split list directly. const { layer, connection } = setup({ checkRows: { public: [], private: [] }, }); return Effect.gen(function* () { yield* dbLint(flags({ schema: ["public", "private"] })); - // Both schemas linted — the handler no longer does CSV splitting itself. expect(connection.linted).toEqual(["public", "private"]); }).pipe(Effect.provide(layer)); }); it.live("writes the linked-project cache for --linked (Go PersistentPostRun)", () => { - // --linked via args (Changed-based detection) routes to the linked branch and - // writes the linked-project cache. const { layer, projectRef, cache } = setup({ isLocal: false, checkRows: { public: [] }, @@ -479,16 +467,14 @@ describe("db lint", () => { }); return Effect.gen(function* () { yield* dbLint(flags({ schema: ["public"] })); - // Resolved via the non-prompting load and cached for telemetry grouping. expect(projectRef.calls).toContain("loadProjectRef"); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); it.live("lints the project given via --project-ref, overriding the workdir's own ref", () => { - // The fake resolver's own fallback (VALID_REF) represents whatever - // the workdir would resolve to absent the flag (e.g. .temp/project-ref) — - // the flag must win over it and drive the cached ref. + // `VALID_REF` stands in for whatever the workdir would resolve to absent the + // flag; the flag must win. const FLAG_REF = "flagflagflagflagflag"; const { layer, cache } = setup({ isLocal: false, @@ -507,14 +493,11 @@ describe("db lint", () => { const { layer, cache } = setup({ checkRows: { public: [] } }); return Effect.gen(function* () { yield* dbLint(flags({ schema: ["public"] })); - // The cache is only written when a ref is known. expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); it.live("rejects --project-ref on the default local target", () => { - // lint defaults to local when no target flag is set — the guard must fire - // from the flag alone, with no explicit --local/--db-url needed. const FLAG_REF = "flagflagflagflagflag"; const { layer, connection, cache } = setup({ checkRows: { public: [] } }); return Effect.gen(function* () { @@ -543,11 +526,7 @@ describe("db lint", () => { }); }); - // ── Changed-based routing (explicitly-set flag, not its value) ─────────── - it.live("--linked=false routes to the linked branch (Changed, not value)", () => { - // "Changed" fires when the flag appears on the command line regardless of - // its value: `--linked=false` is still "explicitly set" → linked branch. const { layer, projectRef, cache } = setup({ isLocal: false, checkRows: { public: [] }, @@ -574,7 +553,6 @@ describe("db lint", () => { }); it.live("--local=false --linked fails with mutual-exclusion (sorted set [linked local])", () => { - // Both flags are explicitly set → mutual exclusion fires with the sorted set. const { layer } = setup({ args: ["--local=false", "--linked"] }); return Effect.gen(function* () { const exit = yield* Effect.exit(dbLint(flags({ schema: ["public"] }))); @@ -588,7 +566,6 @@ describe("db lint", () => { }); it.live("--local=false alone routes to the local branch (Changed local, connType=local)", () => { - // `--local=false` is Changed for `local` → connType="local" (Changed-first: local). const { layer, out, cache } = setup({ checkRows: { public: [] }, args: ["--local=false"] }); return Effect.gen(function* () { yield* dbLint(flags({ schema: ["public"] })); diff --git a/apps/cli/src/commands/db/lint/lint.layers.ts b/apps/cli/src/commands/db/lint/lint.layers.ts index bc69c674a8..bb4086960c 100644 --- a/apps/cli/src/commands/db/lint/lint.layers.ts +++ b/apps/cli/src/commands/db/lint/lint.layers.ts @@ -14,31 +14,19 @@ import { linkedProjectCacheLayer } from "../../../telemetry/linked-project-cache import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; /** - * Runtime layer for `supabase db lint`, which spans local and linked DB access: + * Runtime layer for `supabase db lint`, spanning local and linked DB access: + * `--local`/`--db-url` use the Postgres connection and db-config resolver directly; + * `--linked` additionally resolves the project ref and refreshes the linked-project + * cache for telemetry grouping. * - * - **`--local` / `--db-url`** — the Postgres connection + db-config resolver. - * - **`--linked`** — direct DB connection via the db-config resolver's linked - * branch, plus project-ref resolution and the linked-project cache so the - * `--linked` run writes supabase/.temp/linked-project.json for telemetry - * grouping. + * Mirrors `advisors.layers.ts`. Does not use `managementApiRuntimeLayer`, whose eager + * `CommandPlatformApi` would resolve an access token at layer construction and fail + * the auth-free `--local` path before the handler runs (CLAUDE.md invariant 5/7) — the + * project-ref resolver instead gets the lazy `commandPlatformApiFactoryLayer`. * - * Mirrors `advisors.layers.ts`. Deliberately does NOT use - * `managementApiRuntimeLayer`: that layer exposes an *eagerly* built - * `CommandPlatformApi`, which resolves an access token at layer construction, so - * merging it would make the auth-free `--local` path fail before the handler - * runs (CLAUDE.md invariant 5 / 7). The project-ref resolver is instead given - * the **lazy** `commandPlatformApiFactoryLayer`; the linked lint path resolves the - * ref via the non-prompting `loadProjectRef`, which never forces the factory. - * - * `commandSettingsLayer` is provided to each consumer that needs it (item 5: - * `Layer.provide` does not share to merge siblings); layers are memoised by - * reference so the config / credentials / HTTP instances are reused. - * - * `identityStitchLayer` (the one per-command identity stitcher) is provided - * by the SAME reference to the platform-API factory, the linked-project cache, and - * the db-config resolver, so memoisation gives all three a single - * `stitchAttempted` guard. The db-config resolver snapshots that instance - * into its lazy linked stack's ambient layer. + * Layers are memoised by reference, so `identityStitchLayer` is provided by the same + * reference to the platform-API factory, linked-project cache, and db-config resolver, + * giving all three a single shared `stitchAttempted` guard. */ const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); const httpClient = httpClientLayer.pipe(Layer.provide(debugLoggerLayer)); @@ -81,15 +69,7 @@ export const dbLintRuntimeLayer = Layer.mergeAll( credentials, projectRef, linkedProjectCache, - // The one per-command identity stitcher, exposed at top level so - // `withCommandTelemetry` can read - // `stitchedDistinctId()` and attribute the cli_command_executed event to the - // gotrue id. The SAME reference is provided to platformApiFactory / - // linkedProjectCache / dbConfig above, so memoisation makes the linked - // path, the cache GET, and the db-config stack all share one - // `stitchAttempted` guard — aliasing/persisting at most once. Its - // Analytics / TelemetryRuntime / FileSystem / Path deps are ambient (root - // runtime). Mirrors advisors.layers.ts exactly. + // Exposed at top level so `withCommandTelemetry` can read `stitchedDistinctId()`. identityStitchLayer, telemetryStateLayer, commandRuntimeLayer(["db", "lint"]), diff --git a/apps/cli/src/commands/db/lint/lint.layers.unit.test.ts b/apps/cli/src/commands/db/lint/lint.layers.unit.test.ts index 382feb7019..d0015138f9 100644 --- a/apps/cli/src/commands/db/lint/lint.layers.unit.test.ts +++ b/apps/cli/src/commands/db/lint/lint.layers.unit.test.ts @@ -1,25 +1,14 @@ /** - * Layer-exposure tests for `dbLintRuntimeLayer` and - * `dbAdvisorsRuntimeLayer`. + * Layer-exposure tests for `dbLintRuntimeLayer` and `dbAdvisorsRuntimeLayer`. * - * These tests verify that `IdentityStitch` is exposed at the top level of - * each runtime layer (i.e., is a member of the layer's provided-services set) - * so that `withCommandTelemetry` can read `stitchedDistinctId()` via - * `Effect.serviceOption(IdentityStitch)` and attribute the - * `cli_command_executed` event to the gotrue id. + * Verifies `IdentityStitch` is exposed at the top level of each runtime layer, not just + * provided to child layers — `Layer.provide(A, B)` satisfies A's dependency on B but does + * not expose B to sibling layers inside a `Layer.mergeAll`. Losing that top-level exposure + * would silently mis-attribute the `cli_command_executed` event to the device id instead + * of the gotrue id. * - * The bug this guards against: `Layer.provide(A, B)` satisfies A's dep on B but - * does NOT expose B to sibling layers inside a `Layer.mergeAll`. If - * `identityStitchLayer` is only provided to child layers (db-config, - * linked-project-cache, platform-api-factory) and NOT added to the top-level - * `Layer.mergeAll`, then `serviceOption(IdentityStitch)` returns `None` - * and the event is mis-attributed to the device id. - * - * In-process runtime construction: we stub every ambient service the layers - * require from the root runtime (Analytics, TelemetryRuntime, FileSystem, Path, - * RuntimeInfo, Tty, Output, and all legacy flag services) so the full composed - * layer can be built and queried without a real Postgres connection, API, or - * filesystem state. + * Stubs every ambient service the layers need from the root runtime so the full composed + * layer builds without a real Postgres connection, API, or filesystem. */ import { describe, expect, it } from "@effect/vitest"; @@ -107,8 +96,8 @@ function ambientStubs() { return Layer.mergeAll( BunServices.layer, - // The runtime layer under test builds the REAL commandSettingsLayer against - // the real filesystem — see isolatedHomeLayer's docs. + // The runtime layer under test builds the real `commandSettingsLayer` against the + // real filesystem — see `isolatedHomeLayer`'s docs. isolatedHomeLayer(tempRoot.current), mockTty(), mockProcessControl().layer, diff --git a/apps/cli/src/commands/db/pull/pull.command.ts b/apps/cli/src/commands/db/pull/pull.command.ts index 3bf983f05c..8f4a98e969 100644 --- a/apps/cli/src/commands/db/pull/pull.command.ts +++ b/apps/cli/src/commands/db/pull/pull.command.ts @@ -96,9 +96,7 @@ export const dbPullCommand = Command.make("pull", config).pipe( // `password` is a credential — always reaches telemetry as ``. password: flags.password, }, - // TS-only flag with no Go telemetry-safety baseline; Go's nearest - // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most - // others) are unmarked, so it stays redacted. + // Not on the established `--project-ref` safeFlags allowlist, so it stays redacted. aliases: { s: "schema", p: "password" }, config, }), diff --git a/apps/cli/src/commands/db/pull/pull.errors.ts b/apps/cli/src/commands/db/pull/pull.errors.ts index 95b79b0f3d..93389bda8c 100644 --- a/apps/cli/src/commands/db/pull/pull.errors.ts +++ b/apps/cli/src/commands/db/pull/pull.errors.ts @@ -36,15 +36,10 @@ export class DbPullEngineConflictError extends Data.TaggedError("DbPullEngineCon export class DbPullWriteError extends Data.TaggedError("DbPullWriteError")<{ readonly message: string; /** - * Absolute path(s) of migration file(s) `runDbPull` had already written to disk - * before THIS failure — populated when the remote migration-history update - * (`updateMigrationHistory`, the "Update remote migration history table?" step) - * fails AFTER the migration file write it's meant to record already succeeded. - * `undefined`/omitted for every other `DbPullWriteError` (a failed file write - * itself never reaches this field, since nothing new was written in that case). - * Read by `pull.aggregate.ts`'s `pullFailedStepResult` (via `hasWrittenSoFar`) so - * `supabase pull`'s `db` step can report the on-disk migration instead of always - * claiming `written: []` on a failed pull. + * Absolute path(s) already written to disk before this failure — set when the remote + * migration-history update fails after the migration file write it's meant to record + * already succeeded. Read by `pull.aggregate.ts`'s `pullFailedStepResult` so a failed + * pull can still report the on-disk migration instead of `written: []`. */ readonly writtenSoFar?: ReadonlyArray; }> { @@ -63,10 +58,9 @@ export class DbPullDumpError extends Data.TaggedError("DbPullDumpError")<{ readonly message: string; readonly suggestion?: string; /** - * Set when the failure is opening/truncating the local migration file before - * any pg_dump attempt — a filesystem permission problem, not a database - * connection failure. The actual pg_dump-run failures leave it unset and keep - * the `dbConnection` classification. + * Set when the failure is opening/truncating the local migration file before any + * pg_dump attempt (a filesystem problem, not a connection failure); pg_dump-run + * failures leave it unset and keep the `dbConnection` classification. */ readonly fileOpen?: boolean; }> { diff --git a/apps/cli/src/commands/db/pull/pull.handler.ts b/apps/cli/src/commands/db/pull/pull.handler.ts index 7f720976ea..7092cdbdab 100644 --- a/apps/cli/src/commands/db/pull/pull.handler.ts +++ b/apps/cli/src/commands/db/pull/pull.handler.ts @@ -25,21 +25,17 @@ export const dbPull = Effect.fn("db.pull")(function* (flags: DbPullFlags, invoke } else { const schemaWritten = outcome.schemaFiles[0]; if (schemaWritten === undefined) { - // Unreachable: a "migration" outcome only reaches this emission after - // `runDbPull` confirmed at least one migration file was written - // (the migra path always pushes exactly one; the pg-delta path's - // `planFiles` is non-empty whenever the diff wasn't already caught by - // the `diffEmpty` in-sync failure above). + // Unreachable: `runDbPull` only returns a "migration" outcome after confirming at + // least one migration file was written (migra always pushes one; pg-delta's + // `planFiles` is non-empty unless already caught by the in-sync failure above). return yield* Effect.die( new Error("db pull: schemaFiles was empty for a migration outcome"), ); } yield* output.success("Schema pulled.", { declarative: false, - // `schemaWritten` keeps the first written path for released consumers that - // read the string field; `schemaFiles` lists EVERY written migration path - // in write order (a pg-delta plan writes one file per unit), so machine - // callers see all of them, not just the first. + // `schemaWritten` is the first path, kept for consumers reading the string field; + // `schemaFiles` lists every written path in order (pg-delta writes one per unit). schemaWritten, schemaFiles: outcome.schemaFiles, remoteHistoryUpdated: outcome.remoteHistoryUpdated, diff --git a/apps/cli/src/commands/db/pull/pull.integration.test.ts b/apps/cli/src/commands/db/pull/pull.integration.test.ts index 9595e19251..c3661cfecf 100644 --- a/apps/cli/src/commands/db/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.integration.test.ts @@ -69,8 +69,7 @@ const EXPORT_JSON = JSON.stringify({ files: [{ path: "schemas/public/t.sql", order: 0, statements: 1, sql: "create table t ();" }], }); -// Builds the pg-delta diff envelope printed by `templates/pgdelta.ts`: one file -// per execution-aware plan unit (`{version:1,files:[{order,name,transactionMode,sql}]}`). +// Builds the pg-delta diff envelope: one file per plan unit. const pgDeltaDiffEnvelope = ( units: ReadonlyArray<{ name: string; sql: string; transactionMode?: string }>, ): string => @@ -96,46 +95,37 @@ interface SetupOpts { readonly experimental?: boolean; readonly promptConfirmResponses?: ReadonlyArray; readonly resolvedRef?: string; - // Fail the first edge-runtime run with this message (the second succeeds with + // Fails the first edge-runtime run with this message (the second succeeds with // `edgeStdout`), to exercise the pooler-fallback retry. readonly edgeFailFirstWith?: string; - // resolvePoolerFallback returns Some(pooler conn) when true, None otherwise. + // `resolvePoolerFallback` returns `Some(pooler conn)` when true, `None` otherwise. readonly poolerAvailable?: boolean; readonly delegateStdout?: string; // stdout returned by a captured Go-delegate run - // Initial-migra pull: the bytes the native pg_dump container streams to its sink, - // its exit code / stderr, and (when set) an IPv6 stderr that fails the FIRST dump - // attempt so the pooler retry runs (the second attempt then streams `dumpStdout`). + // Initial-migra pull: the bytes the native pg_dump container streams to its sink, its + // exit code/stderr, and (when set) an IPv6 stderr that fails the first dump attempt so + // the pooler retry runs (the second attempt then streams `dumpStdout`). readonly dumpStdout?: string; readonly dumpExitCode?: number; readonly dumpStderr?: string; readonly dumpFailFirstWith?: string; - // Bytes the FIRST dump attempt streams to its sink before it fails with - // `dumpFailFirstWith`, reproducing a direct attempt that emits preamble then - // exits non-zero on an IPv6 drop. + // Bytes the first dump attempt streams before failing with `dumpFailFirstWith`. readonly dumpFailFirstPartialBytes?: string; - // Raw argv seen by the handler (CliArgs). Only consulted when both - // `--declarative` and `--use-pg-delta` are present, to replay pflag's - // last-occurrence-wins ordering; defaults to empty. + // Raw argv seen by the handler (CliArgs). Only consulted when both `--declarative` + // and `--use-pg-delta` are present, to replay pflag's last-occurrence-wins ordering. readonly args?: ReadonlyArray; - // When set, the Nth `writeFileString` fails, exercising cleanup-on-failure. - // `CommandSettings.projectId` (the `SUPABASE_PROJECT_ID` env-only reader). Defaults - // to `Option.some("test")`; pass `Option.none()` to exercise the - // config.toml/workdir-basename fallback `resolveLocalProjectId` provides for - // the pg-delta edge-runtime cache bind. + // `CommandSettings.projectId`; defaults to `Option.some("test")`. Pass + // `Option.none()` to exercise the config.toml/workdir-basename fallback + // (`resolveLocalProjectId`). readonly projectId?: Option.Option; - // Simulates a genuinely unlinked workdir: `loadProjectRef` fails with - // `ProjectRefNotLinkedError` absent an explicit `--project-ref` flag, - // instead of silently falling back to `opts.resolvedRef ?? VALID_REF`. + // Simulates an unlinked workdir: `loadProjectRef` fails with + // `ProjectRefNotLinkedError` absent an explicit `--project-ref` flag. readonly linkedFails?: boolean; - // Swaps the stateless shadow spawner for the stateful Docker model, whose - // `stop`/`cp`/`start` really move bytes. Required by (and only by) the tests that - // enable the shadow BASELINE CACHE — see `mockDockerDaemonCliSpawner`. + // Swaps in the stateful Docker model (real `stop`/`cp`/`start`), required by the + // shadow baseline cache tests. readonly statefulDocker?: boolean; - // Fails the TARGET session's own history upsert (the real - // `supabase_migrations.schema_migrations` write `updateMigrationHistory` issues - // AFTER the migration file is already on disk) with this message — the shadow's - // own internal migration replay is unaffected. Exercises `DbPullWriteError`'s - // `writtenSoFar` (CLI-1272 review). + // Fails the target session's own history upsert (the write `updateMigrationHistory` + // issues after the migration file is already on disk); the shadow's own internal + // migration replay is unaffected. Exercises `DbPullWriteError`'s `writtenSoFar`. readonly historyUpdateFailWith?: string; } @@ -149,9 +139,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { // A real docker-spawner fake backs container create/start/health-inspect/cleanup. const shadowSpawner = mockShadowContainerCliSpawner(); - // The shadow baseline cache's cold export and warm restore only mean anything against a - // daemon that actually holds container state and carries `docker cp` bytes, so the cache - // tests below opt into the stateful model instead. + // Cache tests need the stateful Docker model since `docker cp` needs real container state. const dockerDaemon = opts.statefulDocker === true ? mockDockerDaemonCliSpawner() : undefined; const engineCalls: Array<{ @@ -278,10 +266,9 @@ function setup(workdir: string, opts: SetupOpts = {}) { }, }); - // The initial-migra pull seeds the migration file with a native pg_dump via - // `runStream`; deliver the configured bytes to `onStdout`, then report the exit - // code + stderr. `dumpFailFirstWith` fails the first attempt so the pooler retry - // runs. + // The initial-migra pull seeds the migration via a native pg_dump `runStream`; + // delivers configured bytes to `onStdout` then reports exit code + stderr. + // `dumpFailFirstWith` fails the first attempt so the pooler retry runs. const dumpCalls: Array<{ env: Readonly>; image: string; @@ -293,12 +280,9 @@ function setup(workdir: string, opts: SetupOpts = {}) { runCapture: () => Effect.die("runCapture unused"), runStream: (runOpts, streamOpts) => Effect.gen(function* () { - // The native shadow's PG15+ one-shot platform-baseline jobs - // (`runStartMigrateJob`) go through this same `runStream`, always - // `skipImageResolve: true` (the real `pg_dump` `runStream` call never sets - // it) — succeed unconditionally so shadow setup itself never fails; this - // suite has no assertions over the one-shot jobs' own output, and they must - // not be counted alongside the real `dumpCalls` this suite DOES assert on. + // The shadow's own PG15+ one-shot jobs share this `runStream` but always set + // `skipImageResolve: true`; succeed them unconditionally so they're never + // counted alongside the real `dumpCalls` this suite asserts on. if (runOpts.skipImageResolve === true) { return { exitCode: 0, stderr: "" }; } @@ -322,14 +306,11 @@ function setup(workdir: string, opts: SetupOpts = {}) { const connectedDatabases: Array = []; /** Same connects as {@link connectedDatabases}, keeping the port that tells target from shadow apart. */ const connectTargets: Array<{ readonly database: string; readonly port: number }> = []; - // The resolver mock's own target connection always dials port 5432; the native - // shadow (platform baseline, `CREATE_TEMPLATE`, migrations, and — on the - // declarative branch — the `contrib_regression` override) always dials the - // schema-default shadow port (54320) instead — a reliable way to tell "the - // REAL remote/local target's own history upsert" (which `historyUpserts` is - // meant to count) apart from the shadow's OWN internal migration replay (which - // ALSO issues a parameterized `INSERT_MIGRATION_VERSION` query, into its own - // separate in-shadow history table). + // The resolver mock's target connection always dials port 5432; the shadow always + // dials the schema-default shadow port (54320) instead — a reliable way to tell the + // target's own history upsert (what `historyUpserts` counts) apart from the shadow's + // internal migration replay, which issues the same parameterized query into its own + // separate history table. const TARGET_PORT = 5432; const makeSession = (isShadow: boolean): DbSession => { const exec = (sql: string) => Effect.sync(() => void execLog.push(sql)); @@ -422,15 +403,9 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), }); - // The linked ref is now pre-loaded (for the config-override print, ahead of - // `resolver.resolve()`'s own network work — review: PRRT_kwDOErm0O86XHvYl) via - // `ProjectRefResolver`, mirroring the SAME ref `resolver`'s own mock embeds in - // its `db..` connection host above, so both stay consistent regardless of - // whether a test sets `opts.resolvedRef` (mirrors `reset.integration.test.ts`'s - // identical mock). - // `loadProjectRef` gives an explicit `--project-ref` flag top precedence, same - // as Go's `flags.LoadProjectRef` — mirror that so a test can prove the flag - // (not just `opts.resolvedRef`) drives the linked ref. + // Mirrors the same ref `resolver`'s own mock embeds above, and gives an explicit + // `--project-ref` flag top precedence over `opts.resolvedRef` (mirrors + // `reset.integration.test.ts`'s identical mock). const projectRefResolver = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(opts.resolvedRef ?? VALID_REF), resolveForLink: () => Effect.succeed(opts.resolvedRef ?? VALID_REF), @@ -445,10 +420,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const baseLayer = Layer.mergeAll( - // `BunServices.layer` is listed FIRST so every fake service layer below (most - // importantly `shadowSpawner.layer`'s fake `ChildProcessSpawner`) OVERRIDES its - // real implementation — `Layer.mergeAll` is last-wins on a shared service, - // matching `start.integration.test.ts`'s own established ordering. + // Listed first so the fake service layers below (`Layer.mergeAll` is last-wins) + // override its real implementations, matching `start.integration.test.ts`. BunServices.layer, out.layer, telemetry.layer, @@ -558,13 +531,11 @@ describe("db pull", () => { yield* dbPull(flags({ diffEngine: Option.some("pg-delta"), strictCoverage: true })); const dir = join(tmp.current, "supabase", "migrations"); expect(existsSync(join(dir, `${"20240101000000"}_local.sql`))).toBe(true); - // A single-unit plan keeps the unchanged `_remote_schema.sql` filename. const written = readdirSync(dir).filter((f) => f.endsWith("_remote_schema.sql")); expect(written).toHaveLength(1); expect(readFileSync(join(dir, written[0] ?? ""), "utf8")).toContain( "create table remote ();", ); - // Prints the workdir-relative path, never the absolute one. expect(streamText(s.out, "stderr")).toContain( `Schema written to ${join("supabase", "migrations", written[0] ?? "")}\n`, ); @@ -575,17 +546,15 @@ describe("db pull", () => { expect(s.engineCalls[0]?.strictCoverage).toBe(true); expect(s.edgeRunCount).toBe(0); expect(streamText(s.out, "stdout")).toContain("Finished supabase db pull."); - // The linked ref is pre-loaded (cheap, local-only) before `resolve()` runs, so - // the post-run linked-project cache still gets the ref, matching the pattern - // `db reset`/`db push` use. + // The linked ref is pre-loaded before `resolve()` runs, so the cache still gets + // it, matching the `db reset`/`db push` pattern. expect(s.cache.cached).toBe(true); expect(s.cache.cachedRef).toBe("abcdefghijklmnopqrst"); }).pipe(Effect.provide(s.layer)); }); it.effect("pulls from the project given via --project-ref without a linked workdir", () => { - // The fake resolver fails as "unlinked" (`ProjectRefNotLinkedError`) - // absent the flag — only the flag can resolve a ref here. + // `linkedFails: true` simulates an unlinked workdir; only the flag can resolve a ref. const FLAG_REF = "flagflagflagflagflag"; seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { @@ -611,8 +580,7 @@ describe("db pull", () => { remoteVersions: ["20240101000000"], edgeStdout: pgDeltaDiffEnvelope([{ name: "schema_changes", sql: "create table remote ();" }]), yes: true, - // The workdir already resolves to VALID_REF (e.g. via - // .temp/project-ref) — the flag must win over it. + // A distinct fixed ref proves the flag, not the workdir's own ref, wins. resolvedRef: "abcdefghijklmnopqrst", }); return Effect.gen(function* () { @@ -636,7 +604,6 @@ describe("db pull", () => { expect(JSON.stringify(exit)).toContain( "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", ); - // The guard fires before any connection resolution or cache write. expect(s.resolveCalls).toEqual([]); expect(s.cache.cached).toBe(false); }).pipe(Effect.provide(s.layer)); @@ -656,10 +623,7 @@ describe("db pull", () => { it.effect( "a pg-delta plan with transaction boundaries writes one ordered migration file per unit", () => { - // pg-delta plans that cross a transaction boundary (e.g. ALTER TYPE ... ADD - // VALUE then a statement using the new value) come back as several units; each - // is written to its own migration file with a strictly increasing timestamp and - // recorded in the remote history. + // e.g. ALTER TYPE ... ADD VALUE followed by a statement using the new value. seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], @@ -681,7 +645,6 @@ describe("db pull", () => { .filter((f) => f !== "20240101000000_local.sql") .sort(); expect(written).toHaveLength(3); - // Multi-unit plans append the unit name and carry strictly increasing versions. expect(written[0]).toMatch(/^\d{14}_remote_schema_schema_changes\.sql$/u); expect(written[1]).toMatch(/^\d{14}_remote_schema_after_enum_values\.sql$/u); expect(written[2]).toMatch(/^\d{14}_remote_schema_non_transactional\.sql$/u); @@ -691,15 +654,12 @@ describe("db pull", () => { const nonTransactional = readFileSync(join(dir, written[2] ?? ""), "utf8"); expect(nonTransactional.startsWith("-- pg-delta: transaction=false\n")).toBe(true); expect(nonTransactional).toContain("create index concurrently i on t (c);"); - // One "Schema written to" line per unit, each printing the workdir-relative - // path, and one history upsert per unit. const err = streamText(s.out, "stderr"); expect(err.match(/Schema written to/gu)).toHaveLength(3); for (const file of written) { expect(err).toContain(`Schema written to ${join("supabase", "migrations", file)}\n`); } expect(s.historyUpserts.length).toBe(3); - // Prints all versions space-separated. expect(streamText(s.out, "stderr")).toContain( `Repaired migration history: [${versions.join(" ")}] => applied`, ); @@ -710,8 +670,7 @@ describe("db pull", () => { it.effect( "a multi-unit pg-delta pull reports every written migration path in the json payload", () => { - // The structured payload must list ALL written migration files in write order, - // not just the first (`schemaWritten`). A pg-delta plan writes one file per unit. + // Lists every written path, not just the first (`schemaWritten`). seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { format: "json", @@ -733,11 +692,9 @@ describe("db pull", () => { | { schemaWritten?: string; schemaFiles?: Array } | undefined; expect(data?.schemaFiles).toHaveLength(3); - // Paths appear in write order, each carrying its unit name. expect(data?.schemaFiles?.[0]).toMatch(/_remote_schema_schema_changes\.sql$/u); expect(data?.schemaFiles?.[1]).toMatch(/_remote_schema_after_enum_values\.sql$/u); expect(data?.schemaFiles?.[2]).toMatch(/_remote_schema_non_transactional\.sql$/u); - // `schemaWritten` stays the first written path (unchanged string contract). expect(data?.schemaWritten).toBe(data?.schemaFiles?.[0]); }).pipe(Effect.provide(s.layer)); }, @@ -757,10 +714,9 @@ describe("db pull", () => { }).pipe(Effect.provide(s.layer)); }); - // The transition warning belongs to the bundled next engine only: migra (and the - // legacy pg-delta opt-out) still substitute the declared-schema - // `contrib_regression` target for a local database, so schema_paths does still - // shape their output and the warning would be factually wrong. + // Migra (and the legacy pg-delta opt-out) still substitute the declared-schema + // `contrib_regression` target locally, so schema_paths still shapes their output — + // only the next engine's pull prints this warning. it.effect("pulls with the next engine and warns that schema_paths no longer applies", () => { seedMigration(tmp.current, "20240101000000"); writeFileSync( @@ -814,8 +770,7 @@ describe("db pull", () => { expect(s.shadowSpawned.filter((call) => call.args[0] === "create")).toHaveLength(1); const err = streamText(s.out, "stderr"); expect(err).not.toContain("schema_paths no longer changes the migrations baseline"); - // Go's `ConnectByConfig` prints the Connecting line to stderr before dialing - // (`internal/utils/connect.go:348`), ahead of any other pull output. + // Connecting must print before shadow creation. expect(err).toContain("Connecting to remote database...\n"); expect(err.indexOf("Connecting to remote database...")).toBeLessThan( err.indexOf("Creating shadow database..."), @@ -830,13 +785,9 @@ describe("db pull", () => { it.effect( "validates the shadow's own local config (api.tls cert file) BEFORE resolving the connection", () => { - // `toml` (`readDbToml`'s "D" pipeline) only tracks `api.tls`'s dotted keys for - // remote-override gating, it never reads the cert/key files — that read lives in - // `buildLocalDbContainerInputs`'s own "L" pipeline (see that call's doc comment, - // and `diff.handler.ts`'s identical fix), and it must run strictly before - // `resolver.resolve()` or the connectivity check ever run — so `resolveCalls` must - // stay empty here, proving the shadow's config validation ran first, not just that - // the command failed. + // `api.tls`'s cert/key files are only validated by `buildLocalDbContainerInputs`, + // which must run strictly before `resolver.resolve()` — `resolveCalls` staying + // empty here proves validation ran first, not just that the command failed. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), @@ -868,8 +819,6 @@ describe("db pull", () => { expect(s.engineCalls[0]?.strictCoverage).toBe(true); expect(s.edgeRunCount).toBe(0); const err = streamText(s.out, "stderr"); - // Order: the connectivity check prints Connecting, then the declarative - // export prints Preparing. expect(err).toContain("Connecting to remote database...\n"); expect(err.indexOf("Connecting to remote database...")).toBeLessThan( err.indexOf("Preparing declarative schema export"), @@ -889,9 +838,7 @@ describe("db pull", () => { scope: "database", files: ["public/t.sql"], }); - // Declarative export reads only the live target: the sole connect is the - // top-level target connect (`resolved.conn`, port 5432, database "postgres"), - // and no shadow database is ever provisioned. + // Declarative export reads only the live target; no shadow is provisioned. expect(s.connectTargets).toEqual([{ database: "postgres", port: 5432 }]); expect(s.shadowSpawned.filter((call) => call.args[0] === "create")).toHaveLength(0); expect(s.shadowSpawned.filter((call) => call.args[0] === "rm")).toHaveLength(0); @@ -910,9 +857,8 @@ describe("db pull", () => { it.effect( "pull --declarative writes [db.migrations] schema_paths when pg-delta is disabled", () => { - // Points schema_paths at the declarative dir when pg-delta is disabled in - // config (db pull does not force-enable it), so later db reset/db diff read - // the pulled files. + // Points schema_paths at the declarative dir so later db reset/db diff read the + // pulled files (pg-delta stays disabled). mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "config.toml"), "[db]\n"); const s = setup(tmp.current, { edgeStdout: EXPORT_JSON }); @@ -926,8 +872,8 @@ describe("db pull", () => { ); it.effect("pull --declarative leaves schema_paths untouched when pg-delta is enabled", () => { - // For an enabled config the declarative dir is already the source of truth, so - // the schema_paths rewrite is skipped (the gate reads the config value). + // An enabled config already treats the declarative dir as source of truth, so the + // rewrite is skipped. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); const original = "[experimental.pgdelta]\nenabled = true\n"; writeFileSync(join(tmp.current, "supabase", "config.toml"), original); @@ -971,14 +917,10 @@ describe("db pull", () => { ); it.effect("passes the config/workdir-resolved project id to the pg-delta engine", () => { - // No `SUPABASE_PROJECT_ID` env and no `supabase/config.toml` `project_id` — Go's - // `Config.ProjectId` falls back to the workdir basename (`pkg/config/config.go:563-570`) - // and `UpdateDockerIds` names the edge-runtime volume from that already-sanitized value - // (`internal/utils/config.go:57-76`). Before the fix, `ctx.projectId` came from - // `CommandSettings.projectId` alone (env-only) and resolved to `""`, mounting - // `supabase_edge_runtime_:/root/.cache/deno:rw` regardless of the real project — reachable - // here via the declarative-export path (`declarativeExportPgDelta`), which reads - // `ctx.projectId` before any local shadow diff even starts. + // Absent env/config project_id, the workdir basename must reach the pg-delta + // engine's project id, including on the declarative-export path (read before any + // local shadow diff starts) — not an empty string from `CommandSettings.projectId` + // alone. const s = setup(tmp.current, { edgeStdout: EXPORT_JSON, projectId: Option.none() }); const expectedProjectId = basename(tmp.current); return Effect.gen(function* () { @@ -990,12 +932,9 @@ describe("db pull", () => { it.effect( "a linked [remotes.]'s project_id outranks a conflicting SUPABASE_PROJECT_ID", () => { - // `readDbToml` already gates `toml.projectId` behind `remoteOverrideKeys` so it - // reflects the matched remote's OWN `project_id` (review: PRRT_kwDOErm0O86XHGDL) — but - // `resolveLocalProjectId` tries `cliSettings.projectId` (raw, ungated env) FIRST, so - // an ambient `SUPABASE_PROJECT_ID` that differs from the matched remote must be - // suppressed here too, or it silently wins back over the already-gated `toml.projectId` - // (mirrors `diff.integration.test.ts`'s identically-named test). + // `readDbToml` gates `toml.projectId` behind `remoteOverrideKeys` for the matched + // remote, but `resolveLocalProjectId` tries the raw ambient env first — an ambient + // `SUPABASE_PROJECT_ID` for an unrelated project must not win back over it. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), @@ -1004,8 +943,6 @@ describe("db pull", () => { const s = setup(tmp.current, { edgeStdout: EXPORT_JSON, resolvedRef: "abcdefghijklmnopqrst", - // Simulates an ambient `SUPABASE_PROJECT_ID` scoped to an unrelated (e.g. local) - // project — must NOT win over the matched remote's own `project_id`. projectId: Option.some("unrelated-env-project"), }); return Effect.gen(function* () { @@ -1018,9 +955,8 @@ describe("db pull", () => { it.effect( "--declarative --use-pg-delta=false stays in migration mode (Go last-occurrence-wins)", () => { - // Both flags bind to one variable, so the last occurrence wins: this - // invocation ends false => migration mode + history repair, NOT declarative - // export. OR-ing the two parsed flags would wrongly take the declarative path. + // Both flags bind to one variable, so the last occurrence wins — ORing the two + // parsed flags would wrongly take the declarative path instead. seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], @@ -1060,8 +996,6 @@ describe("db pull", () => { return Effect.gen(function* () { yield* dbPull(flags({ declarative: Option.some(true), usePgDelta: Option.some(true) })); expect(s.engineCalls[0]?.operation).toBe("export"); - // Reaching the declarative write (rather than a migration file / history - // upsert) proves the declarative export path ran. expect(existsSync(join(tmp.current, "supabase", "schemas", "public", "t.sql"))).toBe(true); expect(s.historyUpserts.length).toBe(0); }).pipe(Effect.provide(s.layer)); @@ -1079,8 +1013,6 @@ describe("db pull", () => { it.effect( "an initial pull (no local migrations, migra) dumps the schema natively then appends the diff", () => { - // The initial-pull dump (pg_dump, now native) plus the migra diff appended. - // No Go delegation. const s = setup(tmp.current, { remoteVersions: [], dumpStdout: "create table dumped ();\n", @@ -1132,8 +1064,8 @@ describe("db pull", () => { expect(s.proxyCalls).toHaveLength(0); expect(s.proxyCaptureCalls).toHaveLength(0); const success = s.out.messages.find((m) => m.type === "success"); - // Machine mode never prompts, so history is updated on the default (true); - // `schemaWritten` is the real native migration path (not null as when delegated). + // Machine mode never prompts, so history updates by default (true); `schemaWritten` + // is the real native path (not null as when delegated). expect(success?.data).toMatchObject({ declarative: false, remoteHistoryUpdated: true, @@ -1143,15 +1075,13 @@ describe("db pull", () => { | { schemaWritten?: string; schemaFiles?: Array } | undefined; expect(data?.schemaWritten).toMatch(/_remote_schema\.sql$/u); - // The single-unit case lists exactly one written migration path, and it is the - // same path as the singular `schemaWritten` field. + // Single-unit case: exactly one path, matching `schemaWritten`. expect(data?.schemaFiles).toHaveLength(1); expect(data?.schemaFiles?.[0]).toBe(data?.schemaWritten); }).pipe(Effect.provide(s.layer)); }); it.effect("an initial pull swallows an empty migra diff once the dump wrote content", () => { - // After the pg_dump seed, an empty second pass is success, not "in sync". const s = setup(tmp.current, { remoteVersions: [], dumpStdout: "create table dumped ();\n", @@ -1195,12 +1125,9 @@ describe("db pull", () => { ); it.effect("an initial pull with an empty schema reports 'No schema changes found'", () => { - // An empty dump + empty diff leaves the file empty → in sync. The owned empty - // seed file must not be left behind (CLI-1272 review): a leftover zero-byte - // `_remote_schema.sql` is a phantom local migration with no remote - // counterpart that a later pull's history reconciliation trips over, and - // `--with-migration-history` can't clear it since fetching empty remote history - // never deletes local files. + // A leftover zero-byte `_remote_schema.sql` seed file is a phantom local + // migration with no remote counterpart; `--with-migration-history` can't clear it + // since fetching empty remote history never deletes local files. const s = setup(tmp.current, { remoteVersions: [], dumpStdout: "", edgeStdout: "" }); return Effect.gen(function* () { const error = yield* dbPull(flags()).pipe(Effect.flip); @@ -1220,11 +1147,9 @@ describe("db pull", () => { expect(first.message).toBe("No schema changes found"); expect(existsSync(dir) ? readdirSync(dir) : []).toEqual([]); - // Without the cleanup, the first run's leftover empty seed file would - // desynchronize this second run's reconciliation: an empty remote history - // plus a local-only version is a `DbPullMigrationConflictError`, not a - // repeat "No schema changes found" — the exact regression the reviewer - // reproduced across two consecutive `db pull` invocations. + // Without the cleanup, the leftover seed file would desync this second run's + // reconciliation: an empty remote history plus a local-only version is a + // `DbPullMigrationConflictError`, not a repeat "No schema changes found". const second = yield* dbPull(flags()).pipe(Effect.flip); expect(second).toMatchObject({ _tag: "DbPullInSyncError", @@ -1238,11 +1163,9 @@ describe("db pull", () => { it.effect( "an initial-pull direct write that IPv6-fails then an empty pooler retry reports 'No schema changes found'", () => { - // Regression: the direct attempt streams preamble bytes then drops over IPv6; - // the pooler retry succeeds empty. The file is truncated before the retry and - // in-sync is decided from the file on disk, so an empty pooler retry + empty - // diff is in sync — not a schema write + migration-history upsert. The sticky - // `seedWroteBytes` flag must therefore reset per attempt. + // The file is truncated before the pooler retry, and in-sync is decided from the + // file on disk, so an empty retry + empty diff is in sync, not a schema write. The + // sticky `seedWroteBytes` flag must reset per attempt. const s = setup(tmp.current, { remoteVersions: [], dumpFailFirstWith: "could not translate host name: network is unreachable", @@ -1293,8 +1216,7 @@ describe("db pull", () => { const err = streamText(s.out, "stderr"); expect(err).toContain("does not support IPv6"); expect(err).toContain("Retrying via the IPv4 connection pooler"); - // The "Dumping schema…" line is printed once (before the fallback), not - // re-printed on the pooler retry. + // Printed once, before the fallback, not re-printed on the pooler retry. expect(err.match(/Dumping schema from remote database/gu)).toHaveLength(1); }).pipe(Effect.provide(s.layer)); }); @@ -1318,9 +1240,8 @@ describe("db pull", () => { seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], edgeStdout: "" }); return Effect.gen(function* () { - // Go's message and non-zero exit are the contract; the generic - // "rerun with --debug" footer is replaced by an explanation instead - // (docs/go-cli-divergences.md). + // The message and non-zero exit are the contract; the generic --debug footer is + // replaced with this explanation instead (docs/go-cli-divergences.md). const error = yield* dbPull(flags()).pipe(Effect.flip); expect(error).toMatchObject({ _tag: "DbPullInSyncError", @@ -1406,11 +1327,9 @@ describe("db pull", () => { it.effect( "a remote-history update failure after a successful write reports the written migration path (CLI-1272)", () => { - // The migration write itself succeeds and is already on disk; ONLY the - // subsequent "Update remote migration history table?" write fails. The - // resulting `DbPullWriteError` must still carry that already-written path - // via `writtenSoFar` (the shape `pull.aggregate.ts`'s `hasWrittenSoFar` - // duck-types), not report the write as if nothing happened. + // The migration write succeeds; only the subsequent history-table write fails. + // The resulting `DbPullWriteError` must still carry the already-written path via + // `writtenSoFar`, not report the write as if nothing happened. const s = setup(tmp.current, { remoteVersions: [], dumpStdout: "", @@ -1425,24 +1344,20 @@ describe("db pull", () => { const file = readdirSync(dir).find((f) => f.endsWith("_remote_schema.sql")); expect(file).toBeDefined(); const writtenPath = join(dir, file ?? ""); - // The file really is on disk — the failure happened strictly after the - // write, not instead of it. + // Confirms the failure happened after the write, not instead of it. expect(existsSync(writtenPath)).toBe(true); expect((error as { writtenSoFar?: ReadonlyArray }).writtenSoFar).toEqual([ writtenPath, ]); - // No history row was recorded — the failure is real, not swallowed. expect(s.historyUpserts.length).toBe(0); }).pipe(Effect.provide(s.layer)); }, ); it.effect("updates history on an empty non-interactive stdin (Go default)", () => { - // Scans stdin and only falls back to the default (`true`) when the scan is - // empty/exhausted. With no piped input a non-interactive `db pull` therefore - // proceeds to update the remote history. (The production clack prompt would - // hang on a non-TTY — that no-hang behavior is proven end-to-end in - // `pull.live.test.ts`; here the empty piped scan defaults.) + // Only falls back to the default (`true`) when the piped scan is empty/exhausted; + // a non-interactive pull without piped input therefore updates history. (The clack + // prompt would hang on a non-TTY; see `pull.live.test.ts` for that end-to-end proof.) seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], @@ -1456,9 +1371,8 @@ describe("db pull", () => { }); it.effect("declines the history update on a piped 'n' (non-tty)", () => { - // Regression: piped stdin is scanned before defaulting, so a piped `n` cancels - // the history update even on a non-terminal — `schema_migrations` must not be - // touched against the user's explicit decline. + // Piped stdin is scanned before defaulting, so a piped `n` cancels even on a + // non-terminal. seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], @@ -1469,7 +1383,6 @@ describe("db pull", () => { return Effect.gen(function* () { yield* dbPull(flags()); expect(s.historyUpserts.length).toBe(0); - // Prints the label then echoes the consumed answer. expect(streamText(s.out, "stderr")).toContain( "Update remote migration history table? [Y/n] n", ); @@ -1487,8 +1400,7 @@ describe("db pull", () => { return Effect.gen(function* () { yield* dbPull(flags()); expect(streamText(s.out, "stdout")).not.toContain("Finished supabase db pull."); - // Diagnostics still go to stderr in machine mode (the Connecting line is - // written regardless of output format); stdout stays payload-only. + // Diagnostics still go to stderr in machine mode; stdout stays payload-only. expect(streamText(s.out, "stderr")).toContain("Connecting to remote database...\n"); const success = s.out.messages.find((m) => m.type === "success"); expect(success?.data).toMatchObject({ declarative: false, remoteHistoryUpdated: true }); @@ -1510,9 +1422,8 @@ describe("db pull", () => { }); it.effect("honors SUPABASE_YES for the initial-pull history update", () => { - // `SUPABASE_YES` auto-confirms even on a TTY with no piped answer. The native - // path resolves `yes` via `resolveYesWithProjectEnv`, not the raw `--yes` - // flag, so the shell env var is honored here too. + // `SUPABASE_YES` auto-confirms even on a TTY with no piped answer, via the native + // path's `resolveYesWithProjectEnv`. const prev = process.env["SUPABASE_YES"]; process.env["SUPABASE_YES"] = "1"; seedMigration(tmp.current, "20240101000000"); @@ -1540,10 +1451,8 @@ describe("db pull", () => { }); it.effect("honors SUPABASE_YES from supabase/.env for the initial-pull history update", () => { - // The project `.env` is loaded before the history prompt, so `SUPABASE_YES` set - // only in `supabase/.env` auto-confirms — with no shell env or `--yes`. The - // native path resolves via `resolveYesWithProjectEnv`, reading the loaded - // project env map. + // The project .env is loaded before the history prompt, so `SUPABASE_YES` there + // auto-confirms with no shell env or --yes (`resolveYesWithProjectEnv`). const prev = process.env["SUPABASE_YES"]; delete process.env["SUPABASE_YES"]; // only the project .env value must apply seedMigration(tmp.current, "20240101000000"); @@ -1573,10 +1482,9 @@ describe("db pull", () => { it.effect( "resolves the pg_dump image via SUPABASE_INTERNAL_IMAGE_REGISTRY from supabase/.env", () => { - // The project `.env` is applied before resolving the registry image, so a - // registry mirror set only in `supabase/.env` is used for the native pg_dump - // seed. The handler applies it with `applyProjectEnv` (scoped to the run, - // reverted on close); the loader itself stays pure. + // Applied before resolving the registry image, so a mirror set only in + // supabase/.env is used for the native pg_dump seed (scoped to the run via + // `applyProjectEnv`, reverted on close). const prev = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; mkdirSync(join(tmp.current, "supabase"), { recursive: true }); @@ -1610,9 +1518,8 @@ describe("db pull", () => { it.effect( "resolves the pg_dump network via SUPABASE_NETWORK_ID from supabase/.env when neither the flag nor the ambient env is set", () => { - // Host networking is the default, but a resolved `--network-id`/`SUPABASE_NETWORK_ID` - // value overrides it whenever non-empty — a value sourced only from `supabase/.env` - // still wins over host. + // A `SUPABASE_NETWORK_ID` sourced only from `supabase/.env` still overrides host + // networking. const prev = process.env["SUPABASE_NETWORK_ID"]; delete process.env["SUPABASE_NETWORK_ID"]; mkdirSync(join(tmp.current, "supabase"), { recursive: true }); @@ -1640,10 +1547,8 @@ describe("db pull", () => { ); it.effect("an explicit --yes=false overrides SUPABASE_YES and honors the piped answer", () => { - // An explicit `--yes=false` wins over the SUPABASE_YES env. `printf 'n\n' | - // SUPABASE_YES=1 supabase --yes=false db pull` must let the piped `n` decline - // the history update rather than auto-confirming — schema_migrations stays - // untouched. + // An explicit `--yes=false` wins over the SUPABASE_YES env — a piped `n` still + // declines the history update rather than auto-confirming. const prev = process.env["SUPABASE_YES"]; process.env["SUPABASE_YES"] = "1"; seedMigration(tmp.current, "20240101000000"); @@ -1671,11 +1576,9 @@ describe("db pull", () => { it.effect( "a bare --password consumes the following token, so SUPABASE_YES still auto-confirms", () => { - // Same value-token-consuming hazard as the --experimental scanner fix above: - // `--password --yes=false` parses as `--password`'s VALUE being the literal - // string "--yes=false" — `--yes` was never actually set — so SUPABASE_YES=1 - // must still auto-confirm the history update rather than a scanner wrongly - // reading an explicit `--yes=false` here. + // `--password --yes=false` parses as `--password`'s value being the literal string + // "--yes=false" — `--yes` itself was never set — so SUPABASE_YES=1 must still + // auto-confirm rather than the scanner misreading an explicit `--yes=false`. const prev = process.env["SUPABASE_YES"]; process.env["SUPABASE_YES"] = "1"; seedMigration(tmp.current, "20240101000000"); @@ -1743,10 +1646,9 @@ describe("db pull", () => { const prev = process.env["SUPABASE_EXPERIMENTAL"]; process.env["SUPABASE_EXPERIMENTAL"] = "true"; try { - // `forceMigrationMode: true` — the CLI-1272 fix — keeps an in-process - // caller (`pull`) in migration mode even though the ambient - // `SUPABASE_EXPERIMENTAL` gate would otherwise select the deprecated - // declarative export. + // `forceMigrationMode: true` keeps an in-process caller in migration mode + // even though the ambient `SUPABASE_EXPERIMENTAL` gate would otherwise select + // the deprecated declarative export. yield* runDbPull(flags({ diffEngine: Option.some("pg-delta") }), { forceMigrationMode: true, }).pipe(Effect.provide(forced.layer)); @@ -1755,9 +1657,8 @@ describe("db pull", () => { "Preparing declarative schema export", ); - // Unset — the resolution `dbPull`/`dbRemoteCommit` still use — keeps - // the exact original behavior: the ambient gate still switches to the - // in-process declarative export. + // Unset preserves the original behavior: the ambient gate still switches to + // the in-process declarative export. yield* runDbPull(flags()).pipe(Effect.provide(unforced.layer)); expect(unforced.engineCalls[0]?.operation).toBe("export"); expect(streamText(unforced.out, "stderr")).toContain( @@ -1836,8 +1737,8 @@ describe("db pull", () => { it.effect( "--declarative wins over --experimental and is unaffected by the deprecated experimental mode", () => { - // Declarative mode is checked before EXPERIMENTAL: declarative export must - // still run normally even when --experimental is also set. + // Declarative mode is checked before the experimental gate, so it runs normally + // even when --experimental is also set. const s = setup(tmp.current, { experimental: true, edgeStdout: EXPORT_JSON }); return Effect.gen(function* () { yield* dbPull(flags({ declarative: Option.some(true) })); @@ -1853,10 +1754,8 @@ describe("db pull", () => { it.effect( "an explicit --experimental=false wins over SUPABASE_EXPERIMENTAL=true and pulls normally", () => { - // A SET flag value wins over env regardless of whether it's true or false, so - // `--experimental=false` must NOT be overridden by a truthy - // `SUPABASE_EXPERIMENTAL` — the pull proceeds as a normal migration instead of - // the deprecated experimental export. + // A set flag value wins over env regardless of true/false, so `--experimental=false` + // isn't overridden by a truthy `SUPABASE_EXPERIMENTAL`. const prev = process.env["SUPABASE_EXPERIMENTAL"]; process.env["SUPABASE_EXPERIMENTAL"] = "true"; seedMigration(tmp.current, "20240101000000"); @@ -1884,12 +1783,10 @@ describe("db pull", () => { it.effect( "a migration name literally '--experimental=false' after -- does not suppress SUPABASE_EXPERIMENTAL", () => { - // Both pflag/cobra and this CLI's own lexer (effect/unstable/cli/internal/lexer.ts, - // `argv.indexOf("--")`) stop parsing - // flags at the first bare `--` — `db pull -- --experimental=false` passes - // "--experimental=false" as the positional migration-name argument, NOT as an - // explicit flag occurrence. Unlike the unterminated `--experimental=false` - // case above, this must still take the experimental export. + // Both pflag/cobra and this CLI's own lexer stop parsing flags at the first bare + // `--`, so `db pull -- --experimental=false` passes it as the positional + // migration-name argument, not a flag occurrence — unlike the unterminated case + // above, this must still take the experimental export. const prev = process.env["SUPABASE_EXPERIMENTAL"]; process.env["SUPABASE_EXPERIMENTAL"] = "true"; const s = setup(tmp.current, { @@ -1915,10 +1812,9 @@ describe("db pull", () => { it.effect( "a repeated --experimental=false --experimental=true still exports (last Set() wins)", () => { - // pflag/viper bind ONE variable per flag: repeated occurrences collapse to - // whichever Set() call happened LAST. A resolver that only checks "does any - // pre-terminator token say false" gets this ordering backwards and would - // incorrectly skip the experimental export. + // pflag/viper bind one variable per flag: repeated occurrences collapse to + // whichever Set() call happened last, so a resolver must not get this ordering + // backwards. const s = setup(tmp.current, { args: ["db", "pull", "--experimental=false", "--experimental=true"], edgeStdout: EXPORT_JSON, @@ -1934,13 +1830,9 @@ describe("db pull", () => { it.effect( "a bare --password consumes the following token, so SUPABASE_EXPERIMENTAL still gates the experimental export", () => { - // pflag accepts `--flag value` (space form) for `--password` (a string flag, - // `pull.command.ts`'s `password: Flag.string(...)`), so `--password - // --experimental=false` parses as `--password`'s VALUE being the literal string - // "--experimental=false" — `--experimental` was never actually Changed. A scanner - // that examines every pre-terminator token without skipping consumed values would - // wrongly read an explicit `--experimental=false` here and let the pull proceed - // normally instead of falling back to SUPABASE_EXPERIMENTAL=true. + // `--password --experimental=false` parses as `--password`'s value being the + // literal string "--experimental=false" — `--experimental` itself was never + // Changed, so the pull must still fall back to `SUPABASE_EXPERIMENTAL=true`. const prev = process.env["SUPABASE_EXPERIMENTAL"]; process.env["SUPABASE_EXPERIMENTAL"] = "true"; const s = setup(tmp.current, { @@ -1997,11 +1889,9 @@ describe("db pull", () => { }); it.effect("db pull --local with migra uses the declarative target override", () => { - // Go derives the shadow targetLocal from utils.IsLocalDatabase and substitutes - // the declarative contrib_regression target override (diff.go:190,196-197); a - // real declarative schema file makes the native `loadDeclaredSchemas` branch - // non-empty, so `prepareShadowSource` redirects the diff target to the - // shadow's own `contrib_regression` override database. + // A real declarative schema file makes the native `loadDeclaredSchemas` branch + // non-empty, so `prepareShadowSource` redirects the diff target to the shadow's + // own `contrib_regression` override database. seedMigration(tmp.current, "20240101000000"); mkdirSync(join(tmp.current, "supabase", "schemas"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "schemas", "public.sql"), "select 1;\n"); @@ -2032,8 +1922,7 @@ describe("db pull", () => { "a migration name with a path separator fails instead of an empty-version repair", () => { // The repair globs `_*.sql`, which fails when the name has a path - // separator (the file is nested), so the native path must not silently upsert - // an empty-version migration-history row. + // separator, so the native path must not silently upsert an empty-version row. seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], @@ -2051,11 +1940,9 @@ describe("db pull", () => { it.effect( "a migration name whose nested basename is itself a valid migration filename still fails", () => { - // `dir/20250101000000_backfill` writes a nested file whose basename - // (`20250101000000_backfill.sql`) matches the migration regex, but the repair - // glob `_*.sql` never crosses the `/`, so it misses and fails. - // Anchoring on the generated timestamp must reject this rather than upserting - // the user's nested timestamp as applied. + // The basename (`20250101000000_backfill.sql`) matches the migration regex, but the + // repair glob `_*.sql` never crosses the `/`, so it misses — anchoring + // on the generated timestamp must reject this rather than upserting the nested one. seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], @@ -2073,9 +1960,9 @@ describe("db pull", () => { ); it.effect("machine output in a TTY without --yes skips the prompt and emits the payload", () => { - // Regression: json/stream-json layers fail every prompt as non-interactive, so - // the history-update prompt must be skipped (default = yes) instead of failing - // the command before the structured success payload is emitted. + // json/stream-json layers fail every prompt as non-interactive, so the + // history-update prompt must be skipped (default = yes) instead of failing before + // the payload emits. seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { format: "json", @@ -2122,8 +2009,6 @@ describe("db pull", () => { return Effect.gen(function* () { yield* dbPull(flags({ linked: Option.some(true) })); expect(s.engineCalls[0]?.operation).toBe("diff"); - // pg-delta selection is ref-aware (read from the remote-merged `toml.pgDelta`) - // and is proven by `edgeStdout`'s envelope shape parsing successfully below. expect(streamText(s.out, "stderr")).toMatch( /Schema written to supabase[/\\]migrations[/\\]\d{14}_remote_schema\.sql\n/u, ); @@ -2133,11 +2018,8 @@ describe("db pull", () => { it.effect( "caches the linked ref even when the merged config fails to load afterward (review: PRRT_kwDOErm0O86XLe6s)", () => { - // The project ref is cached the moment it's known, and stays cached even when - // a LATER step (here, `readDbToml`'s own config-load) fails afterward. - // `db.migrations.enabled = "notabool"` fails `readDbToml`'s own bool - // parse AFTER the ref is already known, exercising exactly that gap - // (`diff.integration.test.ts`'s identical fix/test). + // `db.migrations.enabled = "notabool"` fails config-load after the ref is + // already cached. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), @@ -2160,15 +2042,9 @@ describe("db pull", () => { it.effect( "a linked [remotes.] db.major_version override reaches the shadow's OWN container spec, not just toml", () => { - // The WHOLE config is remote-merged uniformly on the linked path — the shadow's - // container spec (image, JWT secret, root key, db.settings, service - // enabled-for-setup flags) must reflect the matched `[remotes.]` override - // too, not just the `toml` read used for pg-delta/schema_paths (mirrors - // `diff.integration.test.ts`'s identically-named test). - // `major_version` is a clean, directly-observable probe: PG <= 14 is the ONLY branch - // that emits a `--tmpfs` flag on the shadow's `docker create` argv - // (`buildShadowPostgresContainerSpec`) — a base config of 17 (>= 15, no tmpfs) - // overridden by a remote block's `major_version = 14` must flip that flag on. + // The remote's own container spec must reflect the `[remotes.]` override too, + // not just the config read for pg-delta/schema_paths; `major_version` is used as a + // probe since PG <= 14 is the only branch that emits `--tmpfs` on `docker create`. seedMigration(tmp.current, "20240101000000"); mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( @@ -2200,17 +2076,9 @@ describe("db pull", () => { ); it.effect("retries the migration-style diff through the IPv4 pooler on an IPv6 error", () => { - // The linked diff retries against the IPv4 pooler when the direct host is - // unreachable over IPv6 from the container. The first edge run fails with an - // IPv6 connectivity error; the retry succeeds and the migration is written. - // - // This retries the WHOLE shadow-provisioning + diff operation on this path, - // not just the diff engine (shadow provisioning prints "Creating shadow - // database..."/"Diffing schemas..." before ever touching the target - // connection) — so the pooler retry re-provisions and tears down a FRESH - // shadow and re-prints both banners, rather than reusing the first attempt's - // shadow. Assert that shape directly, not just that the migration eventually - // gets written. + // Retries the whole shadow-provisioning + diff operation, not just the diff engine — + // the pooler retry re-provisions and tears down a fresh shadow and re-prints both + // banners, rather than reusing the first attempt's shadow. seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], @@ -2238,9 +2106,7 @@ describe("db pull", () => { }); it.effect("retries the declarative export through the IPv4 pooler on an IPv6 error", () => { - // The declarative export retries through the pooler in the same IPv6 - // scenario. The export reads only the live target, so no shadow database is - // ever provisioned on this path. + // The export reads only the live target, so no shadow is ever provisioned on this path. const s = setup(tmp.current, { edgeFailFirstWith: "error exporting declarative schema:\nnetwork is unreachable", edgeStdout: EXPORT_JSON, @@ -2258,8 +2124,6 @@ describe("db pull", () => { }); it.effect("an IPv6 diff error with no pooler available surfaces the original error", () => { - // A pooler resolution failure surfaces the ORIGINAL diff error rather than a - // retry error. seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], @@ -2278,8 +2142,6 @@ describe("db pull", () => { }); it.effect("a non-IPv6 diff error is not retried through the pooler", () => { - // Only IPv6 connectivity errors are eligible; any other failure surfaces as-is - // without consulting the pooler. seedMigration(tmp.current, "20240101000000"); const s = setup(tmp.current, { remoteVersions: ["20240101000000"], @@ -2318,8 +2180,8 @@ describe("db pull", () => { * Runs `db pull` with the shadow baseline cache on and artifacts under the temp root, * against the stateful Docker model the export/restore round trip needs. * - * Each run gets its OWN workdir so the migration file the previous pull wrote cannot shift - * the second run's behaviour — the cache key is global and deliberately workdir-independent, + * Each run gets its own workdir so the migration file the previous pull wrote cannot + * shift the second run's behavior — the cache key is global and workdir-independent, * so two worktrees with identical settings still collide on the same tar. */ const runCached = (engine: "migra" | "pg-delta") => { @@ -2351,11 +2213,9 @@ describe("db pull", () => { ).pipe(Effect.as(s)); }; - // Regression: both migrate paths used to pass a hardcoded `{ webhooks: "enabled" }`, so the - // migra run's forced-`pg_net` baseline and the pg-delta run's config-following baseline keyed - // to the SAME tar and silently restored each other's cluster. The handler now forks the - // policy on `migrationMode`; `shadow-cache.integration.test.ts` covers the cache's half of - // the contract, this covers `db pull`'s call site. + // A migra baseline and a pg-delta baseline must not key to the same cache tar and + // silently restore each other's cluster; `shadow-cache.integration.test.ts` covers + // the cache's own half, this covers the call site. it.live("a migra-engine baseline is never restored into a pg-delta run", () => { return Effect.gen(function* () { // Migra's migrate path forces `pg_net` on regardless of config, and publishes @@ -2366,7 +2226,7 @@ describe("db pull", () => { expect(migraTars).toHaveLength(1); // pg-delta follows the config (webhooks are off here), so it must cold-provision - // and publish its OWN baseline rather than restore the forced-on one above. + // and publish its own baseline rather than restore the forced-on one above. const pgDeltaRun = yield* runCached("pg-delta"); expect(pgDeltaRun.dockerDaemon?.stepCalls("cp-in")).toHaveLength(0); expect(pgDeltaRun.dockerDaemon?.stepCalls("cp-out")).toHaveLength(1); diff --git a/apps/cli/src/commands/db/pull/pull.live.test.ts b/apps/cli/src/commands/db/pull/pull.live.test.ts index 9a18250136..ae78e29e17 100644 --- a/apps/cli/src/commands/db/pull/pull.live.test.ts +++ b/apps/cli/src/commands/db/pull/pull.live.test.ts @@ -5,11 +5,9 @@ import { expect } from "vitest"; import { requireLiveSuccess, test, throwWithCleanup } from "../../../../tests/helpers/live.ts"; -// `db pull` exits non-zero when the diff comes back empty (the in-sync -// finding, see IN_SYNC_SUGGESTION in pull.handler.ts), so the journey seeds a remote-only -// marker table through `db query` — no local migration and no history row. -// The marker cannot exist in the freshly provisioned shadow, so the diff is -// never empty regardless of engine and the pull deterministically writes it. +// `db pull` exits non-zero on an empty diff (the in-sync finding), so this seeds a +// remote-only marker table via `db query` with no local migration/history row — the +// marker can't exist in the fresh shadow, so the diff is never empty. test("pulls the remote schema into an initial migration", async ({ cli, project, workspace }) => { const marker = `e2e_pull_${randomUUID().slice(0, 8)}`; const migrations = join(workspace.path, "supabase", "migrations"); diff --git a/apps/cli/src/commands/db/pull/pull.sync.integration.test.ts b/apps/cli/src/commands/db/pull/pull.sync.integration.test.ts index a6610233b1..092504ca14 100644 --- a/apps/cli/src/commands/db/pull/pull.sync.integration.test.ts +++ b/apps/cli/src/commands/db/pull/pull.sync.integration.test.ts @@ -11,9 +11,8 @@ import type { DbSession } from "../../../command-internal/db-connection.service. import { DbPullWriteError } from "./pull.errors.ts"; import { updateMigrationHistory, type PulledMigration } from "./pull.sync.ts"; -// Records exec statements and successful upserts in one ordered log so the tests -// can assert the transaction envelope (BEGIN / UPSERT / COMMIT / ROLLBACK) around -// the version writes. `failUpsertAt` fails the Nth upsert to simulate a dropped +// Records exec statements and successful upserts in order so tests can assert the +// transaction envelope. `failUpsertAt` fails the Nth upsert to simulate a dropped // connection mid-loop. function mockSession(opts: { readonly failUpsertAt?: number } = {}) { const calls: Array = []; @@ -62,11 +61,10 @@ describe("updateMigrationHistory", () => { yield* updateMigrationHistory(session, fs, path, migrations).pipe(Effect.provide(out.layer)); - // The create-table setup runs its own BEGIN/COMMIT first; the upsert - // transaction is the trailing envelope around every version write. expect(calls).not.toContain("ROLLBACK"); + // Sliced to the trailing 4 calls since createMigrationTable's own BEGIN/COMMIT + // runs first. expect(calls.slice(-4)).toEqual(["BEGIN", "UPSERT", "UPSERT", "COMMIT"]); - // The success line matches the established output contract. expect(out.stderrText).toContain( "Repaired migration history: [20240101000000 20240101000001] => applied", ); @@ -87,14 +85,11 @@ describe("updateMigrationHistory", () => { Effect.flip, ); - // First upsert applied, second failed → the upsert transaction ends in - // ROLLBACK, never COMMIT (the create-table setup's own COMMIT ran earlier). + // Sliced to the trailing 3 calls for the same reason as above. expect(calls.slice(-3)).toEqual(["BEGIN", "UPSERT", "ROLLBACK"]); expect(calls[calls.length - 1]).toBe("ROLLBACK"); - // Error message shape stays byte-identical to the pre-transaction version. expect(error).toBeInstanceOf(DbPullWriteError); expect(error.message).toBe("failed to update migration table: connection reset by peer"); - // No success line when the repair failed. expect(out.stderrText).not.toContain("Repaired migration history"); }).pipe(Effect.provide(BunServices.layer)), ); diff --git a/apps/cli/src/commands/db/pull/pull.sync.ts b/apps/cli/src/commands/db/pull/pull.sync.ts index 4a009c5d0f..e9a418573a 100644 --- a/apps/cli/src/commands/db/pull/pull.sync.ts +++ b/apps/cli/src/commands/db/pull/pull.sync.ts @@ -17,12 +17,11 @@ export interface PulledMigration { } /** - * Records the pulled migration(s) as applied in - * `supabase_migrations.schema_migrations` WITHOUT re-executing them (the schema - * already exists on the remote): create the history table, then UPSERT each - * version row with the migration's name + statements. A pg-delta pull whose - * plan crosses a transaction boundary writes several ordered files, so several - * versions are recorded in one pass. + * Records the pulled migration(s) as applied in `supabase_migrations.schema_migrations` + * without re-executing them (the schema already exists on the remote): creates the + * history table, then upserts each version row with the migration's name + statements. + * A pg-delta pull whose plan crosses a transaction boundary writes several files, so + * several versions are recorded in one pass. */ export const updateMigrationHistory = ( session: DbSession, @@ -32,12 +31,9 @@ export const updateMigrationHistory = ( ) => Effect.gen(function* () { const output = yield* Output; - // Resolve each file by globbing `_*.sql` against the migrations dir, - // failing when nothing matches. The glob is anchored on the GENERATED version - // and `*` never crosses a path separator, so a migration name with a separator - // writes a nested file the glob can't reach — require the basename to both - // match the pattern AND carry the generated version rather than trusting - // `path.basename`. + // The glob (`_*.sql`) never crosses a path separator, so a migration name + // with one writes a nested file it can't reach — require the basename to both match + // the pattern and carry the generated version, rather than trusting `path.basename`. const resolved: Array<{ version: string; name: string; migrationPath: string }> = []; for (const migration of migrations) { const match = MIGRATE_FILE_PATTERN.exec(path.basename(migration.path)); @@ -55,15 +51,11 @@ export const updateMigrationHistory = ( }); } yield* Effect.gen(function* () { - // Create the history schema/table first, in its OWN transaction. Keeping it - // outside the upsert transaction below avoids nesting BEGINs - // (`createMigrationTable` issues its own BEGIN/COMMIT). + // Created in its own transaction, outside the upsert transaction below, to avoid + // nesting BEGINs (`createMigrationTable` issues its own BEGIN/COMMIT). yield* createMigrationTable(session); - // Record every version in ONE explicit transaction: a mid-loop failure - // (dropped connection, unreadable migration file) must record NONE of them. - // Without a transaction here each UPSERT autocommits, so a failure partway - // through would leave partial remote history that fails the next pull's - // sync check. + // One explicit transaction: without it, each UPSERT autocommits, so a mid-loop + // failure would leave partial remote history that fails the next pull's sync check. yield* Effect.gen(function* () { yield* session.exec("BEGIN"); for (const entry of resolved) { @@ -73,10 +65,8 @@ export const updateMigrationHistory = ( } yield* session.exec("COMMIT"); }).pipe( - // Roll back on ANY failure inside the transaction — including a migration - // file read that fails after BEGIN. `Effect.ignore` keeps a ROLLBACK - // failure from masking the original error (`tapError` re-raises the - // original). Mirrors `createMigrationTable`'s rollback handling. + // `Effect.ignore` keeps a ROLLBACK failure from masking the original error + // (`tapError` re-raises it); mirrors `createMigrationTable`'s rollback handling. Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore)), ); }).pipe( @@ -87,9 +77,8 @@ export const updateMigrationHistory = ( }), ), ); - // Prints `Repaired migration history: [ ...] => applied` to stderr - // (established output contract; space-separated versions). Plain text on - // stderr, so it does not interfere with machine-output payloads on stdout. + // Established output contract; printed to stderr so it never interferes with a + // machine-output payload on stdout. const versions = resolved.map((entry) => entry.version).join(" "); yield* output.raw(`Repaired migration history: [${versions}] => applied\n`, "stderr"); }); diff --git a/apps/cli/src/commands/db/push/push.command.ts b/apps/cli/src/commands/db/push/push.command.ts index c2ca1d496c..c6f2330b01 100644 --- a/apps/cli/src/commands/db/push/push.command.ts +++ b/apps/cli/src/commands/db/push/push.command.ts @@ -43,19 +43,11 @@ const config = { Flag.withDescription("Pushes to the local database."), Flag.withDefault(false), ), - // TS-only flag on every user-facing `db` subcommand (Go's user-facing `db` - // commands never registered --project-ref; only the SUPABASE_PROJECT_ID env - // var could override the linked ref). The one Go exception is a hidden seam, - // not a user-facing flag: `db declarative __catalog --project-ref` exists - // solely so the native TS declarative commands can forward the resolved - // linked ref to the bundled Go binary (`apps/cli-go/cmd/pgdelta_catalog.go:44`). - // Feeds ProjectRefResolver.loadProjectRef, which keeps Go's precedence: - // flag > SUPABASE_PROJECT_ID > supabase/.temp/project-ref. Unlike that env - // var, this flag ONLY feeds ref resolution — it does not affect local - // container ids or the pg-delta project id (see db-config.types.ts's - // `linkedProjectRef` doc for the full non-overlap), and is rejected outright - // on a non-linked target rather than silently ignored (see the handler's - // guard). + // Feeds `ProjectRefResolver.loadProjectRef`: flag > `SUPABASE_PROJECT_ID` > + // `supabase/.temp/project-ref`. Only feeds ref resolution — it does not affect local + // container ids or the pg-delta project id (see `db-config.types.ts`'s + // `linkedProjectRef` doc) — and is rejected outright on a non-linked target rather + // than silently ignored (see the handler's guard). projectRef: Flag.string("project-ref").pipe( Flag.withDescription("Project ref of the Supabase project."), Flag.optional, @@ -90,9 +82,7 @@ export const dbPushCommand = Command.make("push", config).pipe( // `password` is a credential — always reaches telemetry as ``. password: flags.password, }, - // TS-only flag with no Go telemetry-safety baseline; Go's nearest - // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most - // others) are unmarked, so it stays redacted. + // Not on the established `--project-ref` safeFlags allowlist, so it stays redacted. aliases: { p: "password" }, }), withJsonErrorHandling, diff --git a/apps/cli/src/commands/db/push/push.handler.ts b/apps/cli/src/commands/db/push/push.handler.ts index 18f2a3a942..aeddd3c50d 100644 --- a/apps/cli/src/commands/db/push/push.handler.ts +++ b/apps/cli/src/commands/db/push/push.handler.ts @@ -60,12 +60,10 @@ export const dbPush = Effect.fn("db.push")(function* (flags: DbPushFlags) { // push defaults `--linked` to true, so no target flag → linked. const connType = target.connType ?? "linked"; - // TS-only guard: `--project-ref` never implies `--linked` and must not be - // silently discarded on a non-linked target. Deliberately STRICTER than the - // `SUPABASE_PROJECT_ID` env var, which is read unconditionally but simply - // goes unused (no error) on a `--local`/`--db-url` target — an explicitly - // typed `--project-ref` flag silently doing nothing on e.g. `db push - // --local` is a footgun the env var doesn't share, so this errors instead. + // `--project-ref` never implies `--linked` and must not be silently discarded on a + // non-linked target. This is stricter than `SUPABASE_PROJECT_ID`, which is read + // unconditionally but simply goes unused on `--local`/`--db-url` — an explicitly + // typed flag doing nothing silently would be a footgun the env var doesn't share. if (Option.isSome(flags.projectRef) && connType !== "linked") { return yield* Effect.fail( new DbPushTargetFlagsError({ @@ -85,16 +83,11 @@ export const dbPush = Effect.fn("db.push")(function* (flags: DbPushFlags) { linkedRefForCache = projectRef; } - // Single config load, except that `--skip-vault` omits only `[db.vault]` - // secret resolution: decodes the whole config with env-expansion + - // weak-typed boolean parsing (so `enabled = "env(SEED_ENABLED)"` etc. - // load), applies `SUPABASE_*` env overrides, merges a matching - // `[remotes.]` block, and decrypts selected `encrypted:` secrets - // with the shell AND project-`.env` `DOTENV_PRIVATE_KEY*` keys — aborting - // here (before connecting or writing) on any undecryptable/invalid - // config. This must resolve BEFORE `resolver.resolve()`'s network - // activity (temp-role minting, pooler fallback) so a matching - // `[remotes.]` override prints before it. + // Single config load (except `--skip-vault`, which omits `[db.vault]` secret + // resolution): decodes with env-expansion + weak-typed booleans, applies + // `SUPABASE_*` overrides, merges a matching `[remotes.]` block, and decrypts + // `encrypted:` secrets — aborting here on any invalid config, before + // `resolver.resolve()`'s network activity, so a remote override prints first. const toml = yield* checkDbToml(fs, path, workdir, projectRef !== "" ? projectRef : undefined, { resolveVaultSecrets: !flags.skipVault, }); diff --git a/apps/cli/src/commands/db/push/push.integration.test.ts b/apps/cli/src/commands/db/push/push.integration.test.ts index 6e98aa4b42..4c1767d879 100644 --- a/apps/cli/src/commands/db/push/push.integration.test.ts +++ b/apps/cli/src/commands/db/push/push.integration.test.ts @@ -168,12 +168,9 @@ function setup( failExec?: string; failExecWith?: { message: string; code?: string; detail?: string; position?: number }; noProjectId?: boolean; - // Simulates the real `DbConfigResolver`'s own "Initialising login - // role..." stderr line (`db-config.layer.ts`'s `initLoginRole`), - // fired as part of `resolve()`'s own connection-resolution work — i.e. - // strictly before `dbPushCore` (and its "DRY RUN: …" line) ever runs. - // `mockResolver` is otherwise silent, so tests pin the established output - // ordering against this stand-in line. + // Simulates the real `DbConfigResolver`'s "Initialising login role..." stderr line, + // fired as part of `resolve()`'s own connection work, strictly before `dbPushCore` + // runs; `mockResolver` is otherwise silent, so tests pin output ordering against it. simulateInitialisingLoginRole?: boolean; }, ) { @@ -196,9 +193,8 @@ function setup( resolve: () => Effect.succeed(opts.projectRef ?? VALID_REF), resolveForLink: () => Effect.succeed(opts.projectRef ?? VALID_REF), resolveOptional: () => Effect.succeed(Option.some(opts.projectRef ?? VALID_REF)), - // Go's `loadProjectRef` gives `--project-ref` top precedence, short-circuiting - // BEFORE the "not linked" failure — mirror that here so a test can prove the - // flag resolves a ref even when the workdir would otherwise fail to link. + // An explicit `--project-ref` flag takes top precedence, short-circuiting before + // the "not linked" failure. loadProjectRef: (flagValue: Option.Option) => Option.isSome(flagValue) && flagValue.value.length > 0 ? Effect.succeed(flagValue.value) @@ -230,9 +226,9 @@ function setup( ...(opts.noProjectId === true ? { projectId: Option.none() } : {}), }), BunServices.layer, - // Prompts (migration/seed confirmation) are answered through mockOutput's - // `promptConfirmResponses` (the TTY/clack path), so mark stdin a TTY. Stdin is - // only referenced by promptYesNo's non-TTY branch (unreached here). + // Prompts are answered through mockOutput's `promptConfirmResponses` (the + // TTY/clack path); Stdin is only used by promptYesNo's non-TTY branch (unreached + // here). mockTty({ stdinIsTty: true }), mockStdin(true), Layer.succeed(CliArgs, { args: opts.args ?? ["db", "push", "--local"] }), @@ -314,10 +310,8 @@ describe("db push", () => { return Effect.gen(function* () { yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); - // "supabase db push" is wrapped in Aqua (cyan) on stdout (established output contract). expect(out.stdoutText).toContain("Finished"); expect(out.stdoutText).toContain("supabase db push"); - // The migration body + history insert ran inside a transaction. expect(conn.execs).toContain("BEGIN"); expect(conn.execs).toContain("COMMIT"); expect(conn.queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations"))).toBe( @@ -403,13 +397,9 @@ describe("db push", () => { it.live( "prints the DRY RUN heads-up line after the connection resolves, not before (Go's push.Run order)", () => { - // The connection-resolution phase (which prints "Initialising login - // role..." when minting a temp role) runs BEFORE the push itself runs — - // and "DRY RUN: …" is the literal first line the push prints, so it - // prints AFTER that resolution output, never before. - // `simulateInitialisingLoginRole` stands in for the real resolver's - // stderr line (the fake resolver is otherwise silent) so this asserts on - // actual accumulated stderr text ordering, not internal call timing. + // `simulateInitialisingLoginRole` stands in for the real resolver's stderr line + // (the fake resolver is otherwise silent), so this asserts on actual stderr + // ordering, not internal call timing. const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', simulateInitialisingLoginRole: true, @@ -525,9 +515,8 @@ describe("db push", () => { }); it.live("expands a directory in [db.seed].sql_paths to its sorted .sql children", () => { - // A matched directory is walked and its regular `.sql` files seeded - // recursively; non-.sql files are skipped. Without dir expansion the - // directory path reached `readFileString()` and failed. + // Directories are walked recursively; without expansion the path would reach + // `readFileString()` and fail. const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.seed]\nsql_paths = ["seeds"]\n', files: { @@ -561,11 +550,9 @@ describe("db push", () => { }); it.live("hashes a non-UTF-8 seed file by its raw bytes (Go's io.Copy parity)", () => { - // The seed file hashes the raw byte stream; a UTF-8 string decode would - // replace the invalid bytes and change the hash. Write invalid UTF-8 and - // pre-seed the remote with the RAW-byte sha256 — the push must treat it as - // already-applied (byte hash matches), not re-run it. A string-decoded hash - // here would differ and mark the seed dirty. + // Writing invalid UTF-8 and pre-seeding the remote with the raw-byte sha256 proves + // the push hashes bytes, not a UTF-8 decode (which would replace invalid bytes and + // mark the seed dirty). const raw = Buffer.from([0x2d, 0x2d, 0x20, 0xff, 0xfe, 0x00, 0x01, 0x0a]); const rawHash = createHash("sha256").update(raw).digest("hex"); const { layer, out } = setup(tmp.current, { @@ -606,9 +593,7 @@ describe("db push", () => { }); it.live("--include-roles without a roles.sql pushes migrations and skips globals", () => { - // `supabase/roles.sql` is only globbed when it exists; an absent file is - // silently skipped (no error, no "Seeding globals" line) and the rest - // pushes. + // An absent roles.sql is silently skipped (no error, no "Seeding globals" line). const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), @@ -694,7 +679,6 @@ describe("db push", () => { return Effect.gen(function* () { yield* dbPush({ ...DEFAULT_FLAGS, includeSeed: true }).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Updating seed hash to supabase/seed.sql..."); - // Dirty seed only upserts the hash; the body statement is not executed. expect(conn.execs).not.toContain("insert into t values (1);"); }); }); @@ -806,10 +790,8 @@ describe("db push", () => { }); it.live("decrypts an encrypted vault secret keyed by the project .env (not process.env)", () => { - // Regression: the old point-of-use vault decryption keyed only on `process.env`, so a - // `DOTENV_PRIVATE_KEY` present only in the project `.env` failed to decrypt. The - // config load merges the project `.env` into the key set (`checkDbToml`), so - // it resolves. + // `DOTENV_PRIVATE_KEY` present only in the project .env must still decrypt — the + // config load merges the project .env into the key set (`checkDbToml`). const PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; const ENCRYPTED = "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; @@ -860,9 +842,8 @@ describe("db push", () => { }); it.live("renders Go's caret, Detail line, and 42704 extension hint on a failed migration", () => { - // Established failure-rendering format: the `^` caret under the - // server-reported error position, the `Detail` line, and the - // undefined-object extension hint. + // Established failure-rendering format: caret under the error position, Detail + // line, and undefined-object extension hint. const stat = "CREATE TABLE test (path ltree NOT NULL)"; const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n', @@ -910,7 +891,6 @@ describe("db push", () => { includeRoles: true, includeSeed: true, }).pipe(Effect.provide(layer)); - // The roles path is wrapped in Bold (ANSI). expect(out.stderrText).toContain("Would create custom roles"); expect(out.stderrText).toContain("roles.sql"); expect(out.stderrText).toContain("Would push these migrations:"); @@ -953,7 +933,7 @@ describe("db push", () => { const { layer, out } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { ...migrationFile("20240101000000"), "supabase/.env": "SUPABASE_YES=true\n" }, - // Deliberately no `confirm` responses — the prompt must be auto-confirmed. + // No `confirm` responses; the prompt must be auto-confirmed. }); return Effect.gen(function* () { yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer)); @@ -967,19 +947,16 @@ describe("db push", () => { const exit = yield* dbPush(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - // Config loads through the shared reader (`checkDbToml`), so a - // malformed config aborts with the established `failed to load config` - // message (the reader path), same as the other db commands - // (diff/dump/pull/migration). + // Config loads through the shared reader (`checkDbToml`), same as the other db + // commands. expect(JSON.stringify(exit.cause)).toContain("failed to load config"); } }); }); it.live("loads a Go-style env() boolean in config (no CliConfigParseError)", () => { - // Regression for the strict @supabase/config loader rejecting `enabled = "env(VAR)"`: - // env-expansion + boolean parsing must resolve it, so the config loads and the - // migration proceeds. Previously native push aborted before that parse ran. + // env-expansion + boolean parsing must resolve `env(VAR)` so the config loads and + // the migration proceeds. const previous = process.env["SEED_ENABLED"]; process.env["SEED_ENABLED"] = "true"; const { layer, out } = setup(tmp.current, { @@ -1003,9 +980,7 @@ describe("db push", () => { it.live("a matched remote block's migrations.enabled beats the shell env override", () => { // A matched [remotes.] block overrides the shell env, so // `[remotes.preview.db.migrations] enabled = false` wins over - // `SUPABASE_DB_MIGRATIONS_ENABLED=true` and the push skips migrations. - // (Before the config-reader convergence, push resolved this gate - // env-first and wrongly applied.) + // `SUPABASE_DB_MIGRATIONS_ENABLED=true`. const previous = process.env["SUPABASE_DB_MIGRATIONS_ENABLED"]; process.env["SUPABASE_DB_MIGRATIONS_ENABLED"] = "true"; const { layer, out } = setup(tmp.current, { @@ -1064,8 +1039,7 @@ describe("db push", () => { }); it.live("pushes to the project given via --project-ref without a linked workdir", () => { - // No `.temp/project-ref` and the resolver's own ref-file fallback is - // simulated as failing (`linkedFails`) — only the flag can resolve a ref. + // `linkedFails: true` simulates an unlinked workdir; only the flag can resolve a ref. const { layer, out, linkedCache, resolver } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000"), @@ -1092,9 +1066,8 @@ describe("db push", () => { }); it.live("--project-ref drives which [remotes.] block merges into config", () => { - // The `[remotes.staging]` block's `project_id` matches the FLAG ref, not the - // resolver's own `VALID_REF` fallback — the override only announces if - // the flag (not the fallback) actually resolved the ref config merges against. + // `[remotes.staging]`'s `project_id` matches the flag ref, not the resolver's own + // `VALID_REF` fallback, so the override only announces if the flag resolved it. const { layer, out } = setup(tmp.current, { toml: `project_id = "base"\n\n[remotes.staging]\nproject_id = "${FLAG_PROJECT_REF}"\n`, args: ["db", "push", "--linked"], @@ -1117,8 +1090,7 @@ describe("db push", () => { files: migrationFile("20240101000000"), args: ["db", "push", "--linked"], isLocal: false, - // The workdir is linked to VALID_REF (e.g. via .temp/project-ref) — - // the flag must win over it. + // A distinct fixed ref proves the flag, not the workdir's own ref, wins. projectRef: VALID_REF, confirm: [true], }); diff --git a/apps/cli/src/commands/db/push/push.layers.ts b/apps/cli/src/commands/db/push/push.layers.ts index 5af61cb7f9..adf0d0a288 100644 --- a/apps/cli/src/commands/db/push/push.layers.ts +++ b/apps/cli/src/commands/db/push/push.layers.ts @@ -20,12 +20,12 @@ import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts * connection, the db-config resolver, project-ref resolution, and the * linked-project cache. * - * Like `db lint`, it deliberately uses the **lazy** `commandPlatformApiFactoryLayer` - * (not the eager management-API runtime) so the auth-free `--local` path never - * resolves an access token at layer-build time. `commandSettingsLayer` is provided - * to each consumer that needs it (CLAUDE.md invariant 5); the single - * `identityStitchLayer` reference is shared so the factory, the cache, and - * the db-config resolver share one `stitchAttempted` guard. + * Like `db lint`, it uses the lazy `commandPlatformApiFactoryLayer` (not the eager + * management-API runtime) so the auth-free `--local` path never resolves an access + * token at layer-build time. `commandSettingsLayer` is provided to each consumer that + * needs it (CLAUDE.md invariant 5); the single `identityStitchLayer` reference is + * shared so the factory, the cache, and the db-config resolver share one + * `stitchAttempted` guard. */ const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); const httpClient = httpClientLayer.pipe(Layer.provide(debugLoggerLayer)); From 61cb5289c21a9654d3758c83be155edec728319f Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 10 Sep 2026 12:09:27 +0100 Subject: [PATCH 07/56] chore(repo): trim comments, shard 11 (1742 -> 826 comment lines) --- .../command-internal/get-tenant-api-keys.ts | 20 +- apps/cli/src/command-internal/git-status.ts | 31 +-- apps/cli/src/command-internal/glob.ts | 146 +++-------- .../src/command-internal/glob.unit.test.ts | 48 +--- apps/cli/src/command-internal/global-flags.ts | 247 +++++------------- .../global-flags.unit.test.ts | 16 +- .../command-internal/go-child-exit.error.ts | 50 +--- apps/cli/src/command-internal/go-duration.ts | 145 +++------- .../command-internal/go-duration.unit.test.ts | 39 --- apps/cli/src/command-internal/go-float.ts | 18 +- apps/cli/src/command-internal/go-json.ts | 71 ++--- .../src/command-internal/go-json.unit.test.ts | 12 - apps/cli/src/command-internal/go-jwt.ts | 188 ++++--------- .../src/command-internal/go-jwt.unit.test.ts | 35 +-- .../src/command-internal/go-output-flag.ts | 29 +- .../command-internal/go-output.encoders.ts | 115 +++----- .../go-output.encoders.unit.test.ts | 35 +-- .../src/command-internal/go-proxy.layer.ts | 78 ++---- .../go-proxy.layer.unit.test.ts | 104 ++------ .../src/command-internal/go-proxy.service.ts | 49 +--- apps/cli/src/command-internal/go-quote.ts | 39 +-- .../go-struct-output.encoders.ts | 209 +++++---------- .../go-struct-output.encoders.unit.test.ts | 43 +-- ...struct-output.types-gen-drift.unit.test.ts | 24 +- .../go-struct-output.types-gen-parser.ts | 25 +- 25 files changed, 446 insertions(+), 1370 deletions(-) diff --git a/apps/cli/src/command-internal/get-tenant-api-keys.ts b/apps/cli/src/command-internal/get-tenant-api-keys.ts index 5743d073b9..ab19ab08ac 100644 --- a/apps/cli/src/command-internal/get-tenant-api-keys.ts +++ b/apps/cli/src/command-internal/get-tenant-api-keys.ts @@ -1,22 +1,12 @@ import { mapHttpError, type NetworkErrorFactory, type StatusErrorFactory } from "./http-errors.ts"; /** - * Error mapper for Go's `tenant.GetApiKeys` - * (`apps/cli-go/internal/utils/tenant/client.go:70-84`): a transport failure maps - * to `failed to get api keys: `; a non-200 response maps to Go's - * `ErrAuthToken`, `Authorization failed for the access token and project ref - * pair: ` (`client.go:15,77-78`). + * Error mapper for resolving a project's service-role key: a transport failure maps to + * `failed to get api keys: `, and a non-200 response maps to `Authorization failed for the + * access token and project ref pair: `. * - * Both the `link` and `seed buckets` linked paths resolve the service-role key - * through `tenant.GetApiKeys` — `seed buckets` via `client.NewStorageAPI` - * (`internal/storage/client/api.go:22`), `link` directly — so both must surface - * this exact message. This is distinct from the `projects api-keys` helper - * (`get-api-keys.ts`), which ports `apiKeys.RunGetApiKeys` and maps a - * non-200 to `unexpected get api keys status ...`. - * - * Parameterized on the caller's tagged-error classes so `link` and `seed` keep - * their own `Link*` / `Seed*` error tags while sharing the message - * shape and the truncation / classification policy in `mapHttpError`. + * Shared by `link` and `seed buckets`; parameterized on the caller's tagged-error classes so each + * keeps its own error tags while sharing this message shape. */ export const mapTenantApiKeysError = (opts: { readonly networkError: NetworkErrorFactory; diff --git a/apps/cli/src/command-internal/git-status.ts b/apps/cli/src/command-internal/git-status.ts index 276d2a2754..36e45449da 100644 --- a/apps/cli/src/command-internal/git-status.ts +++ b/apps/cli/src/command-internal/git-status.ts @@ -6,32 +6,15 @@ import { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner import { collectText } from "./container-cli.ts"; /** - * Whether `path` — a file OR a directory — has uncommitted changes in its git - * working tree. Backs `config pull`'s own dirty guard (CLI-2064 plan §1.4) - * over `supabase/config.toml`, and `pull`'s own generalized dirty guard - * (CLI-1272) over `supabase/config.toml`, `supabase/migrations`, and - * `supabase/functions`: each command warns (TTY, no `--force`) or aborts - * (machine format, no `--force`) before silently overwriting local edits. + * Whether `path` (a file or directory) has uncommitted changes in its git working tree. * - * Runs `git status --porcelain -- ` with `cwd` set to `path`'s own - * parent directory (reusing `spawn`/`collectText` exactly as - * `container-cli.ts`'s `containerCliExitCodeAndStdout` does) — the path - * argument is always the bare basename, listed after `--`, so a name that - * happens to look like a flag is never misread as one. Git's own pathspec - * matching already treats a directory pathspec as "anything under that - * tree", so passing a directory here needs no special-casing: a non-empty - * result means at least one file under it is modified or untracked. + * Runs `git status --porcelain -- ` with `cwd` set to `path`'s parent directory, listing + * the basename after `--` so a name that looks like a flag isn't misread as one. A directory + * pathspec matches anything under that tree, so a non-empty result means at least one file under + * it is modified or untracked. * - * git plumbing here is advisory, never load-bearing, mirroring - * `detectGitBranch`'s (`../../shared/git/git-branch.ts`) own - * degrade-silently philosophy: `Option.none()` covers every case that isn't a - * clean yes/no answer — `path`'s parent directory isn't a git working tree, - * `git` isn't installed or isn't on `PATH` (spawn failure), `git status` - * exits non-zero for any other reason, or the command otherwise fails for any - * reason — so a caller never needs to distinguish "definitely clean" from - * "couldn't tell". `Option.some(true)` is a non-empty porcelain output - * (uncommitted changes exist); `Option.some(false)` is an exit-0 run with - * empty output (clean working tree). + * Returns `Option.none()` whenever the answer can't be determined (not a git working tree, `git` + * missing, a non-zero exit, or any other failure) rather than treating that as "clean". */ export function pathHasUncommittedChanges( path: string, diff --git a/apps/cli/src/command-internal/glob.ts b/apps/cli/src/command-internal/glob.ts index bd3d905b60..1507937c3c 100644 --- a/apps/cli/src/command-internal/glob.ts +++ b/apps/cli/src/command-internal/glob.ts @@ -4,50 +4,30 @@ import type { PlatformError } from "effect/PlatformError"; import { pathMatch } from "./path-match.ts"; /** - * Hoisted `Config.Glob` filesystem-matching primitives — `io/fs.Glob` (per-pattern - * matching) plus the workdir-relative-vs-absolute resolution the config - * loader applies to every glob-shaped config field. Originally private to - * `seed.ts` (the first caller, `[db.seed] sql_paths`); hoisted here once - * `migrate-and-seed.ts`'s declarative-schema-files branch (`[db.migrations] - * schema_paths`, `Glob.SQLFiles`) became a second caller — see `apps/cli/CLAUDE.md`'s - * "Hoist Before You Duplicate". + * Filesystem-matching primitives for glob-shaped config fields (e.g. `[db.seed] sql_paths`, + * `[db.migrations] schema_paths`), resolving each pattern relative to the workdir unless it's + * absolute. */ -// `io/fs.hasMeta` (`glob.go`): the magic-character set is `*`, `?`, `[`, and `\` -// (escape) — `\` counts so a pattern whose only glob syntax is a backslash escape (e.g. -// `foo\.sql`) is globbed via `pathMatch` (which handles the escape) instead of being -// treated as a literal filename and missing the real file. `filepath.ToSlash` applies -// before globbing, so a `\` here is always a glob escape, never a path separator. +// `\` counts as a glob metacharacter alongside `*`, `?`, `[`, since it's the escape character +// {@link pathMatch} handles. Paths are normalized to `/` separators before this runs. const hasGlobMeta = (pattern: string): boolean => /[*?[\\]/u.test(pattern); -// `path.Split` + `cleanGlobPath` (`io/fs/glob.go`) reduces a Windows drive-root -// pattern like `C:/*.sql` (post `filepath.ToSlash`) to a bare `C:` directory component — one -// level up from `globPattern`'s own slash-split below — which is still part of the SAME -// already-absolute pattern's root, never something to join under the workdir, even though -// `path.isAbsolute("C:")` is `false` (Node's win32 rules require the trailing separator, -// `C:\`/`C:/`, to call a path absolute; a bare `C:` alone is technically "drive-relative"). -// Recognize that exact shape so it reaches `fs.readDirectory`/`fs.exists` verbatim, mirroring -// it passing straight through to `ReadDir`/`Stat` on the same real, unrooted `afero.NewOsFs`. +// A bare Windows drive root like `C:` is not `path.isAbsolute` (Node requires the trailing +// separator), but it's already an absolute path component and must not be joined under the +// workdir. const isWindowsDriveRoot = (p: string): boolean => /^[A-Za-z]:$/.test(p); -// Glob-config paths resolve through an OS-root-rooted `afero.NewOsFs`, where the -// CLI's "workdir" is just `os.Chdir(workdir)` — which only -// affects RELATIVE paths. An absolute glob-config entry, preserved verbatim by the config -// loader (gated on `!filepath.IsAbs`), therefore resolves at the OS -// root, never under the workdir. Mirror that: only join under the workdir when the path is -// relative (`path.join` would otherwise collapse `/repo` + `/tmp/seed.sql` to -// `/repo/tmp/seed.sql`). +// Only a relative path resolves under the workdir; an absolute path stays absolute (`path.join` +// would otherwise collapse `/repo` + `/tmp/seed.sql` into `/repo/tmp/seed.sql`). export const resolveUnderWorkdir = (path: Path.Path, workdir: string, p: string): string => path.isAbsolute(p) || isWindowsDriveRoot(p) ? p : path.join(workdir, p); /** - * Resolves a single glob pattern against the workdir, returning the matched paths RELATIVE - * to the workdir (so callers stay Go-compatible, e.g. `seed_files.path`). Mirrors Go's - * `fs.Glob`: a literal pattern (no glob metacharacter anywhere) returns itself iff it - * exists; a pattern with metacharacters lists each parent directory and matches per segment - * via `pathMatch` (Go's `path.Match`). The caller validates the whole pattern up - * front (`pathMatch(pattern, "").badPattern`), so a malformed class never reaches - * here. + * Resolves a single glob pattern against the workdir, returning matched paths relative to the + * workdir. A literal pattern (no glob metacharacter) returns itself if it exists; a pattern with + * metacharacters lists each parent directory and matches per segment via {@link pathMatch}. The + * caller validates the whole pattern up front, so a malformed character class never reaches here. */ export const globPattern = ( fs: FileSystem.FileSystem, @@ -56,15 +36,8 @@ export const globPattern = ( pattern: string, ): Effect.Effect> => Effect.gen(function* () { - // `Glob.files` calls `fs.Glob(fsys, filepath.ToSlash(pattern))` - // (comment: "Glob expects / as path separator on windows") — a no-op on POSIX, where - // `path.sep` is already `/`, but on Windows it replaces every `\` with `/` BEFORE any - // meta-detection or directory-splitting happens. Without this, a Windows entry with - // backslashes — an absolute one is preserved verbatim by `resolveSeedSqlPath`, but - // even a relative one can carry them — never matches the `/`-only split below and - // `hasGlobMeta` misreads a plain backslash as glob syntax, so a literal path like - // `schemas\foo.sql` or a real pattern like `schemas\*.sql` would silently resolve to - // nothing instead of the configured file. + // Normalize `\` to `/` before matching (POSIX already uses `/`) so a Windows path like + // `schemas\foo.sql` isn't misread by `hasGlobMeta` as containing glob-escape syntax. const normalized = path.sep === "/" ? pattern : pattern.replaceAll("\\", "/"); if (!hasGlobMeta(normalized)) { const exists = yield* fs @@ -73,16 +46,9 @@ export const globPattern = ( return exists ? [normalized] : []; } const slash = normalized.lastIndexOf("/"); - // `path.Split`/`cleanGlobPath` (`io/fs/glob.go`) keep a root-only directory distinct - // from "no directory at all": splitting a POSIX-root pattern like `/*.sql` yields a bare - // `/`, which Go still globs as the fsys root — NOT the workdir `afero.NewOsFs()` happens to - // have `chdir`-ed into — and every match it returns stays `/`-prefixed. Collapsing that to - // `""` here (indistinguishable from the truly relative no-slash case below, where `""` - // correctly means "resolve under workdir") would silently glob the workdir instead of the - // real root for a pattern whose ONLY slash is the leading one. Confirmed empirically - // against the real, unrooted `afero.NewOsFs()` Go itself globs through: `Glob{"/*"}. - // Files(fsys)` lists the actual filesystem root's entries, each still `/`-prefixed, not - // Go's cwd. + // A leading-slash-only pattern (e.g. `/*.sql`) keeps its directory as `/`, distinct from the + // no-slash case where an empty directory means "resolve under workdir" — otherwise it would + // glob the workdir instead of the filesystem root. const dirPattern = slash === -1 ? "" : slash === 0 ? "/" : normalized.slice(0, slash); const filePattern = slash === -1 ? normalized : normalized.slice(slash + 1); const dirs = hasGlobMeta(dirPattern) @@ -94,8 +60,8 @@ export const globPattern = ( const names = yield* fs.readDirectory(absDir).pipe(Effect.orElseSucceed(() => [])); for (const name of names) { if (pathMatch(filePattern, name).matched) { - // `dir` is already `/` for the bare-root case above — appending `/${name}` the same - // way every other (non-root) `dir` value does below would double the separator. + // `dir` is already `/` for the root case above; appending `/${name}` the same way as + // other `dir` values would double the separator. result.push( dir.length === 0 ? name : dir.endsWith("/") ? `${dir}${name}` : `${dir}/${name}`, ); @@ -106,62 +72,23 @@ export const globPattern = ( }); /** - * `sort.Strings` compares byte-wise over each string's UTF-8 encoding; JS's default - * `Array.prototype.sort()` instead compares UTF-16 CODE UNITS, which diverges from byte/codepoint - * order for a supplementary-plane character (encoded as a surrogate pair, code units - * `0xD800`-`0xDBFF` + `0xDC00`-`0xDFFF`) alongside a BMP private-use character (`0xE000`- - * `0xFFFF`): JS ranks the surrogate pair BEFORE the private-use character (`0xD800 < 0xE000`), - * while Go's UTF-8 byte order — which preserves Unicode codepoint order — ranks the - * supplementary-plane codepoint (`>= U+10000 > U+FFFF`) AFTER it. Verified empirically: - * `["a\u{1F600}.sql","a.sql"].sort()` (default) disagrees with `Buffer.compare` on the - * same two strings' UTF-8 bytes. Used for every `sort.Strings` this module (and its callers - * across `shadow-source.ts`/`migration-list.ts`) ports, so a directory with such - * filenames applies/lists in the same order Go would. + * Byte-wise UTF-8 order, not JS's UTF-16 code-unit order; the two disagree for supplementary-plane + * characters (encoded as surrogate pairs) relative to Basic Multilingual Plane private-use + * characters, e.g. `["a\u{1F600}.sql","a.sql"].sort()` disagrees with a byte-wise compare. */ export function compareUtf8Bytes(a: string, b: string): number { return Buffer.compare(Buffer.from(a, "utf8"), Buffer.from(b, "utf8")); } /** - * Port of `walkMatchedDir` (called by `Glob.SQLFiles` on - * every directory match) AND `afero.Walk` (`commands/db/shared/shadow-source.ts`'s - * declared-schema walkers' own counterpart) — both are `Lstat`-based and therefore never - * descend into a symlinked directory: - * `io/fs.WalkDir`'s doc comment: "WalkDir does not follow symbolic links found in directories, - * but if root itself is a symbolic link, its target will be walked"; `afero.walk` confirms the - * same via its own `lstatIfPossible` call, which reports a - * symlinked subdirectory's `IsDir()` as false so the recursive `walk` call returns without - * descending. The `FileSystem` service exposes no non-following `lstat`; `fs.readLink` - * succeeding on a path IS Effect's only non-following "is this a symlink" primitive, so it - * stands in for that check at each level, both for recursion (a symlinked directory is skipped, - * not walked) and for file inclusion (a symlinked `.sql` file is skipped, not applied) — using - * `fs.stat` (which follows) here instead would silently include a symlink's target. - * Deliberately never checks its OWN root (`dir`) for being a symlink — only entries it reads - * FROM that root — so each caller layers on whichever of the two root-symlink behaviors its - * own counterpart needs (`fs.WalkDir` follows a symlinked root; `afero.Walk` does not) on - * top of this shared walk. + * Recursively lists `.sql` files under `dir`, byte-sorted, skipping symlinked directories and + * files. The `FileSystem` service exposes no non-following `lstat`, so a successful + * `fs.readLink` stands in for "this entry is a symlink" at every level; `dir` itself is never + * checked, so a symlinked root walks normally. * - * Both walkers byte-sort every directory level (`os.ReadDir`/`io/fs.ReadDir`'s - * `bytealg.CompareString`/`sort.Strings`, not just the final flattened result), so the - * traversal order itself (which determines which - * entry's read/stat error surfaces first when a walk aborts early) uses - * {@link compareUtf8Bytes} too, not JS's default UTF-16-code-unit comparator (review: - * PRRT_kwDOErm0O86XAlIo). They also both finish with a plain `sort.Strings` over the complete - * set of collected paths — a full - * lexicographic sort over full relative paths, NOT merely a per-directory-level sort (a - * directory's own files can byte-sort AFTER a sibling's full path even though the directory - * NAME sorts first, e.g. `"foo.sql" < "foo/bar.sql"` since `.` (`0x2E`) sorts before `/` - * (`0x2F`)) — so the final sort below is required even though entries are already read in - * sorted order at each level. Returns paths relative to `dir`. - * - * Hoisted here (from `migrate-and-seed.ts`, the first caller, for `db.migrations. - * schema_paths`) once `seed.ts`'s `db.seed.sql_paths` resolution became a second - * caller, and `shadow-source.ts`'s declared-schema walkers (a matched `schema_paths` - * directory, or the `supabase/schemas`/pg-delta-declarative-dir fallbacks) became a third — - * `Glob.SQLFiles` and `afero.Walk` are the SAME shape - * (byte-sorted, no-follow, regular-`.sql`-file-filtered) every one of these config fields - * resolves through, so a matched directory must expand to its sorted regular `.sql` files - * identically regardless of which config field it came from. + * Sorts every directory level and the final flattened list: a directory's own files can byte-sort + * after a sibling's full path even though the directory name sorts first (e.g. `"foo.sql" < + * "foo/bar.sql"`), so per-level order alone isn't enough. Returns paths relative to `dir`. */ export const walkSqlFiles = ( fs: FileSystem.FileSystem, @@ -179,14 +106,9 @@ export const walkSqlFiles = ( Effect.orElseSucceed(() => false), ); if (isSymlink) continue; - // Unlike the `readLink` probe above (where failure legitimately just means "not a - // symlink"), a `stat` failure here means something actually went wrong reading an entry - // `readDirectory` just listed (removed mid-walk, permission denied, I/O error) — - // `fs.WalkDir` (`walkMatchedDir`) propagates that exact - // error from its walk callback, aborting `Glob.SQLFiles`/`Config.Load` entirely rather than - // silently treating the entry as absent. Swallowing it here would let a declared - // `db.migrations.schema_paths`/`db.seed.sql_paths` directory silently apply an incomplete - // set of files instead of failing the command (review: PRRT_kwDOErm0O86WXFqr). + // Unlike the `readLink` probe above (where failure just means "not a symlink"), a `stat` + // failure here means something went wrong reading an entry `readDirectory` just listed; + // propagate it rather than silently treating the entry as absent. const info = yield* fs.stat(absChild); if (info.type === "Directory") { files.push(...(yield* walkSqlFiles(fs, absChild, relChild))); diff --git a/apps/cli/src/command-internal/glob.unit.test.ts b/apps/cli/src/command-internal/glob.unit.test.ts index 1203d220a5..5fa2f0164d 100644 --- a/apps/cli/src/command-internal/glob.unit.test.ts +++ b/apps/cli/src/command-internal/glob.unit.test.ts @@ -4,15 +4,8 @@ import { Effect, Exit, FileSystem, Layer, Option, Path, PlatformError } from "ef import { compareUtf8Bytes, globPattern, resolveUnderWorkdir, walkSqlFiles } from "./glob.ts"; -/** - * A `FileSystem.FileSystem` that answers `readDirectory` from a fixed map (keyed by the exact - * directory string `globPattern` asks for) instead of touching the real filesystem — lets - * these tests assert Go's root-vs-workdir distinction (`Glob{"/*"}.Files(fsys)` reads the fsys - * root, not the cwd `afero.NewOsFs()` happens to be `chdir`-ed into) without depending on what's - * actually present at the real OS root. Every other `FileSystem` method delegates to the real - * Bun filesystem (unused by `globPattern`'s glob-meta branch, which only calls - * `readDirectory`). - */ +// Answers `readDirectory` from a fixed map keyed by the requested directory; every other method +// delegates to the real Bun filesystem. function fakeReadDirFs(entries: Record>) { const calls: Array = []; const layer = Layer.effect( @@ -34,10 +27,6 @@ describe("globPattern", () => { it.effect( "globs a root-anchored absolute pattern (/*.sql) against the filesystem root, not the workdir", () => { - // `path.Split`/`cleanGlobPath` (`io/fs/glob.go`) reduce `/*.sql` to a bare `/` - // directory — confirmed empirically against the real Go CLI's own (unrooted) - // `afero.NewOsFs()`: `config.Glob{"/*"}.Files(fsys)` lists the actual filesystem root's - // entries, each still `/`-prefixed, never the process's cwd. const { layer, calls } = fakeReadDirFs({ "/": ["one.sql", "two.sql", "notes.txt"], "/some/workdir": ["should-not-be-read.sql"], @@ -69,11 +58,7 @@ describe("globPattern", () => { it.effect( "globs a Windows drive-root pattern (C:\\*.sql) against the drive root, not the workdir", () => { - // Mirrors the POSIX root case one level up: Go's split also collapses a drive-root - // pattern to a bare `C:` directory component (`filepath.ToSlash` turns `C:\*.sql` into - // `C:/*.sql` first, then the SAME `path.Split`/`cleanGlobPath` logic applies) — still - // part of the same already-absolute pattern, never something to join under the workdir. - // Uses the real Node win32 path module (via `BunPath.layerWin32`) so this is deterministic + // Uses the real Node win32 path module (`BunPath.layerWin32`) so this is deterministic // regardless of the host OS running the test. const { layer, calls } = fakeReadDirFs({ "C:": ["x.sql", "y.sql"], @@ -127,15 +112,9 @@ const statFailure = (path: string) => pathOrDescriptor: path, }); -/** - * A `FileSystem.FileSystem` entirely backed by fixed maps: `readDirectory` answers from - * `entries`, `readLink` succeeds (with a dummy target) only for paths listed in `symlinks` — - * every other entry looks like "not a symlink" to `walkSqlFiles`'s probe — and `stat` - * answers from `statTypes`, except for `statFailsFor`, which fails, simulating a - * permission/I/O error reading an entry `readDirectory` just listed (distinct from a benign - * not-a-symlink `readLink` failure). Every other method is `walkSqlFiles`-unreachable - * noise, so it's left as `FileSystem.makeNoop`'s default `NotFound` failure. - */ +// A `FileSystem.FileSystem` backed by fixed maps: `readDirectory`/`stat` answer from `entries`/ +// `statTypes`, `readLink` succeeds only for paths in `symlinks`, and `statFailsFor` makes `stat` +// fail for that one path. function fakeWalkFs( entries: Record>, statTypes: Record, @@ -158,11 +137,6 @@ function fakeWalkFs( describe("walkSqlFiles", () => { it.effect("propagates a stat failure instead of silently treating the entry as absent", () => { - // `fs.WalkDir` (`walkMatchedDir`) propagates a - // per-entry stat/lstat error from its walk callback, aborting the SQL-files glob entirely — - // a directory `readDirectory` can list but `stat` then fails to read (permission denied, - // removed mid-walk, I/O error) must fail the whole walk, not silently resolve to "no file - // here" (review: PRRT_kwDOErm0O86WXFqr). const layer = fakeWalkFs({ "/schemas": ["broken.sql"] }, {}, "/schemas/broken.sql"); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; @@ -195,8 +169,6 @@ describe("walkSqlFiles", () => { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const files = yield* walkSqlFiles(fs, "/schemas", ""); - // No caller-side `.sort()` — `walkSqlFiles` now returns the fully-sorted result - // itself, matching both Go walkers' own trailing `sort.Strings(files)`. expect([...files]).toEqual(["nested/inner.sql", "top.sql"]); }).pipe(Effect.provide(layer)); }); @@ -204,12 +176,8 @@ describe("walkSqlFiles", () => { it.effect( "sorts by UTF-8 byte order, not JS's default UTF-16 code-unit order (review: PRRT_kwDOErm0O86XAlIo)", () => { - // A supplementary-plane character (U+1F600, encoded as a UTF-16 surrogate pair - // 0xD800-0xDFFF) alongside a BMP private-use character (U+E000): JS's default - // `Array.prototype.sort()` ranks the surrogate pair FIRST (0xD800 < 0xE000), while Go's - // `sort.Strings` (byte-wise over UTF-8, which preserves Unicode codepoint order) ranks - // the supplementary-plane codepoint (0x1F600 > 0xE000) AFTER it — see - // `compareUtf8Bytes`'s own doc comment. + // U+1F600 (a UTF-16 surrogate pair) sorts before U+E000 in JS's default order but after it + // in byte-wise UTF-8 order. const surrogatePair = "a\u{1f600}.sql"; const privateUse = "a\u{e000}.sql"; const layer = fakeWalkFs( diff --git a/apps/cli/src/command-internal/global-flags.ts b/apps/cli/src/command-internal/global-flags.ts index 8db34099b6..5f9120ac67 100644 --- a/apps/cli/src/command-internal/global-flags.ts +++ b/apps/cli/src/command-internal/global-flags.ts @@ -5,29 +5,17 @@ import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { VALUE_CONSUMING_LONG_FLAGS, VALUE_CONSUMING_SHORT_FLAGS } from "./db-target-flags.ts"; import { viperEnvBool, viperEnvBoolWithProjectFallback } from "./viper-env.ts"; -// The Effect CLI hoists global flags out of the token stream before the leaf -// parse and builds ONE tree-wide registry, so a command cannot redeclare an -// `output` global to vary its allowed values (the registry throws on duplicate -// names). Go instead registers `--output` per command: resource commands accept -// `env|pretty|json|toml|yaml`, while `db query` accepts `json|table|csv`. We -// model that single global as the UNION of those value sets; each handler honors -// only the values its Go counterpart does (e.g. `db query` reads `table`/`csv`, -// resource commands ignore them and fall through to text). `table`/`csv` are -// only meaningful to `db query`. +// The CLI's global-flag registry is tree-wide, so `-o/--output` can't be redeclared per command to +// vary its allowed values; this models it as the union of every command's accepted values, and +// each handler honors only the subset it cares about (e.g. `db query` reads `table`/`csv`; other +// commands ignore them and fall through to text). // -// Every description string below is copied VERBATIM (including Go's own -// lowercase, no-trailing-period house style for root persistent flags) from -// `apps/cli-go/cmd/root.go:337-348` — this text is directly user-visible now -// that native shell completion (CLI-1965) surfaces it in `__complete` -// candidate descriptions, where a prior Go-binary passthrough used to emit -// Go's own text byte-for-byte; before that, this only reached the TS-native -// `--help` renderer, whose overall layout already diverges from cobra's, so -// the mismatch was harder to notice (CLI-1965 review finding). +// These flag description strings are user-visible in shell completion (`__complete` candidate +// descriptions), so keep them accurate when changing a flag. /** - * Every value the global `-o/--output` flag accepts — the single source of - * truth for per-command `outputFormats` overrides that widen the wrapper's - * enum check to "all values" (e.g. `config diff`'s handler-level rejection), - * so a value added here automatically flows to those commands and their tests. + * Every value the global `-o/--output` flag accepts. Per-command `outputFormats` overrides (e.g. + * `config diff`'s handler-level rejection) widen against this list, so a value added here flows to + * those commands and their tests automatically. */ export const GLOBAL_OUTPUT_FORMATS = [ "env", @@ -113,27 +101,12 @@ export const AgentFlag = GlobalFlag.setting("agent")({ }); /** - * Every global/persistent flag declared above, mirroring the set Go registers on - * the root command (`apps/cli-go/cmd/root.go:337-348`). + * Every global/persistent flag declared above. * - * Adding a VALUE-taking flag here also means adding its name to - * `PERSISTENT_VALUE_FLAG_NAMES` (`shared/cli/cobra-flag-groups.ts`): the - * handler-side pflag scans read it directly, and the pre-parse scanners - * (`globalFlagsWithValues` in `shared/cli/run.ts`, the `agent-output.ts` - * predicates) derive their token set from it (`GLOBAL_VALUE_FLAG_TOKENS`), - * so that one edit covers them all. The same obligation covers the CLI - * library's own value-taking built-ins (`--log-level` — issue #6482; - * `--completions` is scoped per `PERSISTENT_VALUE_FLAG_NAMES`'s doc). The - * pre-parse scanners must run even for `--help`/`--version`/bare-group - * invocations, which cobra serves before `PersistentPreRunE` and so never - * expose parsed flag values to read instead. A flag missed in the shared - * registry still fails silently rather than loudly: an unregistered value - * flag does not consume its following token, so `supabase --new-flag - * --workdir other ` makes the upgrade notice read/write - * `other/supabase/.temp/cli-latest`, where Go — which lets `--new-flag` eat - * `--workdir` — resolves against the cwd. Only the bare space-separated - * spelling diverges (`--new-flag=x --workdir other` agrees), which is what - * makes it easy to miss. + * A value-taking flag added here must also be added to `PERSISTENT_VALUE_FLAG_NAMES` + * (`shared/cli/cobra-flag-groups.ts`), which the handler-side pflag scans and pre-parse token + * scanners both derive their token set from. A flag missed there fails silently: an unregistered + * value flag won't consume its following token, so that token gets misread as positional. */ export const GLOBAL_FLAGS = [ OutputFlag, @@ -149,28 +122,14 @@ export const GLOBAL_FLAGS = [ ] as const; /** - * Resolves the current value of every global/persistent flag above, keyed by - * its own CLI flag name (each flag's `.id`, e.g. `debug`, `workdir`). Used by - * `telemetry/command-telemetry.ts` to mirror Go's - * `changedFlags()` walking `cmd.Parent()`'s `PersistentFlags()` in addition to - * a command's own flags (`cmd/root_analytics.go:53-76`) — global flags here - * live in a single Effect-context-wide registry rather than per-ancestor - * `pflag.FlagSet`s, so this reads all of them unconditionally instead of - * walking a parent chain (CLI-1896). + * Resolves the current value of every global/persistent flag above, keyed by its own CLI flag + * name. Used by `telemetry/command-telemetry.ts` as a fallback for a global flag not present in a + * handler's own `flags` record. * - * Read via `Effect.serviceOption` (adds no `R` requirement) so a caller that - * hasn't wired the global-flag context — e.g. a focused unit test — simply - * gets an empty record instead of a missing-service defect; production always - * provides every global flag through `Command.withGlobalFlags` at the CLI - * root (`cli/root.ts`). - * - * Reads each flag individually (rather than looping `GLOBAL_FLAGS`) - * because each `Setting` has a distinct value type `A` — a homogeneous - * loop widens the union in a way `Effect.serviceOption` can't resolve back to - * a single service lookup without an `as` cast, which this codebase forbids. - * `global-flags.unit.test.ts` asserts the resolved id set stays exactly in - * sync with `GLOBAL_FLAGS` — extend both together when adding a new - * global flag. + * Reads via `Effect.serviceOption` so a caller without the global-flag context (e.g. a focused + * unit test) gets an empty record instead of a missing-service defect. Reads each flag + * individually, since each `Setting`'s distinct value type can't be looped over without an + * `as` cast; `global-flags.unit.test.ts` keeps the resolved id set in sync with `GLOBAL_FLAGS`. */ export const globalFlagValues = Effect.gen(function* () { const values: Record = {}; @@ -193,16 +152,11 @@ export const globalFlagValues = Effect.gen(function* () { const PFLAG_FALSE_VALUES = new Set(["0", "f", "F", "false", "FALSE", "False"]); /** - * Raw argv truncated at the first bare `--` operand terminator. Both pflag/cobra - * (verified against the pinned `apps/cli-go` versions: a value placed after `--` - * never sets `cmd.Flags().Changed(...)`) and this CLI's own lexer - * (`effect/unstable/cli/internal/lexer.ts`, which splits on `argv.indexOf("--")` - * into parsed tokens vs. `trailingOperands`) stop parsing flags at the first `--` - * — everything after is a positional operand, e.g. a migration name literally - * called `--experimental=false` passed as `db pull -- --experimental=false`. The - * argv-scanning `*ExplicitlyFalse` heuristics below must only look at the - * flag-parsing region, or a positional operand that merely looks like a flag gets - * mistaken for an explicit one. + * Raw argv truncated at the first bare `--` operand terminator. This CLI's own lexer stops parsing + * flags there too, so everything after is a positional operand, e.g. a migration name literally + * called `--experimental=false` passed as `db pull -- --experimental=false`. The argv-scanning + * `*ExplicitlyFalse` heuristics below must only look at this region, or a positional operand that + * merely looks like a flag gets mistaken for an explicit one. */ const argsBeforeOperandTerminator = (args: ReadonlyArray): ReadonlyArray => { const terminatorIndex = args.indexOf("--"); @@ -210,21 +164,11 @@ const argsBeforeOperandTerminator = (args: ReadonlyArray): ReadonlyArray }; /** - * Drops tokens that pflag would consume as a value-consuming flag's value in - * space-separated form (`--flag value` / `-f value`), so the `--yes`/`--experimental` - * argv scanners below don't mistake a consumed value token for an explicit - * occurrence of the global flag. `--yes`/`--experimental` are global, - * position-independent flags (bound anywhere in argv), so any LOCAL command's - * bare value-consuming flag immediately before one of them "eats" it under real - * pflag semantics — e.g. `db pull --password --experimental=false` parses as - * `--password`'s value being the literal string `"--experimental=false"`, not a - * changed `--experimental` (verified against the review finding on CLI-1957: the - * repository's own argv scanner already documents and handles this exact case for - * `resolveDbTargetFlags`/`changedLinkedLocalFlags` - * (`command-internal/db-target-flags.ts`) and `extractChangedFlagNames` - * (`telemetry/command-telemetry.ts`), which this reuses the - * same `VALUE_CONSUMING_LONG_FLAGS`/`VALUE_CONSUMING_SHORT_FLAGS` registries for, - * so the three scans can't drift out of sync). + * Drops tokens that pflag would consume as a value-consuming flag's value in space-separated form + * (`--flag value` / `-f value`), so the `--yes`/`--experimental`/`--debug` argv scanners below + * don't mistake a consumed value token for an explicit occurrence of the global flag — e.g. + * `db pull --password --experimental=false` treats `--experimental=false` as `--password`'s + * value, not a changed `--experimental`. */ const nonValueConsumedTokens = (args: ReadonlyArray): ReadonlyArray => { const kept: Array = []; @@ -247,16 +191,10 @@ const nonValueConsumedTokens = (args: ReadonlyArray): ReadonlyArray` (pflag's `ParseBool` - * false set). Go binds `--yes` to viper, so a *set* pflag value wins over - * `AutomaticEnv`; `YesFlag` is a plain boolean that can't distinguish an - * explicit `--yes=false` from the omitted default, so we scan the raw argv (global - * flags are position-independent) up to the first `--` operand terminator (see - * {@link argsBeforeOperandTerminator}), skipping tokens consumed as another flag's - * value (see {@link nonValueConsumedTokens}). Only `--yes=false` needs special - * handling: for `--yes` / `--yes=true` the flag is already `true`, so `flag || env` - * matches Go, and for an omitted flag the env fallback matches Go. Reading the raw - * argv also sidesteps however the CLI parser coerces `--yes=false`. + * True when the raw argv contains an explicit `--yes=`. A plain boolean flag can't + * distinguish an explicit `--yes=false` from the omitted default, so this scans the raw argv up to + * the first `--` operand terminator (see {@link argsBeforeOperandTerminator}), skipping tokens + * consumed as another flag's value (see {@link nonValueConsumedTokens}). */ const yesFlagExplicitlyFalse = (args: ReadonlyArray): boolean => nonValueConsumedTokens(argsBeforeOperandTerminator(args)).some( @@ -264,11 +202,8 @@ const yesFlagExplicitlyFalse = (args: ReadonlyArray): boolean => ); /** - * `--yes` resolved with Go's viper `AutomaticEnv` fallback: when the flag is not - * passed, `SUPABASE_YES` is honored (`apps/cli-go/cmd/root.go:318-320` binds - * every persistent flag, so `console.PromptYesNo` reading `viper.GetBool("YES")` - * picks up the env var). An explicit `--yes` — including `--yes=false` — wins over - * the env, matching viper's bound-pflag precedence. Prefer this over reading + * `--yes` resolved with an env fallback: when the flag isn't passed, `SUPABASE_YES` is honored. An + * explicit `--yes` (including `--yes=false`) wins over the env. Prefer this over reading * {@link YesFlag} directly anywhere a command auto-confirms a prompt. */ export const resolveYes = Effect.gen(function* () { @@ -282,15 +217,10 @@ export const resolveYes = Effect.gen(function* () { /** * `--yes` resolved with the project `.env` consulted too, for commands that load the nested - * project env before prompting (`migration down`, `migration repair --all`). Go runs - * `loadNestedEnv` — `godotenv.Load`, which only sets keys absent from the shell env — - * inside `ParseDatabaseConfig` before `PromptYesNo` reads `viper.GetBool("YES")` - * (`pkg/config/config.go:701`, `internal/utils/console.go:71`), so a `SUPABASE_YES` set - * only in `supabase/.env` auto-confirms. Shell *presence* — any value, including `false`, - * empty, or garbage — suppresses the file value entirely (see - * {@link viperEnvBoolWithProjectFallback}). An explicit `--yes` (including - * `--yes=false`) wins over both. `projectEnv` is the loaded map from - * `loadProjectEnv`. + * project env before prompting (`migration down`, `migration repair --all`). Shell env + * *presence* (any value) suppresses the file value entirely (see + * {@link viperEnvBoolWithProjectFallback}); an explicit `--yes` wins over both. `projectEnv` is + * the loaded map from `loadProjectEnv`. */ export const resolveYesWithProjectEnv = (projectEnv: Record) => Effect.gen(function* () { @@ -303,27 +233,11 @@ export const resolveYesWithProjectEnv = (projectEnv: Record) => }); /** - * Resolves the raw argv's *last* explicit `--experimental` occurrence to a boolean, or - * `undefined` when the flag never appears before the first `--` operand terminator (see - * {@link argsBeforeOperandTerminator}). `--experimental` is bound to viper the same way - * `--yes` is (`apps/cli-go/cmd/root.go:318-334`): pflag/viper share ONE variable per flag, - * so repeated occurrences collapse to whichever `Set()` call happened LAST — verified - * empirically against the pinned `apps/cli-go` cobra@v1.10.2/pflag@v1.0.10/viper@v1.21.0 - * versions (`--experimental=false --experimental=true` resolves `viper.GetBool` to `true`, - * and `--experimental=true --experimental=false` resolves to `false`). A plain - * "does any occurrence say false" scan gets this backwards for the first ordering — it - * would report `false` even though the final, authoritative value is `true` — so this - * scans in argv order and keeps overwriting the result, same pattern as - * {@link resolveDeclarativeFromArgs} (`diff-engine.ts:94-104`) uses for - * `--declarative`/`--use-pg-delta`. `ExperimentalFlag` alone can't be used here: a - * plain boolean can't distinguish an explicit `--experimental=false` from the omitted - * default, and (independently) this CLI's flag parser resolves a repeated flag from its - * FIRST occurrence rather than pflag's last-occurrence-wins, so the caller must reread - * the raw argv rather than trust the parsed flag whenever `--experimental` is set at all. - * Tokens consumed as another (local) flag's value are skipped (see - * {@link nonValueConsumedTokens}) so e.g. `db pull --password --experimental=false` — where - * pflag treats `--experimental=false` as `--password`'s space-separated value, not a changed - * `--experimental` — doesn't falsely report an explicit occurrence. + * Resolves the raw argv's *last* explicit `--experimental` occurrence to a boolean, or `undefined` + * if it never appears before the first `--` operand terminator. Repeated occurrences resolve to + * the last one, the opposite of how this CLI's own flag parser resolves a repeat (first wins), so + * a caller must reread raw argv rather than trust the parsed flag. Tokens consumed as another + * flag's value are skipped (see {@link nonValueConsumedTokens}). */ const experimentalFlagFromArgs = (args: ReadonlyArray): boolean | undefined => { let result: boolean | undefined; @@ -338,12 +252,9 @@ const experimentalFlagFromArgs = (args: ReadonlyArray): boolean | undefi }; /** - * `--experimental` resolved with Go's viper `AutomaticEnv` fallback: the gate in - * `rootCmd.PersistentPreRunE` reads `viper.GetBool("EXPERIMENTAL")` - * (`apps/cli-go/cmd/root.go:94`), so `SUPABASE_EXPERIMENTAL` enables experimental - * commands just like the flag. An explicit `--experimental` — including - * `--experimental=false`, and the last of a repeated flag — wins over the env, matching - * viper's bound-pflag precedence. + * `--experimental` resolved with an env fallback: `SUPABASE_EXPERIMENTAL` enables experimental + * commands when the flag isn't passed. An explicit `--experimental` (including + * `--experimental=false`, and the last of a repeated flag) wins over the env. */ export const resolveExperimental = Effect.gen(function* () { const flag = yield* ExperimentalFlag; @@ -358,15 +269,9 @@ export const resolveExperimental = Effect.gen(function* () { /** * `--experimental` resolved with the project `.env` consulted too, for commands that load the * nested project env before branching on the experimental gate (`db reset`, - * `db schema declarative generate`/`sync`). Go's `ParseDatabaseConfig` / - * `dbDeclarativeCmd.PersistentPreRunE` run `loadNestedEnv` — `godotenv.Load`, which only - * sets keys absent from the shell env — before reading `viper.GetBool("EXPERIMENTAL")`, so - * a `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` enables the experimental path. - * Shell *presence* — any value, including `false`, empty, or garbage — suppresses the file - * value entirely (see {@link viperEnvBoolWithProjectFallback}); an explicit - * `--experimental` — including `--experimental=false`, and the last of a repeated flag — - * wins over both, matching viper's bound-pflag precedence. `projectEnv` is the loaded map - * from `loadProjectEnv`. + * `db schema declarative generate`/`sync`). Shell env *presence* suppresses the file value + * entirely (see {@link viperEnvBoolWithProjectFallback}); an explicit `--experimental` wins over + * both. `projectEnv` is the loaded map from `loadProjectEnv`. */ export const resolveExperimentalWithProjectEnv = (projectEnv: Record) => Effect.gen(function* () { @@ -380,22 +285,11 @@ export const resolveExperimentalWithProjectEnv = (projectEnv: Record` occurrence in argv resolves to a pflag `false` - * (`PFLAG_FALSE_VALUES`, matching `ParseBool`'s false set). pflag's `Value.Set` runs for every - * occurrence in argv order, so the last one wins: `--debug=false --debug=true` (or a trailing - * bare `--debug`) is `true` to Go/pflag, not `false` — the Effect parser itself resolves repeats - * first-wins instead (binary-verified precedent for this exact pflag-vs-Effect divergence: - * `apps/cli/src/commands/sso/sso.pflag-reconcile.ts:306-321`). `--debug` is bound to - * viper the same way as `--yes`/`--experimental` (`apps/cli-go/cmd/root.go:318-334`). - * {@link yesFlagExplicitlyFalse}/{@link experimentalFlagExplicitlyFalse} above have - * the identical `Array.some` "any occurrence is false" gap (review: PRRT_kwDOErm0O86XKYiG) — - * left as-is here as a pre-existing, cross-cutting fix spanning those two flags too, not folded - * into this port (same scoping precedent as this file's own {@link resolveDebugWithProjectEnv} - * doc comment for existing `DebugFlag` call sites). Like those siblings, this scans only the - * flag-parsing region (see {@link argsBeforeOperandTerminator}) and skips tokens pflag would - * consume as another flag's value (see {@link nonValueConsumedTokens}) — `db pull -- --debug=false` - * and `db pull --password --debug=false` leave `--debug` unchanged to pflag, so `SUPABASE_DEBUG` - * must still win. + * True when the LAST `--debug`/`--debug=` occurrence in argv resolves to false. Tracks the + * last occurrence (unlike {@link yesFlagExplicitlyFalse}'s "any occurrence" check), since a + * trailing bare `--debug` after an earlier `--debug=false` must win. Scans only the flag-parsing + * region (see {@link argsBeforeOperandTerminator}) and skips tokens consumed as another flag's + * value (see {@link nonValueConsumedTokens}). */ const debugFlagExplicitlyFalse = (args: ReadonlyArray): boolean => { let lastExplicitlyFalse = false; @@ -410,28 +304,11 @@ const debugFlagExplicitlyFalse = (args: ReadonlyArray): boolean => { }; /** - * `--debug` resolved with Go's viper `AutomaticEnv` fallback (EVERY Go debug read goes through - * `viper.GetBool("DEBUG")` — never the bare pflag — across the whole Go CLI, `apps/cli-go/cmd/ - * root.go:122,289`, `internal/utils/{connect,docker,edgeruntime,logger}.go`, - * `internal/pgdelta/apply.go:332,342`, …) AND the project `.env` consulted too, for debug-gated - * behavior that runs downstream of a command that has already loaded the nested project env - * (e.g. `applyDeclarativePgDelta`, reached by `db diff`/`db pull` after - * `ParseDatabaseConfig`; `buildShadowCatalogInputs`, reached by `db diff --from/--to - * migrations` and `db schema declarative sync`). Go's `Config.Load` -> `loadNestedEnv` calls - * `godotenv.Load`, which `os.Setenv`s every project `.env` key not already present in the shell - * env (`godotenv@v1.5.1/godotenv.go:184-200`) — a REAL process-wide mutation that persists for - * the rest of that Go process, so a later `viper.GetBool("DEBUG")` (e.g. - * `pgdelta.ApplyDeclarative`, `apply.go:332,342`) sees a `SUPABASE_DEBUG` set only in - * `supabase/.env`. This port's own `loadProjectEnv` is deliberately pure (no - * `process.env` side effect, see its doc comment), so callers that need that same env-file - * value for a `viper.GetBool`-shaped read must pass the loaded map through explicitly instead - * — same shape as {@link resolveYesWithProjectEnv}/ - * {@link resolveExperimentalWithProjectEnv} above (review: PRRT_kwDOErm0O86XL_oz). - * Shell *presence* — any value, including `false`, empty, or garbage — suppresses the file - * value entirely; an explicit `--debug` — including `--debug=false` — wins over both, matching - * viper's bound-pflag precedence. `projectEnv` is the loaded map from `loadProjectEnv` - * (or `readDbToml`'s re-export of it). Existing bare {@link DebugFlag} call sites - * are unaffected — this is additive, for call sites that opt in. + * `--debug` resolved with an env fallback, and the project `.env` consulted too, for debug-gated + * behavior downstream of a command that already loaded the nested project env. `loadProjectEnv` + * is deliberately pure, so callers pass the loaded map through explicitly — same shape as + * {@link resolveYesWithProjectEnv}. Shell env *presence* suppresses the file value; an explicit + * `--debug` wins over both. */ export const resolveDebugWithProjectEnv = (projectEnv: Record) => Effect.gen(function* () { diff --git a/apps/cli/src/command-internal/global-flags.unit.test.ts b/apps/cli/src/command-internal/global-flags.unit.test.ts index 61f9ca06eb..f6153d82a2 100644 --- a/apps/cli/src/command-internal/global-flags.unit.test.ts +++ b/apps/cli/src/command-internal/global-flags.unit.test.ts @@ -38,9 +38,6 @@ describe("globalFlagValues", () => { Effect.provide(layer), Effect.tap((values) => Effect.sync(() => { - // The key set must exactly match GLOBAL_FLAGS's own ids — - // this is what fails loudly if the array grows without a matching - // read here. expect(Object.keys(values).sort()).toEqual(GLOBAL_FLAGS.map((flag) => flag.id).sort()); expect(values).toEqual({ agent: "yes", @@ -86,11 +83,8 @@ describe("resolveDebugWithProjectEnv", () => { it.live( "ignores a --debug=false-style token after the -- operand terminator (not an explicit false)", () => { - // `DebugFlag: true` stands in for a REAL `--debug` occurrence before the `--` - // terminator; the trailing `--debug=false` is a positional operand (e.g. a migration - // name that happens to look like a flag) — `debugFlagExplicitlyFalse`'s - // `argsBeforeOperandTerminator` guard must never see it, so the resolved value stays - // the flag's own `true` rather than being flipped to `false`. + // `DebugFlag: true` stands in for a real `--debug` before the `--`; the trailing + // `--debug=false` is a positional operand (e.g. a migration name). const layer = Layer.mergeAll( Layer.succeed(DebugFlag, true), Layer.succeed(CliArgs, { args: ["db", "pull", "--", "--debug=false"] }), @@ -109,10 +103,8 @@ describe("resolveDebugWithProjectEnv", () => { it.live( "ignores a --debug=false token consumed as another flag's value (e.g. --password)", () => { - // `--password` is a `VALUE_CONSUMING_LONG_FLAGS` entry, so real pflag semantics parse - // `--password --debug=false` as `--password`'s space-separated value being the literal - // string `"--debug=false"`, not a changed `--debug` — `nonValueConsumedTokens` must skip - // it, so the resolved value stays the flag's own `true`. + // `--password` is a `VALUE_CONSUMING_LONG_FLAGS` entry, so `--debug=false` here is its + // space-separated value, not a changed `--debug`. const layer = Layer.mergeAll( Layer.succeed(DebugFlag, true), Layer.succeed(CliArgs, { args: ["db", "pull", "--password", "--debug=false"] }), diff --git a/apps/cli/src/command-internal/go-child-exit.error.ts b/apps/cli/src/command-internal/go-child-exit.error.ts index 9c35f6c4a4..511f1a8d9f 100644 --- a/apps/cli/src/command-internal/go-child-exit.error.ts +++ b/apps/cli/src/command-internal/go-child-exit.error.ts @@ -7,51 +7,13 @@ import { } from "../shared/telemetry/error-actionability.ts"; /** - * A spawned `supabase-go` child process — via `GoProxy.exec`/`execCapture`, or - * (historically, before CLI-1955 removed it) the hidden `db __db-bootstrap` seam — - * exited non-zero, or could not be spawned at all (binary not found). + * A spawned `supabase-go` child process exited non-zero, or couldn't be spawned at all. * - * Carries the child's exact exit code through Effect's `Runtime.errorExitCode` - * marker, so both `runCli` (`shared/cli/run.ts`, text mode) and - * `withJsonErrorHandling` (`shared/output/json-error-handling.ts`, `json`/ - * `stream-json` mode) map the process's own exit code to this EXACT number — - * not a generic `1` — and only AFTER every `Effect.ensuring` finalizer between - * the call site and there has already run (telemetry flush, command - * instrumentation). Calling `ProcessControl.exit()` directly from deep inside a - * handler skips those finalizers entirely (`process.exit()` halts the process - * before the Effect runtime can unwind the remaining scopes) — this error type - * lets the child's status flow through the normal Effect failure channel - * instead, all the way up to the single `ProcessControl.exit()` call `runCli` - * itself makes once finalizers are done. See CLI-1879. - * - * `runCli`'s `handledProgram` special-cases this exact class (an `instanceof` - * check, not a shared Effect marker) to skip its generic `output.fail` stderr - * line in text mode — the child already wrote its own detailed failure (or, - * for the not-found case, `GoProxy`'s own specific diagnostic) to the - * parent's inherited stderr, and Go itself never prints a second, generic line - * on top of that. This is deliberately NOT keyed on Effect's shared - * `[Runtime.errorReported]` marker: `CliError.ShowHelp` also sets that marker - * to `false` for an unrelated reason (the CLI framework already rendered - * help/usage text), and gating on the marker there would ALSO suppress - * `normalizeCause`'s Go-parity rendering for a `MissingOption` wrapped in - * `ShowHelp` (e.g. `Error: required flag(s) "type" not set`) — a real parity - * regression. `withJsonErrorHandling` has no such collision (it runs upstream - * of `runCli`, catching every error uniformly) and still emits the structured - * JSON error envelope for this error like any other. - * - * The envelope's `message` is deliberately generic (`"supabase-go exited with - * code N (see stderr for details)"`) rather than the child's specific failure - * reason: the child's real detail is on stderr (see above), which a - * machine-output consumer reading only stdout won't see — this is an accepted, - * TS-only tradeoff (Go itself has no JSON error-envelope concept to match - * against here), not a parity gap. - * - * Invariant: `exitCode` must be a real non-zero child exit status (1-255, - * matching `ChildProcessSpawner.ExitCode`'s POSIX range), never `0` — every - * construction site guards on `exitCode !== 0` (or hardcodes `1` for the - * binary-not-found case) before constructing this error, since a `0` here - * would be a failure that both `runCli` and `withJsonErrorHandling` read back - * as a *successful* exit. + * Carries the exit code through `Runtime.errorExitCode` so it survives every `Effect.ensuring` + * finalizer up to `runCli`'s own exit call, instead of exiting the process directly and skipping + * them. `runCli` also special-cases this class to skip its own generic stderr line, since the + * child already wrote its own failure detail there. `exitCode` must be a real non-zero status; + * every construction site guards this before creating the error. */ export class GoChildExitError extends Data.TaggedError("GoChildExitError")<{ readonly exitCode: number; diff --git a/apps/cli/src/command-internal/go-duration.ts b/apps/cli/src/command-internal/go-duration.ts index 8be53d3ec3..f9ab1f3526 100644 --- a/apps/cli/src/command-internal/go-duration.ts +++ b/apps/cli/src/command-internal/go-duration.ts @@ -1,17 +1,12 @@ /** - * Go `time.Duration` string parsing and formatting, ported from Go's - * `src/time/time.go` `time.ParseDuration()` and `Duration.String()`. + * Duration string parsing and formatting matching Go's `time.ParseDuration()`/`Duration.String()` + * grammar and rounding — the syntax the hosted API and container env vars expect. * - * Several `config.toml` fields decode in `@supabase/config` as the raw - * duration STRING (e.g. `auth.sessions.timebox = "1h"`, - * `auth.sms.max_frequency = "5s"`) rather than a parsed `time.Duration` - * (nanoseconds as `int64`). A container's env (e.g. - * `GOTRUE_SESSIONS_TIMEBOX`, `GOTRUE_SMS_MAX_FREQUENCY`) is built from the - * PARSED-then-re-serialized value, which normalizes - * the string to the canonical form — `"1h"` becomes `"1h0m0s"`, - * `"90s"` becomes `"1m30s"` — so callers needing byte-exact output must - * round-trip through both functions below, not just pass the configured - * string through unchanged. + * Several `config.toml` duration fields (e.g. `auth.sessions.timebox = "1h"`) are stored as the + * raw string, but a container's env var is built from the parsed-then-reserialized value, which + * normalizes it (`"1h"` becomes `"1h0m0s"`, `"90s"` becomes `"1m30s"`). A caller needing + * byte-exact output must round-trip through both functions below, not pass the string through + * unchanged. */ const NS_PER_SECOND = 1_000_000_000; @@ -20,54 +15,33 @@ const NS_PER_HOUR = 60 * NS_PER_MINUTE; const NS_PER_MS = 1_000_000; const NS_PER_US = 1_000; -// `parseGoDuration`'s own accumulator needs exact integer arithmetic near `int64` -// nanosecond ceiling (~9.223e18) — that magnitude is already ~1000x past `Number.MAX_SAFE_INTEGER` -// (2^53 ≈ 9.007e15), so a plain `number` accumulator (as used by `formatGoDuration` below, -// which never approaches this magnitude for real durations) silently rounds to the nearest -// representable float64, which can flip a legitimate boundary value into a false overflow or vice -// versa. `BigInt` constants, distinct from the `NS_PER_*` `number`s above, exist only for this. +// `BigInt` constants for `parseGoDuration`'s accumulator: near the int64 nanosecond ceiling +// (~9.223e18, past `Number.MAX_SAFE_INTEGER`), a `number` accumulator would silently round a +// legitimate boundary value into a false overflow or vice versa. const NS_PER_SECOND_BIG = 1_000_000_000n; const NS_PER_MINUTE_BIG = 60n * NS_PER_SECOND_BIG; const NS_PER_HOUR_BIG = 60n * NS_PER_MINUTE_BIG; const NS_PER_MS_BIG = 1_000_000n; const NS_PER_US_BIG = 1_000n; -// `time.Duration` ceiling (`math.MaxInt64` nanoseconds, ~292.47 years) — `time.ParseDuration` -// rejects any POSITIVE value whose accumulated nanosecond count would exceed this. Go's real max -// parseable duration is `2562047h47m16.854775807s`. +// Duration ceiling: `math.MaxInt64` nanoseconds (~292.47 years); the max parseable duration is +// `2562047h47m16.854775807s`. const MAX_INT64_NS = 9223372036854775807n; -// `time.ParseDuration` (`src/time/format.go`) accumulates into a `uint64`, checking -// `d > 1<<63` (NOT `1<<63-1`, i.e. `MAX_INT64_NS`) both per-term and on the running total, and -// only applies the STRICTER `d > 1<<63-1` check afterwards, and only when the parsed value is NOT -// negated. A magnitude of exactly `1<<63` therefore survives parsing when the input is negative — -// `-Duration(d)` on `d == 1<<63` wraps via `int64` two's-complement into exactly `math.MinInt64`, -// Go's own minimum representable duration (`-2562047h47m16.854775808s`) — but is rejected when the -// input has no sign, since a positive `time.Duration` can never reach `1<<63` itself. Verified -// against the real `time` package (CLI-1961 Codex review finding): `time.ParseDuration( -// "-9223372036854775808ns")` succeeds and returns `math.MinInt64`, while the unsigned form -// `"9223372036854775808ns"` (identical magnitude, no leading `-`) is rejected as an overflow. +// The accumulator bound is `1<<63`, not `MAX_INT64_NS` (`1<<63-1`): a magnitude of exactly `1<<63` +// only overflows when the result is positive, since negating it wraps to exactly `math.MinInt64`. +// The stricter positive-only check runs after the loop, below. const UINT64_ACCUMULATOR_BOUND_NS = 1n << 63n; /** - * Port of Go `time.ParseDuration`. Returns nanoseconds as a number. Accepts - * the same grammar Go does: a possibly-signed sequence of decimal numbers, - * each with a unit suffix (`"ns"`, `"us"`/`"µs"`/`"μs"`, `"ms"`, `"s"`, `"m"`, `"h"`), - * e.g. `"5s"`, `"1h30m"`, `"300ms"`. Throws on invalid input, matching Go's - * own `errors.New("time: invalid duration ...")` failure mode — including - * overflowing `math.MaxInt64` nanoseconds and a fractional remainder that - * would truncate to a sub-nanosecond value. + * Parses a Go-style duration string to nanoseconds: a possibly-signed sequence of decimal + * numbers, each with a unit suffix (`"ns"`, `"us"`/`"µs"`/`"μs"`, `"ms"`, `"s"`, `"m"`, `"h"`), e.g. + * `"5s"`, `"1h30m"`, `"300ms"`. Throws on invalid input, including an overflowing magnitude. * - * Accumulates internally in `BigInt`, not `number`: durations near Go's real - * max are already well past `Number.MAX_SAFE_INTEGER`, so a `number` - * accumulator can silently round a legitimate boundary value into a false - * overflow (or the reverse) well before the final overflow check ever runs. - * Only the RETURNED value converts to `number` (at the very end), which is - * lossy for a duration this close to the ceiling — an accepted, proportionate - * limit given every real config duration (seconds-hours) is nowhere near it, - * and the exactness that actually matters — whether an out-of-range override - * gets rejected like Go rejects it — no longer depends on `number` precision - * at all. + * Accumulates internally in `BigInt` (durations near the ceiling exceed + * `Number.MAX_SAFE_INTEGER`) and only converts to `number` at the end — lossy that close to the + * ceiling, but every real config duration is nowhere near it, and the overflow check itself + * doesn't depend on `number` precision. */ export function parseGoDuration(value: string): number { const orig = value; @@ -83,11 +57,8 @@ export function parseGoDuration(value: string): number { let total = 0n; while (s.length > 0) { - // Go requires the next character to be `[0-9.]` before consuming a unit - // (`time.ParseDuration`'s `if !(s[0] == '.' || '0' <= s[0] && s[0] <= '9')` - // guard) — without this check, a bare unit like `"s"` or `"m"` would read - // zero digits, skip the "missing unit" guard below (since `s` is still - // non-empty), and silently match the unit anyway. + // Require the next character to be `[0-9.]` before consuming a unit — otherwise a bare unit + // like `"s"` would read zero digits and silently match anyway. if (!(s.charAt(0) === "." || (s.charAt(0) >= "0" && s.charAt(0) <= "9"))) { throw new Error(`time: invalid duration "${orig}"`); } @@ -111,10 +82,8 @@ export function parseGoDuration(value: string): number { } hasFracDigits = i > fracStart; } - // `pre`/`post` guard: a lone `.` with no digits on either side - // (`".s"`, `"."`, `"-."`) is invalid — the leading `[0-9.]` check above lets - // `.` through (it's the first character of a valid fraction like `".5s"`), - // but a `.` that consumes zero digits before AND after it must still fail. + // A lone `.` with no digits on either side (`".s"`, `"."`) is invalid, even though the + // leading `[0-9.]` check above lets `.` start a valid fraction like `".5s"`. if (!hasIntDigits && !hasFracDigits) { throw new Error(`time: invalid duration "${orig}"`); } @@ -126,11 +95,7 @@ export function parseGoDuration(value: string): number { unitNs = 1n; s = s.slice(2); } else if (s.startsWith("us") || s.startsWith("µs") || s.startsWith("μs")) { - // `unitMap` has THREE microsecond spellings: - // "us", "µs" (U+00B5 MICRO SIGN), and "μs" (U+03BC GREEK SMALL LETTER MU) — verified - // directly against the Go standard library: `time.ParseDuration("1μs")` succeeds - // identically to `"1µs"`. The Greek-mu spelling was previously missing here - // (CLI-1961 Codex review finding). + // Accept all three microsecond spellings: "us", "µs" (U+00B5), and "μs" (U+03BC). unitNs = NS_PER_US_BIG; s = s.slice(2); } else if (s.startsWith("ms")) { @@ -149,21 +114,12 @@ export function parseGoDuration(value: string): number { throw new Error(`time: unknown unit in duration "${orig}"`); } - // Go converts the fractional remainder via `uint64(float64(f) * (float64(unit)/scale))` - // (`time/format.go`'s `ParseDuration`) — an intermediate float64 MULTIPLICATION, THEN a - // float64->uint64 conversion that truncates toward zero. That intermediate float64 step - // means this is NOT equivalent to an exact BigInt division: once `frac` exceeds float64's - // 53-bit integer precision, rounding `frac` itself up to the nearest representable double - // can push the product past the next integer, so Go's result rounds UP to a full unit where - // an exact-BigInt computation would truncate DOWN — verified against the real `time` - // package (CLI-1961 Codex review finding): `time.ParseDuration("0.999999999999999999s")` - // (18 nines) returns exactly `1_000_000_000` ns, a full second, not the `999_999_999` an - // exact-BigInt truncation produces. Converting `frac`/`unitNs`/`post` to `Number` before - // multiplying reproduces Go's float64 step — and its rounding — bit-for-bit: both Go's - // `float64(uint64)` and JS's `BigInt`->`Number` conversion round to the nearest - // representable double (IEEE 754 round-to-nearest-even), so the same magnitude rounds the - // same way in both languages. `Math.trunc` mirrors the truncating `uint64(...)` conversion - // (both operands here are always non-negative, so truncation and floor coincide). + // The fractional remainder converts via a float64 multiplication then truncation, not an + // exact BigInt division: once `frac` exceeds float64's 53-bit precision, rounding it to the + // nearest representable double can push the product past the next integer, rounding up a + // full unit where an exact-BigInt computation would truncate down (e.g. + // `"0.999999999999999999s"` parses to exactly `1s`, not `999_999_999ns`). Converting to + // `Number` before multiplying reproduces that same IEEE 754 round-to-nearest-even rounding. let term = n * unitNs; if (frac > 0n) { term += BigInt(Math.trunc(Number(frac) * (Number(unitNs) / Number(post)))); @@ -173,9 +129,8 @@ export function parseGoDuration(value: string): number { throw new Error(`time: invalid duration "${orig}"`); } } - // Only a positive result gets the stricter post-loop bound — see - // `UINT64_ACCUMULATOR_BOUND_NS`'s doc comment for why a negative result is allowed to reach - // one nanosecond further (down to exactly `math.MinInt64`). + // Only a positive result gets the stricter post-loop bound; see `UINT64_ACCUMULATOR_BOUND_NS` + // above for why a negative result may reach one nanosecond further. if (!neg && total > MAX_INT64_NS) { throw new Error(`time: invalid duration "${orig}"`); } @@ -184,11 +139,9 @@ export function parseGoDuration(value: string): number { } /** - * Port of Go `Duration.String()`. `0` formats as `"0s"`; otherwise only the - * units needed to represent the value are shown, with minutes/seconds always - * trailing an hours component (`"1h0m0s"`), and a sub-second remainder - * formatted as a fraction of its largest applicable unit (`"1.5s"`, - * `"300ms"`). + * Formats nanoseconds as a Go-style duration string. `0` formats as `"0s"`; otherwise only the + * needed units show, with minutes/seconds always trailing an hours component (`"1h0m0s"`), and a + * sub-second remainder as a fraction of its largest applicable unit (`"1.5s"`, `"300ms"`). */ export function formatGoDuration(nanoseconds: number): string { if (nanoseconds === 0) return "0s"; @@ -242,28 +195,16 @@ export function formatGoDuration(nanoseconds: number): string { return `${sign}${ns}ns`; } -/** Formats `totalNs / unitNs` with trailing zeros (and a trailing `.`) trimmed, matching `fmtFrac`. */ +/** Formats `totalNs / unitNs` with trailing zeros (and a trailing `.`) trimmed. */ function formatFraction(totalNs: number, unitNs: number): string { return (totalNs / unitNs).toFixed(9).replace(/0+$/, "").replace(/\.$/, ""); } /** - * `Db.HealthTimeout` — a duration STRING - * (`"2m"` default, `packages/config/src/db.ts`) decoded via `mapstructure. - * StringToTimeDurationHookFunc()` inside the same `v.UnmarshalExact` call every `SUPABASE_*` - * override goes through — a malformed value hard-fails - * `Config.Load` (`"failed to parse config: %w"`) before either `start`/`db start` ever runs; it is - * never silently replaced with a default. A valid-but-degenerate value (e.g. `"0s"`) isn't - * special-cased either: Go's backoff policy computes `uint64(timeout.Seconds())` as the retry - * count, and the backoff library returns `Stop` immediately - * when that count is `0` — i.e. exactly one immediate health probe with no wait, not a 30s - * fallback. Throws on a malformed value, matching `parseGoDuration`; the caller wraps that - * into its own typed config-load-failure error so rollback/cleanup still fires (a plain throw here - * would surface as an Effect defect instead of a typed failure). - * - * Hoisted here (was private to `commands/start/start.handler.ts`) once - * `commands/db/start/start.handler.ts`'s own native container bootstrap became a second caller — - * see `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" rule. + * Seconds form of a `Db.HealthTimeout` duration string (`"2m"` default). Throws on a malformed + * value, matching {@link parseGoDuration}; the caller wraps that into a typed config-load-failure + * error so rollback/cleanup still fires. A degenerate value like `"0s"` isn't special-cased: it + * means exactly one immediate health probe with no wait, not a 30s fallback. */ export function resolveHealthTimeoutSeconds(healthTimeout: string): number { return Math.trunc(parseGoDuration(healthTimeout) / 1_000_000_000); diff --git a/apps/cli/src/command-internal/go-duration.unit.test.ts b/apps/cli/src/command-internal/go-duration.unit.test.ts index 4fb67dcdbb..b7b58558a0 100644 --- a/apps/cli/src/command-internal/go-duration.unit.test.ts +++ b/apps/cli/src/command-internal/go-duration.unit.test.ts @@ -30,10 +30,6 @@ describe("parseGoDuration", () => { expect(() => parseGoDuration("")).toThrow('time: invalid duration ""'); }); - // Task 1: a unit with no preceding digit ("s", "m", ...) used to read zero - // digits, skip the "missing unit" guard (since `s` was still non-empty), - // and silently match the unit anyway — returning 0 instead of erroring like - // Go's real `time.ParseDuration`. it.each(["s", "m", "h", "ms", "us", "µs", "μs", "ns"])( 'rejects a bare unit with no preceding digit ("%s")', (input) => { @@ -41,10 +37,6 @@ describe("parseGoDuration", () => { }, ); - // `unitMap` has THREE microsecond spellings: - // "us", "µs" (U+00B5 MICRO SIGN), and "μs" (U+03BC GREEK SMALL LETTER MU) — verified - // directly against the Go standard library. The Greek-mu spelling was previously - // missing here (CLI-1961 Codex review finding). it.each(["us", "µs", "μs"])('accepts every microsecond unit spelling ("1%s")', (unit) => { expect(parseGoDuration(`1${unit}`)).toBe(1_000); }); @@ -57,9 +49,6 @@ describe("parseGoDuration", () => { expect(() => parseGoDuration("-s")).toThrow('time: invalid duration "-s"'); }); - // `pre`/`post` guard: a lone "." with no digits on either side is - // still invalid, even though "." alone passes the leading `[0-9.]` check - // (it's the first character of a valid fraction like ".5s"). it.each([".s", ".", "-.", "+.", ".h"])('rejects a lone "." with no digits ("%s")', (input) => { expect(() => parseGoDuration(input)).toThrow(`time: invalid duration "${input}"`); }); @@ -76,9 +65,6 @@ describe("parseGoDuration", () => { expect(() => parseGoDuration("5x")).toThrow('time: unknown unit in duration "5x"'); }); - // Go converts the fractional remainder via a float64->uint64 conversion, - // which truncates toward zero rather than rounding — `"0.5ns"` must become - // `0`, not `1`. it('truncates a sub-nanosecond fraction instead of rounding ("0.5ns")', () => { expect(parseGoDuration("0.5ns")).toBe(0); }); @@ -87,20 +73,10 @@ describe("parseGoDuration", () => { expect(parseGoDuration("1.9ns")).toBe(1); }); - // Go converts the fractional remainder through an intermediate float64 multiplication - // (`uint64(float64(f) * (float64(unit)/scale))`) BEFORE truncating to `uint64` — not an - // exact-precision division. Once the fraction has enough digits to exceed float64's 53-bit - // integer precision, rounding the fraction itself up can push the float64 product past the - // next integer, so Go's real result rounds UP to a full second here — verified against the - // real `time` package (CLI-1961 Codex review finding): an exact BigInt division would instead - // truncate DOWN to `999_999_999`. it('rounds a long fractional remainder up like Go\'s float64 conversion ("0.999999999999999999s")', () => { expect(parseGoDuration("0.999999999999999999s")).toBe(1_000_000_000); }); - // `time.Duration` is bounded by `math.MaxInt64` nanoseconds - // (~292.47 years); `time.ParseDuration` rejects any value whose - // accumulated nanosecond count would exceed it. it('rejects a duration that overflows math.MaxInt64 nanoseconds ("2562048h")', () => { expect(() => parseGoDuration("2562048h")).toThrow('time: invalid duration "2562048h"'); }); @@ -109,10 +85,6 @@ describe("parseGoDuration", () => { expect(() => parseGoDuration("2562047h47m16.854775807s")).not.toThrow(); }); - // Regression test: an earlier fix's overflow bound was `Number(9223372036854775807n)`, - // which rounds UP to `9223372036854776000` — 193 past the true `math.MaxInt64`. That let - // this exact value (`2^63`, one nanosecond past Go's true ceiling) silently pass instead of - // being rejected like Go's real `time.ParseDuration` rejects it. it('rejects a duration exactly 1ns past Go\'s true math.MaxInt64 ceiling ("9223372036854775808ns")', () => { expect(() => parseGoDuration("9223372036854775808ns")).toThrow( 'time: invalid duration "9223372036854775808ns"', @@ -123,11 +95,6 @@ describe("parseGoDuration", () => { expect(() => parseGoDuration("9223372036854775807ns")).not.toThrow(); }); - // `time.ParseDuration` accumulates into a `uint64` and only rejects `d > 1<<63` - // (NOT `1<<63-1`) during parsing — a magnitude of exactly `1<<63` (one MORE than - // `math.MaxInt64`) survives the loop and, once negated, lands exactly on - // `math.MinInt64`. Verified against the real `time` package (CLI-1961 Codex review - // finding): `time.ParseDuration("-9223372036854775808ns")` succeeds. it("accepts Go's exact minimum representable negative duration (math.MinInt64 ns)", () => { expect(parseGoDuration("-9223372036854775808ns")).toBe(-9223372036854775808); }); @@ -136,8 +103,6 @@ describe("parseGoDuration", () => { expect(parseGoDuration("-2562047h47m16.854775808s")).toBe(-9223372036854775808); }); - // One nanosecond further negative than `math.MinInt64` has no valid `int64` - // representation at all — Go rejects this too, not just the positive overflow. it("rejects a duration 1ns past math.MinInt64", () => { expect(() => parseGoDuration("-9223372036854775809ns")).toThrow( 'time: invalid duration "-9223372036854775809ns"', @@ -167,10 +132,6 @@ describe("formatGoDuration", () => { expect(formatGoDuration(parseGoDuration("90s"))).toBe("1m30s"); }); - // A sub-second remainder must still be included when minutes/hours are - // present, matching `Duration.String()`, which computes the - // fractional-seconds string from the FULL nanosecond count before peeling - // off minutes/hours — not just when seconds is the only component. it("includes a sub-second fraction alongside minutes", () => { expect(formatGoDuration(parseGoDuration("1m0.5s"))).toBe("1m0.5s"); }); diff --git a/apps/cli/src/command-internal/go-float.ts b/apps/cli/src/command-internal/go-float.ts index 16245480aa..286cef0f98 100644 --- a/apps/cli/src/command-internal/go-float.ts +++ b/apps/cli/src/command-internal/go-float.ts @@ -1,22 +1,14 @@ /** - * Render a number the way Go's `fmt.Sprintf("%v", float64)` (and `%+v` — the - * `+` flag only affects structs) does. JSON numbers decode to `float64` in Go, - * so `fmt` uses shortest `%g`: exponent form when the decimal exponent is - * `< -4` or `>= 6` (e.g. `1000000` → `1e+06`, `1.5e8` → `1.5e+08`, `1e-5` → - * `1e-05`), fixed notation otherwise. The exponent is signed and at least two - * digits. JS fixed notation matches Go for the `[-4, 6)` exponent range, so - * only the exponent cases need reformatting — `toExponential()` (no argument) - * yields the same shortest round-trip digits Go's strconv produces. + * Formats a number the way Go's `fmt.Sprintf("%v", float64)` does: shortest `%g`, exponent + * notation when the decimal exponent is `< -4` or `>= 6` (`1000000` → `1e+06`), fixed notation + * otherwise, with a signed, at-least-two-digit exponent. * - * Shared by `db query`'s value formatter (`db/query/query.format.ts`) and - * `postgres-config`'s pretty table (`postgres-config.shared.ts`, Go - * `get.go:32-35`'s `%+v`). + * Shared by `db query`'s value formatter and `postgres-config`'s pretty table. */ export function goFormatFloat(n: number): string { if (Number.isNaN(n)) return "NaN"; if (!Number.isFinite(n)) return n > 0 ? "+Inf" : "-Inf"; - // Go's `%v` preserves the sign of negative zero (`-0`); `n === 0` is true for - // both `+0` and `-0`, so distinguish them with `Object.is` before the shortcut. + // Go's `%v` preserves the sign of negative zero; `n === 0` is true for both `+0` and `-0`. if (Object.is(n, -0)) return "-0"; if (n === 0) return "0"; const neg = n < 0; diff --git a/apps/cli/src/command-internal/go-json.ts b/apps/cli/src/command-internal/go-json.ts index 32b9f2ac3b..1d9d8a3b89 100644 --- a/apps/cli/src/command-internal/go-json.ts +++ b/apps/cli/src/command-internal/go-json.ts @@ -1,30 +1,13 @@ /** - * Byte-faithful reproduction of `encoding/json` value encoder for the - * commands that must match Go's stdout exactly (`db lint` / `db advisors` - * pretty-print `[]Result` / `[]Lint` via `json.Encoder.SetIndent("", " ")`). + * Byte-faithful reproduction of `encoding/json`'s encoder, for commands that must match Go's + * stdout exactly. * - * Unlike `go-output.encoders.ts`'s `encodeGoJson`, this encoder does NOT - * sort object keys — Go serializes structs in field-declaration order, so the - * caller builds plain objects whose key insertion order is the Go struct order - * (JS preserves string-key insertion order, EXCEPT for integer-like keys — see - * the `Map` handling below). `omitempty` is likewise the caller's responsibility: - * simply omit the key. + * Doesn't sort object keys — the caller builds objects whose key insertion order is already the + * desired order; a `Map` is preserved as true insertion order (a plain object reorders + * integer-like string keys into ascending numeric order, which would undo a lexicographic sort). * - * A caller that needs Go's true lexicographic map-key order (e.g. - * `go-output.encoders.ts`'s `sortKeysDeep`, for a genuine Go map like - * `jwt.MapClaims`) must pass a `Map` rather than a plain object at - * that level: a plain object silently reorders integer-like string keys ("2", "10") - * into ascending NUMERIC order on enumeration, regardless of insertion order, which - * would undo a lexicographic sort for any numeric-looking key. `Map` iteration order - * is true insertion order for every key shape, so this walker special-cases it. - * - * The two behaviours `JSON.stringify(x, null, 2)` gets wrong for Go parity are: - * 1. HTML escaping — Go's default encoder escapes `<`, `>`, `&` as - * `<` / `>` / `&` (it does not call `SetEscapeHTML(false)`). - * 2. Control characters — Go emits `\u0008` / `\u000c` for backspace / form - * feed (no `\b` / `\f` shorthand) and escapes U+2028 / U+2029. - * This encoder reproduces both; the indentation/`": "`/`[]`/`{}` shape is - * otherwise identical to `JSON.stringify(x, null, 2)`. + * Differs from `JSON.stringify(x, null, 2)` in escaping `<`, `>`, `&`, and control characters + * (`\u0008`/`\u000c` instead of `\b`/`\f`) the way Go's default encoder does. */ const HEX = "0123456789abcdef"; @@ -34,10 +17,9 @@ function unicodeEscape(codeUnit: number): string { } /** - * Quotes and escapes a string exactly as `encoding/json` does with the - * default `escapeHTML: true`. Iterates by UTF-16 code unit; the only non-ASCII - * runes Go escapes are U+2028 / U+2029 (both single BMP code units), so code - * units suffice. + * Quotes and escapes a string exactly as `encoding/json` does with default HTML escaping. + * Iterates by UTF-16 code unit; the only non-ASCII runes Go escapes (U+2028, U+2029) are single + * BMP code units, so code units suffice. */ export function escapeGoJsonString(value: string): string { let out = '"'; @@ -85,18 +67,9 @@ function walk(value: unknown, depth: number, pretty: boolean): string { case "string": return escapeGoJsonString(value); case "number": - // Finite numbers from JSON parsing render identically to Go for the - // integer and ordinary-float cases relevant here; defer to JSON.stringify - // for the canonical shortest representation — EXCEPT negative zero, which - // `JSON.stringify(-0)` collapses to `"0"` (ECMA-262's `Number::toString` - // prints no sign for negative zero) while `encoding/json` marshals a - // `float64` negative zero as `-0`. Reachable via `gen bearer-jwt`'s - // `--payload '{"extra":-0}'` (or an underflowing literal like `-1e-10000`): - // `json.Unmarshal` into `jwt.MapClaims` (a real `map[string]any`) decodes - // the number as `float64(-0)`, and the signed payload segment carries that - // sign through to `-0` — verified against the real binary (CLI-1961 Codex - // review finding): the compiled Go CLI's signed token payload literally - // contains `"extra":-0`. Special-case it so the signed bytes match. + // JSON.stringify collapses `-0` to `"0"`, but `encoding/json` marshals a float64 negative + // zero as `-0` — reachable via `gen bearer-jwt`'s signed payload, where the signed bytes + // must match. if (!Number.isFinite(value)) return "null"; return Object.is(value, -0) ? "-0" : JSON.stringify(value); case "boolean": @@ -112,14 +85,9 @@ function walk(value: unknown, depth: number, pretty: boolean): string { const items = value.map((item) => indent + walk(item, depth + 1, pretty)); return `[${open}${items.join(separator)}${close}${closeIndent}]`; } - // A plain object silently reorders integer-like string keys ("2", "10") into ascending - // NUMERIC order on any enumeration (`Object.keys`/`Object.entries`), regardless of insertion - // order (ECMA-262 `OrdinaryOwnPropertyKeys`) — `encoding/json` has no such special case, - // so a real Go map's string keys sort purely lexicographically (`"10"` before `"2"`). Callers - // that need that exact order (e.g. `go-output.encoders.ts`'s `sortKeysDeep`) pass a - // `Map` instead of a plain object specifically to carry the sort through intact — `Map` - // iteration order is true insertion order for every key shape, unlike a plain object - // (CLI-1961 Codex review finding: `{"10":"a","2":"b"}` must stay "10" before "2"). + // A plain object reorders integer-like string keys ("2", "10") into ascending numeric order on + // enumeration, unlike a real Go map's lexicographic order; callers needing that order (e.g. + // `sortKeysDeep`) pass a `Map`, whose iteration order is true insertion order. const entries = value instanceof Map ? [...(value as Map).entries()] @@ -151,11 +119,8 @@ export function encodeGoJsonCompact(value: unknown): string { } /** - * `encoding/json` type names for the JSON-representable kinds `json.Unmarshal` - * rejects. Shared by every command that reproduces Go's exact `"json: cannot - * unmarshal into Go value of type "` wording against its own target - * type — `gen bearer-jwt`'s `jwt.MapClaims` (`bearer-jwt.claims.ts`) and `config.JWK` - * (`bearer-jwt.signing-key.ts`) are today's two callers. + * `encoding/json` type names for the JSON-representable kinds `json.Unmarshal` rejects. Used to + * reproduce Go's exact `"json: cannot unmarshal into Go value of type "` message. */ export function goJsonKindName(value: unknown): string { if (Array.isArray(value)) return "array"; diff --git a/apps/cli/src/command-internal/go-json.unit.test.ts b/apps/cli/src/command-internal/go-json.unit.test.ts index 33c4d0244d..d00c4618f3 100644 --- a/apps/cli/src/command-internal/go-json.unit.test.ts +++ b/apps/cli/src/command-internal/go-json.unit.test.ts @@ -76,12 +76,6 @@ describe("encodeGoJsonCompact", () => { expect(encodeGoJsonCompact(null)).toBe("null"); }); - // `JSON.stringify(-0)` collapses to `"0"` (ECMA-262 prints no sign for negative - // zero), but `encoding/json` marshals a `float64` negative zero as `-0` — - // reachable through `gen bearer-jwt --payload`'s `json.Unmarshal` into a real Go - // map. Verified against the real binary (CLI-1961 Codex review finding): the - // compiled Go CLI's signed token payload for `--payload '{"extra":-0}'` literally - // contains `"extra":-0`. it("preserves negative zero's sign, unlike plain JSON.stringify", () => { expect(encodeGoJsonCompact({ extra: -0 })).toBe('{"extra":-0}'); expect(encodeGoJsonCompact({ extra: Number("-1e-10000") })).toBe('{"extra":-0}'); @@ -89,10 +83,6 @@ describe("encodeGoJsonCompact", () => { }); it("iterates a Map in true insertion order, unlike a plain object with integer-like keys", () => { - // A plain object always reorders integer-like string keys ("2", "10") into ascending - // NUMERIC order on enumeration, regardless of insertion order — a `Map` does not, which - // is exactly why `go-output.encoders.ts`'s `sortKeysDeep` builds one to carry a - // lexicographic sort through to this walker intact (CLI-1961 Codex review finding). const map = new Map([ ["10", "a"], ["2", "b"], @@ -107,8 +97,6 @@ describe("goJsonKindName", () => { expect(goJsonKindName(1)).toBe("number"); expect(goJsonKindName("s")).toBe("string"); expect(goJsonKindName(true)).toBe("bool"); - // Never reachable from real JSON.parse output (every call site already excludes - // null/array/object before calling this) — exercised directly for completeness. expect(goJsonKindName(undefined)).toBe("value"); }); }); diff --git a/apps/cli/src/command-internal/go-jwt.ts b/apps/cli/src/command-internal/go-jwt.ts index c7ea89a5a9..36eaadfc2b 100644 --- a/apps/cli/src/command-internal/go-jwt.ts +++ b/apps/cli/src/command-internal/go-jwt.ts @@ -2,16 +2,12 @@ import { createHmac, createPrivateKey, createSign } from "node:crypto"; import { encodeGoJsonCompact } from "./go-json.ts"; /** - * RFC 7517 JWK fields `JWK` struct round-trips (`toml`/`json` tags `kty`, `kid`, `use`, - * `key_ops`, `alg`, `ext`, `n`, `e`, `d`, `p`, `q`, `dp`, - * `dq`, `qi`, `crv`, `x`, `y`) — field names match exactly, so a signing-keys file can be parsed - * straight into this shape (`auth.signing_keys_path` decodes directly into `[]JWK`, - * so `use`/`key_ops`/`ext` on a user's key file must round-trip into - * both `GOTRUE_JWT_KEYS` and the published JWKS just like every other field here). A superset of - * Node's own `crypto.webcrypto.JsonWebKey` (which omits `kid`), so it's still assignable wherever - * that type is expected (e.g. `createPrivateKey`'s `format: "jwk"` input) — `key_ops` is typed as - * a mutable `string[]` rather than `ReadonlyArray` for exactly this reason: Node's own - * `JsonWebKey.key_ops` field is a plain `string[]`, and a `ReadonlyArray` isn't assignable to it. + * An RFC 7517 JWK, with fields matching `auth.signing_keys_path`'s JSON/TOML key format so a + * user's signing key file round-trips into `GOTRUE_JWT_KEYS` and the published JWKS unchanged. + * + * A superset of Node's `crypto.webcrypto.JsonWebKey` (which omits `kid`), so still assignable + * wherever that type is expected (e.g. `createPrivateKey`'s `format: "jwk"` input); `key_ops` is a + * mutable `string[]` to match Node's own field type. */ export interface Jwk { readonly kty: string; @@ -34,19 +30,10 @@ export interface Jwk { } /** - * `NewConfig()` default `Auth.SigningKeys` — - * a single ES256 key, unconditionally present on every resolved config UNLESS overwritten by a - * real `auth.signing_keys_path` file (and only then when `auth.enabled`). - * `ResolveJWKS` iterates `a.SigningKeys` regardless of - * `auth.enabled`, so this default key is always part of the published JWKS unless a configured - * file overrides it — callers must not skip it just because auth happens to be disabled or no - * `signing_keys_path` is set. Shared by GoTrue's own env building (`services/gotrue.service.ts`, - * which signs tokens with it) and JWKS resolution (`resolveLocalJwks`, which must publish - * its public form) so the two can never disagree on the default key. - * - * Typed as `Jwk` directly — see that type's own doc comment for why `key_ops` is a mutable - * `string[]` rather than `ReadonlyArray`, which is also why this is still structurally - * assignable everywhere a `JwkLike` (`shared/auth/jwks.ts`) is expected. + * The default ES256 signing key present on every resolved config unless overridden by a real + * `auth.signing_keys_path` file. Always part of the published JWKS regardless of `auth.enabled`. + * Shared by GoTrue's own env building (which signs tokens with it) and JWKS resolution (which + * publishes its public form), so the two can never disagree on the default key. */ export const DEFAULT_SIGNING_KEY: Jwk = { kty: "EC", @@ -62,25 +49,14 @@ export const DEFAULT_SIGNING_KEY: Jwk = { }; /** - * Go-byte-exact HS256 signer for the default local-dev `anon`/`service_role` - * keys, ported from `CustomClaims`/`generateJWT`. - * {@link generateAsymmetricGoJwt} below covers the RS256/ES256 branch of - * the same Go function, taken when `auth.signing_keys_path` is configured. - * - * This intentionally does NOT reuse `@supabase/stack`'s `generateJwt` - * (`packages/stack/src/JwtGenerator.ts`) — that helper uses `iss:"supabase"`, - * a dynamic `iat`/10-year `exp`, and a different claim order, none of which - * byte-match what Go prints for `supabase status`. Go's claims, in - * declaration order (the outer `CustomClaims.Issuer` field shadows the - * embedded `jwt.RegisteredClaims.Issuer`, so only one `iss` key is emitted): + * HS256 signer for the default local-dev `anon`/`service_role` keys. {@link + * generateAsymmetricGoJwt} below covers the RS256/ES256 branch, taken when + * `auth.signing_keys_path` is configured. * - * iss (fixed "supabase-demo"), ref (omitempty), role, is_anonymous (omitempty), - * then the remaining `jwt.RegisteredClaims` fields (sub, aud, exp, nbf, iat, jti), - * all `omitempty` except `exp`, which Go always sets to the fixed - * `defaultJwtExpiry = 1983812996` unix timestamp (never computed from "now"). - * - * `status` never sets `ref`/`is_anonymous`, so for this signer's two roles the - * payload always serializes to exactly `{"iss":...,"role":...,"exp":...}`. + * Does not reuse `@supabase/stack`'s `generateJwt`: that helper uses a different issuer, a + * dynamic expiry, and a different claim order. This signer's payload always serializes to exactly + * `{"iss":"supabase-demo","role":...,"exp":1983812996}` — a fixed expiry, never computed from + * "now". */ const GO_JWT_ISSUER = "supabase-demo"; @@ -100,7 +76,7 @@ export function generateGoJwt(secret: string, role: "anon" | "service_role"): st return `${data}.${signature}`; } -/** Go's asymmetric-JWT expiry: `time.Now().Add(time.Hour * 24 * 365 * 10)` (10 years). */ +/** Asymmetric-JWT expiry: 10 years from now. */ const GO_JWT_ASYMMETRIC_EXPIRY_SECONDS = 60 * 60 * 24 * 365 * 10; function base64UrlToBigInt(value: string): bigint { @@ -114,7 +90,7 @@ function bigIntToBase64Url(value: bigint): string { return Buffer.from(hex, "hex").toString("base64url"); } -/** Modular inverse of `a` mod `m` via the extended Euclidean algorithm (`a`/`m` coprime, as `q`/`p` always are for a valid RSA key). */ +/** Modular inverse of `a` mod `m` via the extended Euclidean algorithm (`q`/`p` are always coprime for a valid RSA key). */ function modInverse(a: bigint, m: bigint): bigint { let [oldR, r] = [a, m]; let [oldS, s] = [1n, 0n]; @@ -127,20 +103,10 @@ function modInverse(a: bigint, m: bigint): bigint { } /** - * Backfills the RSA CRT parameters (`dp`, `dq`, `qi`) `jwkToRSAPrivateKey` - * never reads — it constructs - * `rsa.PrivateKey{N, E, D, Primes: [p, q]}` from `n`/`e`/`d`/`p`/`q` alone, and - * Go's stdlib `crypto/rsa` (`SignPKCS1v15` -> `precompute()`) lazily derives - * `Dp`/`Dq`/`Qinv` from `p`/`q`/`d` itself when they're absent, so a JWK - * missing them still signs successfully in Go. Node's - * `createPrivateKey({ format: "jwk" })` has no such fallback — it hard-rejects - * an RSA JWK without `dp`/`dq`/`qi` (`The "key.dp" property must be of type - * string`) — so this reproduces Go's derivation before handing the key to - * Node: `dp = d mod (p-1)`, `dq = d mod (q-1)`, `qi = q^-1 mod p` (RFC 7517 - * section 6.3.2 / RFC 3447 section 3.2). A key that already has all three (the common case - * for a Node/openssl-generated JWK) is returned unchanged; one missing - * `d`/`p`/`q` themselves is also returned unchanged — that's a genuinely - * invalid key in Go too, and `createPrivateKey` will raise its own error. + * Backfills the RSA CRT parameters (`dp`, `dq`, `qi`) when absent: Node's `createPrivateKey` + * rejects an RSA JWK without them, unlike Go, which derives them lazily from `p`/`q`/`d` before + * signing. Returns the key unchanged if all three are already present, or if `d`/`p`/`q` are + * missing (an invalid key either way). */ function ensureRsaCrtParams(jwk: Jwk): Jwk { if (jwk.dp !== undefined && jwk.dq !== undefined && jwk.qi !== undefined) { @@ -163,18 +129,10 @@ function ensureRsaCrtParams(jwk: Jwk): Jwk { type SupportedJwtAlgorithm = "RS256" | "ES256"; /** - * `config.Algorithm.UnmarshalText` — - * `encoding/json` calls this automatically whenever a JWK's `alg` field decodes from - * a JSON STRING (the only shape a pasted stdin JWK or a `signing_keys_path` file ever - * provides), rejecting anything other than `RS256`/`ES256` at JSON-DECODE time — well - * BEFORE the JWK ever reaches signing. An absent `alg` never reaches this check at - * all (`encoding/json` only calls `UnmarshalText` for a key present in the source - * JSON; a missing key just leaves the struct field at its zero value), so that case - * is caught later, at SIGN time, by {@link signJwtWithJwk}'s own `unsupported - * algorithm: ` check instead. Throws the established bare `UnmarshalText` error text - * unwrapped; callers apply their own decode-context wrapping (`"failed to parse - * JWK: %w"` for a pasted stdin JWK, `"failed to decode signing keys: failed to parse - * response body: %w"` for a `signing_keys_path` file — see `fetcher.ParseJSON`). + * Validates a JWK's `alg` field, rejecting anything other than `RS256`/`ES256`. An absent `alg` + * is not rejected here — that's caught later, at sign time, by {@link signJwtWithJwk}'s own + * `unsupported algorithm: ` check. Throws the bare error text; callers apply their own + * decode-context wrapping. */ export function assertDecodableJwkAlgorithm(alg: string | undefined): void { if (alg !== undefined && alg !== "RS256" && alg !== "ES256") { @@ -183,11 +141,8 @@ export function assertDecodableJwkAlgorithm(alg: string | undefined): void { } /** - * `jwkToPrivateKey`: validates - * `jwk.kty`/`jwk.crv` ONLY — it has no awareness of `jwk.alg` at all. Throws Go's - * own unwrapped message text; the caller ({@link signJwtWithJwk}) applies - * `GenerateAsymmetricJWT`'s `"failed to convert JWK to private key: %w"` wrapper - * on top. + * Validates `jwk.kty`/`jwk.crv` only — no awareness of `jwk.alg`. Throws the established error + * text; the caller ({@link signJwtWithJwk}) wraps it further. */ function assertSupportedKty(jwk: Jwk): void { if (jwk.kty === "EC") { @@ -202,24 +157,13 @@ function assertSupportedKty(jwk: Jwk): void { } /** - * `jwkToECDSAPrivateKey`/`jwkToRSAPrivateKey` - * decode every numeric field with `base64.RawURLEncoding.DecodeString` immediately after the - * kty/curve check above — and that decoder genuinely REJECTS `=`-padded input (`RawURLEncoding` - * has no pad character at all), unlike Node's own JWK importer - * (`createPrivateKey({format:"jwk"})`), which happily accepts a padded coordinate and signs a - * token Go would have refused to produce (verified empirically: Go's decoder raises - * `illegal base64 data at input byte 43` for a padded 32-byte P-256 coordinate; Node's importer - * raises nothing at all and returns a usable key) — CLI-1961 Codex review finding. + * Reproduces `encoding/base64`'s `CorruptInputError` message for each numeric field, since Go's + * unpadded base64 decoder rejects input Node's own JWK importer would silently accept (e.g. a + * `=`-padded coordinate) and sign a token Go could never have produced. * - * Runs in Go's exact per-field order (EC: x, y, d; RSA: n, e, d, p, q) so the FIRST invalid field - * matches Go's own first-failure-wins decode order. Reproduces - * `encoding/base64`'s `CorruptInputError` text exactly: the reported byte offset is the index of - * the first character outside the `RawURLEncoding` alphabet (`A-Za-z0-9-_` — a padding `=` is - * such a character, since this encoding has no pad character to special-case), or - * `value.length - 1` for an otherwise-valid string whose length is impossible for base64 - * (`length % 4 === 1`) — both verified directly against the Go standard library's - * `decodeQuantum`. An absent field is Go's own zero value (`""`), which decodes cleanly to zero - * bytes, so `undefined` is skipped here rather than treated as invalid. + * Checks fields in Go's exact order (EC: x, y, d; RSA: n, e, d, p, q) so the first invalid field + * matches Go's first-failure-wins order. An absent field is skipped, matching Go's zero value + * decoding to zero bytes. */ function assertDecodableJwkNumericFields(jwk: Jwk): void { const assertField = (label: string, value: string | undefined): void => { @@ -249,17 +193,10 @@ function assertDecodableJwkNumericFields(jwk: Jwk): void { } /** - * There is NO explicit cross-check between `jwk.Algorithm` and `jwk.KeyType` before - * signing: `jwkToPrivateKey` only validates kty/curve (see {@link assertSupportedKty}), - * and `GenerateAsymmetricJWT`'s algorithm switch only validates `jwk.Algorithm` - * itself. A mismatched pair (e.g. `kty: "RSA"` signed as `ES256`) reaches - * `token.SignedString(privateKey)` and fails INSIDE golang-jwt's own signing - * method, which type-asserts the key: - * `"key is of invalid type: "`, wrapped into - * `"failed to sign JWT: %w"`. Node's own `createSign(...).sign(privateKey)` would - * also fail on this mismatch, but with an OpenSSL-level message that does not - * match this text — so this reproduces the established OBSERVABLE error deliberately, ahead - * of ever touching Node's signer. + * There's no explicit cross-check between a JWK's type and its signing algorithm before signing + * (see {@link assertSupportedKty}). A mismatched pair (e.g. `kty: "RSA"` signed as `ES256`) must + * fail with this exact message rather than Node's own OpenSSL-level error, which wouldn't match + * it. */ function assertKeyMatchesAlgorithm(jwk: Jwk, algorithm: SupportedJwtAlgorithm): void { if (algorithm === "RS256" && jwk.kty !== "RSA") { @@ -271,34 +208,13 @@ function assertKeyMatchesAlgorithm(jwk: Jwk, algorithm: SupportedJwtAlgorithm): } /** - * Go's `GenerateAsymmetricJWT`: signs an - * already-encoded JSON claims payload with a JWK private key. Callers own their - * own claims shape/serialization (struct-field order for - * {@link generateAsymmetricGoJwt}'s fixed anon/service_role claims, Go - * map-key alphabetical order for `gen bearer-jwt`'s `jwt.MapClaims`-shaped - * claims) — this function only handles the parts Go's `GenerateAsymmetricJWT` - * itself handles: key validation, header construction, and signing. - * - * Validation order matches Go exactly: kty/curve first (wrapped - * `"failed to convert JWK to private key: %w"`, {@link assertSupportedKty}), - * then the algorithm switch (unwrapped `"unsupported algorithm: %s"`), then the - * kty-vs-alg mismatch Go's OWN signing method raises (wrapped - * `"failed to sign JWT: %w"`, {@link assertKeyMatchesAlgorithm}). The header key - * order (`alg`, `kid`, `typ`) matches `encoding/json` alphabetically - * sorting `map[string]interface{}` keys — `kid` is only present when set on the - * JWK, matching `if len(jwk.KeyID) > 0` guard. - * - * `dsaEncoding: "ieee-p1363"` is required for ES256: Node's default ECDSA - * signature output is DER-encoded, which is not the raw (r‖s) format JWS - * requires — verified by round-tripping through `jose`'s `jwtVerify`. + * Signs an already-encoded JSON claims payload with a JWK private key. Header key order is `alg`, + * `kid` (only when set), `typ`. * - * The header is serialized with {@link encodeGoJsonCompact}, NOT `JSON.stringify` — Go's - * `token.SignedString` marshals the header via `encoding/json`'s default `json.Marshal` - * (`golang-jwt/jwt/v5`'s `Token.SigningString`), which HTML-escapes `<`/`>`/`&` (verified - * directly against the Go standard library: `json.Marshal` of a `kid` containing those - * characters produces `<`/`>`/`&`, where `JSON.stringify` leaves them literal) — - * a `kid` with any of those characters would otherwise sign different header bytes (and thus a - * different signature) than Go for identical input (CLI-1961 Codex review finding). + * `dsaEncoding: "ieee-p1363"` is required for ES256: Node's default ECDSA signature is + * DER-encoded, not the raw (r‖s) format JWS requires. The header is serialized with + * {@link encodeGoJsonCompact}, not `JSON.stringify`, since a `kid` containing `<`/`>`/`&` must + * HTML-escape to sign the same bytes Go would. */ export function signJwtWithJwk(jwk: Jwk, payloadJson: string): string { try { @@ -347,17 +263,9 @@ export function signJwtWithJwk(jwk: Jwk, payloadJson: string): string { } /** - * Go's `(a auth) generateJWT` asymmetric branch, - * reached only when `auth.signing_keys_path` resolves to a non-empty JWK array — - * the first key in the file signs both the anon and service_role tokens. Same - * claim shape as {@link generateGoJwt} (`iss`/`role`/`exp`), except the - * expiry is 10 years from now rather than Go's fixed HMAC-path timestamp, since - * `generateJWT` sets `claims.ExpiresAt` explicitly before calling - * `GenerateAsymmetricJWT` with a `CustomClaims` STRUCT value (not a map) — - * `encoding/json` serializes a struct in field-DECLARATION order, so this - * builds the payload with a plain (insertion-order) `JSON.stringify`, unlike - * `gen bearer-jwt`'s claims (always a real `jwt.MapClaims`, alphabetically - * key-sorted — see `bearer-jwt.claims.ts`). + * The RS256/ES256 signing path, used when `auth.signing_keys_path` resolves to a non-empty JWK + * array — the first key in the file signs both the anon and service_role tokens. Same claim + * shape as {@link generateGoJwt} (`iss`/`role`/`exp`), except the expiry is 10 years from now. */ export function generateAsymmetricGoJwt(jwk: Jwk, role: "anon" | "service_role"): string { const expiresAt = Math.floor(Date.now() / 1000) + GO_JWT_ASYMMETRIC_EXPIRY_SECONDS; diff --git a/apps/cli/src/command-internal/go-jwt.unit.test.ts b/apps/cli/src/command-internal/go-jwt.unit.test.ts index c22888be02..c67c88212f 100644 --- a/apps/cli/src/command-internal/go-jwt.unit.test.ts +++ b/apps/cli/src/command-internal/go-jwt.unit.test.ts @@ -38,8 +38,6 @@ describe("generateGoJwt", () => { const token = generateGoJwt(SECRET, "anon"); const [header] = token.split("."); expect(header).toBeDefined(); - // Go's jwt.NewWithClaims builds Header as map[string]any{"typ":..,"alg":..}; - // encoding/json marshals map keys in sorted order, so "alg" sorts before "typ". expect(decodeSegment(header ?? "")).toBe('{"alg":"HS256","typ":"JWT"}'); }); @@ -48,8 +46,6 @@ describe("generateGoJwt", () => { const [, payload] = token.split("."); expect(payload).toBeDefined(); const raw = decodeSegment(payload ?? ""); - // Byte-exact key order: iss, role, exp — ref/is_anonymous/iat are omitted - // entirely (`omitempty`), matching status's no-ref, non-anonymous use. expect(raw).toBe('{"iss":"supabase-demo","role":"anon","exp":1983812996}'); const parsed = JSON.parse(raw) as Record; @@ -99,11 +95,6 @@ describe("generateAsymmetricGoJwt", () => { }); it("signs an RS256 token from an RSA JWK missing CRT exponents (dp/dq/qi), matching Go", async () => { - // `jwkToRSAPrivateKey` - // never reads `dp`/`dq`/`qi` — it builds the key from `n`/`e`/`d`/`p`/`q` - // alone, and Go's stdlib derives the CRT params itself when absent. A - // hand-authored signing-keys file that omits them (common — RFC 7517 marks - // them optional) must still sign successfully here. const jwk = generateRsaJwk("rsa-kid"); const { dp: _dp, dq: _dq, qi: _qi, ...jwkWithoutCrtParams } = jwk; const token = generateAsymmetricGoJwt(jwkWithoutCrtParams, "anon"); @@ -151,14 +142,6 @@ describe("generateAsymmetricGoJwt", () => { expect(() => generateAsymmetricGoJwt(jwkWithoutAlg, "anon")).toThrow("unsupported algorithm: "); }); - // Go has NO explicit kty<->alg cross-check (verified against the real binary, - // CLI-1961): `jwkToPrivateKey` only validates kty/curve, and the algorithm - // switch only validates `jwk.Algorithm` — a mismatched pair reaches - // `token.SignedString(privateKey)` and fails INSIDE golang-jwt's own signing - // method with "key is of invalid type: ...", wrapped as "failed to sign - // JWT: %w". These two tests previously asserted an - // "unsupported key type" message that Go never actually produces for this - // input — corrected here. it("rejects an EC key forged with alg: RS256 instead of signing garbage", () => { const jwk = { ...generateEcJwk(), alg: "RS256" }; expect(() => generateAsymmetricGoJwt(jwk, "anon")).toThrow( @@ -182,8 +165,6 @@ describe("generateAsymmetricGoJwt", () => { }); it("rejects a JWK with no kty at all, wrapped like Go's GenerateAsymmetricJWT", () => { - // `bearerjwt_test.go`'s "throws error on unsupported kty" fixture uses exactly - // this shape (`{"kty": "oct"}`, no `alg`) — kty is checked before alg regardless. const jwk = { kty: "oct" } as Jwk; expect(() => generateAsymmetricGoJwt(jwk, "anon")).toThrow( "failed to convert JWK to private key: unsupported key type: oct", @@ -199,12 +180,6 @@ describe("generateAsymmetricGoJwt", () => { }); it("rejects a padded EC coordinate instead of signing a token Go would refuse to produce (CLI-1961 Codex review finding)", () => { - // `jwkToECDSAPrivateKey` decodes x/y/d with `base64.RawURLEncoding.DecodeString`, - // which genuinely rejects `=` padding — verified directly against the real binary: - // the exact same padded x coordinate produces - // "failed to convert JWK to private key: failed to decode x coordinate: illegal base64 - // data at input byte 43". Node's own `createPrivateKey({format:"jwk"})` accepts the - // padding and would otherwise sign successfully, minting a token Go could never produce. const jwk = generateEcJwk("ec-kid"); const padded = { ...jwk, x: `${jwk.x}=` }; expect(() => generateAsymmetricGoJwt(padded, "anon")).toThrow( @@ -229,9 +204,8 @@ describe("generateAsymmetricGoJwt", () => { describe("signJwtWithJwk", () => { it("signs the caller's exact pre-encoded payload string verbatim (no re-serialization)", async () => { const jwk = generateEcJwk("ec-kid"); - // Deliberately NOT alphabetically sorted and containing characters Go's - // `encoding/json` would HTML-escape (`&`) — this function must sign exactly - // the bytes it's given, leaving ordering/escaping decisions to the caller. + // Unsorted and containing `&`, which Go's encoder would normally HTML-escape: this function + // must sign exactly the bytes it's given. const payloadJson = '{"role":"postgres","sb-role":"mgmt-api & co"}'; const token = signJwtWithJwk(jwk, payloadJson); const [, payload] = token.split("."); @@ -243,11 +217,6 @@ describe("signJwtWithJwk", () => { }); it("HTML-escapes the kid in the header like Go's json.Marshal, unlike JSON.stringify (CLI-1961 Codex review finding)", () => { - // `token.SignedString` marshals the protected header via `encoding/json`'s - // default `json.Marshal`, which HTML-escapes `<`/`>`/`&` — verified directly against - // the Go standard library. A plain `JSON.stringify` leaves those characters literal, - // which would sign different header bytes (and thus a different signature) than Go - // for an otherwise-identical kid. const jwk = generateEcJwk("ac&d"); const token = signJwtWithJwk(jwk, '{"role":"anon"}'); const [header] = token.split("."); diff --git a/apps/cli/src/command-internal/go-output-flag.ts b/apps/cli/src/command-internal/go-output-flag.ts index 757684b1b2..8e6ad00f44 100644 --- a/apps/cli/src/command-internal/go-output-flag.ts +++ b/apps/cli/src/command-internal/go-output-flag.ts @@ -6,27 +6,21 @@ import { } from "../shared/telemetry/error-actionability.ts"; /** - * Per-command `--output`/`-o` enums, mirroring Go. Go registers `--output` per - * command with a strict `EnumFlag` (`internal/utils/enum.go`); the TS CLI - * instead exposes ONE global `OutputFlag` whose choice is the union - * of every command's values (see `command-internal/global-flags.ts`). Because that - * single flag cannot vary its accepted set per command, each command declares - * the subset its Go counterpart accepts and the command wrapper - * (`withCommandTelemetry`) rejects anything outside it — restoring - * Go's per-command validation. + * Per-command `--output`/`-o` enums. The CLI exposes one global `OutputFlag` whose choice set is + * the union of every command's accepted values (see `command-internal/global-flags.ts`), so each + * command declares its own accepted subset here and the command wrapper + * (`withCommandTelemetry`) rejects anything outside it. */ -/** Go's global `utils.OutputFormat` enum (`internal/utils/output.go:30-39`). */ +/** Accepted `-o`/`--output` values for most resource commands. */ export const RESOURCE_OUTPUT_FORMATS = ["env", "pretty", "json", "toml", "yaml"] as const; -/** Go's `db query` `queryOutput` enum (`cmd/db.go:285-288`). */ +/** Accepted `-o`/`--output` values for `db query`. */ export const QUERY_OUTPUT_FORMATS = ["json", "table", "csv"] as const; /** - * Raised when `-o`/`--output` carries a value the active command does not accept. - * The message is byte-identical to Go's pflag rejection: pflag wraps - * `EnumFlag.Set`'s `must be one of [ a | b | c ]` (`enum.go:21-27`) in - * `invalid argument %q for %q flag: %v` with the shorthand-prefixed flag name. + * Raised when `-o`/`--output` carries a value the active command doesn't accept. The message + * matches the established pflag rejection format: `invalid argument %q for %q flag: %v`. */ export class InvalidOutputFormatError extends Data.TaggedError("InvalidOutputFormatError")<{ readonly message: string; @@ -36,15 +30,14 @@ export class InvalidOutputFormatError extends Data.TaggedError("InvalidOutputFor } } -/** Go's `must be one of [ a | b | c ]` (`enum.go:23`, joined with `" | "`). */ +/** Formats the accepted-values list for an invalid `-o`/`--output` error. */ export function outputFormatEnumMessage(allowed: ReadonlyArray): string { return `must be one of [ ${allowed.join(" | ")} ]`; } /** - * Go's full pflag rejection string for an invalid `-o` value - * (`pflag InvalidValueError`: `invalid argument %q for %q flag: %v`, with the - * `-o, --output` shorthand-prefixed name). + * The full rejection message for an invalid `-o` value, matching the established + * `invalid argument %q for %q flag: %v` pflag format. */ export function invalidOutputFormatMessage(value: string, allowed: ReadonlyArray): string { return `invalid argument "${value}" for "-o, --output" flag: ${outputFormatEnumMessage(allowed)}`; diff --git a/apps/cli/src/command-internal/go-output.encoders.ts b/apps/cli/src/command-internal/go-output.encoders.ts index a56fc37a9e..935e0c48b9 100644 --- a/apps/cli/src/command-internal/go-output.encoders.ts +++ b/apps/cli/src/command-internal/go-output.encoders.ts @@ -5,19 +5,11 @@ import { encodeGoJsonCompact, encodeGoJsonIndented } from "./go-json.ts"; import { goStringCompare } from "./go-struct-output.encoders.ts"; /** - * Reproduces `json.Encoder` output (`utils.EncodeOutput` with `-o json`): - * - Top-level and nested struct fields serialize in alphabetical key order. - * - Go string escaping, including the default HTML escapes (`<` / `>` / `&` - * become `\u003c` / `\u003e` / `\u0026` — Go never calls - * `SetEscapeHTML(false)` on this path), `\u0008`/`\u000c` for - * backspace/form feed, and escaped U+2028/U+2029. - * - Trailing newline (matches `json.Encoder.Encode`). + * Reproduces `json.Encoder` output for `-o json`: alphabetical key order, Go string escaping, and + * a trailing newline. * - * The optional `nullForEmptyArrays` option mirrors `null` serialization for nil - * slices: when the schema decodes both `null` and `[]` to `[]` upstream, the caller can - * list array keys that should re-substitute `null` for empty arrays so the JSON bytes - * match Go's output. Used by `backups list` to preserve its PITR-only `"backups": null` - * shape. Most commands don't need this option. + * `nullForEmptyArrays` re-substitutes `null` for an empty array at the listed keys, for a schema + * that decodes both `null` and `[]` to `[]` upstream (e.g. `backups list`'s `"backups": null`). */ export function encodeGoJson( value: T, @@ -47,30 +39,18 @@ export function encodeGoJson( function sortKeysDeep(value: unknown): unknown { if (Array.isArray(value)) return value.map(sortKeysDeep); if (value === null || typeof value !== "object") return value; - // A plain object silently reorders integer-like string keys ("2", "10") into ascending - // NUMERIC order on any subsequent enumeration (`Object.keys`/`Object.entries` in - // `go-json.ts`'s `walk`), regardless of what order they're inserted in here — Go's - // `encoding/json` has no such special case: a real Go map's string keys sort purely - // lexicographically ("10" before "2"). Building a `Map` instead of a plain object carries - // this sort through to `walk` intact, since `Map` iteration order is true insertion order - // for every key shape (CLI-1961 Codex review finding: `{"10":"a","2":"b"}` must stay "10" - // before "2" all the way through to the final encoded output). + // A plain object reorders integer-like string keys ("2", "10") into ascending numeric order on + // enumeration; building a `Map` instead carries a lexicographic sort through to `go-json.ts`'s + // `walk` intact, since `Map` iteration order is true insertion order. const sorted = new Map(); - // `.sort()` with no comparator uses JS default string comparison, which orders by - // UTF-16 code unit — NOT the same as Go's byte/code-point order once an astral - // character (U+10000+, a surrogate PAIR in UTF-16) meets a high-BMP one (U+E000- - // U+FFFF, a single code unit numerically ABOVE the astral character's leading - // surrogate). `goStringCompare` (hoisted from `go-struct-output.encoders.ts`, - // which already needed it for TOML/struct-map key sorting) reproduces Go's real - // `encoding/json` map-key order instead (CLI-1961 Codex review finding: verified - // against the real binary that `json.Marshal` of a map keyed by U+E000 and U+10000 - // emits the U+E000 key first — the reverse of plain JS `.sort()` on those two keys — - // which matters here because `gen bearer-jwt`'s `--payload` custom claims flow - // through this same `sortKeysDeep` via `encodeGoStructJsonBody` before signing). + // JS's default string sort orders by UTF-16 code unit, which diverges from Go's byte/codepoint + // order once an astral character (a UTF-16 surrogate pair) meets a high-BMP one. + // `goStringCompare` reproduces Go's real map-key order instead — this matters here since `gen + // bearer-jwt`'s custom claims flow through this same sort before signing. for (const key of Object.keys(value as Record).sort(goStringCompare)) { const child = (value as Record)[key]; - // JSON.stringify used to drop undefined properties; the Go-faithful walker - // renders them as null, so drop them here to keep the old key surface. + // Drop `undefined` properties here, matching `JSON.stringify`'s behavior (the Go-faithful + // walker below would otherwise render them as `null`). if (child === undefined) continue; sorted.set(key, sortKeysDeep(child)); } @@ -78,41 +58,30 @@ function sortKeysDeep(value: unknown): unknown { } /** - * Serialize an outbound API request body the way `json.Marshal` would - * for a struct: keys sorted alphabetically (the `@supabase/api`-generated - * structs declare fields alphabetically, and `json.Marshal` serializes in - * field-declaration order), Go string escaping (HTML characters included, - * matching `json.Marshal`'s default `escapeHTML: true`), no indentation, no - * trailing newline. + * Serializes an outbound API request body with sorted keys, Go string escaping, no indentation, + * and no trailing newline, matching `json.Marshal`'s struct output. * - * Use this on the raw-HTTP code path in `sso add` / `sso update` (and future - * handlers that bypass the typed client). The cli-e2e replay server compares - * recorded request bodies via string equality against bodies the Go CLI - * produced, so both key order and escaping must match `json.Marshal`. - * - * `encodeGoJson` is the parallel for human-facing `--output json` output - * (indented + trailing `\n`). + * Used on the raw-HTTP code path (`sso add`/`sso update`) whose request bodies the cli-e2e replay + * server compares by string equality against recorded bodies, so key order and escaping must + * match exactly. {@link encodeGoJson} is the parallel for human-facing `--output json`. */ export function encodeGoStructJsonBody(value: unknown): string { return encodeGoJsonCompact(sortKeysDeep(value)); } /** - * Go-compatible YAML for **map** payloads (`branches get` envs, `sso info`, - * `status`, `postgres-config`, …). Struct payloads must NOT use this — Go's - * yaml.v3 derives keys from the Go field names, not the JSON tags; use - * `encodeGoYaml` from `go-struct-output.encoders.ts` with the - * payload's Go struct spec instead (CLI-1975). + * YAML for map payloads (`branches get` envs, `sso info`, `status`, `postgres-config`). Struct + * payloads must use `encodeGoYaml` in `go-struct-output.encoders.ts` instead, since Go's yaml.v3 + * derives keys from Go field names, not JSON tags. */ export function encodeYaml(value: unknown): string { return stringifyYaml(value); } /** - * Go-compatible TOML for **map** payloads. Struct payloads must NOT use this — - * BurntSushi emits PascalCase Go field names with 2-space table indentation; - * use `encodeGoToml` from `go-struct-output.encoders.ts` with the - * payload's Go struct spec instead (CLI-1975). + * TOML for map payloads. Struct payloads must use `encodeGoToml` in + * `go-struct-output.encoders.ts` instead, since BurntSushi emits PascalCase Go field names with + * 2-space table indentation. */ export function encodeToml(value: unknown): string { // smol-toml refuses top-level non-object values; wrap if needed. @@ -123,18 +92,12 @@ export function encodeToml(value: unknown): string { } /** - * Reproduces the established `godotenv.Marshal` byte shape for the - * `--output env` mode. + * Reproduces the established `godotenv.Marshal` byte shape for `--output env`. * - * - Nested maps flatten using dotted paths; the loop - * then `strings.ToUpper(strings.ReplaceAll(k, ".", "_"))` produces SCREAMING_SNAKE_CASE keys. - * - Flattening does **not** descend into slices. An array value lands as a single - * leaf whose `GetString` rendering is the empty string — so e.g. - * `{backups: [{...}, {...}]}` becomes one `BACKUPS=""` entry, not indexed leaves. - * - Integer-parseable values are emitted unquoted (`KEY=123`), matching - * `godotenv.Marshal`'s `strconv.Atoi` branch. Everything else is double-quoted - * with `"` / `\\` escaped, matching the `fmt.Sprintf("%q", ...)` branch. - * - Lines are sorted lexicographically by key, then joined with `\n`. + * Nested maps flatten to dotted paths, then uppercase with `.` replaced by `_`. Flattening does + * not descend into slices: an array value becomes a single empty-string leaf. Integer-parseable + * values are emitted unquoted; everything else is double-quoted with `"`/`\\` escaped. Lines sort + * lexicographically by key. */ export function encodeEnv(value: unknown): string { const flat = flatten(value); @@ -156,15 +119,12 @@ function flatten( return out; } if (Array.isArray(value)) { - // Go's viper does not descend into slices — the entire array collapses to a - // single empty-string leaf at the array's parent key. + // Arrays don't flatten further — the whole array collapses to a single empty-string leaf. if (prefix.length > 0) out[toEnvKey(prefix)] = ""; return out; } if (typeof value === "object") { - // Go's viper.AllKeys() omits empty nested maps entirely (unlike empty - // slices, which leave a single empty-string leaf). Match that — recurse - // into populated maps; emit nothing for `{}`. + // Empty nested maps emit nothing; only populated maps recurse. for (const [key, child] of Object.entries(value as Record)) { flatten(child, prefix.length === 0 ? key : `${prefix}.${key}`, out); } @@ -186,23 +146,20 @@ function stringifyScalar(value: unknown): string { return String(value); } -// strconv.Atoi accepts an optional +/- sign followed by base-10 digits. Match -// that surface so integer values flow through Go's unquoted `%d` branch. +// An optional +/- sign followed by base-10 digits, so integer values flow through the unquoted +// branch below. const INTEGER_PATTERN = /^[+-]?\d+$/; function formatEnvValue(value: string): string { if (INTEGER_PATTERN.test(value)) { const parsed = Number(value); - // Mirror godotenv's `%d` formatting (round-trip through int — drops a leading - // `+` and any leading zeros, matching Go's strconv.Atoi + fmt.Sprintf("%d"). + // Round-trip through Number to drop a leading `+` or leading zeros. if (Number.isSafeInteger(parsed)) { return String(parsed); } } - // Match `fmt.Sprintf("%q", ...)` escaping: backslash, double-quote, and the - // common C-style control characters \n / \r / \t. Without the control-character - // escapes a multi-line string value could become multiple KEY=VALUE assignments - // when a downstream shell `eval`s or `source`s the output. + // Escaping control characters (`\n`/`\r`/`\t`) prevents a multi-line value from becoming + // multiple KEY=VALUE assignments when a downstream shell evals or sources the output. const escaped = value .replaceAll("\\", "\\\\") .replaceAll('"', '\\"') diff --git a/apps/cli/src/command-internal/go-output.encoders.unit.test.ts b/apps/cli/src/command-internal/go-output.encoders.unit.test.ts index 68498d58b8..98738ed362 100644 --- a/apps/cli/src/command-internal/go-output.encoders.unit.test.ts +++ b/apps/cli/src/command-internal/go-output.encoders.unit.test.ts @@ -3,11 +3,8 @@ import { describe, expect, it } from "vitest"; import { encodeEnv, encodeGoJson, encodeToml, encodeYaml } from "./go-output.encoders.ts"; -// These encoders are type-generic. We keep one fixture shaped like the backups -// response because the `nullForEmptyArrays` option (and the Go-parity byte -// assertions that exercise it) were extracted from the backups port. The -// encoder itself has no backups coupling — see the `{ items, name }` fixtures -// below for plain-object coverage of the option path. +// Shaped like the backups response because the `nullForEmptyArrays` byte-parity assertions were +// extracted from that port; see the `{ items, name }` fixtures below for plain-object coverage. const SAMPLE_RESPONSE: typeof V1ListAllBackupsOutput.Type = { region: "ap-southeast-1", walg_enabled: true, @@ -52,8 +49,6 @@ describe("encodeGoJson", () => { }); it("emits backups: null and an empty physical_backup_data object for a PITR-only response", () => { - // Matches the established "encodes json output" fixture — - // empty backups slice serializes as null, omitempty physical_backup_data fields drop out. const out = encodeGoJson( { region: "ap-southeast-1", @@ -77,7 +72,6 @@ describe("encodeGoJson", () => { }); it("leaves arrays intact when nullForEmptyArrays is not provided", () => { - // Default behaviour for commands (e.g. ssl-enforcement) that have no nil-slice rewrite. const out = encodeGoJson({ items: [], name: "x" }); expect(out).toBe( `{ @@ -103,11 +97,6 @@ describe("encodeGoJson", () => { }); it("keeps Go's true lexicographic order for numeric-looking keys (CLI-1961 Codex review finding)", () => { - // A plain JS object always reorders integer-like string keys ("2", "10") into - // ascending NUMERIC order on enumeration, regardless of insertion order — Go's - // `encoding/json` has no such special case for a real Go map, so "10" sorts before - // "2" lexicographically. `sortKeysDeep` must build a `Map` (not a plain object) to - // carry that sort through to the final encoded output intact. const out = encodeGoJson({ 10: "a", 2: "b", role: "anon" }); expect(out).toBe( `{ @@ -120,12 +109,6 @@ describe("encodeGoJson", () => { }); it("sorts keys by Go's byte/code-point order, not JS's UTF-16 code-unit order (CLI-1961 Codex review finding)", () => { - // U+E000 is a single UTF-16 code unit (0xE000); U+10000 is a surrogate PAIR whose - // leading unit (0xD800) is numerically SMALLER than 0xE000 — so plain JS `.sort()` - // (which compares UTF-16 code units) puts the astral key FIRST, while Go's real - // `encoding/json` (byte/code-point order) puts the high-BMP key first instead. - // Verified against the real binary: `json.Marshal` of a Go map keyed by these two - // strings emits the U+E000 key first. const highBmp = String.fromCodePoint(0xe000); const astral = String.fromCodePoint(0x10000); const out = encodeGoJson({ [astral]: 2, [highBmp]: 1 }); @@ -158,8 +141,6 @@ describe("encodeEnv", () => { const out = encodeEnv(SAMPLE_RESPONSE); const lines = out.split("\n"); expect(lines).toContain('REGION="ap-southeast-1"'); - // Booleans are stringified to "true"/"false" — not integers under strconv.Atoi, - // so godotenv quotes them. expect(lines).toContain('WALG_ENABLED="true"'); expect(lines).toContain('PITR_ENABLED="true"'); }); @@ -172,8 +153,6 @@ describe("encodeEnv", () => { }); it("collapses arrays to a single empty leaf (Go viper does not descend into slices)", () => { - // Go output for `backups: [{...}]` is `BACKUPS=""`, not `BACKUPS_0_STATUS=...` — - // viper.AllKeys() stops at slice boundaries and GetString of a slice is "". const out = encodeEnv(SAMPLE_RESPONSE); const lines = out.split("\n"); expect(lines).toContain('BACKUPS=""'); @@ -181,7 +160,6 @@ describe("encodeEnv", () => { }); it("matches Go's full env output for the sample backup response", () => { - // Verified byte-for-byte against `utils.EncodeOutput("env", ...)`. expect(encodeEnv(SAMPLE_RESPONSE)).toBe( [ 'BACKUPS=""', @@ -200,9 +178,6 @@ describe("encodeEnv", () => { }); it("escapes embedded newlines, carriage returns, and tabs (Go %q parity)", () => { - // Without this, a multi-line string value would render as multiple lines in - // env output and be interpreted as separate KEY=VALUE assignments by a shell - // that `eval`s or `source`s the output. const out = encodeEnv({ description: "line one\nline two\rwith\ttab" }); expect(out).toBe('DESCRIPTION="line one\\nline two\\rwith\\ttab"'); }); @@ -213,15 +188,10 @@ describe("encodeEnv", () => { }); it("omits empty nested maps entirely (Go viper parity)", () => { - // Go output for `{physical_backup_data: {}}` is empty — viper.AllKeys() - // does not surface a key for a map with no children. Contrast with empty - // arrays, which Go DOES surface as `KEY=""`. expect(encodeEnv({ physical_backup_data: {} })).toBe(""); }); it("matches Go for the PITR-only response shape with empty physical_backup_data", () => { - // Verified byte-for-byte against `utils.EncodeOutput("env", ...)` - // with a JSON-decoded V1BackupsResponse whose physical_backup_data is `{}`. expect( encodeEnv({ region: "ap-southeast-1", @@ -238,7 +208,6 @@ describe("encodeEnv", () => { }); it("emits an empty-string value for an explicit null leaf", () => { - // Go: viper does surface a nil leaf as `KEY=""` (it still has a key path). expect(encodeEnv({ physical_backup_data: { earliest_physical_backup_date_unix: null } })).toBe( 'PHYSICAL_BACKUP_DATA_EARLIEST_PHYSICAL_BACKUP_DATE_UNIX=""', ); diff --git a/apps/cli/src/command-internal/go-proxy.layer.ts b/apps/cli/src/command-internal/go-proxy.layer.ts index 9ee17fe8c6..e5c10d39ea 100644 --- a/apps/cli/src/command-internal/go-proxy.layer.ts +++ b/apps/cli/src/command-internal/go-proxy.layer.ts @@ -18,10 +18,6 @@ const markDelegated = Effect.serviceOption(GoProxyInvocation).pipe( ), ); -// --------------------------------------------------------------------------- -// Binary resolution -// --------------------------------------------------------------------------- - const PLATFORM_CANDIDATES: Partial>>>> = { darwin: { arm64: ["darwin-arm64"], x64: ["darwin-x64"] }, @@ -35,10 +31,9 @@ const PLATFORM_CANDIDATES: Partial | null { if (CLI_VERSION === "0.0.0-dev") return null; const archSuffix = process.arch === "x64" ? "amd64" : process.arch === "arm64" ? "arm64" : null; if (archSuffix === null) return null; - // Map Node's `process.platform` to the release asset's OS slug. `win32` is - // historical (Win16 vs Win32); GitHub assets use the modern `windows` slug. + // Node's `process.platform` is `win32`; GitHub release assets use the modern `windows` slug. const osSlug = process.platform === "win32" ? "windows" : process.platform; const asset = `supabase_${CLI_VERSION}_${osSlug}_${archSuffix}.tar.gz`; return [ @@ -120,10 +111,6 @@ export function formatGoBinaryNotFoundError(tried: ReadonlyArray): strin ].join("\n"); } -// --------------------------------------------------------------------------- -// Layer factory -// --------------------------------------------------------------------------- - /** * Creates a GoProxy layer. * @@ -178,10 +165,6 @@ export function makeGoProxyLayer(opts?: { Effect.scoped( Effect.gen(function* () { if (!("found" in resolved)) { - // CLI-1488: never silently fall back to `supabase` on PATH — - // when the shim is on PATH and `supabase-go` is not co-located, - // that fallback resolves to the shim itself and fork-bombs. - // Print a specific diagnostic and fail non-zero instead. yield* Effect.sync(() => { process.stderr.write(`${formatGoBinaryNotFoundError(resolved.notFound)}\n`); }); @@ -194,32 +177,15 @@ export function makeGoProxyLayer(opts?: { } const binary = resolved.found; - // Hold the terminal-signals on the parent for the duration of - // the child's lifetime. Rationale: - // - // 1. Effect's Node/Bun ChildProcessSpawner defaults - // `detached: true` on non-Windows (see NodeChildProcessSpawner.ts), - // which puts the child in its own process group and makes it - // miss tty signals. We explicitly pass `detached: false` below - // so Ctrl+C → SIGINT → foreground pgrp reaches the Go binary, - // and the Go CLI's own handlers (docker cleanup on `start`, - // context cancellation, etc.) run as expected. - // - // 2. Without a userland listener, Bun/Node default-terminates - // the parent on SIGINT with exit code 130, which would race - // the child's graceful shutdown and lose its real exit code. - // `processControl.holdSignals` installs no-op listeners that - // disable the default action so the parent stays blocked on - // `spawner.exitCode` and propagates the Go binary's exit - // status verbatim. - // - // Scoped via `Effect.scoped` so listeners are always removed on - // normal completion, failure, or fiber interruption. + // Hold terminal signals on the parent for the child's lifetime: the child spawner + // defaults to `detached: true` on non-Windows, which would put the child in its own + // process group and miss tty signals, so `detached: false` below lets Ctrl+C reach + // the Go binary directly. Without a listener, Bun/Node would also default-terminate + // the parent on SIGINT before the child's real exit code is known. yield* processControl.holdSignals(["SIGINT", "SIGTERM", "SIGHUP"]); - // Only an instrumented caller that delegates the whole command - // suppresses child telemetry, because there the parent already - // emits `cli_command_executed`. Pure proxy commands have no - // parent event, so the child must stay free to report. + // Only an instrumented caller that delegates the whole command suppresses child + // telemetry: the parent already emits `cli_command_executed` there. Pure proxy + // commands have no parent event, so the child must stay free to report. const env = { ...opts?.env, ...execOpts?.env, @@ -276,12 +242,10 @@ export function makeGoProxyLayer(opts?: { ? { SUPABASE_NO_UPDATE_NOTIFIER: "1" } : {}), }; - // Capture stdout (pipe) while keeping stderr inherited, so the child's - // progress still reaches the user but its stdout is collected for - // wrapping rather than written to our stdout. stdin defaults to - // inherited (interactive); callers pass `"ignore"` to give the child a - // non-TTY stdin so it can't block on a prompt before the wrapper emits - // its machine-output envelope. + // Capture stdout while keeping stderr inherited, so progress still reaches the user + // while stdout is collected for wrapping. Callers pass stdin: "ignore" to give the + // child a non-TTY stdin so it can't block on a prompt before the wrapper emits its + // machine-output envelope. const command = ChildProcess.make(binary, [...globalArgs, ...args], { cwd: execOpts?.cwd ?? opts?.cwd, env, diff --git a/apps/cli/src/command-internal/go-proxy.layer.unit.test.ts b/apps/cli/src/command-internal/go-proxy.layer.unit.test.ts index 31cc85f293..2ea151e9c1 100644 --- a/apps/cli/src/command-internal/go-proxy.layer.unit.test.ts +++ b/apps/cli/src/command-internal/go-proxy.layer.unit.test.ts @@ -11,24 +11,10 @@ import { GoProxy } from "./go-proxy.service.ts"; import { formatGoBinaryNotFoundError, makeGoProxyLayer } from "./go-proxy.layer.ts"; /** - * Regression tests for the SIGINT propagation fix in go-proxy.layer.ts. - * - * Two invariants MUST hold, otherwise Ctrl+C on a proxied long-running command - * (`supabase start`, `supabase login`, `supabase functions serve`, ...) will - * orphan the Go sidecar and/or lose its exit code: - * - * 1. `ChildProcess.make` is called with `detached: false` — Effect's Node/Bun - * spawner defaults `detached: true` on non-Windows, which puts the child - * in its own process group and makes it miss terminal-delivered signals. - * - * 2. `processControl.holdSignals` is called BEFORE the spawn (so parent-side - * no-op listeners are in place when the first tty signal arrives), covers - * SIGINT/SIGTERM/SIGHUP, and its scope is released on every exit path - * (success, failure, interrupt) so listeners don't leak between invocations. - * - * We verify invariant #2 against the service contract rather than against the - * real `process` listener table — the layer under test now delegates to - * `ProcessControl`, so real-listener coverage lives in the ProcessControl tests. + * Regression tests for SIGINT propagation: Ctrl+C on a proxied long-running command must reach + * the Go sidecar and not lose its exit code. `ChildProcess.make` must be called with + * `detached: false`, and `processControl.holdSignals` must be acquired before spawn (covering + * SIGINT/SIGTERM/SIGHUP) and released on every exit path. */ type CapturedCommand = { @@ -55,16 +41,12 @@ type HoldEvent = | { kind: "release"; id: number }; /** - * Records holdSignals(…) acquire/release transitions against an in-memory - * event log. Each acquire gets a monotonically increasing id so tests can - * pair an acquire with its release and distinguish concurrent scopes. + * Records holdSignals(...) acquire/release transitions. Each acquire gets a monotonically + * increasing id so tests can pair an acquire with its release. * - * The layer under test no longer calls `ProcessControl.exit()` itself on a - * non-zero exit or an unresolved binary (CLI-1879 routes both through - * `GoChildExitError` instead, so `runCli` can run finalizers before - * exiting) — `exit()` here only guards against a future regression that - * reintroduces a direct call; it blocks on `Effect.never` since nothing in - * this file exercises it. + * `exit()` here only guards against a regression that reintroduces a direct + * `ProcessControl.exit()` call; it blocks on `Effect.never` since nothing in this file exercises + * it. */ function mockProcessControl() { const holdEvents: HoldEvent[] = []; @@ -109,9 +91,9 @@ function mockProcessControl() { } /** - * Build a mock `ChildProcessSpawner` that records every spawned command and - * returns a controllable exit code. `spawnedBeforeExit` deferred resolves as - * soon as the spawn is observed (useful to sequence a race-then-interrupt). + * Builds a mock `ChildProcessSpawner` that records every spawned command and returns a + * controllable exit code. `spawnedBeforeExit` resolves as soon as the spawn is observed, to + * sequence a race-then-interrupt. */ function mockSpawner(exit: ExitBehavior, spawnedBeforeExit?: Deferred.Deferred) { const spawned: CapturedCommand[] = []; @@ -153,11 +135,7 @@ function mockSpawner(exit: ExitBehavior, spawnedBeforeExit?: Deferred.Deferred { @@ -178,20 +156,15 @@ describe("formatGoBinaryNotFoundError", () => { }); it("omits the curl|tar snippet on dev builds (no CLI_VERSION baked in)", () => { - // The vitest run does not go through the production bundler, so - // CLI_VERSION resolves to the "0.0.0-dev" sentinel from version.ts and - // the snippet is suppressed — we have nothing concrete to point at. const message = formatGoBinaryNotFoundError(TRIED); expect(message).not.toContain("curl -sL"); - // The prose remediation steps still appear so users have actionable hints. expect(message).toContain("Extract the release tarball"); }); }); -// The version- and platform-pinned curl|tar snippet exercised below -// instantiates a fresh module instance with a stubbed CLI_VERSION so we can -// assert against a known release version + asset filename. The fixture lives -// in a child `describe` so it doesn't bleed module mocks into other suites. +// Instantiates a fresh module with a stubbed CLI_VERSION to assert against a known release +// version + asset filename; nested in its own describe so the module mock doesn't bleed into +// other suites. describe("formatGoBinaryNotFoundError - pinned snippet", () => { const TRIED = ["$SUPABASE_GO_BINARY (unset)"]; const PINNED_VERSION = "2.100.0"; @@ -232,15 +205,11 @@ describe("formatGoBinaryNotFoundError - pinned snippet", () => { }); it("maps Node's win32 platform to the release asset's `windows` slug", async () => { - // Release pipeline publishes `.tar.gz` for every (platform, arch) pair, - // Windows included, so the snippet renders on win32 too — just with the - // modern `windows` slug instead of Node's historical `win32`. await withMockedHost({ platform: "win32", arch: "x64" }, (mod) => { const message = mod.formatGoBinaryNotFoundError(TRIED); expect(message).toContain( `https://github.com/supabase/cli/releases/download/v${PINNED_VERSION}/supabase_${PINNED_VERSION}_windows_amd64.tar.gz`, ); - // Never emit Node's internal `win32` token in the user-facing URL. expect(message).not.toContain("win32"); }); }); @@ -254,7 +223,6 @@ describe("formatGoBinaryNotFoundError - pinned snippet", () => { }); it("omits the snippet on unsupported architectures (no release asset)", async () => { - // ia32 has never been a release target — the snippet should not invent a URL. await withMockedHost({ platform: "linux", arch: "ia32" }, (mod) => { expect(mod.formatGoBinaryNotFoundError(TRIED)).not.toContain("curl -sL"); }); @@ -319,9 +287,6 @@ describe("makeGoProxyLayer", () => { const captured = spawner.spawned[0]!; expect(captured.command).toBe(TEST_BINARY); expect(captured.args).toEqual(["--debug", "projects", "list"]); - // The actual regression guard: if anyone drops this option, Effect's - // spawner will fall back to detached:true on non-Windows and we're - // back to the Ctrl+C-orphans-the-child bug. expect(captured.options.detached).toBe(false); expect(captured.options.stdin).toBe("inherit"); expect(captured.options.stdout).toBe("inherit"); @@ -331,10 +296,6 @@ describe("makeGoProxyLayer", () => { }); it.effect("leaves child telemetry enabled for pure proxy commands", () => { - // Pure proxy commands (`migration squash`, `db branch *`, `db remote *`, - // `gen keys`) have no TS instrumentation, so the Go child is the only - // emitter of `cli_command_executed`. Disabling it here would drop those - // commands from telemetry entirely. const spawner = mockSpawner({ kind: "success", code: 0 }); const pc = mockProcessControl(); const layer = makeGoProxyLayer({ binary: TEST_BINARY }).pipe( @@ -352,9 +313,6 @@ describe("makeGoProxyLayer", () => { }); it.effect("suppresses child telemetry when the caller owns the parent event", () => { - // Instrumented handlers that delegate the whole command (db pull/diff/reset, - // functions download) already emit `cli_command_executed` themselves, so the - // child's copy would double-count. const spawner = mockSpawner({ kind: "success", code: 0 }); const pc = mockProcessControl(); const layer = makeGoProxyLayer({ binary: TEST_BINARY }).pipe( @@ -409,20 +367,11 @@ describe("makeGoProxyLayer", () => { expect(error).toBeInstanceOf(GoChildExitError); expect((error as GoChildExitError).exitCode).toBe(7); } - // The layer itself never calls `ProcessControl.exit` — that's now - // `runCli`'s job, after finalizers have run. expect(pc.exitCalls).toEqual([]); }).pipe(Effect.provide(layer)); }); it.effect("lets an Effect.ensuring finalizer run after a non-zero exit (CLI-1879)", () => { - // The whole point of routing a non-zero exit through `GoChildExitError` - // instead of `ProcessControl.exit()` (a real `process.exit()` in production): - // a caller's own `Effect.ensuring` finalizer — e.g. a handler's - // `Effect.ensuring(telemetryState.flush)` — must still run. Under the old - // `processControl.exit()`-based implementation this finalizer would never fire - // (production: the process would already be dead; this mock's `exit()` blocks - // forever on `Effect.never`, so the fiber never reaches `Effect.exit` either). const spawner = mockSpawner({ kind: "success", code: 5 }); const pc = mockProcessControl(); const layer = makeGoProxyLayer({ binary: TEST_BINARY }).pipe( @@ -466,14 +415,10 @@ describe("makeGoProxyLayer", () => { const proxy = yield* GoProxy; yield* proxy.exec([]); - // Exactly one hold scope was opened, with all three terminal signals. const acquires = pc.holdEvents.filter((e) => e.kind === "acquire"); expect(acquires).toHaveLength(1); expect(acquires[0]!.signals).toEqual(["SIGINT", "SIGTERM", "SIGHUP"]); - // Ordering guard: the hold must be acquired before the child is spawned. - // We rely on the fact that spawner.spawned is only populated inside the - // spawner mock, so comparing event counts at this point is sufficient. expect(spawner.spawned).toHaveLength(1); expect(pc.holdEvents[0]).toEqual(expect.objectContaining({ kind: "acquire" })); }).pipe(Effect.provide(layer)); @@ -489,7 +434,6 @@ describe("makeGoProxyLayer", () => { const proxy = yield* GoProxy; yield* proxy.exec([]); - // Acquire then release of scope id 0. expect(pc.holdEvents).toEqual([ { kind: "acquire", id: 0, signals: ["SIGINT", "SIGTERM", "SIGHUP"] }, { kind: "release", id: 0 }, @@ -508,8 +452,6 @@ describe("makeGoProxyLayer", () => { // spawner failures are Effect.orDie'd, so we swallow the defect here. yield* proxy.exec([]).pipe(Effect.exit); - // Release still ran despite the defect — this is the whole point of - // putting holdSignals inside a scope. expect(pc.holdEvents).toContainEqual({ kind: "release", id: 0 }); }).pipe(Effect.provide(layer)); }); @@ -526,14 +468,12 @@ describe("makeGoProxyLayer", () => { const fiber = yield* proxy.exec([]).pipe(Effect.forkChild({ startImmediately: true })); yield* Deferred.await(spawned); - // Scope is open while the child "runs" (Effect.never). expect(pc.holdEvents).toEqual([ { kind: "acquire", id: 0, signals: ["SIGINT", "SIGTERM", "SIGHUP"] }, ]); yield* Fiber.interrupt(fiber); - // Effect.scoped guarantees the release step runs on interruption. expect(pc.holdEvents).toEqual([ { kind: "acquire", id: 0, signals: ["SIGINT", "SIGTERM", "SIGHUP"] }, { kind: "release", id: 0 }, @@ -541,12 +481,6 @@ describe("makeGoProxyLayer", () => { }).pipe(Effect.provide(layer)); }); - // Regression guard for CLI-1488 — the previous `resolveBinary()` returned the - // literal string "supabase" when no Go binary was found, which when run from - // a PATH that contained the shim would fork-bomb the shim against itself - // (silent multi-minute hang in CI followed by SIGTERM). The layer must now - // refuse to spawn anything and surface a specific diagnostic + a - // `GoChildExitError` carrying exit code 1. it.effect( "prints a diagnostic and fails with exit code 1 when supabase-go cannot be resolved", () => { @@ -564,10 +498,7 @@ describe("makeGoProxyLayer", () => { const proxy = yield* GoProxy; const exit = yield* proxy.exec(["db", "start"]).pipe(Effect.exit); - // Did NOT spawn anything — the whole point is to refuse the fork-bomb. expect(spawner.spawned).toHaveLength(0); - // Failed with a GoChildExitError carrying exit code 1, rather than - // calling `ProcessControl.exit` directly — that's now `runCli`'s job. expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const error = Cause.squash(exit.cause); @@ -575,7 +506,6 @@ describe("makeGoProxyLayer", () => { expect((error as GoChildExitError).exitCode).toBe(1); } expect(pc.exitCalls).toEqual([]); - // Wrote the diagnostic to stderr, including each tried location. expect(stderr).toHaveBeenCalledTimes(1); const written = String(stderr.mock.calls[0]![0]); expect(written).toContain("Could not find the `supabase-go` binary"); @@ -627,8 +557,6 @@ describe("makeGoProxyLayer", () => { yield* proxy.exec([`call-${i}`]); } - // Each exec call → acquire immediately followed by release, with - // monotonically increasing scope ids. expect(pc.holdEvents).toEqual([ { kind: "acquire", id: 0, signals: ["SIGINT", "SIGTERM", "SIGHUP"] }, { kind: "release", id: 0 }, diff --git a/apps/cli/src/command-internal/go-proxy.service.ts b/apps/cli/src/command-internal/go-proxy.service.ts index ada7a2c328..452fc2c51c 100644 --- a/apps/cli/src/command-internal/go-proxy.service.ts +++ b/apps/cli/src/command-internal/go-proxy.service.ts @@ -4,27 +4,13 @@ import type { GoChildExitError } from "./go-child-exit.error.ts"; interface GoProxyShape { /** - * Forward the given args to the Go binary, inheriting stdin/stdout/stderr - * and propagating the exit code. On a non-zero exit (or when the binary - * cannot be resolved at all), fails with `GoChildExitError` carrying - * the child's exact exit code; callers don't need to special-case it — it - * flows through the normal Effect failure channel up to `runCli`, which - * maps it to the real process exit code after running any finalizers. + * Forwards args to the Go binary, inheriting stdio and propagating the exit code. Fails with + * `GoChildExitError` (carrying the exact exit code) on a non-zero exit or an unresolvable + * binary. * - * `opts.cwd` overrides the working directory for this call (falls back to the - * layer's construction-time cwd). `opts.env` overlays extra environment - * variables onto the subprocess (merged on top of the inherited process env); - * use it to pass values the user supplied as environment variables back to the - * proxy as environment variables, rather than cross-mapping them onto CLI - * flags (CLI-1617). - * - * `opts.suppressChildTelemetry` disables telemetry in the child. Set it ONLY - * from a handler that is itself wrapped in command instrumentation and - * delegates the whole command to Go, where the parent already emits - * `cli_command_executed` and the child's copy would double-count. Pure proxy - * handlers (no TS instrumentation) must leave it unset: for those the Go - * child is the only emitter, and disabling it would drop the command from - * telemetry entirely. + * `opts.suppressChildTelemetry` disables telemetry in the child; set it only when the caller's + * own command instrumentation already emits `cli_command_executed`, since a pure proxy + * handler's Go child is its only telemetry emitter. */ readonly exec: ( args: ReadonlyArray, @@ -36,24 +22,13 @@ interface GoProxyShape { ) => Effect.Effect; /** - * Like `exec`, but captures the child's stdout and returns it as a string - * instead of inheriting stdout. stderr is still inherited (so progress / - * diagnostics pass straight through). On a non-zero exit (or when the binary - * cannot be resolved at all), fails with `GoChildExitError` carrying - * the child's exact exit code; callers don't need to special-case it — it - * flows through the normal Effect failure channel up to `runCli`, which - * maps it to the real process exit code after running any finalizers. - * - * `opts.stdin` controls the child's stdin: `"inherit"` (default) keeps the - * child interactive (its prompts reach the terminal); `"ignore"` gives it a - * non-TTY stdin so prompts (Go's `PromptYesNo`) take their default instead of - * blocking — required when a machine-output caller delegates a command that - * would otherwise prompt before the JSON envelope is emitted. + * Like `exec`, but captures the child's stdout and returns it as a string instead of + * inheriting it; stderr stays inherited. Fails with `GoChildExitError` the same way `exec` + * does. * - * Used in machine-output mode (`--output-format json|stream-json`) to wrap a - * delegated engine's stdout in a structured payload, instead of letting the - * child's raw bytes land on stdout and corrupt the JSON envelope (the CLI-1546 - * "stdout is payload-only in machine mode" invariant). + * `opts.stdin: "ignore"` gives the child a non-TTY stdin so a prompt (Go's `PromptYesNo`) + * takes its default instead of blocking — required when a machine-output caller delegates a + * command that would otherwise prompt before the JSON envelope is emitted. */ readonly execCapture: ( args: ReadonlyArray, diff --git a/apps/cli/src/command-internal/go-quote.ts b/apps/cli/src/command-internal/go-quote.ts index f12cb18a9f..dd72c5646b 100644 --- a/apps/cli/src/command-internal/go-quote.ts +++ b/apps/cli/src/command-internal/go-quote.ts @@ -1,24 +1,17 @@ /** - * Port of Go's `strconv.Quote` (the `%q` verb) over raw UTF-8 bytes, shared by - * every error message that must reproduce a Go-side `%q` interpolation - * byte-for-byte (snippets download's `invalid urn prefix: %q`, storage cp's - * pflag `invalid argument %q … parsing %q`). + * `strconv.Quote` (the `%q` verb) over raw UTF-8 bytes, shared by every error message that must + * reproduce a `%q` interpolation byte-for-byte. * - * Operating on bytes (not JS strings) matters twice over: Go slices like - * `s[:9]` cut by byte and can split a multibyte rune — which `%q` then renders - * as `\xNN` per orphan byte — and Go's escaping decisions are made per decoded - * rune over those bytes. Callers with a whole JS string in hand encode it - * first (`new TextEncoder().encode(s)`); note Bun's `process.argv` has already - * replaced invalid UTF-8 argv bytes with U+FFFD by then, so byte-identical - * output for *invalid-UTF-8 argv* is unattainable at that boundary — the - * fidelity gap is JS-runtime-wide, not per-call-site. + * Operates on bytes, not JS strings: a byte slice can split a multibyte rune (rendered as `\xNN` + * per orphan byte), and escaping decisions are made per decoded rune over those bytes. Callers + * with a whole JS string encode it first; invalid UTF-8 in `process.argv` has already been + * replaced with U+FFFD by then, so byte-identical output for invalid-UTF-8 argv is unattainable at + * that boundary. */ /** - * `utf8.DecodeRune` semantics over a byte slice: returns the code point and - * byte size at `i`, or `cp: -1` with `size: 1` for an invalid byte (invalid - * lead, truncated/malformed continuation, overlong encoding, surrogate, - * > U+10FFFF) — exactly the cases Go's `%q` renders as a lone `\xNN`. + * `utf8.DecodeRune` semantics over a byte slice: returns the code point and byte size at `i`, or + * `cp: -1` with `size: 1` for an invalid byte — exactly the cases rendered as a lone `\xNN`. */ function decodeUtf8Rune( bytes: Uint8Array, @@ -54,11 +47,9 @@ function decodeUtf8Rune( return { cp, size: extra + 1 }; } -// Go's `unicode.IsPrint` for runes ≥ 0x80: letters, marks, numbers, -// punctuation, symbols (the ASCII range is handled explicitly in -// goQuote). Unicode-table drift between the Go and JS engines is -// possible but only affects which escape a garbage rune gets in one error -// message. +// Printable runes ≥ 0x80: letters, marks, numbers, punctuation, symbols (ASCII is handled +// explicitly in goQuote). Unicode-table drift between engines only affects which escape a garbage +// rune gets in one error message. const GO_PRINTABLE_RE = /[\p{L}\p{M}\p{N}\p{P}\p{S}]/u; const GO_ESCAPES: Readonly> = { @@ -72,10 +63,8 @@ const GO_ESCAPES: Readonly> = { }; /** - * Go `%q` (`strconv.Quote`) over raw UTF-8 bytes (go1.26: `%q` of - * `"12345678\xc3"` → `"12345678\xc3"`). Valid printable runes print - * literally; control/non-printable ones use Go's `\a…\v` shorthands then - * `\xNN` / `\uNNNN` / `\UNNNNNNNN`. + * `%q` (`strconv.Quote`) over raw UTF-8 bytes. Valid printable runes print literally; + * control/non-printable ones use the `\a…\v` shorthands then `\xNN`/`\uNNNN`/`\UNNNNNNNN`. */ export function goQuote(bytes: Uint8Array): string { let out = '"'; diff --git a/apps/cli/src/command-internal/go-struct-output.encoders.ts b/apps/cli/src/command-internal/go-struct-output.encoders.ts index d190b70741..e8d07e326f 100644 --- a/apps/cli/src/command-internal/go-struct-output.encoders.ts +++ b/apps/cli/src/command-internal/go-struct-output.encoders.ts @@ -6,33 +6,14 @@ import { } from "../shared/telemetry/error-actionability.ts"; /** - * Byte-faithful reproductions of the established `-o yaml` / `-o toml` output for - * **struct** payloads (CLI-1975) — an output contract raw Go structs were - * originally passed through `gopkg.in/yaml.v3` and `github.com/BurntSushi/toml` to produce. - * Neither library reads `json:` tags, so the emitted keys are derived from - * the original Go **field names**, not the snake_case JSON the Management API returns: + * Byte-faithful `-o yaml`/`-o toml` output for struct payloads, matching `gopkg.in/yaml.v3` and + * `github.com/BurntSushi/toml`. Neither library reads `json:` tags, so emitted keys are the + * original Go field names, not the snake_case JSON the API returns. * - * - yaml.v3 lowercases the whole field name (`ProjectRef` → `projectref`) - * and renders nil pointers as explicit `null`. - * - BurntSushi keeps the PascalCase field name (`ProjectRef`), omits nil - * pointers entirely, and renders `time.Time` as a native TOML datetime. - * - * Because the TypeScript CLI only ever sees the decoded snake_case JSON, each - * payload family declares a {@link GoType} spec mirroring the original struct - * (field order = original declaration order). The two encoders here then reproduce the - * exact established bytes — including zero-value filling for - * non-pointer fields, nil-vs-empty slice handling, yaml.v3's scalar quoting - * heuristics and 4-space indentation algorithm, and BurntSushi's 2-space table - * indentation and blank-line placement. - * - * Everything in this file is pure and Effect-free so it stays unit-testable. - * The golden bytes asserted in the unit tests were captured from a scratch Go - * program (BurntSushi toml v1.6.0, - * yaml.v3 v3.0.1) over the same payloads. + * Each payload family declares a {@link GoType} spec mirroring the original struct so the decoded + * JSON can be re-expressed with each library's own casing, nil handling, and quoting rules. */ -// Go struct specs - export type GoType = | { readonly kind: "string" } | { readonly kind: "uuid" } @@ -81,10 +62,9 @@ export function goMap(value: GoType): GoType { } /** - * A struct field spec entry: `[jsonName, type]` derives the Go field name - * mechanically (each snake_case token capitalized: `api_key` → `ApiKey`, - * matching oapi-codegen's generated names — verified against `types.gen.go`), - * or `[jsonName, type, goName]` for explicit names. + * A struct field spec entry: `[jsonName, type]` derives the Go field name mechanically (each + * snake_case token capitalized: `api_key` → `ApiKey`), or `[jsonName, type, goName]` for explicit + * names. */ export type GoFieldSpec = | readonly [json: string, type: GoType] @@ -102,13 +82,10 @@ export function goStruct(fields: ReadonlyArray): GoType { } /** - * The anonymous wrapper struct Go list commands use for TOML output, e.g. - * `struct{ Branches []api.BranchResponse `toml:"branches"` }` — the `toml:` - * tag keeps the wrapper key lowercase while the elements keep Go field names. - * - * Also models Go's single-key `map[string]any{"providers": items}` wrapper - * (`sso list`, all formats): a one-field lowercase-keyed struct renders - * identically to a one-key map in both encoders. + * The anonymous wrapper struct list commands use for TOML output (a `toml:` tag keeps the wrapper + * key lowercase while the elements keep Go field names), and also models a single-key map wrapper + * (`sso list`), since a one-field lowercase-keyed struct renders identically to a one-key map in + * both encoders. */ export function goTomlListWrapper(key: string, elem: GoType): GoType { return { kind: "struct", fields: [{ json: key, go: key, type: goSlice(elem) }] }; @@ -122,8 +99,6 @@ export function goFieldName(jsonName: string): string { .join(""); } -// Normalized Go value tree (decoded JSON + spec → what the Go structs hold) - type GoValue = | { readonly k: "nil" } | { readonly k: "str"; readonly v: string } @@ -290,18 +265,13 @@ function normalizeAny(value: unknown): GoValue { } /** - * Render an RFC3339 input the way Go formats a decoded `time.Time` with - * `time.RFC3339Nano`: the fraction truncated (not rounded) to nanoseconds — - * `time`'s `parseNanoseconds` keeps at most 9 fractional digits, so - * `.1234567895` decodes as `.123456789` — then trailing zeros trimmed (the - * dot is dropped when the fraction is all zeros) and a zero offset rendered - * as `Z`. + * Formats an RFC3339 input the way Go renders a decoded `time.Time` with `time.RFC3339Nano`: the + * fraction truncated (not rounded) to at most 9 digits, then trailing zeros trimmed and a zero + * offset rendered as `Z`. */ function normalizeGoTime(value: string): string { - // Go accepts `,` as the fractional separator on decode (`commaOrPeriod`, - // `time/format.go`; probed: `time.Time.UnmarshalJSON` parses - // `…00,123Z` and re-marshals it as `…00.123Z`), so both separators - // normalize to the dot Go emits (review r3684270625). + // Both `.` and `,` are accepted as the fractional separator on decode; normalize to the dot Go + // emits. const match = /^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})([.,]\d+)?(Z|[+-]\d{2}:\d{2})$/.exec(value); if (match === null) return value; const [, base, fraction, offset] = match; @@ -314,16 +284,11 @@ function normalizeGoTime(value: string): string { return `${base}${frac}${zone}`; } -// Go float formatting (strconv.FormatFloat(f, 'g', -1, bits)) - /** - * Shortest round-trip digits for a float32 value (Go marshals via the typed - * field), matching Ryu as used by `strconv.FormatFloat(f, 'g', -1, 32)`: the - * fewest significant digits that parse back to the same float32, taking the - * candidate correctly rounded from the exact binary value — an exact decimal - * tie goes to the even final digit, where JS `toPrecision` would round half - * up (verified against Go: 4249.03125 → `4249.0312`, 4249.09375 → - * `4249.0938`). Returns `e<±exp>` for {@link goFormatFloat}. + * Shortest round-trip digits for a float32 value, matching `strconv.FormatFloat(f, 'g', -1, 32)`'s + * Ryu algorithm: the fewest significant digits that parse back to the same float32, with an exact + * decimal tie rounded to the even final digit (unlike JS `toPrecision`, which rounds half up). + * Returns `e<±exp>` for {@link goFormatFloat}. */ function shortestFloat32(value: number): string { const rounded = Math.fround(value); @@ -437,8 +402,6 @@ export function goFormatFloat(value: number, bits: 32 | 64): string { return `${sign}${digits.slice(0, dp)}.${digits.slice(dp)}`; } -// YAML encoder (gopkg.in/yaml.v3 v3.0.1 semantics) - /** * Encode a decoded payload as the Go CLI's `-o yaml` output for the given Go * struct spec. Returns the full document bytes (trailing newline included). @@ -496,20 +459,13 @@ function yamlMapEntries( } /** - * Go string ordering: `sort.Strings` compares UTF-8 bytes and yaml.v3's - * `keyList.Less` compares runes — both equal Unicode code-point order, which - * differs from JS `<` (UTF-16 code-unit order) when an astral character meets - * a high-BMP one (e.g. Go sorts U+E000 before U+1F600, UTF-16 the reverse). + * Unicode code-point string ordering, matching both `sort.Strings` (UTF-8 byte order) and + * yaml.v3's `keyList.Less` (rune order) — the two are equivalent, and both differ from JS `<` + * (UTF-16 code-unit order) when an astral character meets a high-BMP one (Go sorts U+E000 before + * U+1F600, UTF-16 the reverse). * - * Exported for `go-output.encoders.ts`'s `sortKeysDeep` - - * `encoding/json`'s map-key sort is the exact same Go byte/code-point - * order, so `gen bearer-jwt`'s `--payload` custom-claims object (and every - * other `encodeGoJson`/`encodeGoStructJsonBody` caller) needs this same - * comparator instead of a second, divergent copy (CLI-1961 Codex review - * finding: verified against the real binary that `json.Marshal` of a map - * with a U+E000 key and a U+10000 key emits the U+E000 key FIRST, while - * plain JS `Object.keys(...).sort()` on the same two keys yields the - * reverse order). + * Also used by `go-output.encoders.ts`'s `sortKeysDeep`, since `encoding/json`'s map-key sort is + * the same order. */ export function goStringCompare(a: string, b: string): number { let i = 0; @@ -523,11 +479,9 @@ export function goStringCompare(a: string, b: string): number { } /** - * Port of yaml.v3's `keyList.Less` natural string ordering (sorter.go). - * Digit runs use `unicode.IsDigit` (any Unicode `Nd` digit — probed: Go - * orders `a3, a9, a10, a٢`, the Arabic-Indic key LAST, because the naive - * `rune - '0'` arithmetic yields a huge value for non-ASCII digits; review - * r3685767973). The ASCII-only {@link isDigit} stays for the scalar parser. + * yaml.v3's `keyList.Less` natural string ordering. Digit runs use `unicode.IsDigit` (any Unicode + * `Nd` digit), so a non-ASCII digit like Arabic-Indic sorts by its raw code point and ends up + * after ASCII digit runs. The ASCII-only {@link isDigit} stays for the scalar parser. */ function yamlKeyLess(a: string, b: string): boolean { const ar = [...a]; @@ -558,10 +512,8 @@ function yamlKeyLess(a: string, b: string): boolean { } let ai = i; let bi = i; - // Go accumulates into `int64` WITHOUT overflow checks, so 19+-digit runs - // wrap negative and sort before shorter positive runs (probed: - // `a10000000000000000000` precedes `a9000000000000000000`; - // review r3689635556). `BigInt.asIntN(64, …)` reproduces the wrap. + // Go accumulates into `int64` without overflow checks, so a 19+-digit run wraps negative and + // sorts before a shorter positive run. `BigInt.asIntN(64, …)` reproduces the wrap. for (; ai < ar.length && isSortDigit(ar[ai] as string); ai++) { an = BigInt.asIntN(64, an * 10n + BigInt(((ar[ai] as string).codePointAt(0) as number) - 48)); } @@ -721,10 +673,9 @@ function yamlKeyScalar(key: string): string { type YamlStringStyle = "plain" | "single" | "double" | "literal"; /** - * Mirror of yaml.v3's style selection: `encode.go` requests literal for - * multi-line strings and double quotes for strings that resolve to a - * non-string tag; the emitter (`emitterc.go`) downgrades plain to single (or - * double) based on its scalar analysis. + * yaml.v3's style selection: literal for multi-line strings, double quotes for a string that + * resolves to a non-string tag, otherwise downgraded from plain to single (or double) based on + * scalar analysis. */ function yamlStringStyle(s: string): YamlStringStyle { if (s.length === 0) return "double"; @@ -758,10 +709,9 @@ function yamlHasSpecialChars(s: string): boolean { } /** - * libyaml's `is_printable` (yamlprivateh.go) in code-point terms. Notably the - * byte-oriented original never accepts a 4-byte UTF-8 lead, so every astral - * character — as well as C0/C1 controls, DEL, surrogates, the U+FEFF BOM, and - * U+FFFE/U+FFFF — is "not printable" and gets double-quoted escapes. + * libyaml's `is_printable` in code-point terms. The byte-oriented original never accepts a 4-byte + * UTF-8 lead, so every astral character — along with C0/C1 controls, DEL, surrogates, the U+FEFF + * BOM, and U+FFFE/U+FFFF — is "not printable" and gets double-quoted escapes. */ function yamlIsPrintable(code: number): boolean { if (code === 0x0a) return true; @@ -795,20 +745,17 @@ function yamlResolvesToString(s: string): boolean { if (YAML_BASE60.test(s)) return false; const first = s[0] as string; if (first === ".") { - // resolve()'s '.'-hint branch: strconv.ParseFloat — which ERRORS on - // overflow (±Inf), so an overflowing spelling stays a string and needs - // no quoting (probed: Go emits `1e999` plain; review r3685767974). + // strconv.ParseFloat errors on overflow (±Inf), so an overflowing spelling like `1e999` stays + // a string and needs no quoting. return !(/^\.\d+(?:[eE][+-]?\d+)?$/.test(s) && Number.isFinite(Number(s))); } if (first === "+" || first === "-" || isDigit(first)) { if (yamlIsTimestamp(s)) return false; const plain = s.replaceAll("_", ""); if (goParseIntBase0(plain)) return false; - // strconv.ParseFloat overflow (→ ±Inf) is an error in resolve(), so the - // value resolves as a string and is emitted plain; underflow (1e-999 → 0) - // succeeds and stays float-tagged, hence quoted (probed both against Go, - // review r3685767974). `Number` mirrors the accepted shapes here because - // YAML_STYLE_FLOAT gates the syntax first. + // An overflowing float (→ ±Inf) is a ParseFloat error, so it resolves as a string and stays + // plain; an underflowing one (1e-999 → 0) succeeds and stays float-tagged, hence quoted. + // `Number` mirrors the accepted shapes since YAML_STYLE_FLOAT gates the syntax first. if (YAML_STYLE_FLOAT.test(plain) && Number.isFinite(Number(plain))) return false; return true; } @@ -904,16 +851,13 @@ function goParseIntBase0(plain: string): boolean { } /** - * yaml.v3's `parseTimestamp` layouts (resolve.go `allowedTimestampFormats`), - * which delegates to `time.Parse` — so calendar dates and zone offsets are - * validated exactly like Go's time package (verified against the Go binary: - * `2025-02-31` and `2100-02-29` stay plain, `2024-02-29` is a timestamp). + * yaml.v3's `parseTimestamp` layouts, which delegate to `time.Parse` — so calendar dates and zone + * offsets are validated exactly like Go's time package (`2025-02-31` and `2100-02-29` stay plain, + * `2024-02-29` is a timestamp). */ function yamlIsTimestamp(s: string): boolean { - // Fraction separator is `.` OR `,` — yaml.v3 resolves timestamps through - // `time.Parse`, which accepts either (`commaOrPeriod`; probed: Go - // double-quotes the comma form exactly like the dot form, - // review r3685767963). + // The fraction separator is `.` or `,` — yaml.v3 resolves timestamps through `time.Parse`, + // which accepts either. const match = /^(\d{4})-(\d{1,2})-(\d{1,2})(?:([Tt ])(\d{1,2}):(\d{1,2}):(\d{1,2})(?:[.,]\d+)?(Z|[+-]\d{2}:\d{2})?)?$/.exec( s, @@ -926,9 +870,8 @@ function yamlIsTimestamp(s: string): boolean { if (separator !== " " && offset === undefined) return false; if (Number(hour) > 23 || Number(minute) > 59 || Number(second) > 59) return false; } - // time.Parse's zone-offset range checks (time/format.go): the hour is - // rejected above 24 and the minute above 60 — `+24:59` and `+00:60` are - // accepted, `+25:00` and `+23:99` are not (verified against Go 1.26). + // time.Parse's zone-offset range checks: the hour is rejected above 24 and the minute above 60 + // — `+24:59` and `+00:60` are accepted, `+25:00` and `+23:99` are not. if (offset !== undefined && offset !== "Z") { if (Number(offset.slice(1, 3)) > 24 || Number(offset.slice(4, 6)) > 60) return false; } @@ -1041,15 +984,13 @@ function yamlBlockLiteral(s: string, indent: number): string { const indicator = s.startsWith(" ") || s.startsWith("\n") ? "4" : ""; const lines = s.split("\n"); if (s.endsWith("\n")) lines.pop(); - // yaml.v3 merges a leading empty line's break with the header newline - // (verified empirically: "\nx" → `|4-\n x\n`, "\n\nx" → `|4-\n\n x\n`). + // yaml.v3 merges a leading empty line's break with the header newline (e.g. "\nx" → `|4-\n + // x\n`). if (lines[0] === "") lines.shift(); const body = lines.map((line) => (line.length === 0 ? "" : `${pad}${line}`)).join("\n"); return ` |${indicator}${chomp}\n${body}\n`; } -// TOML encoder (github.com/BurntSushi/toml v1.6.0 semantics) - /** * Thrown when BurntSushi would refuse the payload: a populated * `nullable.Nullable` field (`map[bool]T` has a non-string key type — observed @@ -1068,26 +1009,13 @@ export class GoTomlEncodeError extends Error { } /** - * Encode a decoded payload as the Go CLI's `-o toml` output for the given Go - * struct spec. Returns the full document bytes (BurntSushi emits nothing for - * an all-nil payload, so the result can be the empty string). - * - * Throws {@link GoTomlEncodeError} when a populated nullable field is - * present, matching Go's runtime failure. + * Encodes a decoded payload as the Go CLI's `-o toml` output. Returns the full document, which can + * be empty (BurntSushi emits nothing for an all-nil payload). Throws {@link GoTomlEncodeError} to + * match Go's runtime failure on a populated nullable field. * - * DOCUMENTED BOUND (review r3689784209): on the ERROR path only, Go's stdout - * bytes can differ. BurntSushi encodes through an internal `bufio.Writer`, so - * when more than 4 KiB has been generated before a late encode error (e.g. a - * multi-KB `MetadataXml` scalar followed by a nil array element in a - * sub-table), Go has already auto-flushed whole 4096-byte chunks to stdout — - * probed on the repo's own `utils.EncodeOutput`: a 5000-char scalar + - * `[1, nil]` default leaves EXACTLY 4096 bytes flushed (the buffered tail is - * lost — NOT the full accumulated prefix), while the same payload under 4 KiB - * leaves 0 bytes. This all-in-memory port deliberately emits nothing on - * error: reproducing Go would mean emulating bufio's flush boundaries and - * large-write bypass over BurntSushi's internal write granularity, for - * unparseable partial output on a failure path. Do not "fix" this by - * emitting the accumulated prefix — that emits MORE than Go does. + * On the error path only, Go's real stdout can already contain partial buffered output that this + * in-memory port doesn't reproduce; emit nothing on error rather than the accumulated prefix, + * which would emit more than Go does. */ export function encodeGoToml(value: unknown, type: GoType): string { const state = { out: "", hasWritten: false }; @@ -1267,25 +1195,20 @@ function tomlElement(value: GoValue): string { case "nullable": throw new GoTomlEncodeError(); case "nil": - // BurntSushi's `eElement` rejects nil inline-array elements (verified: - // `[null, "x"]` fails, while nil *map values* are silently skipped). + // BurntSushi's `eElement` rejects nil inline-array elements (`[null, "x"]` fails), while nil + // map values are silently skipped. throw new GoTomlEncodeError("toml: cannot encode array with nil element"); } } /** - * BurntSushi's inline-table form, used for map/struct elements of arrays that - * are not arrays-of-tables (e.g. `interface{}` values holding - * `[{"a":1},"x"]`): `{k = v, ...}` with nil entries skipped and, like block - * tables, non-table values before table values — map keys byte-sorted within - * each group (verified: `[{"a":{"b":1},"z":2},"x"]` → `[{z = 2.0, a = {b = - * 1.0}}, "x"]`). + * BurntSushi's inline-table form, used for map/struct elements of arrays that aren't + * arrays-of-tables: `{k = v, ...}` with nil entries skipped and non-table values before table + * values, map keys byte-sorted within each group. * - * The `", "` separator replicates `eMap`/`eStruct` exactly: it is decided by - * the entry's *position* — for maps, group index with a trailing comma after - * the direct group when sub-tables follow; for structs, declaration index — - * so a skipped nil entry in the final position leaves a dangling `", "` - * (verified: `[{"10":1,"b":null},false]` → `[{10 = 1.0, }, false]`). + * The `", "` separator is decided by the entry's position, not by whether it was actually + * written, so a skipped nil entry in the final position leaves a dangling `", "` (e.g. + * `[{"10":1,"b":null},false]` → `[{10 = 1.0, }, false]`). */ function tomlInlineTable(value: Extract): string { let out = "{"; diff --git a/apps/cli/src/command-internal/go-struct-output.encoders.unit.test.ts b/apps/cli/src/command-internal/go-struct-output.encoders.unit.test.ts index bdc5a52a0a..0c8900aac7 100644 --- a/apps/cli/src/command-internal/go-struct-output.encoders.unit.test.ts +++ b/apps/cli/src/command-internal/go-struct-output.encoders.unit.test.ts @@ -23,13 +23,12 @@ import { } from "./go-struct-output.encoders.ts"; /** - * Every golden byte string in this file was captured from a scratch Go - * program calling the established `utils.EncodeOutput` - * with BurntSushi toml v1.6.0 and - * yaml.v3 v3.0.1 — the exact library versions pinned in the reference `go.mod`. + * Golden byte strings in this file were captured from a scratch Go program calling + * `utils.EncodeOutput`, using the same BurntSushi toml and yaml.v3 versions pinned in the + * reference `go.mod`. */ -// Mirror of `api.BranchResponse` (apps/cli-go/pkg/api/types.gen.go). +// Mirrors the branch response struct. const BRANCH_RESPONSE = goStruct([ ["created_at", goTime], ["deletion_scheduled_at", goPtr(goTime)], @@ -131,7 +130,7 @@ WithData = true }); it("nests sub-tables after primitives with 2-space indentation (hostnames shape)", () => { - // Mirror of api.UpdateCustomHostnameResponse. + // Mirrors the custom-hostname update response struct. const spec = goStruct([ ["custom_hostname", goString], [ @@ -397,9 +396,6 @@ SUPABASE_ANON_KEY = "anon" }); it("quotes comma-fraction timestamp-shaped STRINGS like yaml.v3's resolver", () => { - // Probed on go1.26: the string field "2026-01-01T00:00:00,123Z" is - // double-quoted exactly like the dot form — yaml.v3 resolves timestamps - // through time.Parse, which accepts either separator (review r3685767963). const spec = goStruct([["s", goString, "S"]]); expect(encodeGoYaml({ s: "2026-01-01T00:00:00,123Z" }, spec)).toBe( 's: "2026-01-01T00:00:00,123Z"\n', @@ -407,10 +403,6 @@ SUPABASE_ANON_KEY = "anon" }); it("leaves overflowing float-shaped strings plain like yaml.v3's ParseFloat gate", () => { - // Probed on go1.26: resolve()'s strconv.ParseFloat ERRORS on overflow - // (±Inf), so the value stays string-tagged and needs no quoting; an - // underflowing exponent (1e-999 → 0) parses successfully and IS quoted - // (review r3685767974). const spec = goStruct([["s", goString, "S"]]); expect(encodeGoYaml({ s: "1e999" }, spec)).toBe("s: 1e999\n"); expect(encodeGoYaml({ s: "-1e999" }, spec)).toBe("s: -1e999\n"); @@ -420,9 +412,6 @@ SUPABASE_ANON_KEY = "anon" }); it("wraps 19+-digit numeric key runs like Go's unchecked int64 accumulation", () => { - // Probed on go1.26: `keyList.Less` accumulates into `int64` without - // overflow checks, so `a10000000000000000000` wraps negative and sorts - // BEFORE `a9000000000000000000` (review r3689635556). const spec = goStruct([["default", goAny, "Default"]]); expect( encodeGoYaml({ default: { a9000000000000000000: 1, a10000000000000000000: 2 } }, spec), @@ -430,9 +419,6 @@ SUPABASE_ANON_KEY = "anon" }); it("orders Unicode-digit map keys with yaml.v3's naive rune arithmetic", () => { - // Probed on go1.26: keyList.Less finds digit runs with unicode.IsDigit - // but accumulates values as `rune - '0'`, so the Arabic-Indic key `a٢` - // (U+0662) sorts AFTER a10, not as the number 2 (review r3685767973). const spec = goStruct([["default", goAny, "Default"]]); expect(encodeGoYaml({ default: { a٢: 1, a3: 2, a10: 3, a9: 4 } }, spec)).toBe( "default:\n a3: 2\n a9: 4\n a10: 3\n a٢: 1\n", @@ -440,9 +426,6 @@ SUPABASE_ANON_KEY = "anon" }); it("normalizes Go's accepted comma fractional separator to the dot Go re-emits", () => { - // Probed on go1.26: `time.Time.UnmarshalJSON` parses `…00,123Z` - // (`commaOrPeriod`, `time/format.go`) and `json.Marshal` re-emits - // `…00.123Z` — the encoders must match on both output formats. const spec = goStruct([["t", goTime, "T"]]); expect(encodeGoToml({ t: "2026-01-01T00:00:00,123Z" }, spec)).toBe( "T = 2026-01-01T00:00:00.123Z\n", @@ -453,8 +436,8 @@ SUPABASE_ANON_KEY = "anon" }); it("sorts map keys by UTF-8 byte order like Go's sort.Strings", () => { - // Go orders U+E000/U+FF21 before the astral U+1D400/U+1F600 (UTF-8 byte - // order); JS `<` on UTF-16 units would sort both astral keys first. + // U+E000/U+FF21 sort before the astral U+1D400/U+1F600 in UTF-8 byte order; JS `<` on UTF-16 + // units would sort both astral keys first. const spec = goMap(goString); expect( encodeGoToml( @@ -803,10 +786,8 @@ z: "1" }); it("sorts unicode map keys by rune and escapes astral keys like yaml.v3", () => { - // keyList.Less compares runes, so the astral U+1F600/U+1D400 sort after - // U+E000/U+FF21 (JS `<` on UTF-16 units would say the opposite), and the - // emitter double-quotes astral characters (4-byte UTF-8 is not printable - // to libyaml) as \U-escapes. + // keyList.Less compares runes, so the astral U+1F600/U+1D400 sort after U+E000/U+FF21; the + // emitter double-quotes astral characters as \U-escapes (not printable to libyaml). const spec = goMap(goString); expect( encodeGoYaml( @@ -870,8 +851,6 @@ t12: 1900-02-29 it("truncates time fractions to nanoseconds like time.Time's decoder", () => { const spec = goStruct([["t", goTime, "T"]]); - // time's `parseNanoseconds` keeps at most 9 fractional digits (truncation, - // not rounding), then RFC3339Nano trims trailing zeros. expect(encodeGoYaml({ t: "2026-01-01T00:00:00.1234567895Z" }, spec)).toBe( "t: 2026-01-01T00:00:00.123456789Z\n", ); @@ -946,8 +925,8 @@ describe("goFormatFloat", () => { }); it("breaks exact shortest-digit ties to even like Ryu, not half-up", () => { - // 4249.03125 sits exactly between the two shortest 8-digit candidates; - // strconv keeps the even final digit both downward and upward. + // 4249.03125 sits exactly between the two shortest 8-digit candidates, rounding to the even + // final digit both ways. expect(goFormatFloat(4249.03125, 32)).toBe("4249.0312"); expect(goFormatFloat(4249.09375, 32)).toBe("4249.0938"); expect(goFormatFloat(123456789, 32)).toBe("1.2345679e+08"); diff --git a/apps/cli/src/command-internal/go-struct-output.types-gen-drift.unit.test.ts b/apps/cli/src/command-internal/go-struct-output.types-gen-drift.unit.test.ts index 94ae7830c0..97b435797e 100644 --- a/apps/cli/src/command-internal/go-struct-output.types-gen-drift.unit.test.ts +++ b/apps/cli/src/command-internal/go-struct-output.types-gen-drift.unit.test.ts @@ -10,14 +10,11 @@ import type { GoType } from "./go-struct-output.encoders.ts"; import { compareGoTypeToParsedGoType, parseGoStruct } from "./go-struct-output.types-gen-parser.ts"; /** - * Mechanical drift check for the `*.go-payload.ts` specs against the real Go - * structs they mirror (CLI-1975, review kanadgupta). Parses - * `apps/cli-go/pkg/api/types.gen.go` and structurally compares each entry - * below against the runtime {@link GoType} spec it corresponds to — - * field order, pointer-ness, and coarse kind must match. When - * `types.gen.go` regenerates with a field added/removed/reordered/renamed, - * this test fails instead of silently producing wrong `-o yaml`/`-o toml` - * bytes. + * Mechanical drift check for the `*.go-payload.ts` specs against the real Go structs they mirror. + * Parses `apps/cli-go/pkg/api/types.gen.go` and structurally compares each entry below against the + * runtime {@link GoType} spec it corresponds to — field order, pointer-ness, and coarse kind must + * match. When `types.gen.go` regenerates with a field added/removed/reordered/renamed, this test + * fails instead of silently producing wrong `-o yaml`/`-o toml` bytes. */ const TYPES_GEN_GO_PATH = fileURLToPath( @@ -31,13 +28,10 @@ interface GoPayloadSpecEntry { } /** - * `Create`/`Update`/`DeleteProviderResponse` share `GetProviderResponse`'s - * exact anonymous shape (see the doc comment in `sso.go-payload.ts`), so - * checking `GetProviderResponse` alone covers all four. Wrapper-only specs - * (`GO_*_TOML_WRAPPER`, `GO_SSO_PROVIDERS_WRAPPER`, - * `GO_*_LIST`) aren't distinct Go structs — they're a - * `goTomlListWrapper`/`goSlice` around one of the entries below — - * so they're intentionally excluded. + * `Create`/`Update`/`DeleteProviderResponse` share `GetProviderResponse`'s exact anonymous shape, + * so checking `GetProviderResponse` alone covers all four. Wrapper-only specs + * (`GO_*_TOML_WRAPPER`, `GO_SSO_PROVIDERS_WRAPPER`, `GO_*_LIST`) aren't distinct Go structs — + * they're a `goTomlListWrapper`/`goSlice` around one of the entries below — so they're excluded. */ const GO_PAYLOAD_SPEC_REGISTRY: ReadonlyArray = [ { diff --git a/apps/cli/src/command-internal/go-struct-output.types-gen-parser.ts b/apps/cli/src/command-internal/go-struct-output.types-gen-parser.ts index 8b41518499..b2e86a3d24 100644 --- a/apps/cli/src/command-internal/go-struct-output.types-gen-parser.ts +++ b/apps/cli/src/command-internal/go-struct-output.types-gen-parser.ts @@ -1,21 +1,10 @@ /** - * A best-effort parser for the Go struct declarations in - * `apps/cli-go/pkg/api/types.gen.go`, plus a comparison against the - * {@link GoType} specs the `*.go-payload.ts` files hand-declare to - * mirror them (CLI-1975). + * A best-effort parser for the Go struct declarations in `apps/cli-go/pkg/api/types.gen.go`, + * compared against the {@link GoType} specs the `*.go-payload.ts` files hand-declare to mirror + * them, so drift between the two fails a test instead of surfacing as a byte-format bug. * - * Nothing mechanically checked that a spec still matches its Go struct — if - * `types.gen.go` regenerates (field added/removed/reordered/renamed), a spec - * could silently desync. This module parses the real struct source and walks - * it in lockstep with the runtime `GoType` tree, so drift shows up as a - * failing test instead of a byte-format bug found in the wild (review - * kanadgupta, PR #6002). - * - * This is intentionally scoped to what the 4 current `*.go-payload.ts` specs - * need: `oapi-codegen`-generated struct bodies with `json:"..."` tags, plain - * fields, pointers, slices, `map[string]...`, recursively nested anonymous - * structs, and single-level type aliases (`type X string`). It is not a - * general Go parser. + * Scoped to what the current specs need — `oapi-codegen` struct bodies, pointers, slices, maps, + * nested anonymous structs, and single-level type aliases — not a general Go parser. */ import type { GoType } from "./go-struct-output.encoders.ts"; @@ -200,8 +189,6 @@ function classifyBaseType( return classifyBaseType(basetype.trim(), source, new Set([...seen, text])); } -// Comparison: GoType (runtime spec) <-> GoParsedType (parsed Go source) - export interface GoStructDriftMismatch { readonly path: string; readonly message: string; @@ -223,7 +210,7 @@ export function compareGoTypeToParsedGoType( return mismatches; } -/** `ptr`/`nullable` both mean "Go pointer" for this comparison (CLI-1975 design doc). */ +/** `ptr`/`nullable` both mean "Go pointer" for this comparison. */ function unwrapPointer(type: GoType): { readonly pointer: boolean; readonly inner: GoType; From 22b2c1bc4ee8be8d1d2f1d2d24132bed8ec21e99 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 10 Sep 2026 12:14:58 +0100 Subject: [PATCH 08/56] chore(repo): trim comments, shard 04 (1768 -> 835 comment lines) --- apps/cli/src/cli/complete.unit.test.ts | 316 +------------ apps/cli/src/cli/root.ts | 16 +- .../src/command-internal/api-keys.format.ts | 18 +- apps/cli/src/command-internal/api-url.ts | 28 +- .../src/command-internal/api-url.unit.test.ts | 5 - .../command-internal/bitbucket-pipeline.ts | 26 +- .../cli/src/command-internal/branch-target.ts | 64 +-- apps/cli/src/command-internal/colors.ts | 57 +-- .../src/command-internal/colors.unit.test.ts | 19 +- .../src/command-internal/config-pull-run.ts | 409 +++++----------- ...ig-validate.deep-missing-path.unit.test.ts | 20 +- .../config-validate.parity.unit.test.ts | 54 +-- .../src/command-internal/config-validate.ts | 447 +++++------------- .../config-validate.unit.test.ts | 169 ++----- .../src/command-internal/connect-errors.ts | 181 +++---- .../connect-errors.unit.test.ts | 57 +-- 16 files changed, 475 insertions(+), 1411 deletions(-) diff --git a/apps/cli/src/cli/complete.unit.test.ts b/apps/cli/src/cli/complete.unit.test.ts index 11a4b7efc4..92dd1bf716 100644 --- a/apps/cli/src/cli/complete.unit.test.ts +++ b/apps/cli/src/cli/complete.unit.test.ts @@ -37,17 +37,11 @@ describe("respondToComplete", () => { }); it("returns no candidates and the Default directive for a leaf command with no subcommands and no unset required flags", () => { - // `migration list` has no subcommands; `--db-url`/`--password` are optional, - // `--linked` defaults to true, and `--local` is a plain boolean, so nothing - // is left "required" once resolved (verified against migration/list/list.command.ts). const result = respondToComplete(rootCommand, ["__complete", "migration", "list", ""]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.Default }); }); it("offers a global flag declared once at rootCommand from a nested command path", () => { - // `--debug` is declared exactly once, via GLOBAL_FLAGS -> - // Command.withGlobalFlags on rootCommand (command-internal/global-flags.ts, - // cli/root.ts) — it must still resolve from a resolved subcommand path. const result = respondToComplete(rootCommand, ["__complete", "branches", "--d"]); expect(result?.directive).toBe(CompletionDirective.NoFileComp); expect(result?.candidates.map((c) => c.name)).toContain("--debug"); @@ -63,7 +57,6 @@ describe("respondToComplete", () => { const offered = respondToComplete(rootCommand, ["__complete", "--wiz"]); expect(offered?.candidates.map((c) => c.name)).toContain("--wizard"); - // A boolean built-in must not poison the line or consume the next token. const after = respondToComplete(rootCommand, ["__complete", "--wizard", ""]); expect(after?.directive).toBe(CompletionDirective.NoFileComp); expect(after?.candidates.map((c) => c.name)).toContain("branches"); @@ -77,7 +70,6 @@ describe("respondToComplete", () => { expect(after?.directive).toBe(CompletionDirective.NoFileComp); expect(after?.candidates.map((c) => c.name)).toContain("branches"); - // An invalid shell value still poisons the line, matching the real parse. const invalid = respondToComplete(rootCommand, [ "__complete", "--completions", @@ -88,9 +80,6 @@ describe("respondToComplete", () => { }); it("offers an ancestor's shared flag (Command.withSharedFlags) from a resolved leaf command", () => { - // `--no-cache` is declared once on the `db schema declarative` group via - // Command.withSharedFlags (declarative.shared.ts) and must be visible from - // its `generate` leaf. const result = respondToComplete(rootCommand, [ "__complete", "db", @@ -103,9 +92,6 @@ describe("respondToComplete", () => { }); it("offers a non-root command's own declared global flags from a nested subcommand (Command.withGlobalFlags)", () => { - // seed.command.ts declares --linked/--local as scoped global flags via - // Command.withGlobalFlags on the `seed` group itself, not at rootCommand - // — a collector that only reads root.globalFlags misses these entirely. const atGroup = respondToComplete(rootCommand, ["__complete", "seed", "--l"]); expect(atGroup?.candidates.map((c) => c.name)).toEqual( expect.arrayContaining(["--linked", "--local"]), @@ -118,11 +104,6 @@ describe("respondToComplete", () => { }); it("does not duplicate a flag name that exists both globally and as a command's own local flag", () => { - // db diff declares its own local `output`/`-o` (a file path), shadowing - // the global `--output`/`-o` choice flag declared at root — pflag's - // InheritedFlags() skips a persistent flag shadowed by a same-named local - // one, so exactly one `--output` candidate (the local one) must appear, - // not two with contradictory descriptions. const result = respondToComplete(rootCommand, ["__complete", "db", "diff", "--o"]); const outputCandidates = result?.candidates.filter((c) => c.name === "--output"); expect(outputCandidates).toHaveLength(1); @@ -133,28 +114,18 @@ describe("respondToComplete", () => { describe("subcommand completion is not blocked by a preceding global flag", () => { it("lists root subcommands after a bare global flag with no value", () => { - // `--debug ""` used to return zero candidates entirely: the leftover-args - // computation counted `--debug` itself as "positional leftover," gating - // out subcommand-name completion the way cobra never does for a - // persistent flag: `__complete --debug ''` lists all 36 root commands. const result = respondToComplete(rootCommand, ["__complete", "--debug", ""]); expect(result?.directive).toBe(CompletionDirective.NoFileComp); expect(result?.candidates.map((c) => c.name)).toContain("branches"); }); it("lists subcommands after a value-taking global flag and its value", () => { - // `-o json ""` — "json" is `-o`'s consumed value, not a genuine extra - // positional argument, so it must not count as leftover either. const result = respondToComplete(rootCommand, ["__complete", "-o", "json", ""]); expect(result?.directive).toBe(CompletionDirective.NoFileComp); expect(result?.candidates.map((c) => c.name)).toContain("migration"); }); it("lists subcommands after the built-in --log-level and its value", () => { - // `--log-level error ""` used to return zero candidates: the built-in - // never entered the in-scope flag set, so the strict flag walk treated - // it like an unknown flag and poisoned the line (issue #6482). An - // invalid value must still poison it, matching the real parse. const result = respondToComplete(rootCommand, ["__complete", "--log-level", "error", ""]); expect(result?.directive).toBe(CompletionDirective.NoFileComp); expect(result?.candidates.map((c) => c.name)).toContain("sso"); @@ -171,9 +142,6 @@ describe("respondToComplete", () => { }); it("does not offer --version on any command other than the root", () => { - // Cobra's InitDefaultVersionFlag registers --version non-persistently on - // the root command only — it is never inherited by subcommands the way - // --help is. const atRoot = respondToComplete(rootCommand, ["__complete", "--v"]); expect(atRoot?.candidates.map((c) => c.name)).toContain("--version"); @@ -182,11 +150,6 @@ describe("respondToComplete", () => { }); it("returns Default (not NoFileComp) when the resolved command doesn't match a real subcommand", () => { - // Mirrors cobra: the NoFileComp directive is only set INSIDE the - // `len(finalArgs) == 0` gate, alongside the subcommand loop — a bogus - // trailing token (which becomes non-empty leftover) must leave the - // directive at Default, not force NoFileComp just because `db` itself has - // subcommands: `__complete db bogus ''` → `:0`. const result = respondToComplete(rootCommand, ["__complete", "db", "bogus", ""]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.Default }); }); @@ -209,11 +172,6 @@ describe("respondToComplete", () => { }); it("does not short-circuit on a subcommand's own local --version flag away from the root", () => { - // migration/squash/squash.command.ts declares its own `--version` - // (a target migration version string) — unrelated to cobra's built-in - // root-only version flag. Typing it while completing a *different* - // flag on the same command must behave like normal flag-name - // completion, not trip the root-only help/version short-circuit. const result = respondToComplete(rootCommand, [ "__complete", "migration", @@ -226,11 +184,6 @@ describe("respondToComplete", () => { }); it("does not short-circuit on --help positioned after a genuine `--` terminator (it is positional, not a flag)", () => { - // A raw token scan for the literal string "--help" over-triggers once - // `--help` appears anywhere, even past an unconsumed `--` sentinel, - // where pflag never parses it as a flag at all: `db dump -- --help ""` - // returns zero candidates with the DEFAULT directive — `db dump`'s own - // file completion — not the help short-circuit's NoFileComp. const result = respondToComplete(rootCommand, [ "__complete", "db", @@ -243,11 +196,6 @@ describe("respondToComplete", () => { }); it("does not short-circuit on --version consumed as a PRECEDING flag's own string value", () => { - // `--workdir` is a value-taking global flag; pflag consumes the very - // next token as its value regardless of what that token looks like, - // so `--version` here is `--workdir`'s value, never parsed as a flag - // occurrence: `--workdir --version br` still completes `branches`, - // not the version short-circuit. const result = respondToComplete(rootCommand, ["__complete", "--workdir", "--version", "br"]); expect(result?.candidates.map((c) => c.name)).toContain("branches"); expect(result?.directive).toBe(CompletionDirective.NoFileComp); @@ -256,14 +204,6 @@ describe("respondToComplete", () => { describe("unmatched root-level command (CLI-1965 review)", () => { it("returns Default with zero candidates for a flag typed after an unmatched ROOT-level positional", () => { - // Mirrors cobra's Command.Find -> legacyArgs (args.go:28-37): resolving - // to root itself (no descent at all) with a leftover positional is an - // "unknown command" error there, wins even over the --help/--version - // short-circuit, and is stricter than the same situation under any - // OTHER resolved command: `nosuch --d` and `nosuch --help` both return - // zero candidates with the Default directive, while `db bogus --d` — - // `db` itself resolves — still offers `db`'s own --debug/--dns-resolver - // normally. const unknownRoot = respondToComplete(rootCommand, ["__complete", "nosuch", "--d"]); expect(unknownRoot).toEqual({ candidates: [], directive: CompletionDirective.Default }); @@ -289,11 +229,6 @@ describe("respondToComplete", () => { }); it("does not treat a surviving bare `-` leftover as an unmatched command (pflag's stripFlags drops it)", () => { - // cobra's own stripFlags (command.go:674-710) — which legacyArgs' error - // check runs against — drops a lone `-` from its leftover count - // entirely, unlike resolveCommandPath's own leftoverArgs (which - // deliberately keeps it for other purposes): `__complete - --d` still - // offers root's own --debug/--dns-resolver. const result = respondToComplete(rootCommand, ["__complete", "-", "--d"]); expect(result?.candidates.map((c) => c.name)).toEqual( expect.arrayContaining(["--debug", "--dns-resolver"]), @@ -301,11 +236,6 @@ describe("respondToComplete", () => { }); it("does not apply the unmatched-root check to a genuine `help ...` request", () => { - // `help` is not a real node in this tree (see - // helpArgumentCandidates's doc comment), so the outer resolution - // always sees it as an immediate non-match at root — this must not be - // mistaken for cobra's real "unknown command" error, which never fires - // for `help` since real cobra's help command IS a real child of root. const result = respondToComplete(rootCommand, ["__complete", "help", "db", "d"]); expect(result?.candidates.map((c) => c.name)).toEqual( expect.arrayContaining(["diff", "dump"]), @@ -315,10 +245,6 @@ describe("respondToComplete", () => { describe("required-flag short-circuit", () => { it("offers exactly the one required flag and nothing else for a command with a single required flag", () => { - // domains/create/create.command.ts: `customHostname: Flag.string("custom-hostname")` - // has no `.pipe(Flag.optional)`/`.pipe(Flag.withDefault(...))`, so it genuinely - // fails to parse when omitted — `projectRef` is optional and - // `includeRawOutput` is boolean, so neither is offered alongside it. const result = respondToComplete(rootCommand, ["__complete", "domains", "create", ""]); expect(result).toEqual({ candidates: [ @@ -332,16 +258,8 @@ describe("respondToComplete", () => { }); it("short-circuits on a flag Go marks required even though this port made it optional at parse time", () => { - // vanity-subdomains/activate/activate.command.ts wraps `desiredSubdomain` - // in `.pipe(Flag.optional)` on purpose (presence is enforced later, in - // the handler, to let the --experimental gate and login check run - // first) — but cobra's completion-time required-flag annotation - // (`MarkFlagRequired`, `cmd/vanitySubdomains.go:67`) is independent of - // TS's parse-time validation ordering, so completion must still offer - // exactly this flag, matching real cobra: structural inference from - // `Flag.optional` is not a faithful proxy for "does cobra mark it - // required" — `COMPLETION_REQUIRED_FLAGS` is the explicit table - // that fixes this. + // Required-ness comes from the explicit `COMPLETION_REQUIRED_FLAGS` table, not from + // inferring it off `Flag.optional`. const result = respondToComplete(rootCommand, [ "__complete", "vanity-subdomains", @@ -360,14 +278,6 @@ describe("respondToComplete", () => { }); it("does not treat a zero-minimum variadic flag (Flag.atLeast(0)) as required", () => { - // sso/add/add.command.ts: `domains: ssoAddDomainsFlag` builds on - // `stringSliceFlag`, which is `Flag.string(...).pipe(..., Flag.atLeast(0))` - // — a `Variadic` param with `min: 0`. Go never calls - // `MarkFlagRequired("domains")` (only `type`), so `--domains` must not be - // force-offered here — only the genuinely required `--type`/`-t` should - // appear. (Required-ness now comes from the explicit - // `COMPLETION_REQUIRED_FLAGS` table, not structural inference, but - // this scenario is worth keeping as its own regression case.) const result = respondToComplete(rootCommand, ["__complete", "sso", "add", ""]); expect(result?.candidates.map((c) => c.name)).not.toContain("--domains"); expect(result).toEqual({ @@ -389,10 +299,8 @@ describe("respondToComplete", () => { ])( "restricts $flag on sso $command to the $extension file extension", ({ command, flag, extension }) => { - // sso/add/add.command.ts and sso/update/update.command.ts both declare - // --metadata-file/--attribute-mapping-file; cobra's MarkFlagFilename - // calls for these are captured as a small lookup table - // (COMPLETION_FLAG_FILE_EXTENSIONS) rather than derived generically. + // File extensions come from the COMPLETION_FLAG_FILE_EXTENSIONS lookup table, + // not derived generically. const result = respondToComplete(rootCommand, ["__complete", "sso", command, `--${flag}=`]); expect(result).toEqual({ candidates: [{ name: extension, description: undefined }], @@ -402,18 +310,11 @@ describe("respondToComplete", () => { ); it("never completes a choice flag's value (sso add --type )", () => { - // sso/add/add.command.ts: `type: Flag.choice("type", ["saml"])` has no - // registered ValidArgsFunction equivalent in Go, so its value slot must - // resolve to empty candidates + Default, not an enumeration of "saml". const result = respondToComplete(rootCommand, ["__complete", "sso", "add", "--type", ""]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.Default }); }); it("does not treat a boolean flag as consuming a following value", () => { - // sso/add/add.command.ts: `skipUrlValidation: Flag.boolean("skip-url-validation")`. - // A boolean flag must fall through to Case 3 (bare noun completion) with the - // unchanged toComplete/trimmedArgs, so this must equal the bare `sso add ""` - // response exactly rather than resolve to some flag-value result. const withBooleanFlag = respondToComplete(rootCommand, [ "__complete", "sso", @@ -429,8 +330,6 @@ describe("respondToComplete", () => { describe("changed-flag exclusion and the variadic exception", () => { it("excludes an already-supplied, non-repeatable flag's own name from further completion", () => { - // sso/add/add.command.ts: `metadataUrl: Flag.string("metadata-url")` is not - // variadic, so once supplied it must not be offered again. const result = respondToComplete(rootCommand, [ "__complete", "sso", @@ -445,9 +344,6 @@ describe("respondToComplete", () => { }); it("keeps offering a variadic flag's own name even after it has already been supplied", () => { - // sso/add/add.command.ts: `domains: ssoAddDomainsFlag` is built on - // `Flag.atLeast(0)` (repeatable) — cobra's real doCompleteFlags keeps a - // Slice/Array-typed flag in the completion list even once `flag.Changed`. const result = respondToComplete(rootCommand, [ "__complete", "sso", @@ -462,21 +358,11 @@ describe("respondToComplete", () => { describe("flag terminator (`--`) disables flag completion (CLI-1965 review)", () => { it("does not offer a flag-name candidate for a positional operand after `--`", () => { - // Cobra's flagCompletion gate goes false once a `--` is already present - // in the args (completions.go:364-381), so a positional operand that - // happens to start with `-` after the terminator must not be treated as - // a flag-name completion: `db dump -- --s` returns zero candidates with - // the Default directive, not `--schema`. const result = respondToComplete(rootCommand, ["__complete", "db", "dump", "--", "--s"]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.Default }); }); it("still offers a required flag that appears (as a positional) after `--`", () => { - // completeRequireFlags is called unconditionally in cobra's noun- - // completion branch, even past the terminator — and a token past `--` - // is never parsed as a flag at all, so it must not be marked "changed" - // either: `sso add -- --type --typ` still offers `--type` with the - // Default directive. const result = respondToComplete(rootCommand, [ "__complete", "sso", @@ -494,11 +380,6 @@ describe("respondToComplete", () => { describe("attached shorthand values resolve via pflag's real strict parser (CLI-1965 review)", () => { it("parses a non-boolean shorthand's attached value instead of treating the token as unknown", () => { - // pflag's parseSingleShortArg resolves a shorthand cluster's value by - // its FIRST character, not its last — `-j4` is a fully valid, - // already-resolved `--jobs=4`, so it must not suppress completion of a - // later flag name: `functions deploy -j4 --p` still offers - // --profile/--project-ref/--prune. const result = respondToComplete(rootCommand, [ "__complete", "functions", @@ -515,12 +396,6 @@ describe("respondToComplete", () => { describe("a trailing incomplete flag is a hard parse error only when toComplete is itself flag-shaped (CLI-1965 review)", () => { it("rejects a dangling value-taking flag when toComplete is a bare flag-shaped token", () => { - // Cobra's checkIfFlagCompletion only rescues a trailing incomplete flag - // from ParseFlags() when toComplete is empty or not flag-shaped - // (completions.go:666-687); `-o --d` leaves `-o` dangling with no - // rescue, so the real ParseFlags() call fails outright: zero - // candidates with the Default directive — the error is "flag needs an - // argument: 'o' in -o". const result = respondToComplete(rootCommand, ["__complete", "-o", "--d"]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.Default }); }); @@ -537,14 +412,6 @@ describe("respondToComplete", () => { ); it("rejects a dangling value-taking flag even when toComplete is a DIFFERENT flag's attached-value token", () => { - // A `toComplete` containing `=` still identifies its OWN flag name - // (checkIfFlagCompletion's flagWithEqual branch) — that never rescues - // an earlier, different dangling flag out of finalArgs, so - // ParseFlags() still fails on it: `sso add --type saml --metadata-file - // --attribute-mapping-file=` returns zero candidates with the Default - // directive — the error is "flag needs an argument: --metadata-file" - // — even though `--attribute-mapping-file` is itself a real flag with - // a registered file-extension completion. const result = respondToComplete(rootCommand, [ "__complete", "sso", @@ -560,12 +427,6 @@ describe("respondToComplete", () => { describe("changed-flag tracking honors a long flag's real value consumption (CLI-1965 review)", () => { it("does not mark a value token as its own changed flag, so a still-required flag stays offered", () => { - // pflag's parseLongArg consumes the immediately following token as a - // non-boolean flag's value regardless of its shape — `--domains` (a - // string flag) consumes `--type` here, so `--type` itself was never - // parsed as a flag and must still be offered as required: `sso add - // --domains --type foo --typ` still offers `--type` with the - // NoFileComp directive. const result = respondToComplete(rootCommand, [ "__complete", "sso", @@ -584,11 +445,6 @@ describe("respondToComplete", () => { describe("a boolean flag with an explicit `=` is still flag-VALUE completion (CLI-1965 review)", () => { it("does not fall through to noun completion for `--boolFlag=value`", () => { - // Cobra's checkIfFlagCompletion only resets a boolean flag back to noun - // completion in the no-`=` two-token case (`!flagWithEqual` guard); - // `--debug=maybe` keeps `flag` set and goes to flag-VALUE completion, - // which resolves to zero candidates: the Default directive, not the - // root command list. const result = respondToComplete(rootCommand, ["__complete", "--debug=maybe"]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.Default }); }); @@ -598,10 +454,6 @@ describe("respondToComplete", () => { it.each(["bash", "zsh", "fish", "powershell"])( "returns zero candidates with the NoFileComp directive for `completion %s`", (shell) => { - // Cobra's InitDefaultCompletionCmd registers ValidArgsFunction: - // NoFileCompletions on the completion group and each of its shell - // leaves; getCompletions always calls a resolved command's own - // ValidArgsFunction, overwriting the directive outright. const result = respondToComplete(rootCommand, ["__complete", "completion", shell, ""]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.NoFileComp }); }, @@ -610,10 +462,6 @@ describe("respondToComplete", () => { describe("help's own ValidArgsFunction resolves a second command path from root (CLI-1965 review)", () => { it("completes root subcommand names after `help`", () => { - // Cobra's auto-registered help command has its own ValidArgsFunction - // (command.go:1274-1290) that re-resolves everything after `help` from - // root — `help d` completes as if `d` were being completed at the - // root itself, not as an argument to `help`: `db`, `domains`. const result = respondToComplete(rootCommand, ["__complete", "help", "d"]); expect(result?.directive).toBe(CompletionDirective.NoFileComp); expect(result?.candidates.map((c) => c.name)).toEqual( @@ -622,8 +470,6 @@ describe("respondToComplete", () => { }); it("completes a resolved subcommand's own children after `help `", () => { - // `help db d` completes as `db d` would — db's own subcommands, not - // help's: `diff`, `dump`. const result = respondToComplete(rootCommand, ["__complete", "help", "db", "d"]); expect(result?.directive).toBe(CompletionDirective.NoFileComp); expect(result?.candidates.map((c) => c.name)).toEqual( @@ -632,25 +478,16 @@ describe("respondToComplete", () => { }); it("returns no candidates for a leaf command with no subcommands of its own", () => { - // `db dump` is a leaf; cobra's ValidArgsFunction loops over its empty - // Commands() and finds nothing, but still sets NoFileComp. const result = respondToComplete(rootCommand, ["__complete", "help", "db", "dump", "s"]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.NoFileComp }); }); it("returns no candidates for an unresolved token directly under root", () => { - // Cobra's legacyArgs validator (args.go:28-37) only errors the - // "unknown command" case when the resolved command IS root and a - // token is left over: `help bogus d` -> zero candidates. const result = respondToComplete(rootCommand, ["__complete", "help", "bogus", "d"]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.NoFileComp }); }); it("still lists a resolved non-root command's subcommands past an unresolved token", () => { - // The same legacyArgs validator never errors for a non-root resolved - // command, even with leftover args — subcommands "will always accept - // arbitrary arguments": `help db bogus d` still offers db's own - // subcommands. const result = respondToComplete(rootCommand, ["__complete", "help", "db", "bogus", "d"]); expect(result?.directive).toBe(CompletionDirective.NoFileComp); expect(result?.candidates.map((c) => c.name)).toEqual( @@ -659,8 +496,6 @@ describe("respondToComplete", () => { }); it("includes the synthetic `help` candidate itself when resolved back to root", () => { - // Cobra's help command is one of root's own Commands(), so completing - // help's arguments back at root re-lists help too: `help h` -> `help`. const result = respondToComplete(rootCommand, ["__complete", "help", "h"]); expect(result).toEqual({ candidates: [{ name: "help", description: "Help about any command" }], @@ -675,18 +510,13 @@ describe("respondToComplete", () => { { path: ["migration", "down"], flag: "last" }, { path: ["db", "reset"], flag: "last" }, ])("rejects a negative value for $path --$flag", ({ path, flag }) => { - // These flags are validated as strconv.ParseUint(s, 0, 64) values, - // which reject any sign prefix outright — unlike this tree's plain - // signed Flag.integer, whose generic regex accepts one: zero - // candidates with the Default directive for all three. + // These flags are validated as unsigned integers, which reject a leading sign, + // unlike this tree's plain signed Flag.integer regex. const result = respondToComplete(rootCommand, ["__complete", ...path, `--${flag}`, "-1", ""]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.Default }); }); it("still accepts a valid uint value, including the zero boundary", () => { - // Regression guard: the stricter check must not reject what real pflag - // accepts: `db reset --last 0 --d` still offers - // --debug/--dns-resolver/--db-url. const result = respondToComplete(rootCommand, [ "__complete", "db", @@ -704,20 +534,11 @@ describe("respondToComplete", () => { describe("--output's choice values are validated per-command, not the widened global union (CLI-1965 review)", () => { it("rejects db query's own local values (table/csv) everywhere else", () => { - // The global OutputFlag's choiceKeys is the UNION of root's - // 5-value enum and db query's own 3-value enum (go-output- - // flag.ts), but the root persistent --output only accepts - // env|pretty|json|toml|yaml: `--output table ""` -> zero candidates - // with the Default directive. const result = respondToComplete(rootCommand, ["__complete", "--output", "table", ""]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.Default }); }); it("rejects the resource-command values (env/pretty/toml/yaml) under db query", () => { - // The reverse direction of the same defect: db query's own enum is - // json|table|csv only: `db query --output env ""` -> zero candidates - // with the Default directive, even though `env` is valid everywhere - // else. const result = respondToComplete(rootCommand, [ "__complete", "db", @@ -756,10 +577,6 @@ describe("respondToComplete", () => { describe("flag values are validated the way real pflag parses them (CLI-1965 review)", () => { it("accepts a base-0 hex value for a plain (non-uint) integer flag", () => { - // Plain int64 flags are validated as strconv.ParseInt(s, 0, 64) — base - // 0, so a `0x`-prefixed value is valid, unlike a decimal-only regex: - // `backups restore --timestamp 0x10 --p` still offers - // --profile/--project-ref. const result = respondToComplete(rootCommand, [ "__complete", "backups", @@ -775,13 +592,8 @@ describe("respondToComplete", () => { }); it("rejects a value one past int64 max for a plain (non-uint) integer flag", () => { - // Plain int64 flags are validated as strconv.ParseInt(s, 0, 64), a - // NARROWER signed range than the uint64 bound `Flag.integer` alone - // would suggest — 9223372036854775808 is a syntactically valid uint64 - // but exceeds int64 max by one: `backups restore --timestamp - // 9223372036854775808 --p` returns zero candidates with the Default - // directive, while int64's real bounds, 9223372036854775807 and - // -9223372036854775808, both still offer --profile/--project-ref. + // Validated against the signed int64 range, which is narrower than the uint64 + // bound `Flag.integer` alone would suggest. const overflow = respondToComplete(rootCommand, [ "__complete", "backups", @@ -814,11 +626,6 @@ describe("respondToComplete", () => { }); it("rejects a malformed value for Go's DurationVar flags (gen types --query-timeout, gen bearer-jwt --valid-for)", () => { - // Both are declared Flag.string in TS but validated as DurationVar - // values (cmd/gen.go:161,179), parsed via time.ParseDuration before - // Cobra generates completions: both `bogus` values return zero - // candidates with the Default directive, while `5s`/`1h` still - // complete normally. const queryTimeout = respondToComplete(rootCommand, [ "__complete", "gen", @@ -868,13 +675,6 @@ describe("respondToComplete", () => { }); it("rejects a duration one unit past Go's int64 nanosecond range for Go's DurationVar flags", () => { - // DurationVar values parse via time.ParseDuration, which accumulates - // into an int64 nanosecond count — a syntactically well-formed - // duration can still overflow that range: `gen types --query-timeout - // 2562048h --l` — one hour past the real max — returns zero - // candidates with the Default directive, matching `bogus`, while the - // exact int64 max, `2562047h47m16.854775807s`, still completes - // normally. const overflow = respondToComplete(rootCommand, [ "__complete", "gen", @@ -897,10 +697,6 @@ describe("respondToComplete", () => { }); it("rejects a malformed value for Go's TimeVar flag (gen bearer-jwt --exp, RFC3339 only)", () => { - // Declared Flag.string in TS but validated as a TimeVar constrained - // to time.RFC3339 (cmd/gen.go:178): `bogus` returns zero candidates - // with the Default directive, while a real RFC3339 timestamp still - // completes normally. const invalid = respondToComplete(rootCommand, [ "__complete", "gen", @@ -927,10 +723,8 @@ describe("respondToComplete", () => { }); it("rejects an out-of-range RFC3339 zone offset for gen bearer-jwt --exp", () => { - // time.Parse(time.RFC3339, s) independently caps the offset hour - // at 24 (not 23) and the offset minute at 60 (not 59): "+24:00" and - // "+00:60" parse successfully, while "+25:00" and "+00:61" both fail - // with "time zone offset hour/minute out of range". + // RFC3339 parsing caps the offset hour at 24 (not 23) and the minute at 60 (not + // 59), so "+24:00" and "+00:60" are valid while "+25:00" and "+00:61" are not. const outOfRangeHour = respondToComplete(rootCommand, [ "__complete", "gen", @@ -975,10 +769,7 @@ describe("respondToComplete", () => { }); it("accepts a comma-separated fractional second for gen bearer-jwt --exp", () => { - // time.Parse(time.RFC3339, s) accepts either `.` or `,` before the - // fractional-seconds digits: "2024-01-02T15:04:05,5Z" parses - // identically to "...05.5Z"; a bare "," with no following digit, or - // mixing both separators in one timestamp, both still fail. + // RFC3339 parsing accepts either `.` or `,` before the fractional-seconds digits. const commaFraction = respondToComplete(rootCommand, [ "__complete", "gen", @@ -1023,12 +814,6 @@ describe("respondToComplete", () => { }); it("rejects a negative value for storage cp --jobs even though it's a string-typed flag in TS", () => { - // --jobs is validated as a UintVarP value (cmd/storage.go:107), the - // same as functions deploy/migration down/db reset above — but - // storage cp models it as Flag.string in TS, so the uint override - // must be consulted regardless of primitiveTag: `storage cp --jobs - // -1 --r` returns zero candidates with the Default directive, not - // --recursive. const result = respondToComplete(rootCommand, [ "__complete", "storage", @@ -1041,10 +826,6 @@ describe("respondToComplete", () => { }); it("rejects malformed CSV for a StringSliceVar-backed flag", () => { - // --domains is validated as a StringSliceVarP value (cmd/sso.go:158), - // CSV-split via encoding/csv at parse time; an unterminated quote - // fails that parse: `sso add --domains 'a,"b' --type` returns zero - // candidates with the Default directive, not --type. const result = respondToComplete(rootCommand, [ "__complete", "sso", @@ -1069,10 +850,6 @@ describe("respondToComplete", () => { }); it("does not apply CSV validation to db reset --sql-paths (a plain StringArrayVar, not StringSliceVar)", () => { - // Go registers --sql-paths as a plain StringArrayVar (cmd/db.go:714) — - // no CSV parsing — unlike every other variadic string flag in this - // tree, so a value containing an unbalanced quote must still be - // accepted. const result = respondToComplete(rootCommand, [ "__complete", "db", @@ -1089,11 +866,6 @@ describe("respondToComplete", () => { describe("an attached-value shorthand cluster is validated even when the owning flag is boolean (CLI-1965 review)", () => { it("rejects an invalid boolean value attached via `=` to a boolean shorthand", () => { - // pflag treats `-f=value` as an explicit value for a boolean shorthand - // too (pflag@v1.0.10/flag.go:1005-1033) — an owning flag being boolean - // does not, on its own, mean there is nothing to validate: `storage - // cp -r=maybe --j` returns zero candidates with the Default - // directive, not --jobs. const result = respondToComplete(rootCommand, [ "__complete", "storage", @@ -1121,27 +893,17 @@ describe("respondToComplete", () => { it.each(["--help=false", "--help=true", "-h=false"])( "treats %s the same as a bare --help", (token) => { - // pflag's boolValue.Set marks the flag Changed on either spelling, - // and cobra's helpOrVersionFlagPresent checks .Changed, not the - // parsed value (completions.go:530-537): `--help=false --d` and - // `--help=true --d` both return zero candidates with the - // NoFileComp directive. const result = respondToComplete(rootCommand, ["__complete", token, "--d"]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.NoFileComp }); }, ); it("treats --version=false the same as a bare --version, at the root", () => { - // `--version=false br` returns zero candidates with the NoFileComp - // directive, not `branches`. const result = respondToComplete(rootCommand, ["__complete", "--version=false", "br"]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.NoFileComp }); }); it("does not treat --help=maybe as Changed — an invalid boolean value is an unresolved-flag parse error instead", () => { - // An invalid value fails pflag's own Set() before Changed is ever - // examined, so this is Case 0 (Default), not the help short-circuit - // (NoFileComp). const result = respondToComplete(rootCommand, ["__complete", "--help=maybe", "--d"]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.Default }); }); @@ -1149,11 +911,6 @@ describe("respondToComplete", () => { describe("a `--` consumed as a preceding flag's value is not a genuine terminator (CLI-1965 review)", () => { it("still offers a flag name after `--` was consumed as a value-taking flag's own value", () => { - // pflag's parseLongArg consumes the very next token unconditionally as - // a non-boolean flag's value — including a literal `--` — so it never - // reaches pflag's own end-of-flags sentinel check (pflag@v1.0.10/ - // flag.go:949-952): `db dump --file -- --s` still offers --schema, - // not zero candidates. const result = respondToComplete(rootCommand, [ "__complete", "db", @@ -1167,9 +924,6 @@ describe("respondToComplete", () => { }); it("still disables flag completion for a genuine, unconsumed `--` sentinel", () => { - // Regression guard: only a `--` that isn't claimed as a preceding - // flag's value is a real terminator: `db dump -- --s` returns zero - // candidates with the Default directive. const result = respondToComplete(rootCommand, ["__complete", "db", "dump", "--", "--s"]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.Default }); }); @@ -1177,29 +931,17 @@ describe("respondToComplete", () => { describe("a bare `-` is a positional argument, not a flag (CLI-1965 review)", () => { it("keeps the subcommand-listing gate closed when a bare `-` survives as leftover", () => { - // pflag's isFlagArg requires at least 2 characters, so a bare `-` is - // never flag-shaped — but it also never removes itself from cobra's - // leftover finalArgs the way a matched command name does, so it keeps - // the `len(finalArgs) == 0` subcommand-listing gate closed: `sso - - // --debug a` returns zero candidates with the Default directive, not - // `add`. const result = respondToComplete(rootCommand, ["__complete", "sso", "-", "--debug", "a"]); expect(result).toEqual({ candidates: [], directive: CompletionDirective.Default }); }); it("still lets the descent continue past a bare `-` to a real subcommand match", () => { - // Regression guard: a bare `-` must not stop the descent the way an - // unmatched real token does: `db - dump --da` still offers - // --data-only. const result = respondToComplete(rootCommand, ["__complete", "db", "-", "dump", "--da"]); expect(result?.directive).toBe(CompletionDirective.NoFileComp); expect(result?.candidates.map((c) => c.name)).toContain("--data-only"); }); it("still resolves help's own second command-path lookup past a bare `-`", () => { - // The Case-2 "preceding token is flag-shaped" check must also exclude - // a bare `-`, or it hard-stops before ever reaching help's dispatch: - // `help db - d` still lists db's own subcommands diff/dump. const result = respondToComplete(rootCommand, ["__complete", "help", "db", "-", "d"]); expect(result?.directive).toBe(CompletionDirective.NoFileComp); expect(result?.candidates.map((c) => c.name)).toEqual( @@ -1236,19 +978,12 @@ describe("resolveCommandPath", () => { }); it("skips flag-shaped tokens without stopping descent, and excludes them from leftoverArgs", () => { - // `--debug` is a boolean global flag — it and every genuinely-consumed - // flag token are excluded from `leftoverArgs` entirely (not just skipped - // during subcommand matching), since `leftoverArgs` represents cobra's - // *positional* `finalArgs`, used to gate subcommand-name completion. const result = resolveCommandPath(rootCommand, ["--debug", "migration", "list"]); expect(result.matchedPath).toEqual(["migration", "list"]); expect(result.leftoverArgs).toEqual([]); }); it("also excludes a value-taking flag's consumed value token from leftoverArgs", () => { - // `-o` (the global --output choice flag) is non-boolean, so it consumes - // "json" as its value — "json" must not be treated as an extra - // positional token even though it isn't itself flag-shaped. const result = resolveCommandPath(rootCommand, ["-o", "json", "migration", "list"]); expect(result.matchedPath).toEqual(["migration", "list"]); expect(result.leftoverArgs).toEqual([]); @@ -1267,8 +1002,8 @@ describe("collectInScopeFlags", () => { const { commandChain } = resolveCommandPath(rootCommand, ["branches", "list"]); const flags: ReadonlyArray = collectInScopeFlags(rootCommand, commandChain); const names = flags.map((flag) => flag.name); - expect(names).toContain("debug"); // root global flag - expect(names).toContain("project-ref"); // branches list's own local flag + expect(names).toContain("debug"); + expect(names).toContain("project-ref"); const debugFlag = flags.find((flag) => flag.name === "debug"); expect(debugFlag).toEqual({ @@ -1325,18 +1060,11 @@ describe("collectInScopeFlags", () => { }); it("orders flags like cobra's InheritedFlags().VisitAll then NonInheritedFlags().VisitAll — alphabetical within each block, not declaration order (CLI-1965 review)", () => { - // pflag's VisitAll sorts by canonical (long) flag name; cobra's - // completion path walks the inherited (ancestor) set first, then the - // resolved command's own set — TWO separately-sorted runs, not one - // merged alphabetical list: `db dump -` lists --agent, --create-ticket, - // --debug, ... alphabetically, THEN a second alphabetical run starting - // --completions (the built-ins join the own block), --data-only, - // --db-url, --dry-run, .... const { commandChain } = resolveCommandPath(rootCommand, ["db", "dump"]); const names = collectInScopeFlags(rootCommand, commandChain).map((flag) => flag.name); - const inheritedEnd = names.indexOf("yes"); // last inherited flag, alphabetically - const ownStart = names.indexOf("completions"); // first own flag, alphabetically (a built-in) + const inheritedEnd = names.indexOf("yes"); + const ownStart = names.indexOf("completions"); expect(inheritedEnd).toBeGreaterThanOrEqual(0); expect(ownStart).toBeGreaterThan(inheritedEnd); @@ -1344,8 +1072,6 @@ describe("collectInScopeFlags", () => { const ownBlock = names.slice(ownStart); expect(inheritedBlock).toEqual([...inheritedBlock].sort((a, b) => a.localeCompare(b))); expect(ownBlock).toEqual([...ownBlock].sort((a, b) => a.localeCompare(b))); - // --help is db dump's own NonInherited flag (every command registers its - // own), not part of the shared inherited block. expect(inheritedBlock).not.toContain("help"); expect(ownBlock).toContain("help"); }); @@ -1355,8 +1081,7 @@ describe("collectInScopeFlags", () => { rootCommand, resolveCommandPath(rootCommand, []).commandChain, ); - // `output-format` is TS-only surface with no Go equivalent, so it isn't - // asserted here — every OTHER root flag name must still come out sorted. + // `output-format` is TS-only, so it's excluded from the ordering check. const names = atRoot.map((flag) => flag.name).filter((name) => name !== "output-format"); expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b))); }); @@ -1491,19 +1216,14 @@ describe("tryComplete", () => { exit: (code) => { exits.push(code); }, - // A no-op here keeps this suite's own focus (candidate-computation - // wiring, not telemetry) pure and synchronous-fast — the real - // production capture (`captureCompleteTelemetryEffect`, - // `defaultCompleteDeps`'s own default) is covered separately in - // `complete.integration.test.ts`. + // No-op: telemetry capture is covered separately in complete.integration.test.ts. captureTelemetry: async () => {}, ...overrides, }; return { deps, stdoutWrites, stderrWrites, exits }; } - // `tryComplete` returns `Promise` — it awaits - // `deps.captureTelemetry` before calling `deps.exit`. + // `tryComplete` awaits `deps.captureTelemetry` before calling `deps.exit`. it("returns false and does nothing for non-__complete argv", async () => { const { deps, stdoutWrites, exits } = makeDeps({ argv: ["migration", "list"] }); expect(await tryComplete(deps)).toBe(false); diff --git a/apps/cli/src/cli/root.ts b/apps/cli/src/cli/root.ts index e9db64417e..a522854376 100644 --- a/apps/cli/src/cli/root.ts +++ b/apps/cli/src/cli/root.ts @@ -141,9 +141,8 @@ export const rootCommandForBackend = (backend: StackBackend = "legacy"): CliRoot const cliArgs = yield* CliArgs; const aiTool = yield* AiTool.pipe(Effect.provide(aiToolLayer)); - // An explicit Go --output is a complete format choice (even `-o pretty` - // must keep its human table), so the agent JSON default only applies - // when that flag is absent. + // An explicit --output is a complete format choice (even `-o pretty` keeps its + // human table), so the agent JSON default only applies when it's absent. const outputFormat = resolveAgentOutputFormat({ explicitOutputFormat, goOutputFormat: goOutput, @@ -152,8 +151,6 @@ export const rootCommandForBackend = (backend: StackBackend = "legacy"): CliRoot isBuiltInTextRequest: isBuiltInTextRequest(cliArgs.args), }); - // Build args to prepend to every proxy exec call. - // --output: use explicit --output if set, otherwise map from --output-format. const globalArgs: string[] = []; if (Option.isSome(goOutput)) { globalArgs.push("--output", goOutput.value); @@ -170,12 +167,9 @@ export const rootCommandForBackend = (backend: StackBackend = "legacy"): CliRoot if (createTicket) globalArgs.push("--create-ticket"); if (agent !== "auto") globalArgs.push("--agent", agent); - // Go's `-o {json,yaml,toml,env,csv}` selects a machine encoder the - // handler writes via `output.raw`. Keep the text layer (so errors still - // render as red text on stderr, matching Go), but suppress its progress - // spinner — otherwise clack writes ANSI to stdout and corrupts the - // payload (CLI-1546). `-o pretty` / `-o table` (`db query`'s human - // default) / no `-o` keep the normal text/json layers. + // Machine formats keep the text layer's error rendering but suppress the progress + // spinner, which would otherwise corrupt the stdout payload. `-o pretty`/`-o table` + // (db query's human default) and no `-o` keep the normal text/json layers. const goFmt = Option.getOrUndefined(goOutput); const isGoMachineFormat = goFmt !== undefined && goFmt !== "pretty" && goFmt !== "table"; const outputLayer = isGoMachineFormat diff --git a/apps/cli/src/command-internal/api-keys.format.ts b/apps/cli/src/command-internal/api-keys.format.ts index 5ade6c3483..903fc3d192 100644 --- a/apps/cli/src/command-internal/api-keys.format.ts +++ b/apps/cli/src/command-internal/api-keys.format.ts @@ -2,17 +2,10 @@ import type { ApiKeyResponse } from "@supabase/api/effect"; type ApiKey = typeof ApiKeyResponse.Type; -/** - * Masking placeholder Go substitutes for a nullable-null api key value - * (`apps/cli-go/internal/projects/apiKeys/api_keys.go:61-66`, deleted in - * CLI-1970; last present at commit 7b469f5b3). - */ +/** Placeholder returned for a null or missing api key value. */ const API_KEY_MASK = "******"; -/** - * Reproduces Go's `apiKeys.toValue` (`api_keys.go:61-66`): return the api key - * value, or the `******` mask when the value is nullable-null / absent. - */ +/** Returns the api key value, or the mask when it's null or absent. */ export function apiKeyValue(value: string | null | undefined): string { return value === undefined || value === null ? API_KEY_MASK : value; } @@ -25,10 +18,9 @@ function envSuffix(entry: ApiKey): string { } /** - * Reproduces Go's `apiKeys.ToEnv` (`api_keys.go:51-68`): - * uppercase the name (with `default` publishable → `PUBLISHABLE`), wrap as - * `SUPABASE__KEY`, fall back to `"******"` when the api_key value is - * nullable-null. Shared by `branches get` and `projects api-keys`. + * Builds `SUPABASE__KEY` env entries from api keys, uppercasing the name (the + * default publishable key maps to `PUBLISHABLE`). Shared by `branches get` and + * `projects api-keys`. */ export function apiKeysToEnv(keys: ReadonlyArray): Record { const envs: Record = {}; diff --git a/apps/cli/src/command-internal/api-url.ts b/apps/cli/src/command-internal/api-url.ts index ff22b6a9e3..8a91bbf436 100644 --- a/apps/cli/src/command-internal/api-url.ts +++ b/apps/cli/src/command-internal/api-url.ts @@ -1,11 +1,9 @@ import { goUrlParse } from "./storage-url.ts"; /** - * Local API URL derivation, mirroring Go's `config.go:634-644` + `misc.go:298`: - * an explicit `api.external_url` wins, otherwise `://:` - * where the scheme follows `api.tls.enabled` and the port is `api.port`. - * Hoisted here because `storage-credentials.ts` and - * `local-config-values.ts` both need this exact computation. + * Derives the local API URL: an explicit `api.external_url` wins, otherwise + * `://:` from `api.tls.enabled` and `api.port`. Hoisted here + * because `storage-credentials.ts` and `local-config-values.ts` both need it. */ export function resolveApiExternalUrl( config: { @@ -19,8 +17,7 @@ export function resolveApiExternalUrl( return config.external_url; } const scheme = config.tls.enabled ? "https" : "http"; - // Go builds host:port with net.JoinHostPort (config.go:636-638), bracketing an - // IPv6 host. + // Brackets an IPv6 host, e.g. `[::1]:5432`. const hostPort = hostname.includes(":") ? `[${hostname}]:${config.port}` : `${hostname}:${config.port}`; @@ -28,20 +25,9 @@ export function resolveApiExternalUrl( } /** - * Go's `Config.Validate` rewrite of `Studio.ApiUrl` (`pkg/config/config.go:1074-1078`): - * ```go - * } else if parsed.Host == "" || parsed.Host == c.Hostname { - * c.Studio.ApiUrl = c.Api.ExternalUrl - * } - * ``` - * Runs as the last step of `Config.Load` (`config.go:882`), so by the time - * `start` builds Studio's env, `studio.api_url` has already been rewritten to - * the resolved API external URL (the Kong URL) whenever its host is empty - * (a relative/schemeless value) or matches the bare local hostname exactly - * (no port) — which is the default-config case, since `studio.api_url` - * defaults to `http://127.0.0.1` and `Hostname` defaults to the same - * `"127.0.0.1"`. An explicit, non-matching host (e.g. a custom domain, or a - * host:port pair) is left untouched. + * Rewrites `studio.api_url` to the resolved API external URL when its host is empty or + * matches the bare local hostname with no port (the default-config case). An explicit + * non-matching host, or a host:port pair, is left untouched. */ export function resolveStudioApiUrl( rawApiUrl: string, diff --git a/apps/cli/src/command-internal/api-url.unit.test.ts b/apps/cli/src/command-internal/api-url.unit.test.ts index c569b4e3ce..643b0579cd 100644 --- a/apps/cli/src/command-internal/api-url.unit.test.ts +++ b/apps/cli/src/command-internal/api-url.unit.test.ts @@ -3,9 +3,6 @@ import { describe, expect, it } from "vitest"; import { resolveStudioApiUrl } from "./api-url.ts"; describe("resolveStudioApiUrl", () => { - // Go's `Config.Validate` (`pkg/config/config.go:1074-1078`): a default - // `studio.api_url` (`http://127.0.0.1`) has host `127.0.0.1`, matching the - // default hostname, so it's rewritten to the resolved API external URL. it("rewrites the default studio.api_url to the API external URL", () => { expect(resolveStudioApiUrl("http://127.0.0.1", "127.0.0.1", "http://127.0.0.1:54321")).toBe( "http://127.0.0.1:54321", @@ -25,8 +22,6 @@ describe("resolveStudioApiUrl", () => { }); it("leaves a value with the matching host but an explicit port untouched", () => { - // Go's `parsed.Host` includes the port when one is present, so `127.0.0.1:3000` - // does not equal the bare `Hostname` `127.0.0.1` and is not rewritten. expect( resolveStudioApiUrl("http://127.0.0.1:3000", "127.0.0.1", "http://127.0.0.1:54321"), ).toBe("http://127.0.0.1:3000"); diff --git a/apps/cli/src/command-internal/bitbucket-pipeline.ts b/apps/cli/src/command-internal/bitbucket-pipeline.ts index af16c2ab42..7400b3bf31 100644 --- a/apps/cli/src/command-internal/bitbucket-pipeline.ts +++ b/apps/cli/src/command-internal/bitbucket-pipeline.ts @@ -1,27 +1,15 @@ /** - * `BITBUCKET_CLONE_DIR` — exported so `local-project-context.ts` can install it from a - * project dotenv file into `process.env` before this check ever runs. Unlike - * `SUPABASE_SERVICES_HOSTNAME` (deliberately NOT installed, see that file's own doc comment), - * Go's `os.Getenv("BITBUCKET_CLONE_DIR")` read (`apps/cli-go/internal/utils/docker.go:401`) lives - * inside `DockerStart`, a regular function invoked during the command's own `Run()`, well after - * `flags.LoadConfig` -> `godotenv.Load` has already installed dotenv keys into the process env - * (`pkg/config/config.go:786-791,1261`) — not in a package-level `var` initializer evaluated - * before `godotenv.Load` ever runs (review: PRRT_kwDOErm0O86VmHkm). + * Exported so `local-project-context.ts` can install this key from a project dotenv file + * into `process.env` before {@link isBitbucketPipeline} ever runs. */ export const BITBUCKET_CLONE_DIR_ENV_KEY = "BITBUCKET_CLONE_DIR"; /** - * Whether the current process is running inside a Bitbucket Pipelines runner, - * mirroring Go's `os.Getenv("BITBUCKET_CLONE_DIR") != ""` check - * (`apps/cli-go/internal/utils/docker.go:401`). Bitbucket's Docker-in-Docker - * runner disallows named volumes and `--security-opt` - * (https://support.atlassian.com/bitbucket-cloud/docs/run-docker-commands-in-bitbucket-pipelines/#Full-list-of-restricted-commands), - * so every container-runtime call site that creates volumes or sets security - * options needs this same check. - * - * Hoisted here because it is needed by ≥2 call sites: `docker-run.layer.ts` - * (`docker run`, e.g. `db dump`/`db test`) and `start`'s per-service container - * creation (`command-internal/db-bootstrap/container-lifecycle.ts`). + * Whether the process is running inside a Bitbucket Pipelines runner, whose + * Docker-in-Docker environment disallows named volumes and `--security-opt`. Hoisted here + * for reuse across every container-runtime call site that creates volumes or sets security + * options. + * @see https://support.atlassian.com/bitbucket-cloud/docs/run-docker-commands-in-bitbucket-pipelines/#Full-list-of-restricted-commands */ export function isBitbucketPipeline(): boolean { const value = globalThis.process.env[BITBUCKET_CLONE_DIR_ENV_KEY]; diff --git a/apps/cli/src/command-internal/branch-target.ts b/apps/cli/src/command-internal/branch-target.ts index 224eab280d..0a19e5a22f 100644 --- a/apps/cli/src/command-internal/branch-target.ts +++ b/apps/cli/src/command-internal/branch-target.ts @@ -22,7 +22,7 @@ type BranchLookupBranches = typeof V1ListAllBranchesOutput.Type; * `SchemaError` rejecting the response body), surfaces through the * caller-supplied network-error factory/message. * - * Each caller supplies its OWN error classes and message templates, so the + * Each caller supplies its own error classes and message templates, so the * exact wording stays theirs — this helper only owns the status/transport * dispatch, not the message text. Pair with `Effect.asSome` so the 404 case * collapses cleanly into `Option.none()`: @@ -45,8 +45,8 @@ export function classifyProjectLookupError(opts: { } return cause.response.text.pipe( Effect.orElseSucceed(() => ""), - // Cap + strip control chars, matching `mapHttpError`'s defence-in-depth - // so an oversized / control-char body can't bloat JSON output or inject ANSI. + // Caps and strips control chars so an oversized or control-char body can't + // bloat JSON output or inject ANSI. Effect.map(sanitizeErrorBody), Effect.flatMap((body) => Effect.fail( @@ -72,23 +72,11 @@ export function classifyProjectLookupError(opts: { } /** - * Acquires a Management API client for a best-effort branch-name lookup, - * total (never fails, resolves `None` on any acquisition failure): - * - * 1. `Effect.serviceOption(CommandPlatformApi)` — the cheapest path; existing - * tests provide this directly, and any runtime that eagerly built the - * typed client (e.g. `link`/`branches`) already has it in scope. - * 2. Otherwise `Effect.serviceOption(CommandPlatformApiFactory)` → `factory.make`, - * with every failure (no token, invalid token, network, decode) caught. - * This is the path a token-optional runtime like `status`'s needs: the - * factory's own layer build never resolves a token or touches the - * network — that only happens here, lazily, exactly when a branch lookup - * is actually attempted. - * - * Neither service being in scope (a runtime that wires up neither) also - * degrades to `None` rather than a compile-time requirement — this effect's - * own type carries no `CommandPlatformApi`/`CommandPlatformApiFactory` - * requirement at all, thanks to `Effect.serviceOption`. + * Acquires a Management API client for a best-effort branch lookup; never fails, resolving + * `None` on any acquisition failure. Prefers an already-built `CommandPlatformApi`; falls + * back to `CommandPlatformApiFactory` for a token-optional runtime like `status`'s, since the + * factory only resolves a token and touches the network here, lazily, when a lookup is + * actually attempted. */ const acquireBranchLookupApi = Effect.fnUntraced(function* () { const direct = yield* Effect.serviceOption(CommandPlatformApi); @@ -103,29 +91,23 @@ const acquireBranchLookupApi = Effect.fnUntraced(function* () { ); }); -// A branch-name lookup is pure decoration for any caller and must never -// dominate its latency. The generated client's own retry policy (60s -// attempts × 5 transport retries — `packages/api/src/internal/client.ts:208-229`) -// would otherwise let a single blackholed API stall a caller for ~6 minutes. -// Exported so other bounded-but-best-effort probes in this codebase (e.g. -// `push.branch-target.ts`'s live `getProject` target-detection probe) share -// the same duration + rationale instead of re-deriving it. +/** + * A branch-name lookup is pure decoration and must never dominate a caller's latency — + * without this bound, the generated client's own retry policy could stall a caller for + * several minutes against a blackholed API. Shared with other bounded, best-effort probes + * (e.g. `push.branch-target.ts`'s `getProject` probe) so they don't re-derive the duration. + */ export const BRANCH_LOOKUP_TIMEOUT = Duration.seconds(5); /** - * Best-effort branch-name lookup against `parentRef`'s branches, returning - * the matching branch's `name` (or `undefined` on no match/any failure — the - * degradation point every caller relies on). Shows `options.spinnerLabel` in - * text mode when supplied, but only once an API client is actually - * available — an acquisition failure degrades silently, before ever touching - * the spinner. + * Best-effort branch-name lookup against `parentRef`'s branches, returning the matching + * branch's `name`, or `undefined` on no match or any failure. Shows `options.spinnerLabel` + * in text mode once an API client is available; an acquisition failure degrades silently, + * before the spinner is shown. * - * The WHOLE acquisition-and-listing attempt is hard-bounded by - * {@link BRANCH_LOOKUP_TIMEOUT}; a timeout degrades exactly like any - * other failure. The spinner's cleanup runs via `Effect.ensuring` (not a - * plain sequential `yield*`) so it's guaranteed to fire even when the timeout - * interrupts the in-flight listing call, not just on its normal - * success/failure completion. + * The whole acquisition-and-listing attempt is bounded by {@link BRANCH_LOOKUP_TIMEOUT}; a + * timeout degrades like any other failure. Spinner cleanup runs via `Effect.ensuring` so it + * still fires when the timeout interrupts the in-flight listing call. */ export const findBranchName = Effect.fnUntraced(function* ( parentRef: string, @@ -148,8 +130,8 @@ export const findBranchName = Effect.fnUntraced(function* ( .pipe(Effect.map(Option.some), Effect.ensuring(task?.clear() ?? Effect.void)); }).pipe( Effect.timeout(BRANCH_LOOKUP_TIMEOUT), - // Best-effort: any transport/status/decode failure OR the timeout above - // degrades below — this helper must never fail on a flaky/slow lookup. + // Any failure or the timeout above degrades to `None`; this helper never fails on a + // flaky or slow lookup. Effect.catch(() => Effect.succeed(Option.none())), ); diff --git a/apps/cli/src/command-internal/colors.ts b/apps/cli/src/command-internal/colors.ts index 944e6ae811..fe31b8f853 100644 --- a/apps/cli/src/command-internal/colors.ts +++ b/apps/cli/src/command-internal/colors.ts @@ -11,20 +11,17 @@ export interface ColorStream { } /** - * Port of termenv's colour-profile gate, which is what the established lipgloss default - * renderer consults (`lipgloss/renderer.go` → `termenv.EnvColorProfile`): + * Colour-detection gate following the NO_COLOR/CLICOLOR convention the established CLI output + * uses: * - * 1. `NO_COLOR` non-empty → no colour, beats everything (`EnvNoColor`). - * 2. `CLICOLOR=0` → no colour, unless forced (`EnvNoColor`). - * 3. `CLICOLOR_FORCE` set and not `"0"` → colour even when piped (the - * Ascii→ANSI promotion). + * 1. `NO_COLOR` non-empty → no colour, beats everything. + * 2. `CLICOLOR=0` → no colour, unless forced. + * 3. `CLICOLOR_FORCE` set and not `"0"` → colour even when piped. * 4. `CI` non-empty → treated as non-TTY. - * 5. Otherwise: the stream must be a colour-capable TTY. `hasColors()` is - * faithful on Bun TTYs (it also covers `TERM=dumb`) and absent on piped - * streams. + * 5. Otherwise: the stream must be a colour-capable TTY. `hasColors()` is faithful on Bun + * TTYs (it also covers `TERM=dumb`) and absent on piped streams. * - * termenv does NOT honor Node's `FORCE_COLOR` — only the `CLICOLOR*` pair — - * so neither does this gate. + * Node's `FORCE_COLOR` is not honored — only the `CLICOLOR*` pair. */ function supportsColor(stream: ColorStream): boolean { const env = process.env; @@ -38,28 +35,20 @@ function supportsColor(stream: ColorStream): boolean { } /** - * Ports of `utils.Aqua` / `utils.Bold`. + * Renders plain text when the stream is not a TTY (piped output, CI, tests). Node's + * `styleText` would handle that via `validateStream`, but Bun (1.3.14, the only runtime the + * CLI ships on) does not implement it — it styles unconditionally, even when the stream is + * piped and even under `NO_COLOR=1`. The gate is implemented here instead — see + * {@link supportsColor} — and `validateStream: false` is passed explicitly so this gate stays + * authoritative even if a future Bun starts validating. * - * The established behavior uses lipgloss, which auto-detects the output profile and renders **plain** - * text when the stream is not a TTY (piped output, CI, tests). Node's - * `styleText` would mirror that via `validateStream`, but Bun (1.3.14, the - * only runtime the CLI ships on) does not implement `validateStream`: it - * styles unconditionally, even when the stream is piped and even under - * `NO_COLOR=1`. The gate is therefore implemented here — see - * {@link supportsColor} — and `validateStream: false` is passed - * explicitly so that our gate stays authoritative even if a future Bun starts - * validating. + * `stream` defaults to `process.stderr` because every call site styles progress/suggestion + * lines written to stderr. A caller styling content written to stdout (e.g. `status`'s pretty + * table) must pass `process.stdout` explicitly — otherwise the TTY check runs against the + * wrong stream, and piping stdout while stderr stays a TTY would corrupt the piped output + * with ANSI escapes. * - * `stream` defaults to `process.stderr` because every original call site styles - * progress/suggestion lines written to stderr. A caller styling content that is - * itself written to **stdout** (e.g. `status`'s pretty table) must pass - * `process.stdout` explicitly — otherwise the TTY check runs against the wrong - * stream, and piping stdout while stderr stays a TTY (`supabase status | less`) - * would corrupt the piped output with ANSI escapes (the same bug class CLI-1546 - * fixed for the progress spinner). - * - * lipgloss colour "14" is bright cyan; `"cyan"` is the closest faithful match, - * matching `branches.prompt.ts`'s existing port of `utils.Aqua`. + * Uses `"cyan"`, the closest Node `styleText` colour to the established bright-cyan value. */ export function aqua(text: string, stream: ColorStream = process.stderr): string { return supportsColor(stream) ? styleText("cyan", text, { validateStream: false }) : text; @@ -69,17 +58,17 @@ export function bold(text: string, stream: ColorStream = process.stderr): string return supportsColor(stream) ? styleText("bold", text, { validateStream: false }) : text; } -/** Port of `utils.Yellow` — lipgloss colour "11" (bright yellow). */ +/** Renders in bright yellow. */ export function yellow(text: string, stream: ColorStream = process.stderr): string { return supportsColor(stream) ? styleText("yellow", text, { validateStream: false }) : text; } -/** Port of `utils.Red` — lipgloss colour "9" (bright red). */ +/** Renders in bright red. */ export function red(text: string, stream: ColorStream = process.stderr): string { return supportsColor(stream) ? styleText("red", text, { validateStream: false }) : text; } -/** Port of `utils.Green` — lipgloss colour "10" (bright green). */ +/** Renders in bright green. */ export function green(text: string, stream: ColorStream = process.stderr): string { return supportsColor(stream) ? styleText("green", text, { validateStream: false }) : text; } diff --git a/apps/cli/src/command-internal/colors.unit.test.ts b/apps/cli/src/command-internal/colors.unit.test.ts index 04bde6cb2f..44c7550096 100644 --- a/apps/cli/src/command-internal/colors.unit.test.ts +++ b/apps/cli/src/command-internal/colors.unit.test.ts @@ -3,21 +3,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { ColorStream } from "./colors.ts"; import { aqua, bold, green, red, yellow } from "./colors.ts"; -// Bun's `util.styleText` ignores `validateStream` (verified on Bun 1.3.14: a -// piped stdout still gets `\x1b[36m…\x1b[39m`, even under NO_COLOR=1), so -// `colors.ts` implements termenv's gate itself — the same decision -// order the established lipgloss-style default renderer uses. These tests pin -// that gate deterministically with fake -// streams and stubbed env vars; a piped stream (no `hasColors`) must yield -// PLAIN text, exactly like the established behavior under a piped stdout. +// Bun's `util.styleText` ignores `validateStream`, so `colors.ts` implements the colour gate +// itself. These tests pin that gate deterministically with fake streams and stubbed env vars. const colorTty: ColorStream = { hasColors: () => true }; const monoTty: ColorStream = { hasColors: () => false }; const piped: ColorStream = {}; beforeEach(() => { - // Neutralize the ambient environment (CI sets `CI`, developers may set - // NO_COLOR) so each case controls the gate's inputs exactly. Empty string - // reads as unset for every variable termenv consults. + // Neutralize the ambient environment (CI sets `CI`, developers may set NO_COLOR) so each + // case controls the gate's inputs exactly. Empty string reads as unset for every variable. vi.stubEnv("NO_COLOR", ""); vi.stubEnv("CLICOLOR", ""); vi.stubEnv("CLICOLOR_FORCE", ""); @@ -77,9 +71,8 @@ describe("colors TTY gating (termenv parity)", () => { }); it("defaults to gating on stderr when no stream is given", () => { - // The live TTY-ness of the test process's stderr is environment-dependent, - // so pin the gate closed via the CI branch: the default-stream form must - // still come back plain, proving the default threads through the gate. + // The test process's real stderr TTY-ness is environment-dependent, so pin the gate closed + // via CI to prove the default stream threads through the gate. vi.stubEnv("CI", "true"); expect(aqua("supabase")).toBe("supabase"); expect(bold("text")).toBe("text"); diff --git a/apps/cli/src/command-internal/config-pull-run.ts b/apps/cli/src/command-internal/config-pull-run.ts index 591ef884cd..4ef17d7449 100644 --- a/apps/cli/src/command-internal/config-pull-run.ts +++ b/apps/cli/src/command-internal/config-pull-run.ts @@ -81,20 +81,10 @@ import { } from "../commands/config/pull/pull.errors.ts"; /** - * `config pull`'s plan/apply run-core (CLI-1272): the pieces hoisted out of - * `commands/config/pull/pull.handler.ts`/`pull.format.ts` so the `supabase - * pull` orchestrator (`commands/pull/`) can drive the SAME plan, apply, and - * emission-adjacent formatting logic as the standalone `config pull` command - * without one command family importing another's command-local files - * (`code-structure.unit.test.ts`'s "prevents commands from importing other - * command internals" rule). `commands/config/pull/pull.handler.ts` still owns - * the CLI-facing `configPull` entry point and the reusable `runConfigPull` - * body (steps 1-4 and 9-15); this module owns everything a second caller also - * needs verbatim: opening the base config source (steps 2-3), planning (steps - * 5-8), applying (steps 12-13), and the render/payload builders both callers' - * confirmation/summary/machine output share. `pull.format.ts` re-exports the - * payload/render pieces that moved here so its own (unmodified) test suite - * keeps resolving them from the same path. + * `config pull`'s plan/apply core, hoisted out of `commands/config/pull/` so + * `commands/pull/` can reuse the same plan/apply/formatting logic without importing + * another command's internals. `pull.handler.ts` still owns the CLI entry point; + * `pull.format.ts` re-exports the payload/render pieces that moved here. */ export interface ConfigPullPlanRequest { @@ -115,15 +105,15 @@ export interface ConfigPullRunPlan { } /** - * The run's actual outcome, known only after the confirmation prompt (or - * `--dry-run`) resolves — layered on top of {@link ConfigPullPlan}, - * which only knows what WOULD be written. `dryRun` and `declined` are - * mutually exclusive: a `--dry-run` run never reaches the prompt. + * The run's actual outcome, known only after the confirmation prompt (or `--dry-run`) + * resolves — layered on top of {@link ConfigPullPlan}, which only knows what would be + * written. `dryRun` and `declined` are mutually exclusive: a `--dry-run` run never reaches + * the prompt. */ export interface ConfigPullOutcome { readonly dryRun: boolean; - /** The confirmation prompt (§1.4) was declined — every planned write - * becomes `skipped_reason: "declined"` instead of being applied. */ + /** The confirmation prompt was declined — every planned write becomes + * `skipped_reason: "declined"` instead of being applied. */ readonly declined: boolean; } @@ -138,38 +128,33 @@ export interface ConfigPullContext { * (`supabase/config.toml`). */ readonly configPath: string; readonly format: ConfigFormat; - /** Matched `[remotes.]` block the DIFF operand was merged from — - * independent of `destination` (a brand-new `[remotes.*]` block being - * CREATED has no applied overlay to diff against yet). Mirrors `config - * diff`'s own `target.local_scope`. */ + /** Matched `[remotes.]` block the diff operand was merged from — independent of + * `destination` (a brand-new `[remotes.*]` block being created has no applied overlay to + * diff against yet). Mirrors `config diff`'s own `target.local_scope`. */ readonly appliedRemote: string | undefined; readonly destination: ConfigPullDestination; } /** - * Version of the machine payload's own shape — bump when the payload - * contract changes incompatibly. A NEW payload, independent of `config - * diff`'s `CONFIG_DIFF_PAYLOAD_VERSION` (never bumped by this file). + * Version of the payload shape below; bump when the contract changes incompatibly. + * Independent of `config diff`'s `CONFIG_DIFF_PAYLOAD_VERSION`. */ export const CONFIG_PULL_PAYLOAD_VERSION = 1; /** - * A change's actual disposition once the confirmation prompt (and - * `--dry-run`) are known — broader than {@link ConfigPullSkipReason} - * (the pure PLANNING-time reason a change was never even attempted): a - * change `planConfigPull` planned to write still ends up unwritten when + * A change's actual disposition once the confirmation prompt (and `--dry-run`) are known — + * broader than {@link ConfigPullSkipReason} (the pure planning-time reason a change was never + * even attempted): a change `planConfigPull` planned to write still ends up unwritten when * the run is a dry run or the user declined. */ type ConfigPullChangeSkipReason = ConfigPullSkipReason | "declined" | "dry_run"; /** - * The collision message (`ConfigPullRemoteLabelCollisionError`) — - * worded differently depending on WHICH of `pull.scope.ts`'s two - * `label_collision` situations applies, and whether the label came from an - * explicit `--remote-label` or was derived from a branch name (only - * `--remote-label` can ever reach the "a DIFFERENT block already tracks this - * ref" situation — see `resolveConfigPullDestination`'s own doc - * comment for why a branch-derived label never does). + * The collision message (`ConfigPullRemoteLabelCollisionError`), worded differently + * depending on which of `pull.scope.ts`'s two `label_collision` situations applies and + * whether the label came from an explicit `--remote-label` or a branch name (only + * `--remote-label` can reach the "a different block already tracks this ref" situation — see + * `resolveConfigPullDestination`'s own doc comment for why a branch-derived label never does). */ function configPullLabelCollisionMessage( scopeResult: ConfigPullScopeLabelCollision, @@ -233,50 +218,15 @@ function configPullRefusalRemediation(reason: ConfigEditRefusalReason): string { } /** - * Plan §1.9's convergence check — run once the fixpoint expansion - * (`expandConfigPullChangeSet`, `pull.plan.ts`) has settled, and BEFORE - * `--dry-run` returns (a planner defect must be caught even in a preview - * run) — against the fixpoint's OWN residual (the last round's re-diff, i.e. - * the state once every currently-planned write has been applied). - * - * A residual change at a path this run just planned to write means the write - * didn't actually converge — a defect in THIS command's own planner, never a - * user-facing condition, surfaced as a typed `ConfigPullPlanDefectError` - * (`impossibleState`) rather than a crash: nothing has been written yet at - * this point (this check runs BEFORE the dry-run/prompt/write/validation - * steps), so the error can truthfully say so. A residual `unmanaged` path - * would mean the very value this run just wrote made itself invisible to the - * projection again. Most surviving `@supabase/config` prunes - * (`DISABLED_SENTINEL_PRUNES`, most of `applyRawPresenceMask`) are - * conditional on exactly the state a write establishes — a container's OWN - * decoded `enabled`, or the raw file's OWN declared-ness — so writing a - * value usually satisfies the very condition that would otherwise hide it - * again. CLI-2314 retired the one `DISABLED_SENTINEL_PRUNES`-family prune - * that didn't have this property (`auth.oauth_server`'s old unconditional - * removal, ignoring what had just been written) — see ADR 0021's CLI-2314 - * addendum; `pull.integration.test.ts`'s "no longer trips the ADR 0021 - * unpushable warning" case pins the resulting behavior for that family. This - * branch DOES still have a known live trigger, though, via a DIFFERENT - * cross-path prune: `applyDisabledSentinels`'s own "cross-section rule" - * (`project-config.ts`, search "Cross-section rule: the email rate limit") - * deletes `auth.rate_limit.email_sent` whenever `auth.email.smtp.enabled` is - * explicitly `false` — checked on BOTH arms, not gated by raw presence. On - * the API arm this only spares `email_sent` when the response is genuinely - * SPARSE (never reports `smtp_host` at all, so `enabled` decodes as absent - * rather than an explicit `false`) — an ordinary response with - * `smtp_host: ""` (the common "SMTP not configured" shape) still prunes it - * there too, leaving nothing to diff. So the live trigger is narrow: a - * sparse remote response reporting a real `rate_limit_email_sent` while - * omitting `smtp_host` entirely, pulled into a LOCAL document that also - * never declares `[auth.email.smtp]` (so `applyRawPresenceMask` masks the - * just-written value again on the residual check) — see - * `pull.integration.test.ts`'s matching case for the exact construction. - * Retained as a structural safety net for any OTHER asymmetric/cross-path - * prune too, not dead code: surfaced as a `"unpushable"` warning, reusing - * the SAME - * `plan.warnings` / `renderConfigPullText` "Warnings:" hook the - * planner's own `dual_scope`/`duplicates_root`/`array_drift` warnings - * already render through, rather than adding a new payload field. + * Convergence check run after the fixpoint expansion settles, before `--dry-run` returns, + * against the fixpoint's own residual (the last round's re-diff). A residual change at a + * path this run just planned to write means the write didn't converge — a planner defect, + * surfaced as `ConfigPullPlanDefectError` rather than a crash, since nothing has been written + * yet. A residual `unmanaged` path instead means the value this run just wrote made itself + * invisible to the projection again; that's a known, if rare, side effect of a cross-path + * config prune (see `applyDisabledSentinels`'s cross-section rule in `project-config.ts`), so + * it's surfaced as an `"unpushable"` warning through the same `plan.warnings` hook rather than + * treated as a defect. */ function configPullDefectAndUnpushableCheck( plan: ConfigPullPlan, @@ -311,12 +261,10 @@ function configPullDefectAndUnpushableCheck( } /** - * Builds the FULL raw, on-disk-shaped document `config pull`'s - * schema-validation gate decodes: `rawDocument` (`remotes` intact, - * pre-`env()`-interpolation — the same shape `applyConfigEdits` edits) with - * `writes`' `documentPath`s applied, plus the new block's `project_id` when - * this plan creates one — mirroring step 13's real `edits` array exactly, so - * what gets validated here is what would actually be written. + * Builds the raw, on-disk-shaped document the schema-validation gate decodes: + * `rawDocument` with the planned `writes`' `documentPath`s applied, plus the new block's + * `project_id` when this plan creates one — so validation runs against what would actually + * be written. */ function configPullValidationDocument( rawDocument: Readonly>, @@ -334,13 +282,9 @@ function configPullValidationDocument( } /** - * Restricts a document to the subtree a `ConfigChange.path` (hosted-config, - * destination-agnostic) is relative to: itself for a root destination, or - * `document.remotes[label]` for a `[remotes.*]` destination — the inverse of - * `documentPathFor` (`pull.plan.ts`), needed because the schema-validation - * gate's failing paths (and the pre-write raw document it looks up an env() - * spelling in) must be read in the SAME namespace `ConfigChange.path`/the - * plan's family-root helpers already use. + * Restricts a document to the subtree a `ConfigChange.path` is relative to: itself for a + * root destination, or `document.remotes[label]` for a `[remotes.*]` destination. The + * inverse of `documentPathFor` (`pull.plan.ts`). */ function configPullChangeRelativeValue( document: unknown, @@ -354,27 +298,12 @@ function configPullChangeRelativeValue( } /** - * A failed {@link decodeCliConfigDocumentForValidationEffect} attempt's own - * `SchemaIssue` paths, converted to `ConfigChange.path`-relative ("change - * path") segments — the SAME destination-agnostic namespace `write.change.path` - * already lives in, regardless of where a write physically lands in the - * document. - * - * `isLabelPrefixed` picks between the TWO shapes a decode's own issue paths - * can take — entirely independent of `destination.kind`, since it is the - * PROJECTION (raw vs. `remoteName`-merged; see - * {@link validateConfigPullPlan}) that determines this, not the - * destination: the RAW/unmerged projection of a REMOTE destination decodes - * the whole `remotes` map through `RemotesSchema` (`disableChecks: true`), - * whose own issue paths start with the map's OWN key — the label itself, not - * the literal word `remotes` — so dropping that one leading segment recovers - * the change-path form; every OTHER case (a root destination's raw - * projection, or ANY destination's `remoteName`-merged projection, which - * decodes the merged document at the schema ROOT) already reports - * change-path-relative paths, nothing to strip. Not a `SchemaError` at all - * (should not happen — this only ever runs against a `CliConfigParseError` - * this same module's own decode calls produced) yields no paths, which - * callers treat as "could not attribute this failure". + * Converts a failed {@link decodeCliConfigDocumentForValidationEffect} attempt's + * `SchemaIssue` paths into `ConfigChange.path`-relative segments. `isLabelPrefixed` strips + * one leading segment for the raw/unmerged projection of a remote destination, whose issue + * paths start with the `remotes` map's own key (the label) rather than already being + * change-path-relative like every other case. Returns no paths for a non-`SchemaError` + * cause, which callers treat as "could not attribute this failure". */ function configPullSchemaIssueChangePaths( cause: CliConfigParseError, @@ -402,18 +331,12 @@ function configPullSchemaIssueChangePaths( } /** - * Groups already-resolved change-paths (`configPullSchemaIssueChangePaths`, - * already filtered by the caller to exclude every PRE-EXISTING failure) by - * `configPullFamilyRootForPath`'s nearest-enclosing-table rule, - * enriching each with its local `env(VAR)` spelling when it has one. - * `relativeValidation`/`relativeRaw` are always the DESTINATION-relative view - * (`configPullChangeRelativeValue`) — the subtree a change-path is - * actually relative to on disk — regardless of which projection (raw or - * `remoteName`-merged) reported the failure: a merged projection's own issue - * paths already arrive change-path-relative (see the sibling function - * above), but the SHAPE this function reads off `document` (an enclosing - * "family" table, a field's local raw spelling) lives at the same - * destination-relative location either way. + * Groups already-resolved change-paths (already filtered by the caller to exclude every + * pre-existing failure) by `configPullFamilyRootForPath`'s nearest-enclosing-table rule, + * enriching each with its local `env(VAR)` spelling when it has one. `relativeValidation`/ + * `relativeRaw` are always the destination-relative view + * ({@link configPullChangeRelativeValue}), regardless of which projection reported the + * failure. */ function configPullFamiliesForChangePaths( changePaths: ReadonlyArray>, @@ -449,15 +372,10 @@ function configPullFamiliesForChangePaths( } /** - * Runs {@link decodeCliConfigDocumentForValidationEffect}, capturing only ITS - * OWN `CliConfigParseError` failure into a `Result` the caller inspects (the - * schema-validation gate's "did this decode" check) — `CliProjectEnvParseError`/ - * `PlatformError` (a genuinely malformed `.env`/`.env.local`, or a filesystem - * failure reading one) are not decode-ATTRIBUTION failures at all, so they are - * left in the returned Effect's error channel to propagate uncaught, exactly - * like the SAME two failures already do from the real `loadCliConfig` call - * this command's own initial load makes (`makeConfigLoader` only ever catches - * `CliConfigParseError`/`DuplicateRemoteProjectIdError` there too). + * Runs {@link decodeCliConfigDocumentForValidationEffect}, capturing only its own + * `CliConfigParseError` failure into a `Result`. A genuinely malformed `.env`/`.env.local`, + * or a filesystem failure reading one, is not a decode-attribution failure, so those + * propagate uncaught, matching how the real `loadCliConfig` call already handles them. */ function decodeConfigPullValidation( document: Record, @@ -471,16 +389,12 @@ function decodeConfigPullValidation( /** * The change-path keys ({@link configPathKey}) that already fail - * {@link decodeCliConfigDocumentForValidationEffect} in `rawDocument` AS IT - * SITS ON DISK RIGHT NOW — before this pull's own writes are projected onto - * it. {@link validateConfigPullPlan} excludes every one of these from - * the families it forms: the file was already in that state, so pull - * attributing the failure to its own plan, dropping a write over it, or - * failing the whole command over it would all be wrong — pulling only ever - * needs to leave the file NO WORSE than it already was. Runs the SAME two - * projections the round-by-round gate below runs (raw, plus - * `remoteName`-merged for a remote destination), so a pre-existing failure - * that only surfaces once a `[remotes.*]` block is SELECTED is exempted too. + * {@link decodeCliConfigDocumentForValidationEffect} in `rawDocument` as it sits on disk + * right now, before this pull's own writes are projected. {@link validateConfigPullPlan} + * excludes these from the families it forms — pulling should never attribute, drop, or fail + * over a pre-existing problem; it only needs to leave the file no worse than it was. Runs + * both projections (raw, plus `remoteName`-merged for a remote destination) so a failure + * that only surfaces once a `[remotes.*]` block is selected is exempted too. */ const configPullPreExistingFailingChangePathKeys = Effect.fnUntraced(function* (input: { readonly rawDocument: Readonly>; @@ -518,38 +432,19 @@ const configPullPreExistingFailingChangePathKeys = Effect.fnUntraced(function* ( return keys; }); -/** Cap on how many times {@link validateConfigPullPlan} drops a family - * and re-validates — die-free: hitting the cap fails the whole command with a - * typed `ConfigPullValidationFailedError` rather than writing (per that - * error's own doc comment, reaching the cap "shouldn't happen", since - * dropping a family always restores a state that loaded before this pull - * ran). */ +/** Cap on how many times {@link validateConfigPullPlan} drops a family and re-validates; + * hitting it fails the command with `ConfigPullValidationFailedError` rather than writing. */ const CONFIG_PULL_VALIDATION_ROUND_CAP = 4; /** - * `config pull`'s schema-validation gate (CLI-2064's live-bug fix, layer - * 2): pull must NEVER write a file the CLI itself cannot load. Before the - * TOCTOU re-read/write, decodes the projected FINAL document (every planned - * write, plus a new block's `project_id`, applied to the raw on-disk - * document) through the real `CliConfigSchema` decode, resolving `env(VAR)` - * EXACTLY as the next `loadCliConfig` call will (`decodeCliConfigDocumentForValidationEffect` - * — process env layered with the project's own `.env`/`.env.local`, not bare - * `process.env`). A `[remotes.*]` destination additionally validates the - * `remoteName`-merged projection — the same overlay a future `loadCliConfig` - * targeting THIS project ref applies before its own checks-enabled decode — - * since a written block can pass the raw/unmerged check (remotes decode with - * business-rule checks disabled) yet still fail once actually selected. - * - * A decode failure whose change-path already failed in `rawDocument` BEFORE - * this pull touched it ({@link configPullPreExistingFailingChangePathKeys}) - * is PRE-EXISTING: never attributed to this plan, never dropped, never a - * reason to fail the command — the file was already in that state, and pull - * leaves it no worse. Only a NEW failing change-path drives the drop below: - * every write under its nearest enclosing family/provider table - * (`dropConfigPullUnvalidatableFamilies`), re-validated, repeating up - * to {@link CONFIG_PULL_VALIDATION_ROUND_CAP} times; if validation - * still fails on a NEW path once nothing more can be dropped, fails the whole - * command (`ConfigPullValidationFailedError`) rather than write. + * `config pull`'s schema-validation gate: decodes the projected final document the way the + * next `loadCliConfig` call will (including a `[remotes.*]` destination's `remoteName`-merged + * projection, since a block can pass the raw check yet still fail once selected), and never + * writes a file the CLI itself couldn't load. A failure already present in + * {@link configPullPreExistingFailingChangePathKeys} is ignored; a new one drops its + * enclosing family ({@link dropConfigPullUnvalidatableFamilies}) and retries, up to + * {@link CONFIG_PULL_VALIDATION_ROUND_CAP} times, else fails the command + * (`ConfigPullValidationFailedError`) instead of writing. */ const validateConfigPullPlan = Effect.fnUntraced(function* (input: { readonly plan: ConfigPullPlan; @@ -638,17 +533,9 @@ const validateConfigPullPlan = Effect.fnUntraced(function* (input: { }); }); -/** Builds the file-load helpers for one `cliSettings.workdir` — a small - * factory rather than a shared closure so both `openConfigPullSource` - * (steps 2-3) and `planConfigPullRun` (step 6's conditional reload) get - * their own, independently testable copy without threading `cliSettings` - * through `ConfigPullInput`. Narrowed to `workdir` + - * `explicitWorkdir` (rather than the full `CommandSettings` shape) since - * that's all `loadLocalConfig` (`config.load.ts`, shared with - * `config diff`/`config push`) needs — it owns the parse/duplicate-remote/ - * missing-file message shapes and the ancestor-search decision - * (`shouldSearchAncestors`); only this family's own tagged error class - * is local. */ +/** Builds the file-load helpers for one `cliSettings.workdir`, narrowed to `workdir` and + * `explicitWorkdir` since that's all `loadLocalConfig` needs; only this family's own tagged + * error class is local. */ function makeConfigLoader(cliSettings: { readonly workdir: string; readonly explicitWorkdir: boolean; @@ -667,11 +554,9 @@ function makeConfigLoader(cliSettings: { } /** - * The paired base config load + its exact on-disk text, produced ONLY by - * {@link openConfigPullSource} — never assembled by hand elsewhere, so - * "loaded with NO `[remotes.*]` overlay" and "text read from the SAME path - * immediately after that load" are true by construction, not by caller - * convention. + * The paired base config load and its exact on-disk text, produced only by + * {@link openConfigPullSource} so both properties — no `[remotes.*]` overlay, and text read + * from the same path right after the load — hold by construction, not caller convention. */ export interface ConfigPullSource { readonly loaded: LoadedCliConfig; @@ -679,18 +564,12 @@ export interface ConfigPullSource { } /** - * Opens `config pull`'s base config source (`configPull` steps 2-3): - * loads the local config with NO `[remotes.*]` overlay applied — the overlay - * is keyed by the RESOLVED target ref, applied later inside - * `runConfigPull` step 6 only when block reuse selects it — then takes - * `loaded.rawText` (`@supabase/config`'s own capture of the exact bytes it - * parsed) as this pull's baseline text, rather than reading the file a - * second time: a separate read here would reopen a window for a concurrent - * edit to land BETWEEN the parsed load and that read, silently becoming the - * accepted baseline while the plan below is computed against the (now - * stale) parsed values. The SAME bytes `applyConfigEdits` edits later - * (step 13), and the baseline `runConfigPull` re-reads before writing - * to detect a concurrent edit (step 12). + * Opens `config pull`'s base config source: loads the local config with no `[remotes.*]` + * overlay (applied later, only when block reuse selects an existing block), then uses + * `loaded.rawText` as this pull's baseline text instead of reading the file again — a second + * read would reopen a window for a concurrent edit to land between the parsed load and that + * read, silently becoming the accepted baseline while the plan below is computed against the + * now-stale parsed values. */ export const openConfigPullSource = Effect.fnUntraced(function* () { const cliSettings = yield* CommandSettings; @@ -699,10 +578,8 @@ export const openConfigPullSource = Effect.fnUntraced(function* () { const loaded = yield* loadConfig(undefined); if (loaded.rawText === undefined) { - // The loader contract guarantees `rawText` for any file it actually - // parsed off disk — reaching this would mean that contract broke. Fail - // the same way a genuine concurrent edit does, rather than falling back - // to a second read that would reopen the exact race this baseline + // The loader guarantees `rawText` for any file it parsed off disk; treat this like a + // concurrent edit rather than re-reading, which would reopen the race this baseline // exists to close. return yield* new ConfigPullFileChangedError({ message: `${toRelativeConfigPath(loaded.path)} could not be read: the config loader returned no on-disk text. Rerun the command.`, @@ -713,14 +590,12 @@ export const openConfigPullSource = Effect.fnUntraced(function* () { }); /** - * Steps 5-8 of `config pull` (plan §1.6's plan/apply split): destination - * resolution, the conditional `[remotes.*]`-overlay reload, the remote fetch, - * the fixpoint-expanded diff/plan, and the planner-defect/schema-validation - * gate. Prints the "Pulling config from …" destination line and the - * "Comparison scope: …" line to stderr exactly once, here — never repeated by - * a caller. Never runs the git dirty guard, never prompts, never writes, and - * never calls `output.success`/`emitOutcome`; the caller decides what to do - * with the returned {@link ConfigPullRunPlan}. + * Resolves the pull destination, reloads with the `[remotes.*]` overlay when block reuse + * selects an existing block, fetches the remote config, and runs the fixpoint-expanded + * diff/plan plus the planner-defect/schema-validation gate. Prints the destination and + * comparison-scope lines to stderr exactly once. Never runs the git dirty guard, prompts, + * writes, or calls `output.success`; the caller decides what to do with the returned + * {@link ConfigPullRunPlan}. */ export const planConfigPullRun = Effect.fnUntraced(function* (request: ConfigPullPlanRequest) { const output = yield* Output; @@ -729,9 +604,6 @@ export const planConfigPullRun = Effect.fnUntraced(function* (request: ConfigPul const { ref, branch } = request.target; const { loadConfig, toRelativeConfigPath } = makeConfigLoader(cliSettings); - // 5. Resolve WHERE this pull writes (root vs. an existing/new - // `[remotes.*]` block) — pure, no network call — then print the - // destination line to stderr BEFORE any network call. const branchLabelCandidate = branch !== undefined && !BRANCH_UUID_PATTERN.test(branch) ? branch : undefined; const scopeResult = resolveConfigPullDestination({ @@ -755,8 +627,7 @@ export const planConfigPullRun = Effect.fnUntraced(function* (request: ConfigPul const destination = scopeResult.destination; yield* output.raw(configPullDestinationLine({ projectRef: ref, branch }, destination), "stderr"); - // 6. Reload WITH the `[remotes.*]` overlay only when block reuse selected - // an EXISTING block — a brand-new block has nothing to overlay yet. + // A brand-new block has nothing to overlay yet. let loaded = request.source.loaded; if (destination.kind === "remote" && !destination.created) { loaded = yield* loadConfig(ref); @@ -772,10 +643,8 @@ export const planConfigPullRun = Effect.fnUntraced(function* (request: ConfigPul destination, }; - // 7. Fetch the effective remote config — verbatim from `config diff` - // (ADR 0019 rule 2: `executeRaw` + lenient decode boundary; the caller - // owns the status check, `fromApiProjectConfig`'s lenient decode owns the - // body). + // See ADR 0019 rule 2: `executeRaw` + lenient decode; the caller owns the status check, + // `fromApiProjectConfig`'s lenient decode owns the body. const fetching = output.format === "text" ? yield* output.task("Fetching remote config...") : undefined; const response = yield* api.executeRaw(operationDefinitions.v2GetProjectConfig, { ref }).pipe( @@ -808,9 +677,8 @@ export const planConfigPullRun = Effect.fnUntraced(function* (request: ConfigPul ); yield* fetching?.clear() ?? Effect.void; - // Project the response through CLI-2230's convergence normalizer (ADR - // 0021) and classify — same typed/defect boundary as `config diff` - // (`configProjectConfigTry`, shared across the `config` family). + // Normalizes and classifies the response through the config family's shared + // typed/defect boundary (ADR 0021). const remote = yield* configProjectConfigTry(() => fromApiProjectConfig(responseJson)); const initialChangeSet = yield* configProjectConfigTry(() => diffProjectConfig({ local: loaded, remote }), @@ -822,15 +690,9 @@ export const planConfigPullRun = Effect.fnUntraced(function* (request: ConfigPul ); yield* output.raw(configScopeLine(scope), "stderr"); - // 8. Fixpoint-expand the diff (plan §1.9, extended by CLI-2064's live-bug - // fix): projecting a round's writes can un-gate a sibling ADR 0021's - // disabled-provider gates would otherwise have excluded as unmanaged (e.g. - // flipping a disabled SMS provider's `enabled` on un-gates its credential - // siblings) — `expandConfigPullChangeSet` repeats until nothing new - // appears. Plan the fully-expanded writes, check for a planner defect / - // surface unpushable notes against the fixpoint's own residual (unchanged - // from before), then run the schema-validation gate (layer 2): pull must - // never write a file the CLI itself cannot load. + // Projecting a round's writes can un-gate sibling fields ADR 0021 otherwise excludes as + // unmanaged (e.g. flipping a disabled SMS provider's `enabled` on un-gates its credential + // fields), so `expandConfigPullChangeSet` repeats until nothing new appears. const fixpoint = yield* configProjectConfigTry(() => expandConfigPullChangeSet({ initialChangeSet, @@ -857,10 +719,8 @@ export const planConfigPullRun = Effect.fnUntraced(function* (request: ConfigPul format: loaded.format, }); - // `hasBlockToCreate` is why this is `hasWork`, not merely - // `writes.length === 0`: a zero-drift branch target still has WORK to do - // (creating the block), so it must reach the git guard/confirmation like - // any other write (CLI-2064 bug B). + // A zero-drift branch target can still have work to do — creating the block — so it must + // reach the git guard/confirmation like any other write. const hasBlockToCreate = finalPlan.createdTable !== undefined; const hasWork = finalPlan.writes.length > 0 || hasBlockToCreate; @@ -875,11 +735,9 @@ export const planConfigPullRun = Effect.fnUntraced(function* (request: ConfigPul }); /** - * Steps 12-13 of `config pull`: the TOCTOU re-read against - * {@link ConfigPullSource.text} (someone may have edited the file - * while the confirmation prompt was on screen), `applyConfigEdits`, and the - * atomic write. No emission — the caller renders the final summary/payload - * once this succeeds. + * The TOCTOU re-read against {@link ConfigPullSource.text} (someone may have edited the file + * while the confirmation prompt was on screen), `applyConfigEdits`, and the atomic write. No + * emission — the caller renders the final summary/payload once this succeeds. */ export const applyConfigPullRun = Effect.fnUntraced(function* (input: { readonly runPlan: ConfigPullRunPlan; @@ -888,8 +746,6 @@ export const applyConfigPullRun = Effect.fnUntraced(function* (input: { const fs = yield* FileSystem.FileSystem; const { plan, context, configFilePath } = input.runPlan; - // 12. Re-read and compare against the step-3 baseline — someone may have - // edited the file while the prompt was on screen. const currentText = yield* fs.readFileString(configFilePath).pipe( Effect.catchTag( "PlatformError", @@ -905,16 +761,11 @@ export const applyConfigPullRun = Effect.fnUntraced(function* (input: { }); } - // 13. Apply and write. When this pull CREATES a new `[remotes.( thunk: () => A, diff --git a/apps/cli/src/commands/config/config.project-config.unit.test.ts b/apps/cli/src/commands/config/config.project-config.unit.test.ts index 74a63c85b1..d82138c31d 100644 --- a/apps/cli/src/commands/config/config.project-config.unit.test.ts +++ b/apps/cli/src/commands/config/config.project-config.unit.test.ts @@ -1,11 +1,6 @@ /** - * Unit tests for `configProjectConfigTry` — the shared try-helper for - * `@supabase/config`'s convergence calls (`config.project-config.ts`). - * - * Every real call site (`config diff`, `config pull`, `config push`) is - * covered end to end by its own integration suite; the one branch integration - * coverage cannot exercise directly is the defect arm, so it gets its own - * focused test here. + * Unit tests for `configProjectConfigTry`. Real call sites are covered by their own + * integration suites; the defect arm isn't reachable there, so it gets a focused test here. */ import { describe, expect, it } from "@effect/vitest"; diff --git a/apps/cli/src/commands/config/config.read-status.ts b/apps/cli/src/commands/config/config.read-status.ts index c019d5ce47..71153b1ba5 100644 --- a/apps/cli/src/commands/config/config.read-status.ts +++ b/apps/cli/src/commands/config/config.read-status.ts @@ -5,23 +5,11 @@ import { } from "../../command-internal/http-errors.ts"; /** - * Purpose-written messages for the status codes a wrong or inaccessible ref - * most plausibly produces when reading `GET /v2/projects/{ref}/config` — - * shared by `config diff`, `config pull`, and `config push`, since all three - * read the same endpoint and a bad ref/token fails the same way for each. - * Every other status falls back to `unexpectedStatusMessage`. - * - * `apiHost` (the CLI's own resolved `cliSettings.apiUrl`, not anything the - * response body names) hedges the 404 case: `config push` is an established - * command that used to hit six long-lived v1 endpoints, so a 404 here can - * also mean this v2 endpoint isn't served by the configured API host at all - * (an older self-hosted Management API, a proxy, a `SUPABASE_PROFILE` - * pointing elsewhere) rather than a wrong project ref. `apiUrl` traces back - * to a `SUPABASE_PROFILE` YAML file's `api_url:` value, which is validated - * as a well-formed `http(s)://` URL but not stripped of embedded control - * characters (`profile-load.ts` returns the raw matched string, not - * a re-serialized one) — sanitized the same way `ref` already is, so a - * crafted profile can't inject terminal control sequences via this message. + * Purpose-written messages for the status codes reading `GET /v2/projects/{ref}/config` most + * plausibly produces, shared by `config diff`/`pull`/`push`. The 404 case also hedges on + * `apiHost`, since a misconfigured API host may not serve this endpoint at all — and sanitizes + * it (not guaranteed free of control characters) like `ref`, so a crafted profile can't inject + * terminal escape sequences. Everything else falls back to `unexpectedStatusMessage`. */ export function configReadStatusMessage( status: number, diff --git a/apps/cli/src/commands/config/config.read-status.unit.test.ts b/apps/cli/src/commands/config/config.read-status.unit.test.ts index b6c1e6835b..e554bdccfc 100644 --- a/apps/cli/src/commands/config/config.read-status.unit.test.ts +++ b/apps/cli/src/commands/config/config.read-status.unit.test.ts @@ -25,11 +25,6 @@ describe("configReadStatusMessage", () => { }); test("404 strips control characters from a hostile api host before embedding it inline", () => { - // `apiHost` traces back to a `SUPABASE_PROFILE` YAML file's `api_url:` - // value — validated as a well-formed `http(s)://` URL, but not stripped - // of embedded control characters (`profile-load.ts` keeps the raw - // matched string). A crafted profile must not be able to inject terminal - // control sequences via this message, same as `ref` already can't. const hostileHost = "https://api.supabase.com\x1b[31mFAKE\x1b[0m"; const message = configReadStatusMessage(404, '{"message":"not found"}', REF, hostileHost); expect(message).not.toContain("\x1b"); diff --git a/apps/cli/src/commands/config/diff/diff.command.ts b/apps/cli/src/commands/config/diff/diff.command.ts index 364c465e63..cf6a47e707 100644 --- a/apps/cli/src/commands/config/diff/diff.command.ts +++ b/apps/cli/src/commands/config/diff/diff.command.ts @@ -10,8 +10,7 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { configDiff } from "./diff.handler.ts"; const config = { - // `link`'s settled vocabulary (CLI-2167): one flag that accepts either a - // project ref or a branch of the linked project — no separate `--target`. + // Accepts either a project ref or a branch of the linked project; no separate `--target` flag. projectRef: Flag.string("project-ref").pipe( Flag.withDescription( "Project ref of the Supabase project, or the name (or UUID) of one of its branches. Values that are exactly 20 lowercase letters are always treated as project refs.", @@ -22,37 +21,27 @@ const config = { Flag.withDescription( "Exit with status 2 when any difference is found (errors keep exiting 1).", ), - // Without an explicit default a boolean flag is REQUIRED by the parser, - // making plain `supabase config diff` fail with `required flag(s) - // "exit-code" not set` — pinned by diff.e2e.test.ts, since integration - // tests hand the handler a pre-built flags object and never parse. + // Boolean flags are required by the parser without an explicit default, which would break + // plain `supabase config diff` with a missing-required-flag error. Flag.withDefault(false), ), } as const; export type ConfigDiffFlags = CliCommand.Command.Config.Infer; -// Exported so integration tests can drive the exact wiring -// `Command.withHandler` uses below (same precedent as `configPushHandler`). export const configDiffHandler = (flags: ConfigDiffFlags) => configDiff(flags).pipe( - // `--project-ref` accepts branch names here (CLI-2167 vocabulary), so - // its value is only safe to log verbatim when it is actually ref-shaped - // — a user-created branch name must never reach PostHog. Same guard as - // `link`. + // `--project-ref` also accepts branch names, so its value is safe to log only when it's + // actually ref-shaped — a user-created branch name must never reach PostHog. withCommandTelemetry({ flags, safeFlags: Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) ? ["project-ref"] : [], - // Net-new TS command, no Go parity contract (CLI-2156): the handler - // itself rejects every `-o/--output` value with a message pointing at - // `--output-format`, so the full global choice set — single-sourced - // from the flag's own definition — is declared "allowed" here. - // Otherwise the wrapper's own per-command enum check would reject an - // out-of-set value (e.g. `-o table`) with its generic pflag-style - // message before the handler ever gets a chance to run. + // This command rejects every `-o/--output` value itself with a message pointing at + // `--output-format`, so the wrapper must allow the full choice set through — otherwise its + // own generic enum-check message would fire first and the handler would never run. outputFormats: GLOBAL_OUTPUT_FORMATS, }), withJsonErrorHandling, diff --git a/apps/cli/src/commands/config/diff/diff.e2e.test.ts b/apps/cli/src/commands/config/diff/diff.e2e.test.ts index 358649768f..b1178db5ec 100644 --- a/apps/cli/src/commands/config/diff/diff.e2e.test.ts +++ b/apps/cli/src/commands/config/diff/diff.e2e.test.ts @@ -5,20 +5,12 @@ import { describe, expect, test } from "vitest"; import { runSupabase } from "../../../../tests/helpers/cli.ts"; -// A fake-but-well-formed token (bypasses the auth-layer's own eager -// `SUPABASE_ACCESS_TOKEN` check, same precedent as push's e2e test) so the -// run reaches this command's own handler instead of failing generically on -// "Access token not provided" first. +// A fake-but-well-formed token bypasses the auth layer's eager `SUPABASE_ACCESS_TOKEN` check, +// so the run reaches this command's handler instead of failing on "Access token not provided". const TEST_TOKEN = "sbp_" + "a".repeat(40); describe("config diff CLI surface", () => { test("plain `config diff` parses — no boolean flag is accidentally required", async () => { - // Parser-level regression pin (PR #6295 review): a `Flag.boolean` without - // `Flag.withDefault(false)` is a REQUIRED flag, so the help's own first - // example (`supabase config diff`) failed with `required flag(s) - // "exit-code" not set`. Integration tests hand the handler a pre-built - // flags object and never exercise the parser, so this must be pinned at - // the subprocess boundary. const cwd = await mkdtemp(join(tmpdir(), "supabase-config-diff-e2e-")); try { const { stdout, stderr } = await runSupabase(["config", "diff"], { @@ -27,11 +19,8 @@ describe("config diff CLI surface", () => { }); const combined = `${stdout}\n${stderr}`; expect(combined).not.toContain("required flag"); - // Positive anchor: this hermetic cwd has no `supabase/config.toml` or - // `supabase/config.json`, so a run that actually reached the handler - // (not just a binary-launch failure) surfaces this exact load error. - // A vacuously-green regression here (e.g. the binary failing to start - // at all) would NOT print this fragment. + // This hermetic cwd has no config file, so reaching the handler (not just a binary-launch + // failure) surfaces this load-error text — guarding against a vacuously green regression. expect(combined).toContain( "failed to read supabase/config.toml or supabase/config.json: file not found. Run `supabase init` to create one.", ); diff --git a/apps/cli/src/commands/config/diff/diff.errors.ts b/apps/cli/src/commands/config/diff/diff.errors.ts index 29219ea111..d025953d27 100644 --- a/apps/cli/src/commands/config/diff/diff.errors.ts +++ b/apps/cli/src/commands/config/diff/diff.errors.ts @@ -40,11 +40,7 @@ export class ConfigDiffWorkdirError extends Data.TaggedError("ConfigDiffWorkdirE } } -/** - * The Go-compat global `-o/--output` flag was passed. `config diff` is a - * net-new TS command with no Go parity contract, so machine output goes - * through `--output-format` only (per Colum on CLI-2156). - */ +/** The `-o/--output` flag was passed; this command only supports `--output-format`. */ export class ConfigDiffOutputFlagUnsupportedError extends Data.TaggedError( "ConfigDiffOutputFlagUnsupportedError", )<{ readonly message: string }> { @@ -60,27 +56,23 @@ export const ConfigDiffBranchNotFoundError = targetErrors.BranchNotFoundError; export type ConfigDiffBranchNotFoundError = InstanceType; /** - * `--project-ref` named a branch (by name), but no project is linked to - * search for branches under — none of `SUPABASE_PROJECT_ID`, - * `supabase/.temp/linked-project.json`, or `supabase/.temp/project-ref` - * yielded a candidate. Mirrors `LinkBranchNotLinkedError`'s - * classification (link.errors.ts). + * `--project-ref` named a branch by name, but no project is linked to search branches under — + * none of `SUPABASE_PROJECT_ID`, `supabase/.temp/linked-project.json`, or + * `supabase/.temp/project-ref` yielded a candidate. Mirrors `LinkBranchNotLinkedError`. */ export const ConfigDiffBranchNotLinkedError = targetErrors.BranchNotLinkedError; export type ConfigDiffBranchNotLinkedError = InstanceType; /** - * `--project-ref` named a branch (by name), and a parent-project candidate - * exists but is not ref-shaped — corrupt or stale linked state. Mirrors - * `LinkParentRefInvalidError`'s classification (link.errors.ts). + * `--project-ref` named a branch by name, and a parent-project candidate exists but isn't + * ref-shaped — corrupt or stale linked state. Mirrors `LinkParentRefInvalidError`. */ export const ConfigDiffParentRefInvalidError = targetErrors.ParentRefInvalidError; export type ConfigDiffParentRefInvalidError = InstanceType; /** - * The resolved branch has no project ref yet (still provisioning) — guards - * against an empty/placeholder ref reaching `/v2/projects//config`. Mirrors - * `LinkBranchNotReadyError`'s classification (link.errors.ts). + * The resolved branch has no project ref yet (still provisioning), guarding against an + * empty ref reaching `/v2/projects//config`. Mirrors `LinkBranchNotReadyError`. */ export const ConfigDiffBranchNotReadyError = targetErrors.BranchNotReadyError; export type ConfigDiffBranchNotReadyError = InstanceType; @@ -117,10 +109,8 @@ export class ConfigDiffReadStatusError extends Data.TaggedError( "ConfigDiffReadStatusError", ) { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // `/v2/projects/{ref}/config` names a user-selected resource, so a 404 - // means "wrong project ref" — user-actionable, not an external-service - // problem (same rule as the branch-resolve error above and the - // ref-addressed push.errors.ts status errors). + // `/v2/projects/{ref}/config` names a user-selected resource, so a 404 means "wrong + // project ref" — user-actionable, not an external-service problem. return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); } } diff --git a/apps/cli/src/commands/config/diff/diff.format.ts b/apps/cli/src/commands/config/diff/diff.format.ts index c9c861df22..63b9e54cec 100644 --- a/apps/cli/src/commands/config/diff/diff.format.ts +++ b/apps/cli/src/commands/config/diff/diff.format.ts @@ -13,11 +13,8 @@ import { } from "../config.format.ts"; /** - * Pure formatters, payload builders, and input adapters for `config diff` — - * no Effect, no services, unit-testable in isolation. The API-scope - * classification, target-naming phrase, value/path rendering, and - * masked/unmanaged/not-returned caveat wording shared with `config pull` live - * in `../config.format.ts` (hoisted, CLI-2064). + * Pure formatters, payload builders, and input adapters for `config diff`. Formatting shared + * with `config pull` lives in `../config.format.ts`. */ export interface ConfigDiffContext { @@ -32,10 +29,9 @@ export interface ConfigDiffContext { } /** - * Version of the machine payload's own shape — bump when the payload - * contract changes incompatibly. Distinct from the config document's - * `$schema` URL (`config_schema` in the payload), which is user-controlled - * and per-repo. + * Version of the machine payload's own shape; bump when the contract changes incompatibly. + * Distinct from the config document's `$schema` URL (`config_schema` in the payload), which + * is user-controlled and per-repo. */ export const CONFIG_DIFF_PAYLOAD_VERSION = 1; @@ -51,12 +47,9 @@ export function configDiffComparisonLine(context: ConfigDiffContext): string { } /** - * One-line summary including the not-returned/masked/unmanaged caveats — the - * text-mode count line's caveats also travel with the machine-mode - * `message`, so an agent echoing `.message` never reports "in sync" on a - * partial response (e.g. a scoped token returning `auth: {}`) or a project - * whose masked SMTP password (or unpushable declared value) may have - * drifted. + * One-line summary including the not-returned/masked/unmanaged caveats, so an agent echoing + * the machine-mode `.message` never reports "in sync" on a partial response or a value that + * may have silently drifted (e.g. a masked SMTP password). */ export function configDiffSummaryMessage( changeSet: ConfigChangeSet, @@ -81,10 +74,8 @@ export function configDiffSummaryMessage( } /** - * Human-readable diff body for text mode (stdout). The per-change blocks are - * rendered by `configRenderChangeLines` (`../config.format.ts`, shared - * with `config push`'s per-resource change blocks); this function only adds - * the trailing counts/notes lines. + * Human-readable diff body for text mode. Per-change blocks come from `configRenderChangeLines` + * (shared with `config push`); this only adds the trailing counts/notes lines. */ export function renderConfigDiffText(changeSet: ConfigChangeSet, scope: ConfigApiScope): string { const changeLines = configRenderChangeLines(changeSet.changes); @@ -111,12 +102,9 @@ export function renderConfigDiffText(changeSet: ConfigChangeSet, scope: ConfigAp } /** - * The structured result for `--output-format json|stream-json` (the only - * machine-output mechanism this command honors — `-o/--output` is rejected - * outright, CLI-2156). Unset sides are explicit `null`s, distinguishable from - * empty values. Paths are segment arrays — a record key (an `sms.test_otp` - * phone number, a `[remotes.*]` name) may itself contain a `.`, so consumers - * must never split a joined string. + * The structured result for `--output-format json|stream-json` (the only machine-output this + * command honors). Unset sides are explicit `null`s. Paths stay segment arrays, never a joined + * string, since a record key (e.g. an `sms.test_otp` phone number) may itself contain a `.`. */ export function configDiffPayload( changeSet: ConfigChangeSet, @@ -124,15 +112,11 @@ export function configDiffPayload( context: ConfigDiffContext, ): Record { return { - // The payload contract's own version — what a forward-compat consumer - // gates on. The user's `$schema` document reference is `config_schema`: - // user-controlled and per-repo, never a contract signal. schema_version: CONFIG_DIFF_PAYLOAD_VERSION, config_schema: context.configSchema, target: { project_ref: context.projectRef, - // Omitted (not null) when no branch was targeted — the documented - // contract says optional. + // Omitted, not null, when no branch was targeted. ...(context.branch === undefined ? {} : { branch: context.branch }), local_scope: context.appliedRemote === undefined ? "base" : `remotes.${context.appliedRemote}`, From 21235cb5eedb40ecbf61abe816509b34ce3980dc Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 10 Sep 2026 12:30:46 +0100 Subject: [PATCH 10/56] chore(repo): trim comments, shard 18 (765 -> 362 comment lines) --- .../command-internal/viper-env.unit.test.ts | 13 -- .../src/command-internal/workdir-project.ts | 64 ++------- .../src/command-internal/workdir-search.ts | 34 +---- .../command-internal/workdir-validation.ts | 27 ++-- .../src/commands/backups/list/list.handler.ts | 15 +- .../backups/list/list.integration.test.ts | 6 - .../backups/restore/restore.handler.ts | 8 +- .../restore/restore.integration.test.ts | 6 +- .../commands/bootstrap/bootstrap.dotenv.ts | 25 ++-- .../commands/bootstrap/bootstrap.errors.ts | 18 +-- .../commands/bootstrap/bootstrap.handler.ts | 130 ++++-------------- .../bootstrap/bootstrap.integration.test.ts | 74 +++------- .../commands/bootstrap/bootstrap.layers.ts | 35 ++--- .../bootstrap/bootstrap.layers.unit.test.ts | 36 ++--- .../commands/bootstrap/bootstrap.pgconfig.ts | 39 ++---- .../src/commands/bootstrap/bootstrap.retry.ts | 30 ++-- .../bootstrap/bootstrap.retry.unit.test.ts | 13 +- .../commands/bootstrap/bootstrap.suggest.ts | 10 +- .../commands/bootstrap/bootstrap.templates.ts | 25 ++-- ...ootstrap.workdir-cache.integration.test.ts | 75 +++------- .../src/commands/branches/branches.errors.ts | 23 +--- .../src/commands/branches/branches.format.ts | 54 +++----- .../commands/branches/branches.go-payload.ts | 7 +- .../src/commands/branches/branches.prompt.ts | 25 +--- .../commands/branches/branches.resolver.ts | 5 +- .../branches/create/create.command.ts | 9 +- .../branches/create/create.handler.ts | 33 ++--- .../create/create.integration.test.ts | 20 +-- .../branches/delete/delete.handler.ts | 7 +- .../delete/delete.integration.test.ts | 25 ++-- 30 files changed, 241 insertions(+), 650 deletions(-) diff --git a/apps/cli/src/command-internal/viper-env.unit.test.ts b/apps/cli/src/command-internal/viper-env.unit.test.ts index b84af98240..019307500e 100644 --- a/apps/cli/src/command-internal/viper-env.unit.test.ts +++ b/apps/cli/src/command-internal/viper-env.unit.test.ts @@ -22,8 +22,6 @@ describe("viperEnvBool", () => { }); it("is false for the false set and any unrecognized value", () => { - // viper casts via strconv.ParseBool and swallows the error to `false`, so - // `yes`/`on`/`""`/garbage are NOT truthy (unlike some bool parsers). for (const value of ["0", "f", "F", "FALSE", "false", "False", "yes", "on", "", "nope"]) { process.env[KEY] = value; expect(viperEnvBool(KEY)).toBe(false); @@ -41,11 +39,6 @@ describe("viperEnvBoolWithProjectFallback", () => { delete process.env[KEY]; }); - // Go truth table: godotenv.Load only sets project-.env keys ABSENT from the - // shell env (presence is key-existence, so even an empty shell value blocks - // the file value), then viper.GetBool reads the merged env - // (godotenv@v1.5.1/godotenv.go:184-200, apps/cli-go/pkg/config/config.go). - it("falls back to the project value only when the shell var is absent", () => { delete process.env[KEY]; expect(viperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" })).toBe(true); @@ -59,8 +52,6 @@ describe("viperEnvBoolWithProjectFallback", () => { }); it("treats an empty shell value as present (blocks the project value) and false", () => { - // godotenv's presence check is key-existence in os.Environ(), and viper - // without AllowEmptyEnv resolves "" to the false default. process.env[KEY] = ""; expect(viperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" })).toBe(false); }); @@ -81,8 +72,6 @@ describe("viperEnvBoolWithProjectFallback", () => { }); it("whenUnset: true still yields false for any present non-true value", () => { - // A present value keeps the exact ParseBool semantics — `0`, `false`, empty, and garbage - // all disable, whether from the shell or the project dotenv. process.env[KEY] = "0"; expect(viperEnvBoolWithProjectFallback(KEY, {}, { whenUnset: true })).toBe(false); process.env[KEY] = ""; @@ -119,8 +108,6 @@ describe("viperEnvStringWithProjectFallback", () => { }); it("treats an empty shell value as present (blocks the project value)", () => { - // Same presence-based semantics as viperEnvBoolWithProjectFallback: godotenv.Load's - // "don't override a key already in os.Environ()" check is key-existence, not value-truthiness. process.env[STRING_KEY] = ""; expect(viperEnvStringWithProjectFallback(STRING_KEY, { [STRING_KEY]: "project-value" })).toBe( "", diff --git a/apps/cli/src/command-internal/workdir-project.ts b/apps/cli/src/command-internal/workdir-project.ts index f43c0439c9..9e73fbeeae 100644 --- a/apps/cli/src/command-internal/workdir-project.ts +++ b/apps/cli/src/command-internal/workdir-project.ts @@ -8,32 +8,18 @@ import { } from "../shared/telemetry/error-actionability.ts"; import { sanitizeInlineName } from "./http-errors.ts"; -/** - * `cause.path`/`loaded.path` are anchored under `workdir`; render them - * relative so a message reads `supabase/config.json` like the rest of the - * JSON-capable config-load family (`config diff`/`pull`/`push`, `gen types`), - * regardless of invocation cwd. - */ +/** Renders a config-load path relative to `workdir` (e.g. `supabase/config.json`), regardless of invocation cwd. */ export function relativeConfigPath(workdir: string, path: string): string { return path.startsWith(workdir) ? path.slice(workdir.length).replace(/^[/\\]/, "") : path; } /** - * The established "no project here" message for a JSON-capable config load - * (`config diff`/`push`/`pull`, `gen types`, `storage ls|mv|rm|cp`, `seed - * buckets`) — single source of truth so every one of those commands reports - * the same text for the same condition. + * Shared "no project here" message for JSON-capable config loads (`config diff/push/pull`, + * `gen types`, `storage ls|mv|rm|cp`, `seed buckets`). * - * A DEFAULTED workdir keeps today's exact wording (pinned by - * `diff.e2e.test.ts`/`pull.e2e.test.ts`, both run with no `--workdir`): - * pointing at `supabase init`, since the ancestor walk-up already searched - * every directory between here and the filesystem root. - * - * An EXPLICIT `--workdir`/`SUPABASE_WORKDIR` never climbed past the named - * directory (see `workdir-search.ts`), so a bare `supabase init` hint - * would be misleading — it names the resolved path instead and never - * suggests `init`, since the fix is to point `--workdir`/`SUPABASE_WORKDIR` - * at the right directory, not to scaffold a new project there. + * A defaulted workdir suggests `supabase init`, since the ancestor walk-up already searched up to + * the filesystem root. An explicit `--workdir`/`SUPABASE_WORKDIR` never climbs, so it names the + * resolved path instead of suggesting `init`. */ export function missingProjectConfigMessage(input: { readonly workdir: string; @@ -46,21 +32,11 @@ export function missingProjectConfigMessage(input: { } /** - * As {@link missingProjectConfigMessage}, but appends a - * "Did you mean --workdir ?" hint when an ancestor directory - * actually holds a project. - * - * Only runs the extra ancestor search when `workdir` was set EXPLICITLY: a - * DEFAULTED workdir already exhaustively searched every ancestor up to the - * filesystem root while resolving `workdir` itself (`resolveWorkdir` in - * `command-settings.layer.ts`), so there is never a "missed" ancestor left - * to suggest in that case. + * As {@link missingProjectConfigMessage}, but appends a "Did you mean --workdir ?" hint + * when an ancestor directory holds a project. * - * Purely a message enrichment: `findCliProjectPaths` itself never fails (a - * failed probe reads as "no config here", not an error — see its own doc - * comment), so the extra ancestor search can only ever change which sentence - * comes back, never surface a different error, mask the real failure, or - * crash the command. + * Only searches ancestors for an explicit workdir; a defaulted workdir already searched them all + * while resolving itself. */ export const missingProjectConfigMessageEffect = Effect.fnUntraced(function* (cliSettings: { readonly workdir: string; @@ -80,8 +56,7 @@ export const missingProjectConfigMessageEffect = Effect.fnUntraced(function* (cl * Raised by {@link requireExplicitWorkdirProject} when an explicit * `--workdir`/`SUPABASE_WORKDIR` names a directory with no * `supabase/config.toml`/`config.json` of its own. Callers map this into - * their own command-specific error type, matching the established pattern - * for `WorkdirValidationError`. + * their own command-specific error type. */ class WorkdirProjectMissingError extends Data.TaggedError("WorkdirProjectMissingError")<{ readonly message: string; @@ -92,17 +67,11 @@ class WorkdirProjectMissingError extends Data.TaggedError("WorkdirProjectMissing } /** - * Fails when an EXPLICIT `--workdir`/`SUPABASE_WORKDIR` holds no project — - * a no-op for a DEFAULTED workdir, which keeps its established tolerant - * fallback to embedded defaults unchanged (CLI-2285). + * Fails when an explicit `--workdir`/`SUPABASE_WORKDIR` holds no project; a no-op for a defaulted + * workdir. * - * Only valid for callers that do NOT pass `tomlOnly: true` to their own - * `loadCliConfig` call: `loadCliConfig` with `tomlOnly: true` can return - * `null` even when this probe succeeds — a `config.json`-only project, where - * `findCliProjectPaths` matches it but the TOML-only reader then finds no - * `config.toml` and returns `null` anyway. Both current callers (`config - * push`, `seed buckets`) are non-`tomlOnly`, so the probe result and the - * later `loaded === null` check agree exactly. + * Only valid for callers that don't pass `tomlOnly: true` to `loadCliConfig` — that mode can + * return `null` for a `config.json`-only project even though this probe succeeds. */ export const requireExplicitWorkdirProject = Effect.fnUntraced(function* (cliSettings: { readonly workdir: string; @@ -111,9 +80,6 @@ export const requireExplicitWorkdirProject = Effect.fnUntraced(function* (cliSet if (!cliSettings.explicitWorkdir) { return; } - // `explicitWorkdir` is guaranteed `true` past the guard above, so - // `shouldSearchAncestors` would always evaluate to `false` here — - // spelled out directly rather than through that predicate. const paths = yield* findCliProjectPaths(cliSettings.workdir, { search: false }); if (paths === null) { return yield* new WorkdirProjectMissingError({ diff --git a/apps/cli/src/command-internal/workdir-search.ts b/apps/cli/src/command-internal/workdir-search.ts index b2a16f6232..70619008fe 100644 --- a/apps/cli/src/command-internal/workdir-search.ts +++ b/apps/cli/src/command-internal/workdir-search.ts @@ -1,34 +1,10 @@ /** - * Whether a config load should climb ancestor directories looking for - * `supabase/config.{toml,json}` beyond the resolved - * `CommandSettings.workdir`. + * Whether a config load should climb ancestor directories for `supabase/config.{toml,json}` + * beyond `CommandSettings.workdir`. * - * 1. An explicit workdir is authoritative — letting `loadCliConfig` climb - * again on top of it would let an unrelated ancestor project's config - * win (this is the CLI-2285 bug). - * 2. A defaulted (unset) workdir must still climb inside `loadCliConfig`, - * because the workdir's own default resolution (`resolveWorkdir` in - * `command-settings.layer.ts`) only probes `supabase/config.toml` — a - * `config.json`-only project invoked from a subdirectory relies on this - * second, format-aware climb to be found. - * 3. A caller that already passes `tomlOnly: true` to `loadCliConfig` (e.g. - * `local-project-context.ts`, `gen.signing-keys-config.ts`, - * `shared/functions/serve.ts`'s `goConfigCompat` branch) doesn't need - * this helper — for those, the default-workdir climb and the second - * climb are checking the exact same thing, so they already correctly - * pass `search: false` unconditionally instead. - * 4. Passing this predicate is only half the contract. A JSON-capable load - * that also **tolerates** a `null` result must either hard-fail when - * `explicitWorkdir` is true (`config diff`/`push`/`pull`, `gen types`, - * `storage ls|mv|rm|cp`, `seed buckets`) or document why falling back is - * right for it. The three documented exceptions, all of which - * intentionally scaffold into or report on a bare directory: `functions - * new` (templates use embedded defaults — port/publishable key), - * `experimental workers new` (`workers new api --workdir ./bare-dir` - * must create the entry there), and `seedBucketsRun`'s own load, - * which is only reached by the standalone `seed buckets` command — - * `start` and `db reset` pass `resolvedConfig` and never load config here - * at all. + * An explicit workdir is authoritative and never climbs, so an unrelated ancestor project's + * config can't win. A defaulted workdir still climbs, since its own default resolution only + * probes `config.toml`. */ export function shouldSearchAncestors(cliSettings: { readonly explicitWorkdir: boolean }): boolean { return !cliSettings.explicitWorkdir; diff --git a/apps/cli/src/command-internal/workdir-validation.ts b/apps/cli/src/command-internal/workdir-validation.ts index f73f7d71fb..c9ab77267b 100644 --- a/apps/cli/src/command-internal/workdir-validation.ts +++ b/apps/cli/src/command-internal/workdir-validation.ts @@ -7,13 +7,9 @@ import { } from "../shared/telemetry/error-actionability.ts"; /** - * Raised by {@link validateWorkdirIsDirectory} when the target path - * doesn't exist or isn't a directory. Callers map this into their own - * command-specific error type. Only reachable when the user explicitly set - * `--workdir`/`SUPABASE_WORKDIR` to a bad path — the default walk-up - * resolution can never fail this check (see the doc comment on - * {@link validateWorkdirIsDirectory} below) — so the fix is always - * "pass a different `--workdir`/`SUPABASE_WORKDIR`". + * Raised by {@link validateWorkdirIsDirectory} when the target path doesn't exist or isn't a + * directory. Only reachable when the user explicitly set `--workdir`/`SUPABASE_WORKDIR` to a bad + * path; the fix is always to pass a different one. */ export class WorkdirValidationError extends Data.TaggedError("WorkdirValidationError")<{ readonly message: string; @@ -24,19 +20,12 @@ export class WorkdirValidationError extends Data.TaggedError("WorkdirValidationE } /** - * Validates that `workdir` exists and is a directory, the way Go's - * `ChangeWorkDir` implicitly does via `os.Chdir` (`apps/cli-go/internal/utils/ - * misc.go:231-250`, called from `PersistentPreRunE`, `apps/cli-go/cmd/root.go: - * 93-105`, before any command runs): a missing path or a path that isn't a - * directory fails immediately, before config load or any Docker/API access. + * Validates that `workdir` exists and is a directory, failing immediately before config load or + * any Docker/API access. * - * Callers that resolve `workdir` via `CommandSettings` only need this check - * when `--workdir`/`SUPABASE_WORKDIR` was set explicitly — `command-settings. - * layer.ts`'s default walk-up-for-`supabase/config.toml` resolution always - * returns a real, already-existing directory (either one containing - * `supabase/config.toml`, or the process's own `cwd`), so it can never fail - * this check; calling it unconditionally is therefore safe and simpler than - * threading "was this explicit?" through every caller. + * Only needed when `--workdir`/`SUPABASE_WORKDIR` was set explicitly — the default walk-up + * resolution always returns an already-existing directory, so calling this unconditionally is + * safe and simpler than threading "was this explicit?" through every caller. */ export function validateWorkdirIsDirectory( workdir: string, diff --git a/apps/cli/src/commands/backups/list/list.handler.ts b/apps/cli/src/commands/backups/list/list.handler.ts index 14e1b6c5df..3ef57c4426 100644 --- a/apps/cli/src/commands/backups/list/list.handler.ts +++ b/apps/cli/src/commands/backups/list/list.handler.ts @@ -25,7 +25,7 @@ import { formatTimestamp } from "../../../command-internal/timestamp.format.ts"; import { formatRegion } from "../backups.format.ts"; import type { BackupsListFlags } from "./list.command.ts"; -/** Type shape for `api.V1BackupsResponse` (`apps/cli-go/pkg/api/types.gen.go`). */ +/** Struct shape for `-o yaml|toml` encoding of the backups response. */ const GO_BACKUPS_RESPONSE = goStruct([ [ "backups", @@ -99,11 +99,9 @@ export const backupsList = Effect.fn("backups.list")(function* (flags: BackupsLi const ref = yield* resolver.resolve(flags.projectRef); - // Write the linked-project cache and persist the telemetry state file - // whether the main API call succeeds or fails. yield* Effect.gen(function* () { - // The fetching spinner is only meaningful in human-facing text mode — in JSON / stream-json - // it would surface dangling `[task] start:` lines on stderr with no completion message. + // Spinner is text-mode only; in JSON/stream-json it would leave a dangling `[task] start:` + // line on stderr with no completion message. const fetching = output.format === "text" ? yield* output.task("Fetching backups...") : undefined; const response = yield* api.v1.listAllBackups({ ref }).pipe( @@ -123,9 +121,8 @@ export const backupsList = Effect.fn("backups.list")(function* (flags: BackupsLi return; } if (goFmt === "toml") { - // The schema decodes the PITR-only `"backups": null` to `[]` (see the - // `nullForEmptyArrays` JSON hint above); mirror that by treating an - // empty list as a nil slice, which BurntSushi omits entirely. + // Treats an empty backups list as absent, matching the nullForEmptyArrays JSON handling + // above. yield* output.raw( encodeGoToml( { @@ -142,8 +139,6 @@ export const backupsList = Effect.fn("backups.list")(function* (flags: BackupsLi return; } - // goFmt is undefined or "pretty" — defer to TS --output-format for JSON/stream-json, - // otherwise render the Glamour-styled table (Go --output pretty parity). if (output.format === "json" || output.format === "stream-json") { yield* output.success("", response); return; diff --git a/apps/cli/src/commands/backups/list/list.integration.test.ts b/apps/cli/src/commands/backups/list/list.integration.test.ts index 21742585c3..d38587683b 100644 --- a/apps/cli/src/commands/backups/list/list.integration.test.ts +++ b/apps/cli/src/commands/backups/list/list.integration.test.ts @@ -132,8 +132,6 @@ describe("backups list integration", () => { const { layer, out } = setup({ goOutput: "json", response: PITR_RESPONSE }); return Effect.gen(function* () { yield* backupsList({ projectRef: Option.none() }); - // Byte-identical to `encoding/json` output: alphabetical struct-field - // order, and a nil Backups slice serializes as `null`. expect(out.stdoutText).toBe( `{ "backups": null, @@ -152,7 +150,6 @@ describe("backups list integration", () => { return Effect.gen(function* () { yield* backupsList({ projectRef: Option.none() }); expect(out.stdoutText).toContain("region: ap-southeast-1"); - // yaml.v3 lowercases the whole field name (CLI-1975). expect(out.stdoutText).toContain("walgenabled: true"); }).pipe(Effect.provide(layer)); }); @@ -161,7 +158,6 @@ describe("backups list integration", () => { const { layer, out } = setup({ goOutput: "toml", response: PITR_RESPONSE }); return Effect.gen(function* () { yield* backupsList({ projectRef: Option.none() }); - // BurntSushi emits PascalCase field names (CLI-1975). expect(out.stdoutText).toContain('Region = "ap-southeast-1"'); expect(out.stdoutText).toContain("WalgEnabled = true"); }).pipe(Effect.provide(layer)); @@ -171,8 +167,6 @@ describe("backups list integration", () => { const { layer, out } = setup({ goOutput: "toml", response: LOGICAL_RESPONSE }); return Effect.gen(function* () { yield* backupsList({ projectRef: Option.none() }); - // Byte-exact (CLI-1975): primitives first, then the Backups - // array-of-tables and the (empty) PhysicalBackupData table. expect(out.stdoutText).toBe(`PitrEnabled = true Region = "ap-southeast-1" WalgEnabled = true diff --git a/apps/cli/src/commands/backups/restore/restore.handler.ts b/apps/cli/src/commands/backups/restore/restore.handler.ts index c96a69af94..b4186bfe0b 100644 --- a/apps/cli/src/commands/backups/restore/restore.handler.ts +++ b/apps/cli/src/commands/backups/restore/restore.handler.ts @@ -31,9 +31,7 @@ export const backupsRestore = Effect.fn("backups.restore")(function* (flags: Bac const ref = yield* resolver.resolve(flags.projectRef); const recoveryTimeTargetUnix = Option.getOrElse(flags.timestamp, () => 0); - // Cache + telemetry flush whether the main call succeeds or fails. yield* Effect.gen(function* () { - // Spinner only in human-facing text mode — see list.handler.ts. const restoring = output.format === "text" ? yield* output.task("Initiating PITR restore...") : undefined; yield* api.v1 @@ -46,9 +44,8 @@ export const backupsRestore = Effect.fn("backups.restore")(function* (flags: Bac const goFmt = Option.getOrUndefined(goOutputFlag); - // Go ignores --output entirely (restore.go:22) and always writes the text line to stderr. - // We mirror that for every Go --output value except `json`, where we provide a TS-only - // structured payload (Go has no JSON for restore — adding one is non-breaking). + // --output is ignored for restore except `json`, a TS-only structured payload; every other + // value (including unset) writes the text line to stderr. if (goFmt === "json") { yield* output.raw( JSON.stringify({ message: "Started PITR restore", project_ref: ref }, null, 2) + "\n", @@ -61,7 +58,6 @@ export const backupsRestore = Effect.fn("backups.restore")(function* (flags: Bac return; } - // pretty/yaml/toml/env (Go-compat) + TS text mode → byte-identical text line on stderr. yield* output.raw(`Started PITR restore: ${ref}\n`, "stderr"); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/commands/backups/restore/restore.integration.test.ts b/apps/cli/src/commands/backups/restore/restore.integration.test.ts index 55b3798be5..95cf5c2e38 100644 --- a/apps/cli/src/commands/backups/restore/restore.integration.test.ts +++ b/apps/cli/src/commands/backups/restore/restore.integration.test.ts @@ -204,9 +204,7 @@ describe("backups restore integration", () => { format: "text", promptSelectResponses: [VALID_REF], }); - // The resolver lists projects, then POSTs the restore. Branch on the path - // to give the list endpoint its project array and let the restore endpoint - // succeed with a 201. + // Branches on path: the resolver's list call gets a project array, the restore call gets 201. const api = mockCommandPlatformApi({ handler: (request) => { if (request.url.includes("/v1/projects") && !request.url.includes("/database/backups")) { @@ -263,8 +261,6 @@ describe("backups restore integration", () => { }); it.live("accepts --timestamp short alias -t in the same way (no separate parse path)", () => { - // The flag layer is responsible for parsing -t into `timestamp`; once parsed, - // the handler does not differentiate, so we just verify the handler honors the value. const { layer, api } = setup(); return Effect.gen(function* () { yield* backupsRestore({ diff --git a/apps/cli/src/commands/bootstrap/bootstrap.dotenv.ts b/apps/cli/src/commands/bootstrap/bootstrap.dotenv.ts index 8bc5a9cf53..c2a70303f9 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.dotenv.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.dotenv.ts @@ -5,12 +5,11 @@ import { type DbConfig, toPostgresUrl } from "./bootstrap.pgconfig.ts"; type ApiKey = typeof ApiKeyResponse.Type; -// Env-var keys bootstrap writes / derives. const SUPABASE_SERVICE_ROLE_KEY = "SUPABASE_SERVICE_ROLE_KEY"; const SUPABASE_ANON_KEY = "SUPABASE_ANON_KEY"; const SUPABASE_URL = "SUPABASE_URL"; const POSTGRES_URL = "POSTGRES_URL"; -// Derived keys (only populated when present in .env.example). +// Only populated when present in .env.example. const POSTGRES_PRISMA_URL = "POSTGRES_PRISMA_URL"; const POSTGRES_URL_NON_POOLING = "POSTGRES_URL_NON_POOLING"; const POSTGRES_USER = "POSTGRES_USER"; @@ -23,12 +22,10 @@ const EXPO_PUBLIC_SUPABASE_ANON_KEY = "EXPO_PUBLIC_SUPABASE_ANON_KEY"; const EXPO_PUBLIC_SUPABASE_URL = "EXPO_PUBLIC_SUPABASE_URL"; /** - * Env-map construction: seeds the api-key env vars (`SUPABASE__KEY`), - * the project `SUPABASE_URL`, and the pooled `POSTGRES_URL` (transaction - * mode, port 6543). When a `.env.example` map is supplied, each of its keys - * is merged: the four seeded keys are preserved, the derived `POSTGRES_*` / - * `NEXT_PUBLIC_*` / `EXPO_PUBLIC_*` keys are computed from the db config + - * seeded values, and any other key copies its example value verbatim. + * Builds the bootstrap `.env` map: seeds the api-key vars, `SUPABASE_URL`, and the pooled + * `POSTGRES_URL` (transaction mode, port 6543). When `example` (from `.env.example`) is given, + * seeded keys win, `POSTGRES_*`/`NEXT_PUBLIC_*`/`EXPO_PUBLIC_*` keys are derived from `config` + * and the seeded values, and every other key copies its example value verbatim. */ export function buildDotEnv( keys: ReadonlyArray, @@ -85,8 +82,7 @@ export function buildDotEnv( return initial; } -// godotenv's `doubleQuoteSpecialChars` (`joho/godotenv/godotenv.go`): backslash, -// newline, carriage return, double-quote, `!`, `$`, backtick. +// Chars escaped in double-quoted values: backslash, newline, CR, double-quote, `!`, `$`, backtick. const DOUBLE_QUOTE_SPECIAL = ["\\", "\n", "\r", '"', "!", "$", "`"] as const; function doubleQuoteEscape(line: string): string { @@ -98,14 +94,13 @@ function doubleQuoteEscape(line: string): string { return out; } -// strconv.Atoi surface: optional sign + base-10 digits, parsed within int range. +// Optional sign plus base-10 digits, matching integer values that render unquoted. const INTEGER_PATTERN = /^[+-]?\d+$/; /** - * Reproduces `godotenv.Marshal`: each entry renders as `KEY=` when the value - * parses as an integer (`strconv.Atoi` + `%d`), otherwise `KEY=""`. - * Lines are sorted lexicographically (`godotenv.Marshal` sorts the rendered - * lines, which orders by key) and joined with `\n` (no trailing newline). + * Renders `.env` entries as `KEY=` for integer-valued values, otherwise + * `KEY=""`, sorted lexicographically by key and joined with `\n` (no trailing + * newline). */ export function marshalDotEnv(env: Readonly>): string { const lines: Array = []; diff --git a/apps/cli/src/commands/bootstrap/bootstrap.errors.ts b/apps/cli/src/commands/bootstrap/bootstrap.errors.ts index d268a699f6..479eb75d73 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.errors.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.errors.ts @@ -6,12 +6,6 @@ import { statusCodeActionability, } from "../../shared/telemetry/error-actionability.ts"; -// --------------------------------------------------------------------------- -// Bootstrap-specific tagged errors, one per established failure site. -// Login / create / api-keys / link failures are surfaced by the extracted -// shared cores (`command-internal/legacy-*`), so they are NOT redefined here. -// --------------------------------------------------------------------------- - /** Positional template arg with no case-insensitive match — `"Invalid template: " + name`. */ export class BootstrapInvalidTemplateError extends Data.TaggedError( "BootstrapInvalidTemplateError", @@ -32,7 +26,7 @@ export class BootstrapTemplateListError extends Data.TaggedError("BootstrapTempl } } -/** Reading the target workdir failed — `failed to read workdir: %w`. */ +/** Reading the target workdir failed — `failed to read workdir: ${cause}`. */ export class BootstrapWorkdirReadError extends Data.TaggedError("BootstrapWorkdirReadError")<{ readonly message: string; }> { @@ -42,8 +36,8 @@ export class BootstrapWorkdirReadError extends Data.TaggedError("BootstrapWorkdi } /** - * User declined the overwrite prompt — returns `errors.New(context.Canceled)`. - * Carries no suggestion frame (cancellation, not a fault). + * User declined the overwrite prompt. Carries no suggestion frame since this is a + * cancellation, not a fault. */ export class BootstrapOverwriteDeclinedError extends Data.TaggedError( "BootstrapOverwriteDeclinedError", @@ -55,7 +49,7 @@ export class BootstrapOverwriteDeclinedError extends Data.TaggedError( } } -/** Template download failure — `failed to download template: %w`. */ +/** Template download failure (network, non-200 status, or an unsafe archive entry). */ export class BootstrapTemplateDownloadError extends Data.TaggedError( "BootstrapTemplateDownloadError", )<{ @@ -67,8 +61,8 @@ export class BootstrapTemplateDownloadError extends Data.TaggedError( } /** - * Project health probe failed — `Error status %d: %s` (non-200) or - * `Service not healthy: %s (%s)`. + * Project health probe failed — `Error status : ` (non-200) or + * `Service not healthy: ()`. */ export class BootstrapHealthError extends Data.TaggedError("BootstrapHealthError")<{ readonly message: string; diff --git a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts index 66fc6efb68..c117339785 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.handler.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.handler.ts @@ -55,7 +55,6 @@ import { import { type StarterTemplate, TemplateService } from "./bootstrap.templates.ts"; import type { BootstrapFlags } from "./bootstrap.command.ts"; -// Built-in starter. const SCRATCH_TEMPLATE: StarterTemplate = { name: "scratch", description: "An empty project from scratch.", @@ -79,24 +78,21 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( const telemetryState = yield* TelemetryState; const workdirFlag = yield* WorkdirFlag; const dnsResolver = yield* DnsResolverFlag; - // `--yes` OR `SUPABASE_YES`. const yesFlag = yield* resolveYes; const isText = output.format === "text"; const retry = { schedule: retrySchedule, times: BOOTSTRAP_MAX_RETRIES } as const; - // `process.chdir` changes into the resolved workdir; restore the original cwd in a - // finalizer so the surrounding process is left untouched once this command - // returns (every step below reads its own explicit `workdir` var, never - // `process.cwd()`, so nothing else depends on the chdir staying in effect). + // Restores the original cwd once bootstrap returns, since `process.chdir` below is a global + // 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; - // Resolved bootstrap workdir, hoisted so the linked-project-cache finalizer writes - // beside the other `supabase/.temp/` files instead of `cliSettings.workdir`. + // Hoisted so the linked-project-cache finalizer writes beside the other `supabase/.temp/` + // files instead of `cliSettings.workdir`. let resolvedWorkdir: string | undefined; yield* Effect.gen(function* () { - // A. Resolve workdir (flag -> env -> prompt -> cwd). // Reads the prefixed `SUPABASE_WORKDIR` only (never plain `WORKDIR`). const workdirRaw = Option.isSome(workdirFlag) ? workdirFlag.value @@ -113,7 +109,6 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( : path.join(runtimeInfo.cwd, workdirInput); resolvedWorkdir = workdir; - // B. List templates + resolve the starter. const samples = yield* templateService.listSamples; const allTemplates = [...samples, SCRATCH_TEMPLATE]; let starter: StarterTemplate; @@ -134,7 +129,6 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( starter = allTemplates.find((t) => t.name === choice) ?? SCRATCH_TEMPLATE; } - // C. mkdir + overwrite prompt. yield* fs.makeDirectory(workdir, { recursive: true }); const entries = yield* fs .readDirectory(workdir) @@ -144,10 +138,8 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( ), ); if (entries.length > 0) { - // Established prompt behavior: `--yes`/`SUPABASE_YES` auto-confirms with - // the ` [Y/n] y` stderr echo instead of silently skipping the - // prompt, and a non-TTY stdin scans one piped line (100ms) before - // falling back to the Yes default. + // `--yes`/`SUPABASE_YES` auto-confirms with a `<title> [Y/n] y` stderr echo; non-TTY + // stdin scans one piped line (100ms) before falling back to Yes. const overwrite = yield* promptYesNo( output, yesFlag, @@ -161,15 +153,11 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( } } - // D. chdir + "Using workdir" to stderr. - // Only prints the line when the resolved workdir differs from the - // original cwd. yield* Effect.sync(() => process.chdir(workdir)); if (workdir !== runtimeInfo.cwd) { yield* output.raw(`Using workdir ${bold(workdir)}\n`, "stderr"); } - // E. Download template OR scaffold a blank project. if (starter.url.length > 0) { if (isText) yield* output.raw(`Downloading: ${starter.url}\n`, "stdout"); yield* templateService.download(starter.url, workdir); @@ -185,12 +173,8 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( }); } - // F. Ensure login (browser flow when no token). yield* ensureLogin({ openBrowser: tty.stdinIsTty }); - // G. Create project (echoes via the shared create core). - // `-p` binds to `DB_PASSWORD`; with the `SUPABASE` env prefix the env - // fallback is `SUPABASE_DB_PASSWORD` (consumed by `flags.PromptPassword`). const seededPassword = Option.isSome(flags.password) ? flags.password.value : (process.env["SUPABASE_DB_PASSWORD"] ?? ""); @@ -209,9 +193,8 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( const projectRef = created.ref; createdRef = projectRef.length > 0 ? projectRef : undefined; - // H. Fetch api keys with backoff; each attempt prints "Linking project...". - // The notify wrapper reproduces the established retry-callback shape - // (`<err>\nRetry (n/8):` after each failed attempt); a fresh counter per block. + // 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 keys = yield* Effect.gen(function* () { if (isText) yield* output.raw("Linking project...\n", "stderr"); @@ -219,15 +202,9 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( }).pipe(apiKeysNotify, Effect.retry(retry)); const { anon } = extractServiceKeys(keys); - // I. Load config.toml + link services (best-effort, anon key) + mandatory - // project-ref write. Established ordering: the config load runs FIRST — - // right before `link.LinkServices` — and a malformed config.toml aborts - // bootstrap here (a hard `return err`), before `link.LinkServices`, the - // health poll, or the `.env` write ever run. This also fixes the "Loading - // config override: [remotes.x]" print's position to match. `applyProjectEnv`'s - // scope (mirroring the established process-lifetime `os.Setenv`) is opened - // here and stays open for the rest of the handler — see the `Effect.scoped` - // on this function's own outer pipe below. + // Config load must run before link/health/`.env` steps: a malformed config.toml aborts here + // rather than after side effects start. `applyProjectEnv`'s scope stays open for the rest of + // this handler (closed by the outer `Effect.scoped` below). const projectEnv = yield* loadProjectEnv(fs, path, workdir); yield* applyProjectEnv(projectEnv); const pushYes = yield* resolveYesWithProjectEnv(projectEnv); @@ -246,7 +223,6 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( yield* fs.makeDirectory(path.dirname(paths.projectRef), { recursive: true }); yield* fs.writeFileString(paths.projectRef, projectRef); - // J. Poll health until db is healthy. const healthNotify = bootstrapRetryNotify(); yield* Effect.gen(function* () { if (isText) yield* output.raw("Checking project health...\n", "stderr"); @@ -262,11 +238,8 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( } }).pipe(healthNotify, Effect.retry(retry)); - // K. Derive db config + write .env (non-fatal). Kept as the naive - // direct-host connection (matching the established `NewDbConfigWithPassword` - // shape, minus its own IPv6/pooler-fallback — a pre-existing, out-of-scope - // `.env` divergence: unlike step L below, `.env` is never used to actually - // connect, so it doesn't need the real probe+fallback resolution). + // Uses a naive direct-host db config with no IPv6/pooler fallback, since `.env` is written + // for reference only and, unlike the push connection below, is never used to actually connect. const dbConfig = deriveDbConfig(projectRef, created.dbPassword, cliSettings.projectHost); const supabaseUrl = `https://${projectRef}.${cliSettings.projectHost}`; const envFilePath = path.join(workdir, ".env"); @@ -296,60 +269,14 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( ), ); - // L. Push migrations — native call to `dbPushCore` (CLI-1953): - // `includeAll: false, includeRoles: true, includeSeed: true, dryRun: false`. - // - // The connection itself is resolved via `resolveLinkedConn` — the - // same dial-direct-host / fall-back-to-IPv4-pooler logic used elsewhere, - // not the naive `deriveDbConfig` used for `.env` above. New Supabase - // projects commonly have an IPv6-only direct DB host, so without this - // fallback the push would burn all 9 retries and fail on IPv4-only - // networks — the exact regression this fix closes. `created.dbPassword` - // is always non-empty by this point (the create step already prompted - // for/generated one), so the temp-login-role branches are never actually - // reached; only the TCP probe and a read of the - // `<workdir>/supabase/.temp/pooler-url` file `link.LinkServices` already - // wrote in step I. Given a non-empty password, the only reachable - // failure is the direct host being unreachable with no saved pooler URL - // yet (`DbConfigIpv6Error`) — the established resolver still - // returns its best-effort direct-host config alongside that error, - // logging it to stderr and pressing on rather than aborting. The push - // dials fresh on every call and bootstrap retries the push itself, so - // this leniency buys real reconnect attempts across the backoff window — - // e.g. while a freshly created project's link/pooler metadata is still - // propagating — not a guaranteed repeat failure. Reproduced below: catch - // that one error tag, log it, and fall back to the same direct-host - // shape already computed for `.env` above (step K's `dbConfig`) instead - // of failing bootstrap outright. - // - // The project ref or config.toml is never re-resolved for push (reuses - // what step I already loaded above) — so this passes - // `workdir`/`projectRef`/`toml` straight through as plain values instead - // of calling `dbPush` (the full flags-based command), which would - // re-resolve them via `ProjectRefResolver`/`DbConfigResolver` - // — both keyed off `CommandSettings.workdir`, stale after this handler's - // own `process.chdir` above (step D) since that layer is built once, - // before the handler runs. - // - // `bootstrapRetryNotify`/`Effect.retry(retry)` reproduce the - // established retry-reset-and-notify wrap around the push call, matching - // the api-keys/health-poll retries above — only the push itself is - // retried, not the connection resolution (which runs once, outside the - // loop). No instrumentation wrap: `dbPushCore` is the bare handler - // function, not `push.command.ts`'s wrapped command, so it never fires - // its own `cli_command_executed` — no double-count risk. - // - // `resolveLinkedConn` (unlike `DbConfigResolver.resolve`) returns a - // bare connection with no `suggestionContext` attached — that context is normally - // stapled on by the resolver layer bootstrap deliberately bypasses (see this - // call's own doc comment above). Attach it here too, so a connect failure inside - // the native push (refused/auth/IPv6/wrong-profile) still renders the - // established connect-suggestion hint instead of silently falling back - // to the generic "--debug" suggestion. + // `resolveLinkedConn` doesn't attach a suggestionContext like the full resolver does; build + // one manually so a connect failure inside the push below still gets the established hint. const suggestionContext: ConnectSuggestionContext = { dashboardUrl: cliSettings.dashboardUrl, profileName: cliSettings.profile, }; + // Falls back to the naive `dbConfig` on `DbConfigIpv6Error` instead of aborting, since a + // freshly created project's pooler metadata may still be propagating. const resolvedConn = yield* resolveLinkedConn( projectRef, workdir, @@ -363,6 +290,9 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( ), ); const conn = { ...resolvedConn, suggestionContext }; + // 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(); yield* dbPushCore({ workdir, @@ -381,7 +311,6 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( emitStructuredResult: false, }).pipe(pushNotify, Effect.retry(retry)); - // M. Start suggestion. if (isText) { const suggestion = suggestAppStart(runtimeInfo.cwd, workdir, starter.start, aqua); yield* emitSuccessTrailer(`${suggestion}\n`); @@ -413,20 +342,15 @@ export const bootstrap = Effect.fn("bootstrap")(function* ( ), ), Effect.ensuring(telemetryState.flush), - // Load-bearing: `applyProjectEnv` (step I) uses `Effect.acquireRelease` - // to revert `SUPABASE_INTERNAL_IMAGE_REGISTRY` when its scope closes. Its - // lifetime must span the rest of this handler (link services, health poll, - // `.env` write, and the push step's own edge-runtime/pg-delta cache use of - // that env var) — matching the established process-lifetime `os.Setenv` - // behavior — so the scope is closed here, at the outermost pipe, not - // narrowly around a single step. + // `applyProjectEnv` above uses `Effect.acquireRelease` to revert + // `SUPABASE_INTERNAL_IMAGE_REGISTRY` when its scope closes; that scope must span the rest of + // this handler (link services, health poll, `.env` write, and the push step's own use of + // that var), so it's closed here rather than narrowly around a single step. Effect.scoped, ); }); -// Whether `cause` is the generated client's `SchemaError` — a 200 response the -// client could not decode, as opposed to a transport failure (DNS, TLS, -// timeout). +// 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; @@ -434,7 +358,7 @@ function isDecodeFailureCause(cause: unknown): boolean { return cause._tag === "SchemaError"; } -// Non-200 branch: `Error status %d: %s`. +// 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; diff --git a/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts index 23985d1d24..dd8d8852da 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.integration.test.ts @@ -84,11 +84,9 @@ interface SetupOpts { readonly apiKeysFailTimes?: number; readonly pushConnectFailTimes?: number; /** - * When `false`, the pooler-config route reports no PRIMARY pooler (Go's - * `utils.GetPoolerConfig` returning nil) — `linkServicesCore`'s - * best-effort `linkPooler` step then never writes `<workdir>/supabase/.temp/ - * pooler-url`, so `resolveLinkedConn`'s push-connection resolution has - * neither a reachable direct host nor a saved pooler URL to fall back to. + * When `false`, the pooler-config route reports no PRIMARY pooler, so `linkServicesCore` never + * writes `<workdir>/supabase/.temp/pooler-url` and `resolveLinkedConn` has no pooler URL to + * fall back to. */ readonly poolerAvailable?: boolean; readonly health?: { readonly status: number; readonly body: unknown }; @@ -131,15 +129,11 @@ function setup(opts: SetupOpts = {}) { if (url.includes("/v1/organizations")) { return Effect.succeed(jsonResponse(request, 200, ORGS)); } - // Pooler config: the in-process test's direct db host is never reachable (no - // real network), so `resolveLinkedConn`'s push-connection resolution - // always falls back to the IPv4 pooler — matching the real-world "common - // case" this fallback exists for (CLI-1953). `linkServicesCore`'s own - // `linkPooler` step (step I) fetches this same route and saves it to - // `<workdir>/supabase/.temp/pooler-url`, which the fallback then reads. + // Pooler config: the test's direct db host is never reachable, so `resolveLinkedConn` always + // falls back to the IPv4 pooler. `linkServicesCore`'s `linkPooler` step fetches this route + // and saves it to `<workdir>/supabase/.temp/pooler-url`, which the fallback then reads. if (recorded.method === "GET" && url.includes("/config/database/pooler")) { if (opts.poolerAvailable === false) { - // No PRIMARY entry — mirrors `utils.GetPoolerConfig` returning nil. return Effect.succeed(jsonResponse(request, 200, [])); } return Effect.succeed( @@ -182,18 +176,16 @@ function setup(opts: SetupOpts = {}) { }), }); - // Native push (CLI-1953): the scratch/downloaded-template fixtures never scaffold - // migrations/seed.sql/roles.sql, so `dbPushCore` always reaches the "up to - // date" short-circuit right after connecting — no query results or edge-runtime - // invocation are needed beyond a successful connect. + // The scratch/downloaded-template fixtures never scaffold migrations/seed.sql/roles.sql, so + // `dbPushCore` always reaches the "up to date" short-circuit right after connecting — no query + // results or edge-runtime invocation are needed. const pushConnectCalls: Array<PgConnInput> = []; const dbConnectionLayer = Layer.succeed(DbConnection, { connect: (conn: PgConnInput) => Effect.suspend(() => { pushConnectCalls.push(conn); - // Fails the first N connect attempts (retry coverage for the push step's - // own `bootstrapRetryNotify()` + `Effect.retry(retry)` wrap, CLI-1953) - // before succeeding, mirroring `apiKeysFailTimes`'s pattern above. + // Fails the first N connect attempts (retry coverage for the push step's own retry + // wrap), succeeding after, mirroring `apiKeysFailTimes` above. if (pushConnectCalls.length <= (opts.pushConnectFailTimes ?? 0)) { return Effect.fail(new DbConnectError({ message: "connection refused" })); } @@ -218,8 +210,7 @@ function setup(opts: SetupOpts = {}) { api.httpClientLayer, cliSettings, mockTty({ stdinIsTty: opts.stdinIsTty ?? true, stdoutIsTty: false }), - // cwd differs from the (absolute) workdir so the "Using workdir" line prints, - // matching the established `cwd != CurrentDirAbs` guard. + // cwd differs from the workdir so the "Using workdir" line prints. mockRuntimeInfo({ cwd: dirname(tempRoot.current) }), telemetry.layer, linkedCache.layer, @@ -272,18 +263,14 @@ describe("bootstrap integration", () => { const s = setup(); return Effect.gen(function* () { yield* bootstrap(flags({ template: Option.some("scratch") }), FAST_BACKOFF); - // Blank init scaffolded config.toml. expect(existsSync(join(s.workdir, "supabase", "config.toml"))).toBe(true); - // Project ref written for the delegated db push. expect(readFileSync(join(s.workdir, "supabase", ".temp", "project-ref"), "utf8")).toBe( VALID_REF, ); - // .env populated with derived keys. const env = readFileSync(join(s.workdir, ".env"), "utf8"); expect(env).toContain('SUPABASE_ANON_KEY="anon-key"'); expect(env).toContain("SUPABASE_URL="); expect(env).toContain("POSTGRES_URL="); - // Progress + create echo on stderr. expect(s.out.stderrText).toContain("Using workdir"); expect(s.out.stderrText).toContain("Created a new project at"); expect(s.out.stderrText).toContain("To start your app:"); @@ -367,8 +354,6 @@ describe("bootstrap integration", () => { writeFileSync(join(tempRoot.current, "existing.txt"), "keep me"); return Effect.gen(function* () { yield* bootstrap(flags({ template: Option.some("scratch") }), FAST_BACKOFF); - // Established behavior: the auto-accepted overwrite question echoes to - // stderr under the global YES flag. expect(s.out.stderrText).toContain("Do you want to overwrite existing files in "); expect(s.out.stderrText).toContain(" directory? [Y/n] y\n"); expect(existsSync(join(s.workdir, "supabase", "config.toml"))).toBe(true); @@ -394,7 +379,6 @@ describe("bootstrap integration", () => { yield* bootstrap(flags({ template: Option.some("scratch") }), FAST_BACKOFF); const events = s.analytics.captured.map((c) => c.event); expect(events).not.toContain("cli_login_completed"); - // Bootstrap calls link.LinkServices (not link.Run) — no cli_project_linked. expect(events).not.toContain("cli_project_linked"); }).pipe(Effect.provide(s.layer)); }, @@ -411,18 +395,12 @@ describe("bootstrap integration", () => { }); it.live("retries the native push connection until it succeeds", () => { - // Regression coverage: `bootstrapRetryNotify()` + `Effect.retry(retry)` - // wraps the push step the same way as the api-keys/health-poll retries above - // (`bootstrap.go:122-127`'s `backoff.RetryNotify`). Deleting that wrap would - // leave this test's second and third connect attempts unreached. const s = setup({ pushConnectFailTimes: 2, debug: true }); return Effect.gen(function* () { yield* bootstrap(flags({ template: Option.some("scratch") }), FAST_BACKOFF); expect(s.pushConnectCalls).toHaveLength(3); - // Failures 1-2 go to the debug logger; the notice reaches stderr only from - // the 3rd failure onward (`bootstrapRetryNotify`'s `failureCount * 3 > - // maxRetries` gate) — with only 2 failures here, this never fires, so assert - // via `--debug` instead (`debug: true` above) that both attempts were logged. + // The stderr retry notice needs 3+ failures to fire; asserting via the debug logger + // (`debug: true` above) catches both attempts with only 2 failures here. const retryLines = s.out.stderrText.match(/connection refused\nRetry \(\d\/8\): /g) ?? []; expect(retryLines.length).toBe(2); }).pipe(Effect.provide(s.layer)); @@ -486,11 +464,8 @@ describe("bootstrap integration", () => { it.live( "pushes natively — falls back to the IPv4 pooler when the direct host is unreachable, no Go subprocess", () => { - // The in-process test's direct db host is never reachable (no real network), - // so `resolveLinkedConn` transparently falls back to the IPv4 pooler - // (CLI-1953) — exactly the real-world path new (IPv6-only) Supabase projects - // take. `setup()`'s pooler-config mock feeds `linkServicesCore`'s saved - // `<workdir>/supabase/.temp/pooler-url`, which this fallback reads. + // The test's direct db host is never reachable, so `resolveLinkedConn` falls back to the + // IPv4 pooler fed by `setup()`'s pooler-config mock via the saved pooler-url file. const s = setup(); return Effect.gen(function* () { yield* bootstrap(flags({ template: Option.some("scratch") }), FAST_BACKOFF); @@ -506,19 +481,10 @@ describe("bootstrap integration", () => { it.live( "falls back to the direct-host config and keeps retrying push when connection resolution itself fails", () => { - // Regression coverage (review thread on CLI-1953): when the direct host is - // unreachable AND no pooler URL was ever saved, `resolveLinkedConn` - // fails with `DbConfigIpv6Error`. The established resolver logs - // that same error and presses on with its best-effort direct-host - // config, letting the retry-wrapped push get real reconnect attempts - // instead of aborting bootstrap outright. This asserts the native flow - // does the same instead of failing before `dbPushCore` ever runs. const s = setup({ poolerAvailable: false, pushConnectFailTimes: 1 }); return Effect.gen(function* () { yield* bootstrap(flags({ template: Option.some("scratch") }), FAST_BACKOFF); expect(s.out.stderrText).toContain("IPv6 is not supported on your current network"); - // Falls back to the same direct-host shape as the `.env` config (step K), - // not the pooler — and still reaches/retries the native push. expect(s.pushConnectCalls).toHaveLength(2); expect(s.pushConnectCalls[0]?.host).toBe(`db.${VALID_REF}.supabase.co`); expect(s.pushConnectCalls[0]?.port).toBe(5432); @@ -542,11 +508,9 @@ describe("bootstrap integration", () => { }); it.live("pushes with the prompted password when --password is empty", () => { - // An explicit `--password ""` (e.g. unset `$SUPABASE_DB_PASSWORD` expanded by - // the shell) leaves the password empty, so the create step prompts — and the - // in-process push reuses that exact same resolved connection (the push - // step always uses the create-resolved password; there is no separate - // flag/env channel to preserve once the call is in-process, CLI-1953). + // An explicit `--password ""` (e.g. unset `$SUPABASE_DB_PASSWORD` expanded by the shell) + // leaves the password empty, so the create step prompts, and the push reuses that same + // resolved connection. const s = setup({ promptPasswordResponses: ["prompted-pw"] }); const prev = process.env["SUPABASE_DB_PASSWORD"]; delete process.env["SUPABASE_DB_PASSWORD"]; diff --git a/apps/cli/src/commands/bootstrap/bootstrap.layers.ts b/apps/cli/src/commands/bootstrap/bootstrap.layers.ts index 30858b4169..1578d0c369 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.layers.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.layers.ts @@ -18,20 +18,11 @@ import { loginApiLayer } from "../../command-internal/login-api.layer.ts"; import { loginCryptoLayer } from "../../command-internal/login-crypto.layer.ts"; import { templateServiceLayer } from "./bootstrap.templates.ts"; -// `bootstrap` is a meta-orchestrator: it needs the full Management-API stack -// (create / api-keys / link cores), the browser-login stack (ensure-login), and -// the GitHub template service. `Layer.provide` does not share to siblings inside -// a `Layer.mergeAll` (CLAUDE.md invariant 5), so every sub-layer that requires -// `CommandSettings` / `HttpClient` / `CommandCredentials` is fed those explicitly. -// Shared sub-layers are memoised by reference so the merge reuses one keyring -// reader / one debug-logging HTTP wrapper / one config loader. -// -// `Output`, `Analytics`, `Stdio`, `Tty`, `RuntimeInfo`, `ProcessControl`, and -// `BunServices` (`FileSystem` / `Path` / `ChildProcessSpawner`) come from the root -// layer (`cli/root.ts` + `runCli`). `DebugLogger` is -// NOT provided by the root, so every base layer that reads it for `--debug` traces -// (`commandSettingsLayer`, `httpClientLayer`, `commandCredentialsLayer`, -// `commandPlatformApiLayer`) is fed `debugLoggerLayer` here — matching `login.layers.ts`. +// `bootstrap` needs the full Management-API stack, the browser-login stack, and the GitHub +// template service. `Layer.provide` doesn't share to siblings inside `Layer.mergeAll` (CLAUDE.md +// invariant 5), so every sub-layer needing `CommandSettings`/`HttpClient`/`CommandCredentials` is +// fed those explicitly; shared sub-layers are memoised by reference so the merge reuses one +// instance of each. `DebugLogger` isn't provided by the root layer, so it's fed here too. const debugLogger = debugLoggerLayer; const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLogger)); const httpClient = httpClientLayer.pipe(Layer.provide(debugLogger)); @@ -63,17 +54,13 @@ export const bootstrapRuntimeLayer = Layer.mergeAll( ), telemetryStateLayer, dbConnectionLayer, - // Exposed bare (not just used to feed sibling sub-layers, as elsewhere in this - // file) because `bootstrap.handler.ts` now calls `resolveLinkedConn` - // (CLI-1953's IPv4-pooler-fallback push connection) directly, which reads it. + // Exposed bare, not just fed to sibling sub-layers, because `bootstrap.handler.ts` calls + // `resolveLinkedConn` directly and reads it. debugLogger, - // The one per-command identity stitcher (a single root-context `sync.Once`), - // exposed at top level so `withCommandTelemetry` can read - // `stitchedDistinctId()` and attribute the cli_command_executed event to the - // gotrue id. The SAME reference is provided to platformApi / linkedProjectCache - // above, so memoisation gives all transports one `stitchAttempted` guard — - // aliasing/persisting at most once. Its Analytics / TelemetryRuntime / - // FileSystem / Path deps are ambient (root runtime). Mirrors advisors.layers.ts. + // Exposed at top level so `withCommandTelemetry` can read `stitchedDistinctId()` and attribute + // `cli_command_executed` to the gotrue id. The same reference is provided to + // platformApi/linkedProjectCache above, so memoisation gives every transport one + // stitch-attempted guard, aliasing/persisting at most once. identityStitchLayer, loginApiLayer.pipe(Layer.provide(httpClient), Layer.provide(cliSettings)), loginCryptoLayer, diff --git a/apps/cli/src/commands/bootstrap/bootstrap.layers.unit.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.layers.unit.test.ts index 12474c2ca8..ac1b69f3e7 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.layers.unit.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.layers.unit.test.ts @@ -1,14 +1,4 @@ -/** - * Layer-exposure test for `bootstrapRuntimeLayer`. - * - * Verifies that `IdentityStitch` is exposed at the top level of the - * runtime layer so that `withCommandTelemetry` can read - * `stitchedDistinctId()` via `Effect.serviceOption(IdentityStitch)` and - * attribute the `cli_command_executed` event to the gotrue id. - * - * See `db/lint/lint.layers.unit.test.ts` for the canonical pattern and a - * detailed explanation of the bug this guards against. - */ +/** See `db/lint/lint.layers.unit.test.ts` for the canonical pattern this test follows. */ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; @@ -55,11 +45,8 @@ import { bootstrapRuntimeLayer } from "./bootstrap.layers.ts"; const tempRoot = useTempWorkdir("supabase-bootstrap-layers-"); -/** - * Stub layer satisfying every external service required by - * `bootstrapRuntimeLayer` from the root runtime. Services under test are - * left as `Effect.die` no-ops — layer construction must not invoke them. - */ +// Stub layer for every external service `bootstrapRuntimeLayer` needs from the root runtime. +// Services under test are `Effect.die` no-ops — layer construction must not invoke them. function ambientStubs() { const analytics = mockAnalytics(); const out = mockOutput(); @@ -74,11 +61,9 @@ function ambientStubs() { Layer.succeed(CliArgs, { args: [] }), ); - // Bootstrap's runtime layer provides CommandPlatformApi, CommandPlatformApiFactory, - // and ProjectRefResolver by building them from real sub-layers. These - // stubs are present so that the Effect type system sees those services as - // satisfiable in the outer ambient context; the runtime layer's own provisions - // take precedence at runtime. + // These stubs exist only so the Effect type system sees CommandPlatformApi, + // 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") }), @@ -104,12 +89,9 @@ function ambientStubs() { return Layer.mergeAll( BunServices.layer, - // The runtime layer under test builds the REAL commandSettingsLayer against - // the real filesystem — see isolatedHomeLayer's docs. Bootstrap's - // commandPlatformApiLayer additionally eagerly validates the access token at - // layer-construction time, so inject a valid token via the isolated env — - // the same mechanism the cli-e2e harness uses (SUPABASE_ACCESS_TOKEN env - // var, CLAUDE.md invariant 4 dual-mode profile). + // Builds the real `commandSettingsLayer` against the real filesystem (see + // `isolatedHomeLayer`'s docs). `commandPlatformApiLayer` eagerly validates the access token at + // construction time, so inject a valid one via the isolated env (`SUPABASE_ACCESS_TOKEN`). isolatedHomeLayer(tempRoot.current, { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), mockTty(), mockProcessControl().layer, diff --git a/apps/cli/src/commands/bootstrap/bootstrap.pgconfig.ts b/apps/cli/src/commands/bootstrap/bootstrap.pgconfig.ts index ad025a3f4f..dbfa65ae65 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.pgconfig.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.pgconfig.ts @@ -1,10 +1,7 @@ /** - * Pure Postgres connection-string helpers. Used only to build the `.env` file's - * `POSTGRES_URL`/derived keys (`bootstrap.dotenv.ts`) — no live DB connection here. - * The push step's actual connection is resolved separately, by - * `resolveLinkedConn` (`db-config.layer.ts`), which reproduces the - * *rest* of `NewDbConfigWithPassword` this module doesn't: the direct-host - * reachability probe and the IPv4 pooler fallback for IPv6-only projects. + * Pure Postgres connection-string helpers for the `.env` file's `POSTGRES_URL`/derived keys; no + * live DB connection here. The push step's actual connection (with reachability probing and IPv4 + * pooler fallback) is resolved separately by `resolveLinkedConn`. */ export interface DbConfig { @@ -15,15 +12,14 @@ export interface DbConfig { readonly database: string; } -// `url.UserPassword` escapes userinfo with the `encodeUserPassword` mode: -// unreserved chars + the sub-delims `$ & + , ; =` pass through; the reserved -// `@ / ? :` and everything else are percent-encoded (`net/url.shouldEscape`). +// Percent-encodes everything except unreserved chars and the sub-delims `$ & + , ; =`; the +// reserved `@ / ? :` are always escaped. const USERINFO_UNESCAPED = new Set( "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~$&+,;=".split(""), ); -// `url.PathEscape` uses `encodePathSegment`: escape `/ ; , ?` and anything -// outside unreserved + the remaining reserved sub-delims `$ & + : = @`. +// Percent-encodes everything except unreserved chars and the sub-delims `$ & + : = @`; `/ ; , ?` +// are always escaped. const PATH_SEGMENT_UNESCAPED = new Set( "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_.~$&+:=@".split(""), ); @@ -43,11 +39,8 @@ function percentEscape(value: string, allowed: ReadonlySet<string>): string { } /** - * Reproduces `ToPostgresURL`: - * `postgresql://<user>:<pass>@<host>:<port>/<db>?connect_timeout=10`, with - * percent-encoded userinfo, a path-escaped database, and IPv6 hosts wrapped in - * square brackets. Bootstrap passes no `RuntimeParams`, so the only query - * parameter is the default `connect_timeout=10`. + * Renders `postgresql://<user>:<pass>@<host>:<port>/<db>?connect_timeout=10`, with + * percent-encoded userinfo, a path-escaped database, and IPv6 hosts wrapped in square brackets. */ export function toPostgresUrl(config: DbConfig): string { const userinfo = `${percentEscape(config.user, USERINFO_UNESCAPED)}:${percentEscape( @@ -60,15 +53,11 @@ export function toPostgresUrl(config: DbConfig): string { } /** - * Derives the remote project's naive direct (session-mode) connection shape — - * `host = db.<ref>.<projectHost>`, `user = postgres`, `database = postgres`, - * direct port `5432` — for the `.env` file only. Unlike - * `flags.NewDbConfigWithPassword`, this never probes reachability or falls back - * to the IPv4 pooler, so on an IPv6-only project the `.env`'s `POSTGRES_URL` - * (and derived keys) point at a host the user's own machine may not be able to - * reach directly — a pre-existing, narrow divergence tracked as out-of-scope - * for CLI-1953 (which fixed this same gap for the actual push connection; - * see `resolveLinkedConn`). + * Derives the remote project's naive direct (session-mode) connection shape for the `.env` file + * only: `host = db.<ref>.<projectHost>`, `user = postgres`, `database = postgres`, port `5432`. + * Never probes reachability or falls back to the IPv4 pooler, so on an IPv6-only project the + * `.env`'s `POSTGRES_URL` may point at a host the user's machine can't reach directly; the actual + * push connection is resolved separately via `resolveLinkedConn`. */ export function deriveDbConfig(ref: string, password: string, projectHost: string): DbConfig { return { diff --git a/apps/cli/src/commands/bootstrap/bootstrap.retry.ts b/apps/cli/src/commands/bootstrap/bootstrap.retry.ts index 56cb98caeb..773901111f 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.retry.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.retry.ts @@ -3,25 +3,18 @@ import { Duration, Effect, Random, Schedule } from "effect"; import { DebugFlag } from "../../command-internal/global-flags.ts"; import { Output } from "../../shared/output/output.service.ts"; -/** - * `maxRetries` from the established `utils.NewBackoffPolicy`. - * `backoff.WithMaxRetries(b, 8)` performs 8 retries -> 9 total attempts, matching - * `Effect.retry({ schedule, times: 8 })`. - */ +/** 8 retries (9 total attempts) via `Effect.retry({ schedule, times: 8 })`. */ export const BOOTSTRAP_MAX_RETRIES = 8; const MAX_INTERVAL = Duration.seconds(60); /** - * Go-parity backoff for the api-keys and health retry loops - * (`utils.NewBackoffPolicy` -> `cenkalti/backoff` `NewExponentialBackOff`): + * Backoff for the api-keys and health retry loops: * * - 3s initial interval, multiplier `1.5` - * - `MaxInterval` capped at 60s (applied to the base interval **before** jitter, - * matching cenkalti's order — so an individual delay can reach ~90s) - * - `RandomizationFactor` `0.5` -> each delay is jittered into `[0.5x, 1.5x]` - * - `MaxElapsedTime` 15m (intersected via `during`; in practice the 8-retry cap - * always trips first, but reproduced for completeness) + * - Capped at 60s before jitter is applied, so an individual delay can reach ~90s + * - Jittered into `[0.5x, 1.5x]` of the (possibly capped) interval + * - Bounded to 15 minutes total elapsed time, though the 8-retry cap always trips first */ export const bootstrapBackoff = Schedule.exponential("3 seconds", 1.5).pipe( Schedule.modifyDelay(({ duration }) => Effect.succeed(Duration.min(duration, MAX_INTERVAL))), @@ -34,14 +27,11 @@ export const bootstrapBackoff = Schedule.exponential("3 seconds", 1.5).pipe( ); /** - * Reproduces the established `utils.NewErrorCallback`: after each failed - * attempt it prints `<err>\nRetry (n/8): ` to a logger that starts as the - * debug logger (discarded unless `--debug`) and switches to stderr once - * `failureCount*3 > maxRetries` (i.e. from the 3rd failure on). Notify fires only for - * attempts that will be retried, never the final exhausted one. + * After each failed attempt, prints `<err>\nRetry (n/8): ` to the debug logger (discarded unless + * `--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, mirroring Go's - * per-`RetryNotify` `NewErrorCallback()` + `policy.Reset()`. + * Returns a fresh wrapper with its own failure counter per call. */ export const bootstrapRetryNotify = () => { let failureCount = 0; @@ -50,7 +40,7 @@ export const bootstrapRetryNotify = () => { Effect.tapError((error) => Effect.gen(function* () { failureCount += 1; - // No notify on the final attempt (cenkalti returns `Stop` before notifying). + // No notify on the final, exhausted attempt. if (failureCount > BOOTSTRAP_MAX_RETRIES) return; const toStderr = failureCount * 3 > BOOTSTRAP_MAX_RETRIES; const debug = yield* DebugFlag; diff --git a/apps/cli/src/commands/bootstrap/bootstrap.retry.unit.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.retry.unit.test.ts index fa53b9dadf..5a53397450 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.retry.unit.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.retry.unit.test.ts @@ -5,9 +5,9 @@ import { mockOutput } from "../../../tests/helpers/mocks.ts"; import { DebugFlag } from "../../command-internal/global-flags.ts"; import { bootstrapBackoff, bootstrapRetryNotify } from "./bootstrap.retry.ts"; -// Drive the schedule's step function directly (no real sleeping): each call returns the -// next delay, and we feed an artificial `now` advanced by that delay so the `during` -// (max-elapsed) gate sees virtual time pass. +// Drives the schedule's step function directly (no real sleeping): each call returns the next +// delay, and an artificial `now` advances by that delay so the max-elapsed gate sees virtual +// time pass. const collectDelays = (maxAttempts: number) => Effect.gen(function* () { const step = yield* Schedule.toStep(bootstrapBackoff); @@ -34,8 +34,6 @@ describe("bootstrapBackoff", () => { it.effect("caps each delay at the 60s max interval (plus 50% jitter)", () => Effect.gen(function* () { const { delays } = yield* collectDelays(40); - // 60s `MaxInterval` capped before jitter, then jittered up to 1.5x => 90s ceiling. - // Without the cap, the exponential base alone would exceed 90s within ~9 attempts. for (const ms of delays) { expect(ms).toBeGreaterThan(0); expect(ms).toBeLessThanOrEqual(90_000); @@ -54,8 +52,6 @@ describe("bootstrapBackoff", () => { it.effect("stops at the 15m max-elapsed cap, independent of the 8-retry limit", () => Effect.gen(function* () { const { delays, stopped } = yield* collectDelays(60); - // The schedule itself is bounded by elapsed time (15m), not the `times: 8` the - // handler layers on, so it yields more than 8 delays before halting. expect(stopped).toBe(true); expect(delays.length).toBeGreaterThan(8); }), @@ -83,10 +79,8 @@ describe("bootstrapRetryNotify", () => { it.effect("routes failures 1-2 to the debug logger (suppressed without --debug)", () => Effect.gen(function* () { const out = yield* runNotify({ debug: false }); - // Failures 1-2 are debug-only; with --debug unset they must not reach stderr. expect(out.stderrText).not.toContain("Retry (1/8): "); expect(out.stderrText).not.toContain("Retry (2/8): "); - // Failures 3+ always print to stderr, preceded by the error message. expect(out.stderrText).toContain("boom\nRetry (3/8): "); expect(out.stderrText).toContain("Retry (8/8): "); }), @@ -104,7 +98,6 @@ describe("bootstrapRetryNotify", () => { it.effect("emits no notice on the final exhausted attempt", () => Effect.gen(function* () { const out = yield* runNotify({ debug: true }); - // 8 retries => 9 attempts, but cenkalti returns Stop before notifying the last one. expect(out.stderrText).not.toContain("Retry (9/8): "); }), ); diff --git a/apps/cli/src/commands/bootstrap/bootstrap.suggest.ts b/apps/cli/src/commands/bootstrap/bootstrap.suggest.ts index 52e96a3296..050a305916 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.suggest.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.suggest.ts @@ -1,14 +1,10 @@ import { relative } from "node:path"; /** - * Builds the "To start your app:" hint printed at the end of bootstrap. Computes - * the relative path from the original working directory (`utils.CurrentDirAbs`) to - * the (post-chdir) project directory; a non-trivial relative path adds a `cd <rel>` - * line, and a non-empty start command adds a second line. + * Builds the "To start your app:" hint printed at the end of bootstrap: a `cd <rel>` line when + * the project directory differs from the original cwd, then the start command if non-empty. * - * Colour is applied via the injected `colorize` callback (each command line - * is wrapped in `utils.Aqua`). It defaults to identity so unit tests assert - * the raw text, matching a non-TTY (uncoloured) profile. + * `colorize` defaults to identity so unit tests can assert raw, uncoloured text. */ export function suggestAppStart( currentDirAbs: string, diff --git a/apps/cli/src/commands/bootstrap/bootstrap.templates.ts b/apps/cli/src/commands/bootstrap/bootstrap.templates.ts index fe41d0364b..2b99abe9bf 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.templates.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.templates.ts @@ -14,15 +14,11 @@ export interface StarterTemplate { } interface TemplateServiceShape { - /** - * Fetches and decodes `samples.json` from the `supabase-community/supabase-samples` - * repo (`ListSamples`). Returns the declared starter templates. - */ + /** Fetches and decodes `samples.json` from the `supabase-community/supabase-samples` repo. */ readonly listSamples: Effect.Effect<ReadonlyArray<StarterTemplate>, BootstrapTemplateListError>; /** - * Downloads every file under a `https://github.com/<owner>/<repo>/tree/<ref>/<root>` - * template URL into `targetDir`, preserving the directory layout below `<root>` - * (`downloadSample`). Concurrency matches the established job queue (5). + * Downloads every file under a `https://github.com/<owner>/<repo>/tree/<ref>/<root>` template + * URL into `targetDir`, preserving the directory layout below `<root>`, with concurrency 5. */ readonly download: ( templateUrl: string, @@ -75,8 +71,7 @@ export const templateServiceLayer = Layer.effect( const path = yield* Path.Path; const output = yield* Output; - // Go reads `GITHUB_TOKEN` directly (`utils.GetGitHubClient`) to raise the - // anonymous GitHub API rate limit. When unset, requests are anonymous. + // Raises the anonymous GitHub API rate limit when set; requests are anonymous otherwise. const githubToken = process.env["GITHUB_TOKEN"]; const contentsRequest = (owner: string, repo: string, contentPath: string, ref: string) => { @@ -183,10 +178,9 @@ export const templateServiceLayer = Layer.effect( for (const entry of listing) { const entryPath = entry.path ?? ""; if (entry.type === "file") { - // Strip `<root>` on a path-segment boundary so a sibling directory that - // merely shares the prefix (e.g. `examples/app-2` under `root="examples/app"`) - // is never mis-sliced. The contents API only returns children of the queried - // directory, but matching on `root + "/"` is the obviously-correct form. + // Strip `<root>` on a path-segment boundary so a sibling directory that merely + // shares the prefix (e.g. `examples/app-2` under `root="examples/app"`) is never + // mis-sliced. const relative = root === "" ? entryPath @@ -208,9 +202,8 @@ export const templateServiceLayer = Layer.effect( message: `failed to download template: entry escapes target directory: ${entryPath}`, }); } - // GitHub returns a null `download_url` for files over 1 MB and for - // submodules; without an explicit guard the `?? ""` fallback would issue - // `GET ""` and surface a confusing transport error instead of a clear one. + // GitHub returns a null `download_url` for files over 1 MB and submodules; without + // this guard, the `?? ""` fallback would issue `GET ""` with a confusing error. if (entry.download_url == null || entry.download_url.length === 0) { return yield* new BootstrapTemplateDownloadError({ message: `failed to download template: unsupported entry (no download URL): ${entryPath}`, diff --git a/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts index 8eda142158..920b20ee44 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts @@ -60,12 +60,9 @@ const ORGS = [{ id: "org-1", slug: "acme", name: "Acme Inc" }]; const API_KEYS = [{ name: "anon", api_key: "anon-key" }]; const HEALTHY = [{ name: "db", healthy: true, status: "ACTIVE_HEALTHY" }]; -// Drives the handler through the *prompt* workdir path (no `--workdir` flag and no -// `SUPABASE_WORKDIR` env) with the real config + linked-project-cache layers. This is -// the case the rest of the suite never covers: when the workdir comes from the prompt, -// `cliSettings.workdir` (the cwd-walk result) diverges from the bootstrap workdir, and the -// cache must follow the bootstrap workdir so `linked-project.json` lands beside -// `project-ref` (matching the established config-load-after-chdir ordering). +// Drives the handler through the prompt workdir path (no `--workdir` flag, no `SUPABASE_WORKDIR` +// env): `cliSettings.workdir` (the cwd-walk result) then diverges from the bootstrap workdir, so +// the cache must follow the bootstrap workdir for `linked-project.json` to land beside `project-ref`. describe("bootstrap linked-project cache location", () => { it.live( "writes linked-project.json into the prompted bootstrap workdir, not cliSettings.workdir", @@ -74,18 +71,14 @@ describe("bootstrap linked-project cache location", () => { const subdir = "myproj"; const bootstrapWorkdir = join(parent, subdir); - // Pre-seed a migration file at the bootstrap workdir (before it even exists) so - // the push step's migrations lookup is empirically provable: `dbPushCore` - // must find it via the `workdir` local variable — the prompted bootstrap - // workdir — never `cliSettings.workdir` (the cwd-walk result from `parent`, which - // has no `supabase/migrations` of its own and would wrongly report "up to date"). + // Pre-seeds a migration file at the bootstrap workdir (before it exists) so the push step + // must find it via the `workdir` local variable, never `cliSettings.workdir` (the cwd-walk + // result from `parent`, which has no `supabase/migrations` and would report "up to date"). const migrationsDir = join(bootstrapWorkdir, "supabase", "migrations"); mkdirSync(migrationsDir, { recursive: true }); writeFileSync(join(migrationsDir, "20240101000000_test.sql"), "create table t ();"); - // Also pre-seed `supabase/roles.sql` so the push step's `includeRoles: true` - // (bootstrap always passes it) is actually pinned under test — without a - // roles.sql file present, the - // custom-roles branch is a no-op and `includeRoles`'s value is unasserted. + // Also pre-seeds `supabase/roles.sql` so `includeRoles: true` is pinned under test; without + // it, the custom-roles branch is a no-op and that value goes unasserted. writeFileSync(join(bootstrapWorkdir, "supabase", "roles.sql"), "create role app;"); // Token via env => ensure-login is a no-op and the cache has a bearer token. @@ -110,13 +103,9 @@ describe("bootstrap linked-project cache location", () => { if (url.includes("/v1/organizations")) { return Effect.succeed(jsonResponse(request, 200, ORGS)); } - // Pooler config: the direct db host is never reachable in-process, so - // `resolveLinkedConn`'s push-connection resolution always falls - // back to the IPv4 pooler (CLI-1953). `linkServicesCore`'s own - // `linkPooler` step (step I) fetches this same route and saves it to - // `<bootstrapWorkdir>/supabase/.temp/pooler-url`, which the fallback reads. - // Checked before the broader `/v1/projects/{ref}` GET below, which would - // otherwise also match this path. + // Pooler config: the direct db host is never reachable in-process, so `resolveLinkedConn` + // falls back to the IPv4 pooler fed by the saved pooler-url file. Checked before the + // broader `/v1/projects/{ref}` GET below, which would otherwise also match this path. if (recorded.method === "GET" && url.includes("/config/database/pooler")) { return Effect.succeed( jsonResponse(request, 200, [ @@ -145,9 +134,8 @@ describe("bootstrap linked-project cache location", () => { }; const api = mockCommandPlatformApi({ handler }); - // Native push (CLI-1953): `dbPushCore` needs a `DbConnection` — - // tracked here so the test can assert it targets the created project's ref, - // not a divergent one. + // `dbPushCore` needs a `DbConnection`; tracked here so the test can assert it targets the + // created project's ref, not a divergent one. const pushConnectCalls: Array<PgConnInput> = []; const dbConnectionLayer = Layer.succeed(DbConnection, { connect: (conn: PgConnInput) => @@ -168,8 +156,8 @@ describe("bootstrap linked-project cache location", () => { download: () => Effect.void, }); - // GlobalFlag services don't cross sibling boundaries in Layer.mergeAll - // (apps/cli/CLAUDE.md item 5), so provide them explicitly into the real config layer. + // GlobalFlag services don't cross sibling boundaries in `Layer.mergeAll` (CLAUDE.md + // invariant 5), so provide them explicitly into the real config layer. const flagsLayer = Layer.mergeAll( Layer.succeed(ProfileFlag, "supabase"), Layer.succeed(WorkdirFlag, Option.none()), @@ -194,9 +182,8 @@ describe("bootstrap linked-project cache location", () => { Layer.provide(configLayer), Layer.provide(credentials.layer), Layer.provide(api.httpClientLayer), - // The cache GET stitches identity from X-Gotrue-Id (the established - // identityTransport) via the single `IdentityStitch` service. Consent "denied" makes the - // stitch a no-op so this workdir-caching test's assertions are unchanged. + // Stitches identity from X-Gotrue-Id via the single `IdentityStitch` service; consent + // "denied" makes the stitch a no-op, so this test's assertions are unchanged. Layer.provide( identityStitchLayer.pipe( Layer.provide(mockAnalytics().layer), @@ -204,8 +191,7 @@ describe("bootstrap linked-project cache location", () => { Layer.provide(BunServices.layer), ), ), - // The cache also fires org/project groupIdentify, reading - // Analytics directly. + // The cache also fires org/project groupIdentify, reading Analytics directly. Layer.provide(mockAnalytics().layer), Layer.provide(BunServices.layer), ); @@ -249,41 +235,24 @@ describe("bootstrap linked-project cache location", () => { const cacheInWorkdir = join(bootstrapWorkdir, "supabase", ".temp", "linked-project.json"); const cacheInParent = join(parent, "supabase", ".temp", "linked-project.json"); - // project-ref already goes to the right place... expect(existsSync(projectRef)).toBe(true); - // ...so linked-project.json must land beside it (Go writes both into workdir). expect(existsSync(cacheInWorkdir)).toBe(true); expect(existsSync(cacheInParent)).toBe(false); - // Native push (CLI-1953) correctness: `dbPushCore` connects to the - // just-created project (the `projectRef` bootstrap already holds in - // memory, never re-resolved via `ProjectRefResolver`) and finds the - // pre-seeded migration under `<bootstrapWorkdir>/supabase/migrations` — the - // `workdir` local variable, not `cliSettings.workdir` (which cwd-walks from - // `parent` and would find nothing, wrongly reporting "up to date"). The - // direct db host is never reachable in-process, so `resolveLinkedConn` - // falls back to the IPv4 pooler (CLI-1953) — reading the saved - // `<bootstrapWorkdir>/supabase/.temp/pooler-url` `linkServicesCore` - // (step I) wrote, which is itself proof the fallback is workdir-scoped - // correctly too. expect(pushConnectCalls).toHaveLength(1); expect(pushConnectCalls[0]?.host).toBe("aws-0-us-east-1.pooler.supabase.com"); expect(pushConnectCalls[0]?.user).toBe(`postgres.${VALID_REF}`); expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); - // Pins `includeRoles: true` (the pre-seeded `supabase/roles.sql` above): - // without it, the custom-roles prompt/apply below is unreachable and - // `includeRoles`'s value goes unasserted. The confirm prompt itself is - // interactive UI (clack), not `output.raw` text, so it's recorded in - // `promptConfirmCalls`, not `stderrText`. + // The confirm prompt is interactive UI (clack), not `output.raw` text, so it's recorded + // in `promptConfirmCalls`, not `stderrText`. expect( out.promptConfirmCalls.some((c) => c.message.includes("Do you want to create custom roles in the database cluster?"), ), ).toBe(true); expect(out.stderrText).toContain("Seeding globals from roles.sql..."); - // Pins `includeSeed: true`: with no `supabase/seed.sql` file, the seed - // glob matches nothing, so the push step reports seeds up to date — a - // line that only prints at all when `includeSeed` is true. + // With no `supabase/seed.sql` file, the seed glob matches nothing, so this line only + // prints when `includeSeed` is true. expect(out.stderrText).toContain("Seed files are up to date."); }).pipe( Effect.provide(layer), diff --git a/apps/cli/src/commands/branches/branches.errors.ts b/apps/cli/src/commands/branches/branches.errors.ts index e6a8341fed..293e7722c5 100644 --- a/apps/cli/src/commands/branches/branches.errors.ts +++ b/apps/cli/src/commands/branches/branches.errors.ts @@ -7,11 +7,6 @@ import { statusCodeActionability, } from "../../shared/telemetry/error-actionability.ts"; -// --------------------------------------------------------------------------- -// HTTP-bound errors — one (Network + UnexpectedStatus) pair per error site. -// Templates match the established `errors.Errorf(...)` phrasing byte-for-byte. -// --------------------------------------------------------------------------- - export class BranchesListNetworkError extends Data.TaggedError("BranchesListNetworkError")<{ readonly message: string; readonly decode?: boolean; @@ -55,10 +50,8 @@ export class BranchesCreateUnexpectedStatusError extends Data.TaggedError( readonly upgradeSuggested?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // A non-gated 409 on `branches create` means the branch name already - // exists — user input, not a raw API status. The gate - // guard stays ahead so a confirmed plan-limited 409 still classifies as - // plan_limit via the shared policy. + // A non-gated 409 means the branch name already exists (user input, not a raw API status); + // the gate check stays ahead so a plan-limited 409 still classifies as plan_limit. if (this.upgradeSuggested !== true && this.status === 409) { return { ...actionability.invalidInput, fingerprint_suffix: "conflict" }; } @@ -93,8 +86,7 @@ export class BranchesFindUnexpectedStatusError extends Data.TaggedError( } } -// `branches get` detail phase + the resolver's UUID branch (both use -// V1GetABranchConfig; Go shares the same error template). +// `branches get` detail phase + the resolver's UUID branch (both use V1GetABranchConfig). export class BranchesGetNetworkError extends Data.TaggedError("BranchesGetNetworkError")<{ readonly message: string; readonly decode?: boolean; @@ -293,10 +285,6 @@ export class BranchesDisableUnexpectedStatusError extends Data.TaggedError( } } -// --------------------------------------------------------------------------- -// Pure-path errors (validation, prompt-time semantics, user cancellation). -// --------------------------------------------------------------------------- - export class BranchesEnvNotSupportedError extends Data.TaggedError("BranchesEnvNotSupportedError")<{ readonly message: string; }> { @@ -326,9 +314,8 @@ export class BranchesBranchingDisabledError extends Data.TaggedError( )<{ readonly message: string; /** - * Established suggestion text: "Create your first branch with: supabase - * branches create". Picked up by `normalizeCliError` and printed after the - * error message in text mode. + * Suggestion text ("Create your first branch with: supabase branches create") picked up by + * `normalizeCliError` and printed after the error message in text mode. */ readonly suggestion: string; }> { diff --git a/apps/cli/src/commands/branches/branches.format.ts b/apps/cli/src/commands/branches/branches.format.ts index 026df447a5..fdf7f53ab6 100644 --- a/apps/cli/src/commands/branches/branches.format.ts +++ b/apps/cli/src/commands/branches/branches.format.ts @@ -9,12 +9,6 @@ import { renderGlamourTable } from "../../output/glamour-table.ts"; import { apiKeysToEnv } from "../../command-internal/api-keys.format.ts"; import { formatTimestamp } from "../../command-internal/timestamp.format.ts"; -// --------------------------------------------------------------------------- -// Pure formatters — no Effect / no service dependencies, kept unit-testable. -// Match the established byte output for `branches list`, `branches create`, -// `branches get`. -// --------------------------------------------------------------------------- - const LIST_HEADERS = [ "ID", "NAME", @@ -39,17 +33,12 @@ const GET_HEADERS = [ type Branch = typeof BranchResponse.Type; /** - * Established markdown-table-to-glamour render pipeline: the markdown - * intermediate wraps each cell in backticks and escapes `|` with `\|`; - * glamour decodes the escape sequence back to a literal `|` and strips the - * backticks. `renderGlamourTable` lays out cells directly, so raw values are - * passed through — including any literal `|` in the name / git branch — and - * the byte output matches the established fixture. + * Renders the `branches list` table. Cell values pass through raw (including any literal `|` in + * the name or git branch); `renderGlamourTable` lays out cells directly rather than through a + * markdown/glamour escape roundtrip. * - * `activeRef`, when given, marks the row whose `project_ref` matches by - * rendering its NAME cell as `<name> (active)`. TS-only QoL - * (CLI-2167 follow-up, no Go counterpart): the pretty table only, never the - * `-o json|yaml|toml` / `--output-format json|stream-json` payloads. + * `activeRef`, when given, renders the matching row's NAME cell as `<name> (active)` — the + * pretty table only, never the `-o json|yaml|toml`/`--output-format json|stream-json` payloads. */ export function renderBranchesListTable( branches: ReadonlyArray<Branch>, @@ -69,8 +58,8 @@ export function renderBranchesListTable( } /** - * Pretty-table render: one row with 7 columns. `db_user` / `db_pass` / - * `jwt_secret` render as `******` when the API returns nil/undefined. + * Pretty-table render: one row with 7 columns. `db_user`/`db_pass`/`jwt_secret` render as + * `******` when the API returns null. */ export function renderBranchGetTable(detail: typeof V1GetABranchConfigOutput.Type): string { const rows = [ @@ -104,17 +93,10 @@ export type PoolerParseResult = | { readonly ok: false; readonly error: string }; /** - * Removes the `[YOUR-PASSWORD]` placeholder text from the connection string - * (it confuses pgconn's strict URL parser) and then parses the host/port/user. - * - * On failure, returns a structured result with the parse error description — - * not the raw connection string. The established WARNING line carries the - * pgconn parse error message (e.g. `failed to parse pooler URL: parse "...": - * invalid port`), never the URL itself. Returning the URL would leak the - * pooler username, host, and port into stderr logs. - * - * This display-only parser intentionally does not enforce the profile-domain or - * tenant-ref guards used by `poolerConfigFromConnectionString`. + * Removes the `[YOUR-PASSWORD]` placeholder before parsing host/port/user, since it isn't valid + * URL syntax. On failure, returns a structured error description, never the raw connection + * string, since that would leak the pooler username, host, and port into stderr logs. Doesn't + * enforce the profile-domain or tenant-ref guards used by `poolerConfigFromConnectionString`. */ export function parsePoolerConnectionString(connString: string): PoolerParseResult { const sanitized = connString.replaceAll(POOLER_PASSWORD_PLACEHOLDER, ""); @@ -122,9 +104,8 @@ export function parsePoolerConnectionString(connString: string): PoolerParseResu try { url = new URL(sanitized); } catch { - // Node's URL constructor embeds the input string in its error message. - // Return a stable description with no input fragments so the warning - // line on stderr never leaks the pooler URL. + // Node's URL constructor embeds the input in its error message; return a stable + // description with no input fragments so the warning line never leaks the pooler URL. return { ok: false, error: "invalid URL" }; } if (url.protocol !== "postgresql:" && url.protocol !== "postgres:") { @@ -161,11 +142,11 @@ interface PgConfig { } /** - * Established URL shape: + * Renders: * * postgresql://<urlencode(user):urlencode(pass)>@<host>:<port>/<pathEscape(db)>?connect_timeout=10[&k=urlencode(v)] * - * IPv6 hosts get wrapped in square brackets. ConnectTimeout defaults to 10. + * IPv6 hosts are wrapped in square brackets; `connect_timeout` defaults to 10. */ export function toPostgresUrl(config: PgConfig, connectTimeoutSeconds: number = 10): string { const params = new URLSearchParams(); @@ -190,10 +171,7 @@ type Detail = typeof V1GetABranchConfigOutput.Type; export interface StandardEnvsResult { readonly envs: Record<string, string>; - /** - * Set when the pooler URL failed to parse, so the caller can print the - * established `fmt.Fprintln(os.Stderr, utils.Yellow("WARNING:"), err)` line. - */ + /** Set when the pooler URL failed to parse, so the caller can print the established WARNING line. */ readonly poolerWarning?: string; } diff --git a/apps/cli/src/commands/branches/branches.go-payload.ts b/apps/cli/src/commands/branches/branches.go-payload.ts index 8a72c23f17..0faf589740 100644 --- a/apps/cli/src/commands/branches/branches.go-payload.ts +++ b/apps/cli/src/commands/branches/branches.go-payload.ts @@ -37,11 +37,8 @@ export const GO_BRANCH_RESPONSE: GoType = goStruct([ ["with_data", goBool], ]); -/** `branches list -o yaml` encodes the bare `[]api.BranchResponse`. */ +/** `branches list -o yaml` encodes the bare array of branch structs. */ export const GO_BRANCHES_LIST: GoType = goSlice(GO_BRANCH_RESPONSE); -/** - * `branches list -o toml` wraps the slice: - * `struct{ Branches []api.BranchResponse `toml:"branches"` }`. - */ +/** `branches list -o toml` wraps the array under a top-level `branches` key. */ export const GO_BRANCHES_TOML_WRAPPER: GoType = goTomlListWrapper("branches", GO_BRANCH_RESPONSE); diff --git a/apps/cli/src/commands/branches/branches.prompt.ts b/apps/cli/src/commands/branches/branches.prompt.ts index dd635e00e0..373fbbd723 100644 --- a/apps/cli/src/commands/branches/branches.prompt.ts +++ b/apps/cli/src/commands/branches/branches.prompt.ts @@ -22,19 +22,12 @@ const mapListError = mapHttpError({ }); /** - * Prompts for a branch identifier when the positional `[name]` argument is - * omitted: + * Prompts for a branch identifier when the positional `[name]` argument is omitted: non-TTY + * reads stdin with a git-branch default, failing with "branch name cannot be empty" if both are + * empty; TTY lists branches and presents a `promptSelect`, failing with "branching is disabled" + * if none exist. * - * - Non-TTY: read from stdin via `Output.promptText`. The prompt label - * includes the current git branch as a default when one is detected. - * If the user enters an empty string and no git branch is available, - * fail with "branch name cannot be empty". - * - TTY: call the list endpoint; if empty, fail with "branching is disabled". - * Otherwise present a `promptSelect` and write `"Selected branch ID: <ref>"` - * to stderr (text mode only). - * - * Used by `get`, `update`, `pause`, `unpause`, `delete` whenever the positional - * `[name]` argument is omitted. + * Used by `get`, `update`, `pause`, `unpause`, `delete`. */ export const promptBranchId = Effect.fnUntraced(function* ( input: Option.Option<string>, @@ -48,11 +41,9 @@ export const promptBranchId = Effect.fnUntraced(function* ( const output = yield* Output; if (!tty.stdinIsTty) { - // Non-TTY path: read once from stdin, optionally with a git-branch default. const gitBranch = yield* detectGitBranch(); const defaultBranch = Option.getOrElse(gitBranch, () => ""); - // Established styling: the default is colorized (lipgloss color "14" maps - // to ANSI bright cyan; `styleText("cyan", ...)` is the closest faithful match). + // Cyan matches the established color for this default value. const label = defaultBranch.length > 0 ? `Enter the name of your branch (or leave blank to use ${styleText("cyan", defaultBranch)}): ` @@ -69,8 +60,6 @@ export const promptBranchId = Effect.fnUntraced(function* ( return resolved; } - // TTY path: list branches via the same endpoint as `branches list`, then - // present a select prompt keyed by branch ref. const api = yield* CommandPlatformApi; const branches = yield* api.v1 .listAllBranches({ ref: projectRef }) @@ -78,7 +67,7 @@ export const promptBranchId = Effect.fnUntraced(function* ( if (branches.length === 0) { return yield* new BranchesBranchingDisabledError({ message: "branching is disabled", - // The command name is wrapped in lipgloss color "14" (ANSI cyan). + // Cyan matches the established color for the suggested command. suggestion: `Create your first branch with: ${styleText("cyan", "supabase branches create")}`, }); } diff --git a/apps/cli/src/commands/branches/branches.resolver.ts b/apps/cli/src/commands/branches/branches.resolver.ts index 360e41e8d7..9dd8e8516b 100644 --- a/apps/cli/src/commands/branches/branches.resolver.ts +++ b/apps/cli/src/commands/branches/branches.resolver.ts @@ -22,9 +22,8 @@ const mapGetError = mapHttpError({ }); /** - * The branches family's binding of the shared branch-ref resolver - * (`command-internal/branch-ref.resolver.ts`) to this family's error - * classes. See the shared module for resolution semantics. + * Binds the shared branch-ref resolver to this family's error classes. See + * `command-internal/branch-ref.resolver.ts` for resolution semantics. */ export function resolveBranchProjectRef(input: string, projectRef: string) { return resolveBranchProjectRefShared(input, projectRef, { mapGetError, mapFindError }); diff --git a/apps/cli/src/commands/branches/create/create.command.ts b/apps/cli/src/commands/branches/create/create.command.ts index a8854507c0..fb24bad899 100644 --- a/apps/cli/src/commands/branches/create/create.command.ts +++ b/apps/cli/src/commands/branches/create/create.command.ts @@ -67,10 +67,8 @@ const config = { Flag.withDescription("Select a desired instance size for the branch database."), Flag.optional, ), - // Optional so the handler can distinguish "flag explicitly set false" - // from "flag absent". Effect CLI surface: `--persistent` sets - // `Option.some(true)`, `--no-persistent` sets `Option.some(false)`, - // absent stays `Option.none()`. + // Optional so the handler can distinguish "flag explicitly set false" from "flag absent": + // `--persistent`/`--no-persistent` set `Option.some(true|false)`; absent stays `Option.none()`. persistent: Flag.boolean("persistent").pipe( Flag.withDescription("Whether to create a persistent branch."), Flag.optional, @@ -100,7 +98,6 @@ export const branchesCreateCommand = Command.make("create", config).pipe( withJsonErrorHandling, ), ), - // `stdinLayer`: the confirmation prompt reads piped stdin via `promptYesNo` - // on a non-TTY stdin. + // `stdinLayer` lets the confirmation prompt read piped stdin via `promptYesNo` on a non-TTY. Command.provide(Layer.mergeAll(managementApiRuntimeLayer(["branches", "create"]), stdinLayer)), ); diff --git a/apps/cli/src/commands/branches/create/create.handler.ts b/apps/cli/src/commands/branches/create/create.handler.ts index 12baa476c5..ae49e81e11 100644 --- a/apps/cli/src/commands/branches/create/create.handler.ts +++ b/apps/cli/src/commands/branches/create/create.handler.ts @@ -41,26 +41,19 @@ export const branchesCreate = Effect.fn("branches.create")(function* (flags: Bra const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - // ----------------------------------------------------------------------- - // Branch-name resolution: defaults to the current git branch when the arg - // is omitted, prompting Y/N first. The decline path returns - // `context.Canceled` — tag-error and short-circuit before resolving the - // project ref so the linked-project cache write does not fire. - // ----------------------------------------------------------------------- + // Branch-name resolution defaults to the current git branch when the arg is omitted, after a + // Y/N prompt. Declining short-circuits before resolving the project ref, so the linked-project + // cache write never fires. let branchName = Option.getOrElse(flags.name, () => ""); - // An explicit `--git-branch` flag takes precedence over the auto-detected - // branch (the flag sets `body.GitBranch`, guarded during auto-detect). + // An explicit `--git-branch` flag takes precedence over the auto-detected branch. let gitBranchForBody = Option.getOrUndefined(flags.gitBranch); if (branchName.length === 0) { const gitBranch = yield* detectGitBranch(); if (Option.isSome(gitBranch) && gitBranch.value.length > 0) { - // Established prompt behavior: `--yes`/`SUPABASE_YES` auto-confirms - // with the `<title> [Y/n] y` stderr echo instead of blocking a TTY, - // and a non-TTY stdin prints the label and scans one piped line - // (100ms) before falling back to the Yes default — - // `echo n | supabase branches create` cancels. The branch name is - // wrapped in `utils.Aqua`. + // `--yes`/`SUPABASE_YES` auto-confirms with a `<title> [Y/n] y` stderr echo; non-TTY + // stdin scans one piped line (100ms) before falling back to Yes — `echo n | supabase + // branches create` cancels. const yes = yield* resolveYes; const confirmed = yield* promptYesNo( output, @@ -84,9 +77,9 @@ export const branchesCreate = Effect.fn("branches.create")(function* (flags: Bra }); } - // `branches` is PARENT-scoped: after `supabase link <branch>`, - // `supabase/.temp/project-ref` holds the branch's own ref, and the platform - // 403s on that ref for every branches-management endpoint (CLI-2167 follow-up). + // `branches` is parent-scoped: after `supabase link <branch>`, `supabase/.temp/project-ref` + // holds the branch's own ref, and the platform 403s on that ref for every branches-management + // endpoint. const ref = yield* resolveParentScopedProjectRef(flags.projectRef); yield* Effect.gen(function* () { @@ -107,8 +100,7 @@ export const branchesCreate = Effect.fn("branches.create")(function* (flags: Bra }) .pipe( Effect.tapError(() => creating?.fail() ?? Effect.void), - // On any non-201 status (including gated 4xx), run the plan-gate - // check before mapping the error. + // Runs the plan-gate check before mapping the error, even for a gated 4xx. Effect.catch( gateMapError( { projectRef: ref, featureKey: "branching_limit" }, @@ -134,8 +126,7 @@ export const branchesCreate = Effect.fn("branches.create")(function* (flags: Bra const goFmt = Option.getOrUndefined(goOutputFlag); - // Established output: "Created preview branch:" writes to stdout, then - // the table or the encoded payload. + // "Created preview branch:" always writes first, then the table or encoded payload. if (goFmt === "json") { yield* output.raw("Created preview branch:\n"); yield* output.raw(encodeGoJson(created)); diff --git a/apps/cli/src/commands/branches/create/create.integration.test.ts b/apps/cli/src/commands/branches/create/create.integration.test.ts index 2d773ac5b3..0c8aa2966d 100644 --- a/apps/cli/src/commands/branches/create/create.integration.test.ts +++ b/apps/cli/src/commands/branches/create/create.integration.test.ts @@ -242,12 +242,7 @@ describe("branches create integration", () => { ); }); - // --------------------------------------------------------------------------- - // Git-branch auto-name confirmation — Go `create.go:17-28` routes it through - // `PromptYesNo(title, true)` (`console.go:64-82`). `GITHUB_HEAD_REF` drives - // `detectGitBranch` deterministically (its highest-priority source). - // --------------------------------------------------------------------------- - + // `GITHUB_HEAD_REF` drives `detectGitBranch` deterministically (its highest-priority source). const withGitBranch = <A, E, R>(effect: Effect.Effect<A, E, R>, branch = "feat-y") => { const prevHead = process.env["GITHUB_HEAD_REF"]; process.env["GITHUB_HEAD_REF"] = branch; @@ -266,8 +261,6 @@ describe("branches create integration", () => { return withGitBranch( Effect.gen(function* () { yield* branchesCreate(baseFlags); - // Established behavior: the `--yes` branch echoes `<title> [Y/n] y` - // to stderr instead of blocking the TTY prompt. expect(out.stderrText).toContain("Do you want to create a branch named "); expect(out.stderrText).toContain("? [Y/n] y\n"); expect(api.requests[0]?.body).toMatchObject({ @@ -308,7 +301,6 @@ describe("branches create integration", () => { if (Exit.isFailure(exit)) { expect(JSON.stringify(exit.cause)).toContain("BranchesCreateCancelledError"); } - // The piped answer is echoed to stderr, matching the non-TTY prompt. expect(out.stderrText).toContain("? [Y/n] n\n"); expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)), @@ -320,7 +312,6 @@ describe("branches create integration", () => { return withGitBranch( Effect.gen(function* () { yield* branchesCreate(baseFlags); - // Label printed, empty scan echoed, true default wins (`console.go:64-102`). expect(out.stderrText).toContain("? [Y/n] \n"); expect(api.requests[0]?.body).toMatchObject({ branch_name: "feat-y" }); }).pipe(Effect.provide(layer)), @@ -458,10 +449,6 @@ describe("branches create integration", () => { }).pipe(Effect.provide(layer)); }); - // The established --size enum is an 18-value list that does not include - // "nano" (or "pico") and rejects any other value at flag-parse time. TS - // previously listed "nano" as a valid choice, silently succeeding where - // it should error. it.live("rejects --size nano at flag-parse time, matching Go's 18-value enum", () => { const root = Command.make("supabase").pipe( Command.withSubcommands([branchesCreateCommand]), @@ -480,9 +467,8 @@ describe("branches create integration", () => { }); }); -// Distinguishes "the --size flag itself was rejected at parse time" from any -// other failure (e.g. a missing runtime service in this minimal test setup), -// so the regression test above can't pass for the wrong reason. +// Distinguishes "the --size flag itself was rejected at parse time" from any other failure, so +// the test above can't pass for the wrong reason. function rejectsInvalidSizeChoice(error: unknown): boolean { if (typeof error !== "object" || error === null || !("errors" in error)) return false; const { errors } = error; diff --git a/apps/cli/src/commands/branches/delete/delete.handler.ts b/apps/cli/src/commands/branches/delete/delete.handler.ts index 05b1a1532f..c3422efe50 100644 --- a/apps/cli/src/commands/branches/delete/delete.handler.ts +++ b/apps/cli/src/commands/branches/delete/delete.handler.ts @@ -31,9 +31,9 @@ export const branchesDelete = Effect.fn("branches.delete")(function* (flags: Bra // requires it) resolves. The yielded value itself is unused. void (yield* Tty); - // `branches` is PARENT-scoped: after `supabase link <branch>`, - // `supabase/.temp/project-ref` holds the branch's own ref, and the platform - // 403s on that ref for every branches-management endpoint (CLI-2167 follow-up). + // `branches` is parent-scoped: after `supabase link <branch>`, `supabase/.temp/project-ref` + // holds the branch's own ref, and the platform 403s on that ref for every branches-management + // endpoint. const ref = yield* resolveParentScopedProjectRef(flags.projectRef); yield* Effect.gen(function* () { @@ -48,7 +48,6 @@ export const branchesDelete = Effect.fn("branches.delete")(function* (flags: Bra ); yield* deleting?.clear() ?? Effect.void; - // Established behavior: writes `"Deleted preview branch: <ref>\n"` to STDERR. if (output.format === "json" || output.format === "stream-json") { yield* output.success("Deleted preview branch", { project_ref: branchRef }); return; diff --git a/apps/cli/src/commands/branches/delete/delete.integration.test.ts b/apps/cli/src/commands/branches/delete/delete.integration.test.ts index c328c40793..b47c415319 100644 --- a/apps/cli/src/commands/branches/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/branches/delete/delete.integration.test.ts @@ -17,14 +17,12 @@ import { import type { BranchesDeleteFlags } from "./delete.command.ts"; import { branchesDelete } from "./delete.handler.ts"; -// V1DeleteABranchInput.branch_id_or_ref is a oneOf [project-ref, uuid] union. -// A 20-lowercase project ref matches BOTH branches → schema rejects. -// Tests pass a v4 UUID so the schema picks exactly one branch. +// `branch_id_or_ref` is a oneOf [project-ref, uuid]; a 20-lowercase ref would match both shapes +// and get rejected, so tests use a v4 UUID to pick exactly one. const BRANCH_UUID = "11111111-1111-4111-8111-111111111111"; -// V1GetABranchConfigOutput body — used by the resolver's UUID path. The -// returned `ref` becomes the `branch_id_or_ref` for the DELETE call, so it -// must be UUID-shaped. +// Used by the resolver's UUID path; the returned `ref` becomes `branch_id_or_ref` for the DELETE +// call, so it must be UUID-shaped. const BRANCH_CONFIG = { ref: BRANCH_UUID, postgres_version: "15", @@ -146,9 +144,8 @@ describe("branches delete integration", () => { const PARENT_REF = "parentprojectrefxxxx"; const BRANCH_OWN_REF = "branchownrefyyyyyyyy"; const RESOLVED_BRANCH_UUID = "22222222-2222-4222-8222-222222222222"; - // `V1GetABranchOutput` body for the name lookup — its own `project_ref` - // becomes `branch_id_or_ref` for the subsequent DELETE call, so it must - // be UUID-shaped (see the oneOf note on `BRANCH_UUID` above). + // Its own `project_ref` becomes `branch_id_or_ref` for the subsequent DELETE call, so it + // must be UUID-shaped (see the oneOf note on `BRANCH_UUID` above). const NAME_LOOKUP_BRANCH = { id: "33333333-3333-4333-8333-333333333333", name: "my-feature", @@ -179,10 +176,9 @@ describe("branches delete integration", () => { projectId: Option.none(), }); const layer = buildTestRuntime({ out, api, cliSettings }); - // Simulate the state left by `supabase link <branch>`: project-ref holds - // the branch's OWN ref, but linked-project.json still holds the real - // parent — `branches delete` must resolve the parent for the name - // lookup, not the branch ref sitting in project-ref. + // Simulates the state left by `supabase link <branch>`: project-ref holds the branch's own + // ref, but linked-project.json still holds the real parent — `branches delete` must + // resolve the parent for the name lookup, not the branch ref sitting in project-ref. mkdirSync(join(tempRoot.current, "supabase", ".temp"), { recursive: true }); writeFileSync(join(tempRoot.current, "supabase", ".temp", "project-ref"), BRANCH_OWN_REF); writeFileSync( @@ -200,9 +196,6 @@ describe("branches delete integration", () => { (r) => r.method === "GET" && r.url.includes("/branches/my-feature"), ); expect(lookup?.url).toContain(`/v1/projects/${PARENT_REF}/branches/my-feature`); - // The subsequent branch-scoped DELETE is unaffected — it uses whatever - // ref the name lookup resolved to, not the parent and not the stale - // branch-own ref from project-ref. const del = api.requests.find((r) => r.method === "DELETE"); expect(del?.url).toContain(`/v1/branches/${RESOLVED_BRANCH_UUID}`); }).pipe(Effect.provide(layer)); From a0b02651feefbf53d4825232cb301acbfa458e71 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 12:32:57 +0100 Subject: [PATCH 11/56] chore(repo): trim comments, shard 06 (1010 -> 722 comment lines) --- .../db-bootstrap/health-check.unit.test.ts | 34 +-- .../db-bootstrap/image-prepull.ts | 8 +- .../db-bootstrap/image-prepull.unit.test.ts | 12 +- .../db-bootstrap/pgdata-snapshot.ts | 250 ++++++------------ .../db-bootstrap/postgres.service.ts | 232 +++++----------- .../db-bootstrap/recreate-local-database.ts | 80 ++---- .../db-bootstrap/reset-local-database.ts | 96 +++---- .../db-bootstrap/restart-services.ts | 48 ++-- .../db-bootstrap/shadow-cache.e2e.test.ts | 31 +-- .../shadow-cache.integration.test.ts | 84 +++--- 10 files changed, 293 insertions(+), 582 deletions(-) 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 3cb447e96d..24126157ea 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 @@ -143,10 +143,9 @@ const runningStarting = JSON.stringify({ const notRunning = JSON.stringify({ Status: "exited", Running: false }); /** - * `waitForHealthyServices` structurally requires `HttpClient.HttpClient` - * (only exercised on the PostgREST HTTP-HEAD branch) — every test provides - * some `HttpClient.HttpClient`, and this one fails loudly if a - * container-only-health-check test ever calls it unexpectedly. + * `waitForHealthyServices` structurally requires `HttpClient.HttpClient` (only exercised on the + * PostgREST HTTP-HEAD branch); this layer fails loudly if a container-only-health-check test + * ever calls it unexpectedly. */ const unusedHttpClientLayer = Layer.succeed( HttpClient.HttpClient, @@ -234,7 +233,7 @@ describe("waitForHealthyServices", () => { { timeoutSeconds: 2 }, ).pipe(Effect.provide(unusedHttpClientLayer), Effect.forkChild({ startImmediately: true })); - // 2 retries after the initial attempt (Go's `WithMaxRetries(..., timeout.Seconds())`). + // 2 retries after the initial attempt. yield* TestClock.adjust("1 seconds"); yield* TestClock.adjust("1 seconds"); const exit = yield* Fiber.await(fiber); @@ -249,8 +248,6 @@ describe("waitForHealthyServices", () => { (args) => args[0] === "container" && args[1] === "inspect" && args[2] === "supabase_rest_proj", ); - // The healthy container is probed exactly once, then narrowed out of - // the "still watching" set — the unhealthy one is probed on every round. expect(kongCalls).toHaveLength(1); expect(restCalls).toHaveLength(3); }), @@ -407,8 +404,6 @@ describe("waitForHealthyServices", () => { new Map([["supabase_inbucket_proj", "public.ecr.aws/supabase/mailpit:v1.30.2"]]), ); - // A dropped pipe part-way through the dump must not discard a marker the - // scanner already matched — that is exactly when this bug class shows up. expect(error.suggestion).toContain( "docker image rm -f public.ecr.aws/supabase/mailpit:v1.30.2", ); @@ -430,9 +425,8 @@ describe("waitForHealthyServices", () => { it.effect("detects the marker when it arrives on the container's stderr stream", () => Effect.gen(function* () { - // Where the container runtime actually writes it: `docker logs` demuxes - // the container's stderr onto its own stderr pipe, so this is the real - // path, not the stdout one every other scenario here scripts. + // `docker logs` demuxes the container's stderr onto its own stderr pipe, so this is the + // real path, not the stdout one every other scenario here scripts. const mock = mockHealthSpawner(() => notRunning, { supabase_inbucket_proj: { stderr: ["exec /mailpit: exec format error\n"] }, }); @@ -546,17 +540,14 @@ describe("waitForHealthyServices", () => { ]), ); - // Both containers are named against the shared image... expect(error.suggestion).toContain("supabase_rest_proj's image"); expect(error.suggestion).toContain("supabase_realtime_proj's image"); - // ...which the removal command lists once, not twice. expect(error.suggestion).toContain( "docker image rm -f public.ecr.aws/supabase/postgrest:v14.15", ); expect(error.suggestion).not.toContain( "postgrest:v14.15 public.ecr.aws/supabase/postgrest:v14.15", ); - // The healthy-logged container's own image is never suggested. expect(error.suggestion).not.toContain("storage-api"); }), ); @@ -842,10 +833,9 @@ describe("waitForShadowReady", () => { "resolves on the first successful connect, while Docker still reports the healthcheck as starting", () => Effect.gen(function* () { - // The shadow container's healthcheck runs on a 10s interval with no - // start period, so Docker cannot report `healthy` before t+10s even - // though Postgres accepts connections at ~3.5s — the whole point of - // this wait is that the health status is never consulted at all. + // The shadow container's healthcheck runs on a 10s interval with no start period, so + // Docker cannot report `healthy` before t+10s even though Postgres accepts connections + // at ~3.5s — this wait never consults the health status at all. const mock = mockHealthSpawner(() => runningStarting); const db = mockShadowDbConnection(); @@ -880,8 +870,7 @@ describe("waitForShadowReady", () => { const exit = yield* Fiber.await(fiber); expect(Exit.isSuccess(exit)).toBe(true); - // 2 refused attempts, then the 3rd that connects — a refused connect is - // "not ready yet", never an error in its own right. + // A refused connect is "not ready yet", never an error in its own right. expect(db.attempts).toHaveLength(3); expect(inspectCalls(mock)).toHaveLength(3); expect(db.closedSessions).toBe(1); @@ -915,8 +904,7 @@ describe("waitForShadowReady", () => { { containerId: SHADOW_CONTAINER_ID, reason: "connection refused" }, ]); expect(error.message).toBe(`${SHADOW_CONTAINER_ID} connection refused`); - // 1 initial attempt + `timeoutSeconds * 2` retries at 500ms (same wall - // time as the old 1s poll). + // 1 initial attempt plus `timeoutSeconds * 2` retries at 500ms intervals. expect(db.attempts).toHaveLength(5); expect( mock.spawned.some((args) => args[0] === "logs" && args[1] === SHADOW_CONTAINER_ID), 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 c54f0d8b19..099eab2f74 100644 --- a/apps/cli/src/command-internal/db-bootstrap/image-prepull.ts +++ b/apps/cli/src/command-internal/db-bootstrap/image-prepull.ts @@ -79,12 +79,8 @@ export function ensureImagesCached( } if (failures.length > 0) { - // Go sets the install hint once, sequentially, after the concurrent - // resolve finishes (`SuggestDockerInstallIfConnectionFailed`, - // `start.go:254-259`) rather than from inside the resolver itself, where - // concurrent goroutines would race on the shared `CmdSuggestion` global. - // There is no such global here, so the hint is appended directly onto - // the joined message instead. + // 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({ diff --git a/apps/cli/src/command-internal/db-bootstrap/image-prepull.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/image-prepull.unit.test.ts index 6bbf3420ef..359cb6ec70 100644 --- a/apps/cli/src/command-internal/db-bootstrap/image-prepull.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/image-prepull.unit.test.ts @@ -76,7 +76,6 @@ describe("ensureImagesCached", () => { ["supabase/kong:3", "public.ecr.aws/supabase/kong:3"], ]), ); - // One `image inspect` call per UNIQUE image, not one per (duplicated) input entry. const inspectCalls = mock.spawned.filter( (call) => call[0] === "image" && call[1] === "inspect", ); @@ -96,10 +95,8 @@ describe("ensureImagesCached", () => { if (args[0] === "image" && args[1] === "inspect") { const count = yield* Ref.updateAndGet(started, (n) => n + 1); if (count < 2) { - // A sequential (non-concurrent) implementation would never let the - // second image's `image inspect` call start until this one - // returns, so awaiting here would hang forever — proving - // concurrency is what lets this test complete at all. + // A sequential implementation would hang here forever, since the second call + // never starts until this one returns. yield* Deferred.await(bothStarted); } else { yield* Deferred.succeed(bothStarted, undefined); @@ -128,9 +125,8 @@ describe("ensureImagesCached", () => { }), ); - // Every pull attempt fails, so this drives the real DOCKER_PULL_RETRY_DELAYS_MS - // backoff (4s + 8s) to exhaustion across all 3 registry candidates (~36s) — - // needs more than Vitest's 5s default. + // Every pull attempt fails, driving the real `DOCKER_PULL_RETRY_DELAYS_MS` backoff to + // exhaustion across all 3 registry candidates (~36s) — needs more than Vitest's 5s default. it.live( "aggregates every failed image's message into one combined error", () => { diff --git a/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts b/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts index e97345490d..4cf3f85a21 100644 --- a/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts +++ b/apps/cli/src/command-internal/db-bootstrap/pgdata-snapshot.ts @@ -1,17 +1,15 @@ /** - * Generic PGDATA snapshot/restore primitives, container-agnostic: `shadow-cache.ts` is the only - * caller today, but these are reusable building blocks for savepointing any local Postgres - * container. + * Generic PGDATA snapshot/restore primitives, reusable beyond `shadow-cache.ts` for savepointing + * any local Postgres container. * * The container must be stopped before {@link exportPgDataTar} runs — a snapshot of a running - * Postgres's data directory is not a consistent thing to copy. Callers own the stop/start. + * Postgres data directory is not consistent to copy; callers own the stop/start. + * {@link pgDataRestoreArchive} must be delivered as a tar stream via `docker cp -`, never a + * directory copy, since a directory copy resets file ownership and Postgres refuses to start. * * TODO(hot-save): support `frozen` (`docker pause`/copy/unpause) and `online` * (`pg_backup_start`/`pg_backup_stop`) consistency modes for a live-stack savepoint feature that * can't afford downtime. - * - * {@link pgDataRestoreArchive} must be delivered as a tar stream via `docker cp -`, never a - * directory copy — a directory copy resets file ownership and Postgres refuses to start. */ import { Effect, Option, Stream, type FileSystem } from "effect"; @@ -44,54 +42,37 @@ const PGDATA_DIR_NAME = PGDATA_PATH.slice(PGDATA_PARENT_PATH.length + 1); /** * Proves an archive carries an exported cluster: every Postgres data directory has a - * `PG_VERSION` file at its root, written first by `initdb`. On its own this only proves "a - * PostgreSQL cluster", weaker than what a cache key promises — see - * {@link PGDATA_BASELINE_MARKER_ENTRY}. + * `PG_VERSION` file at its root, written by `initdb`. Weaker than what a cache key promises — + * see {@link PGDATA_BASELINE_MARKER_ENTRY}. */ export const PGDATA_CLUSTER_ENTRY = `${PGDATA_DIR_NAME}/PG_VERSION`; /** - * A file this module writes into PGDATA's ROOT ({@link stampPgDataBaselineMarker}) as the - * last step before the export copies the directory out. Postgres ignores unknown regular files at - * the data directory's root (`pg_upgrade` and friends routinely leave some there), and a restored - * container simply carries it along, so the cost of the stamp is one 512-byte tar member. + * A file this module writes into PGDATA's root ({@link stampPgDataBaselineMarker}) as the last + * step before export. Postgres ignores unknown regular files there, so a restored container + * simply carries it along at the cost of one 512-byte tar member. * - * SCREAMING_SNAKE on purpose, and not by taste: `docker cp` tars a directory through Go's - * `filepath.Walk`, which visits each level in sorted order, so an uppercase root file lands - * immediately next to `PG_VERSION` — near the front of a ~90MB archive rather than behind every - * `base/` page. That is a PERFORMANCE hint for {@link validatePgDataArchive} only: - * the scan is correct at any position, and settles late (not wrongly) if a Docker release ever - * reorders its walk. + * Uppercase so it sorts near `PG_VERSION` at the front of the archive when `docker cp` tars the + * directory in sorted order — a scan hint only; {@link validatePgDataArchive} is correct at any + * position. */ export const PGDATA_BASELINE_MARKER_NAME = "SUPABASE_BASELINE"; /** - * The marker's tar entry — what {@link validatePgDataArchive} looks for, and the reason a - * cached snapshot means "the Supabase platform baseline this key promises" rather than merely "a - * PostgreSQL cluster". + * The marker's tar entry — what {@link validatePgDataArchive} looks for to confirm a snapshot is + * the platform baseline this key promises, not merely a PostgreSQL cluster. * - * Its whole value is WHEN it is written: {@link stampPgDataBaselineMarker} is called from the - * export step alone, after the caller's own baseline has completed and immediately before the - * copy-out. So an archive produced before the baseline ran — a wiring regression that moves the - * snapshot earlier, or a hand-placed bare PGDATA tar dropped into the cache directory — cannot - * carry it, and is rejected before anything is restored. + * {@link stampPgDataBaselineMarker} writes it only after the caller's baseline completes and + * immediately before the copy-out, so an archive produced earlier — or a hand-placed bare PGDATA + * tar — cannot carry it and is rejected before anything is restored. */ export const PGDATA_BASELINE_MARKER_ENTRY = `${PGDATA_DIR_NAME}/${PGDATA_BASELINE_MARKER_NAME}`; /** - * The marker's CONTENT: the caller's own identity token for what the snapshot carries — for the - * shadow baseline cache, the cache key the archive is published under, which is also its - * filename's stem (`shadowBaselineTarFileName`, `shadow-cache.ts`). - * - * Presence alone is strictly weaker than a snapshot's own filename claims: a perfectly valid - * archive COPIED or RENAMED over another key's cache file passes a name-only check and warm-restores - * a baseline built from different roles/vault values/service versions. Binding the marker to the - * key — stamped at export, compared at validation ({@link validatePgDataArchive}) — is what - * makes an archive vouch for the filename it is stored under, not merely for "some baseline". - * - * The trailing newline is the canonical form on BOTH sides: it makes the stamped file a normal - * one-line text file (`cat`-able while debugging a cache directory) and both halves of the contract - * go through this one function, so the two can never drift. + * The marker's content is the cache key the archive is published under (also its filename's + * stem), so a snapshot copied or renamed over another key's cache file fails validation instead + * of silently restoring the wrong baseline. Stamped with a trailing newline so the file stays + * plain text, and both halves of the check go through this one function. */ export const pgDataBaselineMarkerContent = (key: string): string => `${key}\n`; @@ -102,10 +83,9 @@ export const PGDATA_REQUIRED_ENTRIES: ReadonlyArray<string> = [ ]; /** - * Internal-only "the snapshot could not be produced" signal — deliberately NOT a - * `Data.TaggedError`: every caller of {@link exportPgDataTar} decides for itself how to - * degrade (the shadow baseline cache warns and continues uncached), so this must not be mistaken - * for a CLI-facing error. + * Internal "snapshot could not be produced" signal, not a `Data.TaggedError`: callers of + * {@link exportPgDataTar} each decide how to degrade (the shadow baseline cache warns and + * continues uncached), so this must not be mistaken for a CLI-facing error. */ export interface PgDataSnapshotUnavailable { readonly reason: string; @@ -117,12 +97,8 @@ const pgDataSnapshotUnavailable = (reason: string): PgDataSnapshotUnavailable => /** * The one-member tar {@link stampPgDataBaselineMarker} pushes into the container: - * `SUPABASE_BASELINE` relative to the `docker cp` destination, which is PGDATA itself, carrying - * {@link pgDataBaselineMarkerContent}'s token for `key`. - * - * Exported for the unit test that round-trips it through this module's own scanner — the stamp and - * the check have to agree on the entry name AND on the content encoding, and nothing else proves - * that they do. + * `SUPABASE_BASELINE`, relative to PGDATA, carrying {@link pgDataBaselineMarkerContent}'s + * token for `key`. Exported so a unit test can round-trip it through this module's own scanner. */ export const pgDataBaselineMarkerTar = ( key: string, @@ -139,20 +115,11 @@ export const pgDataBaselineMarkerTar = ( }); /** - * Writes {@link PGDATA_BASELINE_MARKER_ENTRY} into the container's PGDATA, so the export - * that follows carries it and {@link validatePgDataArchive} can tell a snapshot of a - * COMPLETED baseline for `key` apart from any other cluster — including a valid snapshot of a - * DIFFERENT key that was copied over this key's cache file. - * - * Delivered as a stdin tar through `docker cp -`, the same form the restore side uses - * (`extractPreStartArchiveIntoContainer`, `container-lifecycle.ts`) and for the same reason: - * it needs no daemon-visible host path, so it works against local, remote-context, and confined - * Docker clients alike. `docker cp` into a STOPPED container is fully supported — which is exactly - * the state this module's coherence contract already requires of the export. - * - * The caller owns the ORDERING that gives the marker its meaning: this must be the last mutation - * before {@link exportPgDataTar}, and must run only once whatever the snapshot is supposed to - * capture is genuinely in place. + * Writes {@link PGDATA_BASELINE_MARKER_ENTRY} into the container's PGDATA so the following + * export carries it, letting {@link validatePgDataArchive} tell a completed baseline for `key` + * apart from any other cluster (including a different key's snapshot copied over this cache + * file). Delivered as a stdin tar via `docker cp -` so it needs no daemon-visible host path. + * Must run as the last mutation before {@link exportPgDataTar}. */ export const stampPgDataBaselineMarker = ( spawner: Spawner, @@ -167,20 +134,11 @@ export const stampPgDataBaselineMarker = ( }); /** - * Streams `docker cp <containerId>:${PGDATA_PATH} -`'s tar straight to a temp file next to - * `tarPath` and `rename`s it into place. The stream never lands in memory: the child's stdout is - * piped into `FileSystem.sink`, so a large snapshot costs one buffer's worth of heap. - * - * The container must already be STOPPED (see this module's own header) — the caller owns the - * stop/start around this call. The `rename` is the LAST step and is what publishes the entry: a - * partially written tar must never be observable under the final name. Any failure removes the - * temp file; nothing is left behind for a later run to find. - * - * The temp name is scoped by pid alone, so two exports to the same `tarPath` are safe across - * processes but not within one: a same-process concurrent writer's pre-clean would unlink this - * writer's live temp file, and the eventual `rename` could publish the other writer's - * half-written bytes. Callers own that serialization — `shadow-cache.ts` holds - * `shadowExportMutex` around every call. + * Streams `docker cp <containerId>:${PGDATA_PATH} -` to a temp file next to `tarPath` and + * `rename`s it into place, so a partially written tar is never visible under the final name; + * any failure removes the temp file. The container must already be stopped (callers own the + * stop/start), and the temp name is scoped by pid alone, so concurrent exports to the same + * `tarPath` must be externally serialized (`shadow-cache.ts` holds `shadowExportMutex`). */ export const exportPgDataTar = ( spawner: Spawner, @@ -190,9 +148,8 @@ export const exportPgDataTar = ( ): Effect.Effect<void, PgDataSnapshotUnavailable> => { const tempPath = `${tarPath}.${process.pid}.partial`; return Effect.gen(function* () { - // Clear any pre-existing file at the temp path (a crashed same-pid predecessor, or an - // adversarially pre-created one on a shared host) so the exclusive-create below starts from - // a genuinely fresh inode — see the sink's own comment. + // Clears a leftover temp file (crashed predecessor or pre-created by another process) so the + // exclusive-create below starts from a fresh inode. yield* fs.remove(tempPath).pipe(Effect.orElseSucceed(() => undefined)); yield* Effect.scoped( Effect.gen(function* () { @@ -212,14 +169,11 @@ export const exportPgDataTar = ( const [exitCode, , stderr] = yield* Effect.all( [ child.exitCode.pipe(Effect.map(Number)), - // `0o600`: the archive is a full PGDATA — vault secret values, the JWT secret, and - // role password hashes are all in its pages — so it must not be group/world-readable - // on a shared host. `rename` preserves the mode, so the published tar inherits it. - // `wx` (O_EXCL), not `w`: a plain truncating open would inherit an attacker- - // PRE-CREATED file's permissive mode instead of applying `mode` (which only governs - // creation). With the best-effort remove above, `wx` only ever fails if someone - // recreated the path in the race window — and that failure degrades to an uncached - // run, never to a world-readable tar. + // 0o600: the archive contains vault secrets, the JWT secret, and role password + // hashes, so it must not be group/world-readable; `rename` preserves the mode. + // `wx` (O_EXCL) avoids inheriting a pre-created file's more permissive mode; if the + // path was recreated in the race window, this degrades to an uncached run rather + // than a world-readable tar. Stream.run(child.stdout, fs.sink(tempPath, { flag: "wx", mode: 0o600 })), collectText(child.stderr), ], @@ -251,10 +205,6 @@ export const exportPgDataTar = ( }).pipe(Effect.onError(() => fs.remove(tempPath).pipe(Effect.orElseSucceed(() => undefined)))); }; -// --------------------------------------------------------------------------- -// Archive validation -// --------------------------------------------------------------------------- - /** POSIX tar's fixed block size: headers, file content, and the end marker are all multiples of it. */ const TAR_BLOCK_SIZE = 512; @@ -263,20 +213,16 @@ const TAR_NO_BYTES = new Uint8Array(0); const tarDecoder = new TextDecoder(); /** - * The most content {@link scanTarChunkForEntries} will ever buffer for `captureEntry`. The - * only entry any caller captures is the baseline marker, whose content is one short identity token, - * so a larger member cannot be a marker this module wrote: it is left UNCAPTURED (`captured` stays - * `undefined`, which every reader treats as "does not match"), rather than being read into memory - * on the word of an untrusted archive's own size field. Keeps the scan's O(1) memory property - * intact whatever a hand-placed tar in the cache directory claims. + * Cap on how much of a captured entry {@link scanTarChunkForEntries} buffers. An oversized + * entry (larger than any marker this module writes) is left uncaptured rather than trusted on + * an untrusted archive's size field. */ const TAR_CAPTURE_MAX_BYTES = 1024; /** - * {@link scanTarChunkForEntries}'s carry-over state — everything needed to resume a header - * walk at an arbitrary chunk boundary, and nothing else. `carry` holds the bytes of a header block - * a chunk ended in the middle of (always `< 512`); `skip` counts the file-content bytes still to be - * STEPPED OVER without buffering, which is what keeps a ~90MB archive off the heap. + * {@link scanTarChunkForEntries}'s carry-over state for resuming a header walk across chunk + * boundaries. `carry` holds a header block split across chunks (always `< 512` bytes); `skip` + * counts file-content bytes still to step over without buffering. */ export interface TarScanState { readonly carry: Uint8Array; @@ -284,22 +230,22 @@ export interface TarScanState { /** Consecutive all-zero blocks seen; two in a row is tar's end-of-archive marker. */ readonly zeroBlocks: number; /** - * The required entries not seen yet. Empty means every one of them was found; whatever is left - * once the scan settles is what the archive is missing, which is what the caller reports. + * Required entries not yet seen. What remains when the scan settles is what the archive is + * missing. */ readonly missing: ReadonlySet<string>; readonly ended: boolean; /** A block that is neither zero nor a checksum-valid header: not a tar (or a truncated one). */ readonly malformed: boolean; /** - * The one entry whose CONTENT the walk reads rather than steps over — the baseline marker, whose - * bytes say which key the archive belongs to. `undefined` for a presence-only walk. + * The one entry whose content the walk reads instead of stepping over — the baseline marker, + * whose bytes say which key the archive belongs to. `undefined` for a presence-only walk. */ readonly captureEntry: string | undefined; /** - * {@link captureEntry}'s content bytes. `undefined` until its header is seen, and STILL undefined - * afterwards when the entry was too large to capture ({@link TAR_CAPTURE_MAX_BYTES}) — - * both mean "no content to compare against", which is the safe verdict either way. + * {@link captureEntry}'s content bytes. `undefined` until its header is seen, or still + * `undefined` afterwards if the entry was too large to capture + * ({@link TAR_CAPTURE_MAX_BYTES}) — either way treated as "no content to compare against". */ readonly captured: Uint8Array | undefined; /** Content bytes of {@link captureEntry} still to be captured; `0` once complete or not capturing. */ @@ -307,9 +253,8 @@ export interface TarScanState { } /** - * A fresh walk looking for `required` — every entry of which must appear for the scan to pass — - * capturing `captureEntry`'s content along the way when given (it must be one of `required`, so - * that "found everything" also means "the captured entry's header was seen"). + * A fresh walk requiring every entry in `required`, capturing `captureEntry`'s content along + * the way when given (it must be one of `required`). */ export const initialTarScanState = ( required: Iterable<string>, @@ -365,10 +310,9 @@ const tarNumericField = (block: Uint8Array, offset: number, length: number): num }; /** - * Tar's own integrity check on a header block: the stored checksum is the sum of all 512 bytes - * with the checksum field itself read as spaces. Both the unsigned and the (historical) signed - * summation are accepted, as every tar reader does. This is what tells a genuine header apart from - * arbitrary bytes, so a non-tar file cannot be walked as if it were one. + * Tar's header checksum: the sum of all 512 bytes with the checksum field itself read as + * spaces. Both the unsigned and historical signed summation are accepted, as every tar reader + * does; this is what tells a genuine header apart from arbitrary bytes. */ const tarChecksumValid = (block: Uint8Array): boolean => { const stored = tarNumericField(block, 148, 8); @@ -394,13 +338,11 @@ const tarEntryName = (block: Uint8Array): string => { const tarBlockIsZero = (block: Uint8Array): boolean => block.every((byte) => byte === 0); /** - * Folds one stream chunk into a tar HEADER walk looking for every entry still in `state.missing`, - * capturing `state.captureEntry`'s content if it has one. Pure and chunk-boundary-agnostic: every - * other member's content is stepped over by byte count rather than buffered, so the whole scan - * costs one partial header block plus (at most) - * {@link TAR_CAPTURE_MAX_BYTES} of memory no matter how large the archive is. Stops (and - * stays stopped) at the first of: the last required entry found (with its capture complete), the - * end-of-archive marker, or a block that is not a valid header. + * Folds one stream chunk into a tar header walk for entries still in `state.missing`, + * capturing `state.captureEntry`'s content if given. Non-captured content is stepped over by + * byte count, bounding memory to one partial header block plus at most + * {@link TAR_CAPTURE_MAX_BYTES}. Stops at the first required entry found (with capture + * complete), the end-of-archive marker, or an invalid header block. */ export const scanTarChunkForEntries = (state: TarScanState, chunk: Uint8Array): TarScanState => { if (tarScanSettled(state)) return state; @@ -412,10 +354,9 @@ export const scanTarChunkForEntries = (state: TarScanState, chunk: Uint8Array): let capturePending = state.capturePending; const { captureEntry } = state; /** - * Appends the leading `capturePending` bytes of a content run being consumed. Content bytes are - * always consumed front-to-back, and `capturePending` never exceeds what is left of the capture - * entry's own content, so this is correct whether the run is the tail carried over from the - * previous chunk or a fresh member's content in this one. + * Appends the leading `capturePending` bytes of a content run. `capturePending` never exceeds + * what remains of the capture entry's content, so this is correct whether the run is carried + * over from the previous chunk or fresh in this one. */ const takeCapture = (bytes: Uint8Array): void => { if (capturePending <= 0 || captured === undefined) return; @@ -486,9 +427,8 @@ export const scanTarChunkForEntries = (state: TarScanState, chunk: Uint8Array): if (size === undefined || size < 0) { return settle({ ended: false, malformed: true }); } - // Arm the capture on the capture entry's FIRST occurrence only (`wasMissing`), so a duplicate - // member later in the archive cannot overwrite what the real one said. An oversized entry is - // left uncaptured — see {@link TAR_CAPTURE_MAX_BYTES}. + // Arm the capture only on the entry's first occurrence (`wasMissing`), so a later duplicate + // member cannot overwrite it. An oversized entry is left uncaptured; see {@link TAR_CAPTURE_MAX_BYTES}. if (wasMissing && name === captureEntry && size <= TAR_CAPTURE_MAX_BYTES) { captured = TAR_NO_BYTES; capturePending = size; @@ -519,43 +459,26 @@ export const scanTarChunkForEntries = (state: TarScanState, chunk: Uint8Array): }; /** - * Why an archive at `tarPath` must not be restored under `key` — the two distinguishable ways - * {@link validatePgDataArchive} can reject one. Both implicate the FILE, never the - * infrastructure, so both are safe for a caller to act on by discarding it. + * The two ways {@link validatePgDataArchive} can reject an archive for `key`. Both implicate + * the file, not the infrastructure, so a caller can safely act on either by discarding it. */ export type PgDataArchiveProblem = /** The header stream never carried one of {@link PGDATA_REQUIRED_ENTRIES}. */ | { readonly _tag: "missing-entries"; readonly entries: ReadonlyArray<string> } /** - * Every entry is there, but the marker vouches for a DIFFERENT key than the one this archive is - * stored under. `found` is the marker's own token (trimmed), or `undefined` when it carried none - * that could be read (an oversized or truncated marker member). + * Every entry is there, but the marker vouches for a different key than the one this archive + * is stored under. `found` is the marker's own token (trimmed), or `undefined` when it + * carried none that could be read. */ | { readonly _tag: "wrong-key"; readonly expected: string; readonly found: string | undefined }; /** - * Whether `tarPath` is a snapshot that may be restored as `key`'s baseline — `Option.none()` when - * it is, the reason it is not otherwise. - * - * Three failures, each invisible to the restore itself. Without `PG_VERSION`, an archive that is - * syntactically fine but carries no cluster (an EMPTY tar qualifies) restores SILENTLY: - * `docker cp -` extracts nothing, the Postgres entrypoint finds an empty PGDATA and runs a fresh - * `initdb`, readiness passes, and the caller is handed a bare cluster it believes carries the - * platform baseline. Without the baseline marker, that same silent-success shape survives one level - * up: a REAL but bare PGDATA tar (dropped into the cache directory by hand, or produced by a future - * regression that exports before the baseline runs) restores, starts, and answers — and only the - * resulting diff would ever show it. And without the marker's CONTENT, it survives one level up - * again: a genuine, fully baselined snapshot of ANOTHER key, copied or renamed over this key's - * cache file, passes every name-only check while carrying different roles, vault values, and - * service-version schema — see {@link pgDataBaselineMarkerContent}. Validating the header - * stream up front is the only place any of the three is observable, so callers must check BEFORE - * restoring. + * Whether `tarPath` is a snapshot that may be restored as `key`'s baseline: `Option.none()` when + * it may, otherwise the reason it may not. * - * Reads the file locally — no Docker, no extraction — and stops as soon as both entries have been - * seen and the marker's few bytes read; see {@link PGDATA_BASELINE_MARKER_NAME} for why that - * is normally within the archive's first blocks. Even a full walk only parses HEADERS (every other - * member's content is stepped over by byte count), so the cost is one sequential read with O(1) - * memory. Only a genuine read failure fails; a valid tar that does not qualify simply reports why. + * Guards against three silent-failure levels: no cluster at all (missing `PG_VERSION`), a bare + * PGDATA never baselined (missing marker), and a different key's baseline copied over this + * cache file (marker content mismatch). Reads the file locally with O(1) memory. */ export const validatePgDataArchive = ( fs: FileSystem.FileSystem, @@ -595,16 +518,15 @@ const pgDataArchiveProblem = ( if (entries.length > 0) return Option.some({ _tag: "missing-entries", entries }); const stamped = tarScanCapturedText(state); if (stamped === pgDataBaselineMarkerContent(key)) return Option.none(); - // Trimmed for the report only — the comparison above is on the exact canonical form, so a marker - // padded with whitespace is a mismatch rather than something to normalize into a match. + // Trimmed for the report only; the comparison above uses the exact canonical form, so a + // whitespace-padded marker is still a mismatch. return Option.some({ _tag: "wrong-key", expected: key, found: stamped?.trim() }); }; /** - * Builds the {@link StartContainerSpec.preStartArchives} entry that restores a + * Builds the {@link StartContainerSpec.preStartArchives} entry that restores an * {@link exportPgDataTar} tar into a container between `docker create` and `docker start`. - * `containerPath` is PGDATA's PARENT, not PGDATA itself — see {@link PGDATA_PARENT_PATH}'s - * own doc comment for why. + * `containerPath` is PGDATA's parent, not PGDATA itself; see {@link PGDATA_PARENT_PATH}. */ export const pgDataRestoreArchive = ( fs: FileSystem.FileSystem, diff --git a/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts b/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts index 9b19fa3793..e1343d75af 100644 --- a/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts +++ b/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts @@ -3,9 +3,8 @@ * `db start`'s native container bootstrap use, including `db start`'s `fromBackup` * entrypoint/bind override. * - * Out of scope: initial schema bootstrap, and actually creating/starting the container and - * waiting for it to become healthy — see {@link createContainer} and - * {@link waitForHealthyServices}, wired up by each caller's own handler. + * Out of scope: initial schema bootstrap, and creating/starting the container and waiting for + * it to become healthy — see {@link createContainer} and {@link waitForHealthyServices}. */ import type { CliConfig } from "@supabase/config"; @@ -34,9 +33,8 @@ const POSTGRES_PGSODIUM_ROOT_KEY_PATH = "/etc/postgresql-custom/pgsodium_root.ke /** * The post-migration hook path: `supabase/postgres`'s bundled `migrate.sh` execs - * `psql -v ON_ERROR_STOP=1 -U supabase_admin -f /etc/postgresql.schema.sql` as - * its last step when the file exists. The docker.io entrypoint heredocs it - * (see {@link postgresEntrypointScriptPg15}). + * `psql -v ON_ERROR_STOP=1 -U supabase_admin -f /etc/postgresql.schema.sql` as its last step + * when the file exists; {@link postgresEntrypointScriptPg15} heredocs it into place. */ const POSTGRES_SCHEMA_SQL_PATH = "/etc/postgresql.schema.sql"; @@ -81,8 +79,8 @@ export interface PostgresStartServiceInput { /** Already resolved/pulled (see `./image-prepull.ts`) — the container's own image. */ readonly image: string; /** - * `image` before registry resolution: the version-tag comparison in - * {@link postgresImageVersionTag} always runs against this un-rewritten value, since a + * `image` before registry resolution. The version-tag comparison in + * {@link postgresImageVersionTag} runs against this un-rewritten value, since a * `SUPABASE_INTERNAL_IMAGE_REGISTRY` override containing a port would otherwise inject an * extra colon that breaks the tag split. */ @@ -91,19 +89,17 @@ export interface PostgresStartServiceInput { readonly rootKey?: string; /** * Absolute host path to a `--from-backup` logical-dump file, already resolved against the - * caller's cwd. `db start`'s only caller. When set, switches to - * {@link postgresEntrypointScriptRestore} regardless of `db.major_version` and appends a - * `<hostPath>:/etc/backup.sql:ro` bind. `undefined` for `supabase start`. + * caller's cwd (only `db start` sets it). Switches to {@link postgresEntrypointScriptRestore} + * regardless of `db.major_version` and appends a `<hostPath>:/etc/backup.sql:ro` bind. */ readonly fromBackup?: string; } /** * Serializes `db.settings` as TOML: only the fields actually set, with `"` replaced by `'`, and - * the fixed header comment prepended. - * - * The empty-settings case is special-cased rather than delegated to {@link encodeToml}, since - * `encodeToml` always appends a trailing newline even for an empty object. + * the fixed header comment prepended. The empty-settings case is special-cased rather than + * delegated to {@link encodeToml}, since it always appends a trailing newline even for an + * empty object. */ export function postgresSettingsToPostgresConfig(settings: CliConfig["db"]["settings"]): string { const defined = Object.fromEntries( @@ -119,18 +115,9 @@ export function postgresSettingsToPostgresConfig(settings: CliConfig["db"]["sett } /** - * Port of Go's `config.VersionCompare` (`apps/cli-go/pkg/config/config.go:885-899`) - * — NOT a real semver comparator. A dotted version with more than 3 components - * truncates to its first 3 as the primary comparison key, and compares the - * remaining components — joined and left-trimmed of leading `0` characters — - * as a secondary tie-break (Go: `semver.Compare("v"+pA, "v"+pB)`). Both real - * inputs at this module's one call site (a Postgres image tag and the - * `"15.8.1.005"` threshold) always have exactly 4 numeric components, so this - * only reproduces Go's `golang.org/x/mod/semver` invalid-version rule (an - * invalid version string sorts before a valid one; two invalid strings - * compare equal — exercised by Go's own `TestVersionCompare` `"oriole-17"` - * cases) for the narrower set of shapes real inputs can take; full semver - * pre-release/build-metadata syntax is out of scope. + * Compares dotted version strings, not a full semver comparator: the first 3 components are + * the primary key, and any remaining components (joined, left-trimmed of leading zeros) break + * ties. An invalid version string sorts before a valid one; two invalid strings compare equal. */ export function postgresVersionCompare(a: string, b: string): number { const [aHead, aTail] = splitVersionHeadTail(a); @@ -167,19 +154,11 @@ function compareVersionStrings(a: string, b: string): number { } /** - * Go's `i := strings.IndexByte(utils.Config.Db.Image, ':'); ...Image[i+1:]` - * (`apps/cli-go/internal/db/start/start.go:79`) — the FIRST colon splits the - * image name from its tag. Go's own `Db.Image` is never registry-prefixed at - * this point in Go's pipeline (registry resolution only overwrites the - * container's `Image` field afterward, inside `DockerStart` — - * `docker.go:365,371`), so the first colon is always the name/tag separator. - * The caller MUST pass the pre-registry-rewrite image (see - * {@link PostgresStartServiceInput.configImage}) — a resolved image can - * carry a registry host prefix with its own colon (e.g. a - * `SUPABASE_INTERNAL_IMAGE_REGISTRY=localhost:5000` override), which would - * otherwise be misparsed as the tag. When no colon is present at all, Go's - * slice expression degrades to the whole string (`Image[0:]`) — reproduced - * here the same way. + * The first colon splits an image reference's name from its tag. The caller must pass the + * pre-registry-rewrite image (see {@link PostgresStartServiceInput.configImage}) — a resolved + * image can carry a registry host prefix with its own colon (e.g. + * `SUPABASE_INTERNAL_IMAGE_REGISTRY=localhost:5000`), which would otherwise be misparsed as the + * tag. Returns the whole string when no colon is present. */ export function postgresImageVersionTag(image: string): string { const colonIndex = image.indexOf(":"); @@ -187,8 +166,8 @@ export function postgresImageVersionTag(image: string): string { } /** - * Go's OrioleDB / version-compare `Env` branch (`start.go:70-81`) — an - * `else if`, so at most one of the two ever fires. + * OrioleDB/S3 env overrides take priority; otherwise falls back to the collate-locale override + * for images older than {@link POSTGRES_INITDB_VERSION_THRESHOLD}. At most one branch fires. */ function postgresExtraEnv( experimental: CliConfig["experimental"], @@ -212,43 +191,17 @@ function postgresExtraEnv( } /** - * PG >= 15 entrypoint (`config.db.major_version > 14`, Go's default branch, - * `start.go:91-104`): writes `/etc/postgresql.schema.sql` (schema.sql + - * webhook.sql + _supabase.sql, concatenated in that exact order), appends - * `postgresConfig` to `postgresql.conf`, then execs `docker-entrypoint.sh`. - * Go also heredocs `/etc/postgresql-custom/pgsodium_root.key` directly into - * this same script (`start.go:96`) — safe for Go, which calls - * `Docker.ContainerCreate` over the Engine API directly rather than shelling - * out. THIS PORT SHELLS OUT to a real `docker create`, so it deliberately - * diverges here: the pgsodium root key travels via - * {@link StartContainerSpec.secretFiles} instead (an in-memory tar - * entry, mode `0644` — world-readable, because Postgres's entrypoint drops - * root and reads this file back as the `postgres` user; see - * `copyStartSecretFilesIntoContainer`'s doc comment — streamed via - * `docker cp - <id>:/` straight into the container at that exact path — see - * {@link buildPostgresStartContainerSpec}), so it never appears in this - * process's own `docker create` argv (CWE-214/522). - * - * The final command is `exec`'d — a deliberate divergence from Go's script - * (which leaves `sh` as PID 1, so SIGTERM is never forwarded and every - * `docker stop` burns the full 10s grace period before SIGKILL; with `exec`, - * Postgres is PID 1 and stops in ~1s). Applies to all three entrypoint - * variants below. Timing is not part of the Go-parity surface (ADR 0016); - * see `shadow-cache.ts`'s own doc comment for why fast shutdown matters to the shadow baseline - * cache's cold path. + * PG >= 15 entrypoint: writes `/etc/postgresql.schema.sql` (schema.sql + webhook.sql + + * _supabase.sql, in that order), appends `postgresConfig` to `postgresql.conf`, then execs + * `docker-entrypoint.sh`. The pgsodium root key travels via + * {@link StartContainerSpec.secretFiles} (mode `0644`, since Postgres drops root and reads it + * back as the `postgres` user) instead of an inline heredoc, so it never appears in this + * process's own `docker create` argv. * - * Otherwise byte-for-byte derived from Go's raw-string concatenation — - * `NewContainerConfig(args ...string)` splices `strings.Join(args, " ")` - * straight after the literal trailing space following `/etc/postgresql` - * (`start.go:95`): `supabase start`'s own Postgres container always calls it - * with zero args (`args` here defaults to `""`, so the trailing space - * survives on its own, unchanged from before), while the shadow-database - * variant (`CreateShadowDatabase`, `apps/cli-go/internal/db/diff/diff.go:140`) - * passes {@link SHADOW_ENTRYPOINT_ARGS} — see - * {@link buildShadowPostgresContainerSpec}. Built via explicit - * `"...\n" +` concatenation rather than a multi-line template literal so that - * the trailing space (when `args` is empty) stays a visible, lint/format-proof - * string character instead of invisible end-of-line whitespace. + * The command is `exec`'d so Postgres becomes PID 1 and stops on SIGTERM in about a second + * instead of waiting out the full stop grace period. Built via string concatenation, not a + * template literal, so a trailing space after `/etc/postgresql` (when `args` is empty) stays a + * visible character. */ function postgresEntrypointScriptPg15(postgresConfig: string, args = ""): string { return ( @@ -266,14 +219,11 @@ function postgresEntrypointScriptPg15(postgresConfig: string, args = ""): string } /** - * PG <= 14 entrypoint (`start.go:106-113`): a shorter script — no - * `schema.sql`/`webhook.sql` (PG >= 15 only) and no pgsodium root key file — - * writes `/docker-entrypoint-initdb.d/supabase_schema.sql` (_supabase.sql - * only), appends `postgresConfig` to `postgresql.conf`, then execs - * `docker-entrypoint.sh`. See {@link postgresEntrypointScriptPg15}'s doc - * comment for why this is explicit concatenation rather than a template - * literal, and for the `args` parameter (same trailing-space splice, same - * default). + * PG <= 14 entrypoint: a shorter script — no `schema.sql`/`webhook.sql` (PG >= 15 only) and no + * pgsodium root key file — writes `/docker-entrypoint-initdb.d/supabase_schema.sql` + * (_supabase.sql only), appends `postgresConfig` to `postgresql.conf`, then execs + * `docker-entrypoint.sh`. See {@link postgresEntrypointScriptPg15} for the `args` parameter + * and string-concatenation rationale. */ function postgresEntrypointScriptPg14(postgresConfig: string, args = ""): string { return ( @@ -289,17 +239,11 @@ function postgresEntrypointScriptPg14(postgresConfig: string, args = ""): string } /** - * `--from-backup` entrypoint (`StartDatabase`'s unconditional `Entrypoint` override, - * `start.go:143-159`) — applies regardless of `db.major_version`, unlike the two scripts above. - * Three heredocs, not four: unlike Go's literal script (which heredocs the pgsodium root key - * inline), this port always carries the root key via {@link StartContainerSpec.secretFiles} - * instead (see {@link buildPostgresStartContainerSpec}'s call site) — an intentional, - * pre-existing TS divergence for every entrypoint variant, not something to "fix toward Go" here. - * Schema heredoc is `initialSchema + _supabaseSchema` — deliberately NO `webhookSchema` (present in - * {@link postgresEntrypointScriptPg15}, absent here, matching Go's own - * `` ` + initialSchema + ` ` + _supabaseSchema + ` `` with no `webhookSchema` splice in the - * `fromBackup` branch). Postgres config gets one extra literal line appended, - * `cron.launch_active_jobs = off`, matching Go's own trailing append. + * `--from-backup` entrypoint, applied regardless of `db.major_version`. Three heredocs, not + * four: the pgsodium root key still travels via {@link StartContainerSpec.secretFiles} (see + * {@link buildPostgresStartContainerSpec}), same as the other entrypoint variants. The schema + * heredoc omits the webhook schema present in {@link postgresEntrypointScriptPg15}. Postgres + * config gets one extra appended line, `cron.launch_active_jobs = off`. */ function postgresEntrypointScriptRestore(postgresConfig: string): string { return ( @@ -321,9 +265,8 @@ function postgresEntrypointScriptRestore(postgresConfig: string): string { /** * Builds the {@link StartContainerSpec} for the Postgres container — shared by `supabase - * start` (always {@link PostgresStartServiceInput.fromBackup} `undefined`) and `db start`'s - * native bootstrap (the only caller that ever sets it) — see this module's header for what's - * deliberately out of scope. + * start` (never sets {@link PostgresStartServiceInput.fromBackup}) and `db start`'s native + * bootstrap (the only caller that does). See this module's header for what's out of scope. */ export function buildPostgresStartContainerSpec( input: PostgresStartServiceInput, @@ -335,12 +278,9 @@ export function buildPostgresStartContainerSpec( const isRestore = input.fromBackup !== undefined; const env: Record<string, string> = { - // The constant `"postgres"` literal, matching Go, where `Db.Password` is - // `toml:"-"` (never decoded from config.toml) and only ever holds the default - // (`pkg/config/db.go:88`, `config.go:459`). The sibling shadow builder below - // (`buildShadowPostgresContainerSpec`) instead threads a config-derived - // `input.password` — a deliberate TS extension on the shadow path only; if this - // container ever honors `[db] password` too, both must change together. + // Always the literal "postgres" password; `buildShadowPostgresContainerSpec` below threads + // a config-derived password instead. If this container ever honors `[db] password` too, + // both must change together. POSTGRES_PASSWORD: POSTGRES_PASSWORD, POSTGRES_HOST: "/var/run/postgresql", JWT_SECRET: input.jwtSecret, @@ -360,11 +300,8 @@ export function buildPostgresStartContainerSpec( env, entrypoint: "sh", cmd: ["-c", script], - // The pgsodium root key heredoc/bind is present whenever the ACTUAL entrypoint in use - // embeds it: both `postgresEntrypointScriptPg15` and - // `postgresEntrypointScriptRestore` do (Go's `fromBackup` override always re-adds - // its own root-key heredoc, `start.go:147,155`, regardless of major version); only the - // PG<=14 script never references it. + // The pgsodium root key secretFile is present whenever the entrypoint in use embeds it: + // both the PG >= 15 and restore scripts do; only the PG <= 14 script never references it. ...(isPg14OrEarlier && !isRestore ? {} : { @@ -372,15 +309,12 @@ export function buildPostgresStartContainerSpec( }), binds: [ `${containerName}:/var/lib/postgresql/data`, - // Go's `StartDatabase` (`start.go:163`) appends this bind ONLY on the `fromBackup` branch — - // `hostConfig.Binds` is otherwise built solely from `NewHostConfig()`'s own volume bind above. + // The backup file bind is appended only on the `fromBackup` branch. ...(input.fromBackup === undefined ? [] : [`${toDockerMountPath(input.fromBackup)}:/etc/backup.sql:ro`]), ], - // Go's `NewHostConfig()` sets `Tmpfs` purely off `db.major_version` (`start.go:127-129`) — that - // check is NOT part of `StartDatabase`'s `fromBackup` override, so this stays keyed on - // `isPg14OrEarlier` alone, independent of `isRestore`. + // Tmpfs is keyed on `isPg14OrEarlier` alone, independent of `isRestore`. ...(isPg14OrEarlier ? { tmpfs: { "/docker-entrypoint-initdb.d": "" } } : {}), ports: [{ hostPort: String(input.db.port), containerPort: "5432" }], healthcheck: { @@ -396,17 +330,13 @@ export function buildPostgresStartContainerSpec( }; } -/** - * Shadow `docker-entrypoint.sh postgres -D /etc/postgresql <args>` splice — - * disables background workers (`CreateShadowDatabase`). - */ +/** Shadow entrypoint arg splice that disables background workers. */ export const SHADOW_ENTRYPOINT_ARGS = "-c max_worker_processes=0"; /** - * Input to {@link buildShadowPostgresContainerSpec} — the subset of - * {@link PostgresStartServiceInput} the shadow variant actually needs (no - * `projectId`/`fromBackup`: the shadow container has no name and never restores from a - * backup) plus the shadow's own host port. + * Input to {@link buildShadowPostgresContainerSpec}: the subset of + * {@link PostgresStartServiceInput} the shadow variant needs (no `projectId`/`fromBackup`, + * since the shadow container has no name and never restores from a backup) plus its own host port. */ export interface ShadowPostgresContainerSpecInput { readonly db: Pick<CliConfig["db"], "major_version" | "settings">; @@ -417,57 +347,23 @@ export interface ShadowPostgresContainerSpecInput { readonly image: string; readonly configImage: string; readonly rootKey?: string; - /** `utils.Config.Db.ShadowPort` — the shadow's own host port, published to `5432/tcp` in-container. */ + /** The shadow's own host port, published to `5432/tcp` in-container. */ readonly shadowPort: number; /** - * `[db] password` (already resolved from `config.toml`, `DEFAULT_DB_PASSWORD`/"postgres" when - * unset). Honoring the toml key is a deliberate TS extension, NOT Go parity: Go's - * `NewContainerConfig` does source `POSTGRES_PASSWORD` from `utils.Config.Db.Password` for both - * the real container and the shadow (`CreateShadowDatabase` reuses it verbatim, `diff.go:140`), - * but in Go that field is invariably the `"postgres"` default — `json:"-"` (`db.go:88`, the tag - * viper decodes with, `config.go:749-750`) makes a literal `[db] password` key a fatal - * `UnmarshalExact` config error, and blocks the env binding. The TS extension mirrors what - * `--local` connections already do on develop (`db-config.layer.ts`). Must be threaded - * through so the shadow's actual Postgres password matches what - * `shadowRunInputFromLocalContainerInputs`'s caller connects with — otherwise a - * non-default `[db] password` authenticates against the wrong secret. + * `[db] password` (already resolved from `config.toml`, defaulting to `"postgres"` when + * unset). Must match what {@link buildShadowPostgresContainerSpec}'s caller connects with — + * a non-default `[db] password` would otherwise authenticate against the wrong secret. */ readonly password: string; } /** - * Builds the {@link StartContainerSpec} for the shadow database container. Port of - * Go's `CreateShadowDatabase` (`apps/cli-go/internal/db/diff/diff.go:138-151`) — reuses - * the EXACT SAME `NewContainerConfig` (image/env/healthcheck/entrypoint-script shape) the - * real local `db` container uses, just with {@link SHADOW_ENTRYPOINT_ARGS} spliced - * into the entrypoint and a materially different `container.HostConfig`/networking: - * - * - **Empty `containerName`** (Go passes `""` to `DockerStart`, letting Docker - * auto-generate one) — see {@link StartContainerSpec.containerName}'s own doc - * comment for how the arg-builder and secret-file staging handle this. - * - **`autoRemove: true`** — Go's `hostConfig.AutoRemove` (`--rm`). - * - **No volume bind** — the shadow is throwaway; Go's `hostConfig` sets no `Binds` at all. - * - **No `restartPolicy`** — Go's `hostConfig` sets no `RestartPolicy` either. - * - **No `networkAliases`** — Go's `networkingConfig` is a bare, empty - * `network.NetworkingConfig{}` (no `db`/`db.supabase.internal` aliases). The shadow - * still joins the network via `DockerStart`'s own default `NetworkMode` (confirmed - * empirically: Docker's embedded DNS resolves a container on a user-defined network by - * BOTH its auto-generated name and its 12-char short container id, with no alias - * needed — see `shadow-database.ts`'s header for why this matters). - * - **Tmpfs on PG <= 14 IS still applied** — same `isPg14OrEarlier` condition as the real - * `db` container. - * - **The pgsodium root key `secretFiles` entry is still applied on PG >= 15** — the - * shadow's entrypoint script is the SAME `postgresEntrypointScriptPg15`, which - * still heredocs it in Go (splice point unaffected by `args`), so this port still needs - * it delivered before `docker start` — via `docker cp` straight into the container - * (`container-lifecycle.ts`), same as every other container's `secretFiles`, never a - * host temp file. - * - **Labels ARE still applied** (merged in by `createContainer`, same as every - * other container) so `supabase stop`'s label-filtered sweep catches an orphaned shadow - * too — Go's `DockerStart` sets `CliProjectLabel`/`composeProjectLabel` unconditionally, - * regardless of the `container.Config` literal passed in. The project label alone is - * enough for that sweep to recognize an orphaned shadow: it filters and removes by - * container id, so the shadow's lack of a stable name doesn't matter. + * Builds the {@link StartContainerSpec} for the shadow database container: same image, env, + * healthcheck, and entrypoint-script shape as the real `db` container (with + * {@link SHADOW_ENTRYPOINT_ARGS} added), but ephemeral and unnamed. It still joins the network + * without aliases — Docker's embedded DNS resolves it by its auto-generated name and short + * container id — and still carries labels, so `supabase stop`'s label-filtered sweep also + * catches an orphaned shadow. */ export function buildShadowPostgresContainerSpec( input: ShadowPostgresContainerSpecInput, 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 3c5aedb0dc..2be34aba41 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 @@ -76,10 +76,8 @@ const errMessage = (e: unknown): string => : String(e); /** - * One or more replication slots are still active (retryable — the WAL sender that owns the slot - * may still be tearing down), or counting them failed outright (permanent). Exported only so the - * exhaustive actionability guard can inspect its declaration; runtime callers discriminate it - * through the {@link RecreateLocalDatabaseError} union's `_tag`. + * One or more replication slots are still active (retryable — the WAL sender that owns the + * slot may still be tearing down), or counting them failed outright (permanent). */ export class ResetReplicationSlotsError extends Data.TaggedError("ResetReplicationSlotsError")<{ readonly message: string; @@ -125,7 +123,7 @@ export interface RecreateLocalDatabaseInput<E> { readonly dbContainerId: string; readonly dbPort: number; readonly containerOpts: ContainerOpts; - /** Fed straight to `buildPostgresStartContainerSpec` — reset has no `fromBackup` concept at all. */ + /** Fed straight to `buildPostgresStartContainerSpec` — reset has no `fromBackup` concept. */ readonly postgresSpec: Omit<PostgresStartServiceInput, "image" | "fromBackup">; /** Lazy: only resolved on the PG15 path. */ readonly resolvePostgresImage: Effect.Effect<string, ImagePrepullError>; @@ -144,10 +142,6 @@ const PG_INVALID_CATALOG_NAME = "3D000"; /** * Disables new connections to `postgres`/`_supabase`, terminates existing backends, then waits * for WAL senders to drop their replication slots (1-second backoff, 10 retries max). - * - * Exported only so `recreate-local-database.unit.test.ts` can pin the retry schedule's exact - * boundary against a mocked {@link DbSession} with no real filesystem/Docker I/O; end-to-end - * retry behavior stays covered by `reset.integration.test.ts`'s `it.live` tests. */ export const resetDisconnectClients = Effect.fnUntraced(function* (session: DbSession) { // Must run sequentially, unwrapped: looping these in a transaction is unsupported, and @@ -163,10 +157,10 @@ export const resetDisconnectClients = Effect.fnUntraced(function* (session: DbSe ).pipe(Effect.result); if (Result.isFailure(disconnectResult)) { const failure = disconnectResult.failure; - // `failure.code` isn't reliably PgError-only: the driver's exec-error mapping falls back to - // a bare OS errno (`ECONNRESET`, `ETIMEDOUT`, ...) as `code` too, so `isSqlState` must gate - // treating it as a real Postgres error code. A non-PgError failure and a 3D000 PgError are - // both silently swallowed. + // `failure.code` isn't reliably PgError-only: the driver's exec-error mapping can also set + // `code` to a bare OS errno (`ECONNRESET`, `ETIMEDOUT`, ...), so `isSqlState` must gate + // treating it as a real Postgres code. A non-PgError failure and a 3D000 PgError are both + // silently swallowed. if ( failure.code !== undefined && isSqlState(failure.code) && @@ -211,16 +205,10 @@ export const resetDisconnectClients = Effect.fnUntraced(function* (session: DbSe ); }); -// Go builds these four as a single `migration.MigrationFile{Statements: [...]}` -// and calls `.ExecBatch` (`reset.go:165-173`), which formats a failed statement -// with the same rich error context (caret-marked position, `Detail` line, the -// SQLSTATE-42704 extension hint, `At statement: <index>`) real migration files -// get — NOT a real SQL transaction: `DROP`/`CREATE DATABASE` cannot run inside a -// `BEGIN`/`COMMIT` block at all, so these stay bare, sequential, UNWRAPPED -// `session.exec` calls instead of using the TS batch primitive, whose single Sync -// would group them in an implicit transaction. Each -// statement's index matches its position in this list, mirroring Go's -// `m.Statements` indexing. +// `DROP`/`CREATE DATABASE` cannot run inside a `BEGIN`/`COMMIT` block, so these run as bare, +// sequential statements rather than the batch primitive (which would wrap them in a +// transaction). Each statement's index matches its position in this list, used for error +// formatting. const RESET_RECREATE_DATABASES_STATEMENTS = [ "DROP DATABASE IF EXISTS postgres WITH (FORCE)", "CREATE DATABASE postgres WITH OWNER postgres", @@ -229,10 +217,9 @@ const RESET_RECREATE_DATABASES_STATEMENTS = [ ] as const; /** - * Port of Go's `recreateDatabase` (`reset.go:157-176`): connect as - * `supabase_admin` to `template1`, disconnect clients, then four UNWRAPPED - * statements. "We are not dropping roles here because they are cluster level - * entities. Use stop && start instead." (Go's own comment.) + * Connects as `supabase_admin` to `template1`, disconnects clients, then runs four sequential + * statements. Roles are not dropped here since they are cluster-level entities — use stop then + * start instead. */ const resetRecreateDatabases = Effect.fnUntraced(function* (session: DbSession) { yield* resetDisconnectClients(session); @@ -251,10 +238,7 @@ const resetRecreateDatabases = Effect.fnUntraced(function* (session: DbSession) } }); -/** - * Port of Go's `resetDatabase15` (`reset.go:114-142`) — see this module's own - * header for the full sequence and citations. - */ +/** PG >= 15 recreate path — see this module's own header for the full sequence. */ const recreateLocalDatabase15 = <E>( spawner: Spawner, input: RecreateLocalDatabaseInput<E>, @@ -289,15 +273,14 @@ const recreateLocalDatabase15 = <E>( }); yield* createContainer(spawner, postgresSpec, input.containerOpts); - // Never swallowed — reset has no `--from-backup`-equivalent gate at all. + // Never swallowed — reset has no `--from-backup`-equivalent gate. yield* waitForHealthyServices(spawner, [postgresSpec.containerName], { timeoutSeconds: input.dbHealthTimeoutSeconds, images: new Map([[postgresSpec.containerName, resolvedPostgresImage]]), }); - // UNCONDITIONAL — no fresh-volume gate: a reset just removed the volume above, so - // it's always fresh. Passes the RESOLVED reset `version`/`seedFlags`, unlike `db - // start`'s own call — see `db-setup.ts`'s header for this one real difference. + // No fresh-volume gate needed: a reset just removed the volume above, so it's always fresh. + // Passes the resolved reset `version`/`seedFlags`, unlike `db start`'s own call. yield* runFreshDbSetup(spawner, { fs: input.fs, path: input.path, @@ -369,21 +352,20 @@ const recreateLocalDatabase14 = <E>( ); yield* initSchema14(session, fs, path, tmpDir, setup.majorVersion); // Same drop-then-conditionally-recreate sequence fresh setup runs - // (`db-setup.ts`'s `requiresPg14WebhooksCleanup`): the PG14 dump installs - // pg_net unconditionally because later statements grant on its schema, so - // without this drop a reset with webhooks disabled left pg_net installed and - // diverged from a fresh `supabase start` — visible as pg_net drift in the next - // engine's shadow baseline. `MigrateAndSeed` re-applies every migration below, - // so a user migration that creates pg_net still gets it back. + // (`db-setup.ts`'s `requiresPg14WebhooksCleanup`): the PG14 dump installs pg_net + // unconditionally, so without this drop a reset with webhooks disabled would leave + // pg_net installed, causing drift against a fresh `supabase start`. `MigrateAndSeed` + // re-applies every migration below, so a user migration that creates pg_net still gets + // it back. yield* removeDatabaseWebhooks(session, fs, path, tmpDir); yield* applyApiPrivileges(session, fs, path, tmpDir, toml.baseline.apiAutoExposeNewTables); yield* applyDatabaseWebhooks(session, fs, path, tmpDir, toml.webhooksEnabled); }), ); - // RestartDatabase: "Restarting containers..." FIRST, then a REAL restart of the `db` - // container itself (pg_cron must restart after `pg_terminate_backend`) — NOT tolerant - // of "not found", unlike the satellite restarts inside `restartServicesAndReloadKong`. + // "Restarting containers..." first, then an actual restart of the `db` container itself + // (pg_cron must restart after `pg_terminate_backend`); not tolerant of "not found", unlike + // the satellite restarts inside `restartServicesAndReloadKong`. yield* output.raw("Restarting containers...\n", "stderr"); yield* restartContainer(spawner, input.dbContainerId); yield* waitForHealthyServices(spawner, [input.dbContainerId], { @@ -391,7 +373,7 @@ const recreateLocalDatabase14 = <E>( }); yield* restartServicesAndReloadKong(spawner, input.projectId); - // Final connect as `postgres`/`postgres` -> apply.MigrateAndSeed(ctx, version, ...). + // Final connect as `postgres`/`postgres` to migrate and seed. yield* Effect.scoped( Effect.gen(function* () { const session = yield* connectAs("postgres", "postgres"); @@ -408,12 +390,8 @@ const recreateLocalDatabase14 = <E>( }); /** - * Runs the exact Go `resetDatabase`/`resetDatabase14`/`resetDatabase15` sequence — - * see this module's header for the full call order and citations. The caller has - * already printed `Resetting local database…`, matching Go's own `resetDatabase` - * wrapper (`reset.go:81-87`) minus that one line (which the seam this replaces - * used to print itself, and which `db/reset/reset.handler.ts` now prints - * directly, exactly like before). + * Runs the PG14/PG15 recreate sequence — see this module's header for the full call order. The + * caller has already printed `Resetting local database…`. */ export const recreateLocalDatabase = <E>( spawner: Spawner, diff --git a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts index 2b686343b2..7196372509 100644 --- a/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/reset-local-database.ts @@ -35,10 +35,7 @@ import { buildLocalDbContainerInputs } from "./local-container-inputs.ts"; import { isLocalDbRunning } from "./local-db-running.ts"; import { recreateLocalDatabase } from "./recreate-local-database.ts"; -/** - * The local database container is not running. Exported only so the exhaustive actionability - * guard can inspect its declaration; runtime callers consume the enclosing effect instead. - */ +/** The local database container is not running. */ class ResetLocalDbNotRunningError extends Data.TaggedError("ResetLocalDbNotRunningError")<{ readonly message: string; }> { @@ -63,10 +60,7 @@ const PLAIN_FULL_RESET: ResetLocalDatabaseInput = { seedFlags: { noSeed: false, sqlPaths: [] }, }; -/** - * Resets the local database in-process. See this module's own header for the full - * design rationale. Mirrors `internal/db/reset/reset.go:57-77`. - */ +/** Resets the local database in-process. See this module's own header for the full design rationale. */ export const resetLocalDatabase = Effect.fnUntraced(function* ( input: ResetLocalDatabaseInput = PLAIN_FULL_RESET, ) { @@ -77,31 +71,22 @@ export const resetLocalDatabase = Effect.fnUntraced(function* ( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runtimeInfo = yield* RuntimeInfo; const networkIdFlag = yield* NetworkIdFlag; - // Threaded into `buildLocalDbContainerInputs`'s own `setup.debug`, so a failed - // fresh-volume Realtime/Storage/Auth migrate job on the PG15 recreate path tees its own - // stderr, matching Go's `initSchema15` passing `utils.GetDebugLogger()` as that job's - // stderr writer (`start.go:349-353`) — reached by BOTH real Go callers of - // `SetupLocalDatabase` (`db start` and `db reset`'s PG15 recreate). + // Threaded into `buildLocalDbContainerInputs`'s `setup.debug`, so a failed fresh-volume + // Realtime/Storage/Auth migrate job on the PG15 recreate path tees its own stderr. const debug = yield* DebugFlag; const workdir = cliSettings.workdir; - // Go's `ParseDatabaseConfig` runs `loadNestedEnv` (which `os.Setenv`s each project-.env key) - // before `reset.Run` reads `viper.GetBool("EXPERIMENTAL")`, so a `SUPABASE_EXPERIMENTAL` set - // only in `supabase/.env` is honored. Load the project env first and resolve against it, as - // `dbReset` does for its own experimental gate. + // Load the project env first so a `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` is + // honored by the experimental gate below. const projectEnv = yield* loadProjectEnv(fs, path, workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); const experimental = yield* resolveExperimentalWithProjectEnv(projectEnv); - // Go's `flags.LoadConfig` (root `PersistentPreRunE` → the local target's per-connType - // `LoadConfig`, `internal/utils/flags/db_url.go:77-80`) runs full config validation before - // `reset.Run` ever reaches `AssertSupabaseDbIsRunning` / the destructive `resetDatabase` - // (`internal/db/reset/reset.go:57-61`). Re-validate here as an explicit, independent gate - // (the same pattern `db start`/`db push` use), so "a malformed config aborts before the - // local database is recreated" is enforced by this function directly. + // Validate config before checking whether the container is running, so a malformed config + // aborts before the local database is recreated — the same pattern `db start`/`db push` use. yield* checkDbToml(fs, path, workdir); - // AssertSupabaseDbIsRunning — error if the local db container is down. + // Error if the local db container is down. const running = yield* isLocalDbRunning( spawner, fs, @@ -116,12 +101,11 @@ export const resetLocalDatabase = Effect.fnUntraced(function* ( }), ); } - // resetDatabase: "Resetting local database…" then recreate + migrate + seed. + // "Resetting local database…" then recreate + migrate + seed. yield* output.raw(`Resetting local database${toLogMessage(input.version)}\n`, "stderr"); - // Build the SAME prelude `db start`'s own handler builds (config values + - // `resolveDbBootstrapConfig`) — Go's `resetDatabase15`/`resetDatabase14` - // recreate the `db` container with byte-identical inputs to `StartDatabase`'s own. + // Build the same prelude `db start`'s own handler builds (config values + + // `resolveDbBootstrapConfig`), so the container is recreated with identical inputs. const inputs = yield* buildLocalDbContainerInputs( spawner, workdir, @@ -151,61 +135,42 @@ export const resetLocalDatabase = Effect.fnUntraced(function* ( dbContainerId, dbPort: values.dbPort, containerOpts, - // `db reset` has no `fromBackup` concept at all, so `postgresSpecBase` — the - // exact same fields `db start` splices its own `fromBackup` on top of — is - // already this composition's WHOLE `postgresSpec`. + // `db reset` has no `fromBackup` concept, so `postgresSpecBase` is already the whole + // `postgresSpec` here. postgresSpec: postgresSpecBase, resolvePostgresImage, dbHealthTimeoutSeconds: bootstrapConfig.dbHealthTimeoutSeconds, version: input.version, seedFlags: input.seedFlags, - // `db reset` resolves `--experimental` EARLIER than this prelude (it gates the - // remote-target Go-delegation decision too, reached before `cfg.isLocal` is even - // known) via the Go-parity nested-env walk (`resolveExperimentalWithProjectEnv` - // over `projectEnv`, above) — override the prelude's OWN `setup.experimental` (resolved - // from its `@supabase/config`-backed context instead) with that earlier value, to - // preserve this pre-existing divergence exactly. See `buildLocalDbContainerInputs`'s - // own header. + // `db reset` resolves `--experimental` earlier than this prelude does, via the nested-env + // walk above; override the prelude's own `setup.experimental` with that value so the two + // stay consistent. setup: { ...setup, experimental }, }); - // Seed objects from supabase/buckets when storage is up (Go gates buckets on - // an existing, healthy storage container). Reuses the ported seed-buckets - // local path; its summary is suppressed (reset emits its own result). + // Seed objects from supabase/buckets when storage is up; summary is suppressed since reset + // emits its own result. const storageReady = yield* awaitStorageReady(spawner, projectId); if (storageReady) { - // Go's `buckets.Run(ctx, "", false, fsys)` — non-interactive: overwrite/prune - // confirmations take their defaults instead of blocking on input. - // - // `resolvedConfig` passes through the SAME config this function already resolved - // via `buildLocalDbContainerInputs`'s `context` (itself loaded through - // `loadLocalProjectContext`, which mirrors Go's full nested-env walk — - // `.env.<SUPABASE_ENV>.local`, `.env.local`, `.env.<SUPABASE_ENV>`, `.env`, across - // both `supabase/` and the project root, `pkg/config/config.go:1220-1257`) — so - // `seedBucketsRun` never independently reloads config.toml through - // `@supabase/config`'s narrower `loadCliConfig` → `loadCliProjectEnvironment` - // (`supabase/.env`/`.env.local` plus ambient env only, - // `packages/config/src/project.ts:209-245`), which used to reject a config whose - // `env(VAR)` reference is backed by e.g. `supabase/.env.development` — genuinely - // Go-valid (Go's `godotenv.Load` calls `os.Setenv`, so the value is real ambient env - // by the time Go resolves it, `config.go:1260-1261`) and already accepted by - // `checkDbToml` and the real recreate above (review CLI-1958). Same pattern - // `start.handler.ts` already uses for its own `seedBucketsRun` calls. + // Non-interactive: overwrite/prune confirmations take their defaults instead of blocking on + // input. `resolvedConfig` reuses the config already resolved via + // `buildLocalDbContainerInputs`'s full nested-env walk, so `seedBucketsRun` never + // independently reloads config.toml through a narrower env resolution that could reject a + // config whose `env(VAR)` reference is backed by a non-default dotenv file. Same pattern + // `start.handler.ts` uses for its own `seedBucketsRun` calls. yield* seedBucketsRun({ projectRef: "", emitSummary: false, interactive: false, - // Go loads nested env before `buckets.Run`, so `SUPABASE_YES` in `supabase/.env` - // auto-confirms bucket/vector/analytics prune prompts. + // `SUPABASE_YES` set in `supabase/.env` auto-confirms bucket/vector/analytics prune + // prompts. yes, resolvedConfig: { config, document: loaded?.document }, - // The same nested-dotenv walk this function already resolved for - // `yes`/`experimental` above — no independent reload in the seed core. + // The same nested-dotenv walk already resolved for `yes`/`experimental` above. projectEnvValues: projectEnv, }).pipe( - // A genuinely invalid bucket entry (bad name, unparseable `file_size_limit`, …) — - // recreate already dropped/rebuilt the DB, so aborting now would leave the reset - // half-done; warn and skip buckets so the reset finishes like Go instead. + // An invalid bucket entry (bad name, unparseable `file_size_limit`, …) can't abort here — + // recreate already dropped/rebuilt the DB — so warn and skip buckets instead. Effect.catchTag("SeedConfigLoadError", (error) => output.raw( `${yellow("WARNING:")} skipped seeding storage buckets: ${error.message}\n`, @@ -215,7 +180,6 @@ export const resetLocalDatabase = Effect.fnUntraced(function* ( ); } - // "Finished supabase db reset on branch <branch>." (both Aqua). const branch = Option.getOrElse(yield* detectGitBranch(workdir), () => "main"); yield* output.raw(`Finished ${aqua("supabase db reset")} on branch ${aqua(branch)}.\n`, "stderr"); }); 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 3571c0db88..a81619a12b 100644 --- a/apps/cli/src/command-internal/db-bootstrap/restart-services.ts +++ b/apps/cli/src/command-internal/db-bootstrap/restart-services.ts @@ -27,7 +27,7 @@ import { serviceContainerName } from "../docker-ids.ts"; type Spawner = ChildProcessSpawner["Service"]; -/** `docker restart <id>` (the db container itself) failed — used only by PG14's `RestartDatabase`. */ +/** `docker restart <id>` (the db container itself) failed — used only by PG14's reset path. */ export class ContainerRestartError extends Data.TaggedError("ContainerRestartError")<{ readonly message: string; }> { @@ -89,7 +89,7 @@ const restartSatelliteService = ( ), ); -/** One or more satellite-service restarts failed. Messages are newline-joined, matching Go's `errors.Join`. */ +/** One or more satellite-service restarts failed. Messages are newline-joined. */ export class RestartServicesError extends Data.TaggedError("RestartServicesError")<{ readonly message: string; }> { @@ -99,14 +99,10 @@ export class RestartServicesError extends Data.TaggedError("RestartServicesError } /** - * Port of Go's `restartServices` restart half (`reset.go:227-239`): restarts - * storage/auth/realtime/pooler CONCURRENTLY (Go's `utils.WaitAll`, a goroutine per - * service) — NOT PostgREST, which "automatically reconnects and listens for schema - * changes" (Go's own comment) — and does NOT wait for them to become healthy - * afterward ("those services may be excluded from starting"). Every per-service - * failure (excluding a tolerated not-found) is joined into one newline-separated - * message, matching `errors.Join`. Not exported outside this module — only - * {@link restartServicesAndReloadKong} calls this directly. + * Restarts storage/auth/realtime/pooler concurrently — not PostgREST, which reconnects and + * listens for schema changes on its own — without waiting for them to become healthy + * afterward, since some may be excluded from the stack. Every per-service failure (excluding a + * tolerated not-found) is joined into one newline-separated message. */ function restartSatelliteServices( spawner: Spawner, @@ -130,11 +126,7 @@ function restartSatelliteServices( }); } -/** - * Gateway-recovery hint, byte-matching Go's `suggestKongRecovery` - * (`reset.go:281-288`): rendered as a `Suggestion:` line by `Output.fail`, mirroring - * `utils.CmdSuggestion`. - */ +/** Gateway-recovery hint, rendered as a `Suggestion:` line by `Output.fail`. */ function kongRecoverySuggestion(kongId: string): string { return ( "Local services restarted, but API routes may return 502 until the gateway reloads.\n" + @@ -185,16 +177,11 @@ function execCaptureCombined( } /** - * Port of Go's `reloadKong` (`reset.go:253-276`): inspect Kong's container — not - * found means Kong is excluded from the stack (`return nil`, not an error); any OTHER - * inspect failure is wrapped with the recovery suggestion; not running means there's - * no stale cache to flush (`return nil`); otherwise `docker exec <kongId> kong reload - * --nginx-conf /home/kong/custom_nginx.template` (the flag is required — a bare - * `kong reload` regenerates nginx.conf from Kong's default template and drops the - * custom `email_templates` server, reintroducing #6059), failing hard (with the same - * suggestion) on a non-zero exit, the combined output appended when non-empty. Not - * exported outside this module — only {@link restartServicesAndReloadKong} - * calls this directly. + * Inspects Kong's container: not found means it's excluded from the stack (no error); any + * other inspect failure or a non-zero reload exit fails hard with the recovery suggestion; not + * running means there's no stale cache to flush. The `--nginx-conf` flag is required — a bare + * `kong reload` regenerates nginx.conf from Kong's default template and drops the custom + * `email_templates` server. */ function reloadKong(spawner: Spawner, projectId: string): Effect.Effect<void, KongReloadError> { const kongId = serviceContainerName("kong", projectId); @@ -218,10 +205,8 @@ function reloadKong(spawner: Spawner, projectId: string): Effect.Effect<void, Ko ]); if (result.exitCode !== 0) { const trimmed = result.output.trim(); - // Go's `DockerExecOnceWithStream` (`utils/docker.go:646-648`) sets a FIXED constant - // error, `errors.New("error executing command")`, for `iresp.ExitCode > 0` — not the - // exit code itself. `reloadKong` then wraps it as `failed to reload kong: %w[:\n%s]` - // (`reset.go:269-274`), so the `%w` slot is always this exact string, never `exit N`. + // The established message is always "error executing command", never "exit N" — kept + // byte-identical for compatibility. return yield* Effect.fail( new KongReloadError({ message: @@ -236,9 +221,8 @@ function reloadKong(spawner: Spawner, projectId: string): Effect.Effect<void, Ko } /** - * Port of Go's `restartServices` (`reset.go:227-241`): the satellite restarts above, - * then {@link reloadKong} — ONLY when every restart succeeded (Go returns the - * joined restart error immediately, without ever attempting the Kong reload). + * Runs the satellite restarts above, then {@link reloadKong} — only when every restart + * succeeded; a restart failure skips the Kong reload entirely. */ export function restartServicesAndReloadKong( spawner: Spawner, 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 a634a8bc2f..475b4bc5fe 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 @@ -4,14 +4,13 @@ * `shadow-baseline-<key>.tar` on the first run, warm-restore that exact tar on the second, and * produce byte-identical diff output either way. * - * Proves what only real process wiring can: that `db diff` actually routes through + * Proves what only real process wiring can: that `db diff` routes through * `acquireShadowDatabase`, that the cache engages with `SUPABASE_SHADOW_CACHE` genuinely unset * (the shipped default), that the cache directory survives a real process boundary, that the - * cache key is stable across two separate CLI processes, and that a warm-restored cluster yields - * the same migration SQL as a cold-provisioned one. + * cache key is stable across two separate CLI processes, and that a warm-restored cluster + * yields the same migration SQL as a cold-provisioned one. * - * The acquire/export/restore mechanics (cold export, warm restore, tar validation and rejection, - * retention/LRU, cache-off and bypass paths) are covered exhaustively by + * The acquire/export/restore mechanics are covered exhaustively by * `shadow-cache.integration.test.ts`, and the pure key/retention logic by * `shadow-cache.unit.test.ts`. Nothing branch-shaped belongs here. */ @@ -109,9 +108,9 @@ describe("shadow baseline cache (e2e, local Docker stack)", () => { // 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 payload whose byte-identity across the cold and warm runs is the actual - // user-visible contract here. (The next engine ignores `schema_paths` when building its - // migrations baseline, so declared schema files cannot supply the drift.) + // 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", @@ -145,7 +144,7 @@ as $$ select 1; $$;`, 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 + // 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), @@ -172,14 +171,14 @@ as $$ select 1; $$;`, break; } - // --- Run 1: cold. The baseline was provisioned and exported as one keyed tar. --- + // 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. --- + // 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); @@ -187,18 +186,16 @@ as $$ select 1; $$;`, // 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"); - // Same single tar, same filename: the key is reproducible across processes, and the warm run - // published nothing of its own. 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); - // The user-visible contract is unchanged by which path ran: stdout carries the migration SQL - // (no `-f`, so `db diff` prints it), and 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`. + // 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, ); 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 f5f41df03d..503f964801 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 @@ -184,12 +184,10 @@ describe("acquireShadowDatabase", () => { const handle = yield* acquireShadowDatabase(docker.spawner, input); expect(handle.baselinePresent).toBe(false); - // `--rm` intact, no PGDATA copies, and the snapshot step is a no-op. The one `cp-secret` - // is the pgsodium root key every shadow gets. + // `--rm` intact, no PGDATA copies; `cp-secret` is the pgsodium root key every shadow gets. expect(docker.calls("create")[0] ?? []).toContain("--rm"); yield* handle.snapshotBaseline; expect(docker.steps()).toEqual(["create", "cp-secret", "start"]); - // Nothing is written to disk at all. expect(yield* soleTarName(fs, path)).toEqual([]); yield* removeShadowDatabase(docker.spawner, handle.containerId); @@ -216,7 +214,6 @@ describe("acquireShadowDatabase", () => { bypassCache: true, }); expect(handle.baselinePresent).toBe(false); - // No restore in, no export out: the bypassed run neither reads nor rewrites the tar. const bypassSteps = docker.steps().slice(docker.steps().lastIndexOf("create")); expect(bypassSteps).toEqual(["create", "cp-secret", "start"]); expect(docker.calls("create").at(-1) ?? []).toContain("--rm"); @@ -236,8 +233,8 @@ describe("acquireShadowDatabase", () => { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; // PG<=14's globals SQL runs `ALTER ROLE … SET …` on the setup session; a snapshot - // boundary would force migrations onto a fresh session that observes those defaults, - // unlike Go's single-connection flow — so the cache must stand down entirely. + // boundary would force migrations onto a fresh session that never observes those + // defaults — so the cache must stand down entirely. const base = shadowInput(fs, path); const input = { ...base, @@ -343,10 +340,9 @@ describe("acquireShadowDatabase", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - // A regular FILE occupies the cache root's path, so its mkdir can never succeed — the - // same terminal shape as an unwritable or root-squashed `SUPABASE_HOME`. Committing to - // the cached lifecycle anyway would drop `--rm` and pay a stop → failed export → restart - // cycle on every invocation, so the acquire must degrade to the plain uncached shadow. + // A regular file occupies the cache root's path, so its mkdir can never succeed — the + // same terminal shape as an unwritable or root-squashed `SUPABASE_HOME`. The acquire + // must degrade to the plain uncached shadow rather than pay a doomed export cycle. const cacheDir = shadowCacheDir(path); yield* fs.makeDirectory(path.dirname(cacheDir), { recursive: true }); yield* fs.writeFileString(cacheDir, "not a directory"); @@ -476,8 +472,6 @@ describe("acquireShadowDatabase", () => { ]); expect(docker.containers.get(handle.containerId)?.running).toBe(true); - // Exactly one tar, published under its final name with the exported bytes intact — no - // `.partial` left behind. const tars = yield* soleTarName(fs, path); expect(tars).toHaveLength(1); expect(tars[0]).toMatch(/^shadow-baseline-[0-9a-f]{16}\.tar$/u); @@ -490,7 +484,6 @@ describe("acquireShadowDatabase", () => { const leftovers = yield* fs.readDirectory(shadowCacheDir(path)); expect(leftovers.filter((entry) => entry.includes("partial"))).toEqual([]); - // Release is the uncached removal, same as ever — nothing is kept. yield* removeShadowDatabase(docker.spawner, handle.containerId); expect(docker.ids()).toEqual([]); }), @@ -516,7 +509,7 @@ describe("acquireShadowDatabase", () => { // Throwaway again: the warm container never gets stopped, so `--rm` is back. expect(docker.calls("create").at(-1) ?? []).toContain("--rm"); - // The restore lands BEFORE the start, and carries the exported bytes into PGDATA's parent. + // The restore lands before the start, and carries the exported bytes into PGDATA's parent. const warmSteps = docker.steps().slice(docker.steps().lastIndexOf("create")); expect(warmSteps).toEqual(["create", "cp-secret", "cp-in", "start", "inspect"]); expect(docker.stepCalls("cp-in").at(-1)).toEqual([ @@ -549,9 +542,8 @@ describe("acquireShadowDatabase", () => { const input = shadowInput(fs, path); const tempDir = shadowCacheDir(path); yield* fs.makeDirectory(tempDir, { recursive: true }); - // An adversarially (or crash-) pre-created temp file at THIS process's own temp path, - // world-readable. The export must not inherit its mode: the pre-remove + `wx` - // exclusive-create guarantees a fresh 0600 inode. + // An adversarially (or crash-) pre-created temp file at this process's own temp path, + // world-readable. The pre-remove + `wx` exclusive-create guarantees a fresh 0600 inode. yield* coldRun(docker, input); // publish once to learn the tar name const [tarName = ""] = yield* soleTarName(fs, path); const tarPath = path.join(tempDir, tarName); @@ -612,8 +604,7 @@ describe("acquireShadowDatabase", () => { expect(first).toHaveLength(1); // A changed baked-in input (jwt expiry) is a different cluster / different key — both - // must coexist in the global cache (unlike the old project-local current-key-only sweep, - // which would have deleted the first). Host publish port is NOT a key input. + // must coexist in the global cache. Host publish port is not a key input. const rekeyed = yield* coldRun(docker, shadowInput(fs, path, { jwtExpiry: 7200 })); const both = yield* soleTarName(fs, path); expect(both).toHaveLength(2); @@ -625,9 +616,8 @@ describe("acquireShadowDatabase", () => { yield* fs.writeFileString(stray, "{}"); // mtime is the LRU ordinal, and the rapid-fire publishes below can land within the - // filesystem's timestamp granularity — an mtime tie makes "oldest" ambiguous and the - // eviction pick arbitrary (observed as a CI-only failure). Age the first tar explicitly: - // this test asserts the keep-cap behavior, not tie-breaking. + // 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); yield* fs.utimes(path.join(shadowCacheDir(path), first[0] ?? ""), anHourAgo, anHourAgo); @@ -780,9 +770,9 @@ describe("acquireShadowDatabase", () => { const path = yield* Path.Path; const handle = yield* acquireShadowDatabase(docker.spawner, shadowInput(fs, path)); // The export itself succeeds (stop + copy-out are fine); the revive `docker start` fails. - // Reporting success here would send the caller's next connect to a dead container's port - // — possibly answered by a DIFFERENT Postgres by then — so this must be a failure, not a - // "not cached" warning. + // Reporting success here would send the caller's next connect to a dead container's + // port — possibly answered by a different Postgres by then — so this must be a hard + // failure, not a "not cached" warning. const exit = yield* handle.snapshotBaseline.pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(out.stderrText).not.toContain("Warning: shadow baseline not cached"); @@ -838,13 +828,13 @@ describe("acquireShadowDatabase", () => { // The container whose restore failed is removed, not orphaned: with the cold run's own // container already released by `coldRun`, only the fallback's remains. expect(docker.ids()).toEqual([fallback.containerId]); - // An extraction failure does NOT implicate the tar's contents (it could just as well be - // a daemon hiccup), so the tar survives the fallback decision... + // An extraction failure does not implicate the tar's contents (it could just as well + // be a daemon hiccup), so the tar survives the fallback decision... expect(yield* soleTarName(fs, path)).toHaveLength(1); - // ...and the cold fallback's own export atomically republishes over it, so a genuinely - // corrupt tar still self-heals within this one run. Corrupt the bytes first: a - // skip-if-published that treated "tar exists" as "sibling just published" would leave - // this garbage in place forever. + // ...and the cold fallback's own export atomically republishes over it, so a corrupt + // tar self-heals within this one run. Corrupt the bytes first: a skip-if-published + // check that treated "tar exists" as "sibling just published" would leave this + // garbage in place forever. const [tarName = ""] = yield* soleTarName(fs, path); const tarPath = path.join(shadowCacheDir(path), tarName); yield* fs.writeFileString(tarPath, "not-a-real-snapshot"); @@ -870,10 +860,10 @@ describe("acquireShadowDatabase", () => { const [tarName = ""] = yield* soleTarName(fs, path); const tarPath = path.join(shadowCacheDir(path), tarName); - // The published artifact is replaced by a tar that is perfectly well-formed but carries no - // PGDATA: `docker cp -` would extract nothing, the entrypoint would `initdb` a fresh - // cluster, readiness would pass, and the caller would diff against a BARE database while - // being told the platform baseline was present. + // The published artifact is replaced by a tar that is well-formed but carries no PGDATA: + // `docker cp -` would extract nothing, the entrypoint would `initdb` a fresh cluster, + // readiness would pass, and the caller would diff against a bare database while being + // told the platform baseline was present. yield* fs.writeFileString(tarPath, FAKE_EMPTY_TAR); const stepsBefore = docker.steps().length; @@ -884,7 +874,7 @@ describe("acquireShadowDatabase", () => { expect(fallback.baselinePresent).toBe(false); // Caught before any container was created, so nothing was ever restored. expect(docker.steps().slice(stepsBefore)).not.toContain("cp-in"); - // The contents ARE the problem, so the tar goes — and the cold fallback republishes a + // The contents are the problem, so the tar goes — and the cold fallback republishes a // good one within the same run, which is what keeps this fail-open. expect(yield* soleTarName(fs, path)).toEqual([]); yield* fallback.snapshotBaseline; @@ -909,11 +899,11 @@ describe("acquireShadowDatabase", () => { const [tarName = ""] = yield* soleTarName(fs, path); const tarPath = path.join(shadowCacheDir(path), tarName); - // A REAL, perfectly restorable PGDATA — but one that never ran the platform baseline. This - // is the failure `data/PG_VERSION` alone cannot see: `docker cp -` extracts a genuine - // cluster, the entrypoint SKIPS `initdb`, readiness passes, and the caller would be told - // the baseline is present while diffing against a bare database. Only the missing marker - // separates it from a usable snapshot. + // A perfectly restorable PGDATA that never ran the platform baseline — the failure + // `data/PG_VERSION` alone cannot see: `docker cp -` extracts a genuine cluster, the + // entrypoint skips `initdb`, readiness passes, and the caller would be told the baseline + // is present while diffing against a bare database. Only the missing marker separates it + // from a usable snapshot. yield* fs.writeFileString(tarPath, FAKE_UNSTAMPED_PGDATA_TAR); const stepsBefore = docker.steps().length; @@ -924,7 +914,7 @@ describe("acquireShadowDatabase", () => { expect(fallback.baselinePresent).toBe(false); // Caught before any container was created, so nothing was ever restored. expect(docker.steps().slice(stepsBefore)).not.toContain("cp-in"); - // The contents ARE the problem, so the tar goes — and the cold fallback republishes a + // The contents are the problem, so the tar goes — and the cold fallback republishes a // marked one within the same run. expect(yield* soleTarName(fs, path)).toEqual([]); yield* fallback.snapshotBaseline; @@ -958,7 +948,7 @@ describe("acquireShadowDatabase", () => { // A's snapshot copied over B's cache file — the shape a copied `~/.supabase/cache` // directory, a restored backup, or a hand-renamed tar produces. Every entry the - // presence check requires is there (it IS a real, fully baselined cluster), so only the + // presence check requires is there (it is a real, fully baselined cluster), so only the // marker's key separates it from B's own baseline: restoring it would silently diff // against A's roles, vault values and service schema. yield* fs.writeFileString(tarPathB, yield* fs.readFileString(tarPathA)); @@ -971,8 +961,8 @@ describe("acquireShadowDatabase", () => { expect(fallback.baselinePresent).toBe(false); // Caught before any container was created, so nothing was ever restored. expect(docker.steps().slice(stepsBefore)).not.toContain("cp-in"); - // Only the MISNAMED COPY goes: nothing else can ever be filed under B's name, while A's - // own tar — still correctly named — is left completely alone. + // Only the misnamed copy goes: nothing else can ever be filed under B's name, while + // A's own tar — still correctly named — is left alone. expect(yield* fs.exists(tarPathB)).toBe(false); expect(yield* fs.readFileString(tarPathA)).toBe( expectedTarFor(`shadow-baseline-${keyA}.tar`), @@ -1005,7 +995,7 @@ describe("acquireShadowDatabase", () => { expect(out.stderrText).toContain(PGDATA_BASELINE_MARKER_ENTRY); // ...the shadow is back up for the caller to reconnect to... expect(docker.containers.get(handle.containerId)?.running).toBe(true); - // ...and nothing is published, because an UNMARKED tar would only be thrown away on the + // ...and nothing is published, because an unmarked tar would only be thrown away on the // next run anyway. The export never even runs. expect(docker.stepCalls("cp-out")).toHaveLength(0); expect(yield* soleTarName(fs, path)).toEqual([]); @@ -1056,7 +1046,7 @@ describe("acquireShadowDatabase", () => { Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - // Plan shadows acquire before either has published, so both go cold with the same key + // Each shadow acquires before either has published, so both go cold with the same key // (host port is not a key input) and race toward the same tar path. const [first, second] = yield* Effect.all( [ From 1aace0354eb6863d1f38baee9953b09305593d3f Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 12:33:57 +0100 Subject: [PATCH 12/56] chore(repo): trim comments, shard 15 (1224 -> 675 comment lines) --- .../migration-timestamp.format.ts | 42 +-- .../src/command-internal/migration.errors.ts | 12 +- .../command-internal/param-introspection.ts | 48 +--- .../param-introspection.unit.test.ts | 6 - .../command-internal/parent-project-ref.ts | 123 +++------ apps/cli/src/command-internal/parse-uint.ts | 108 ++------ .../command-internal/parse-uint.unit.test.ts | 13 - apps/cli/src/command-internal/path-match.ts | 82 ++---- .../command-internal/path-match.unit.test.ts | 16 +- .../src/command-internal/pflag-reconcile.ts | 261 ++++++------------ .../pflag-reconcile.unit.test.ts | 54 +--- apps/cli/src/command-internal/pg-dump.env.ts | 48 ++-- apps/cli/src/command-internal/pg-dump.run.ts | 34 +-- .../src/command-internal/pg-net-guidance.ts | 15 +- .../pg-net-guidance.unit.test.ts | 7 - .../pgdelta-ssl-probe.layer.ts | 33 +-- .../pgdelta-ssl-probe.service.ts | 28 +- apps/cli/src/command-internal/pgdelta-ssl.ts | 7 +- .../cli/src/command-internal/pgdelta.paths.ts | 2 +- apps/cli/src/command-internal/pgdelta.ts | 36 +-- apps/cli/src/command-internal/pgpass.ts | 32 +-- .../cli/src/command-internal/pgservicefile.ts | 31 +-- apps/cli/src/command-internal/postgres-url.ts | 40 +-- apps/cli/src/command-internal/profile-load.ts | 146 +++------- .../profile-load.unit.test.ts | 25 +- apps/cli/src/command-internal/profile.ts | 31 +-- .../command-internal/project-create-core.ts | 36 +-- .../command-internal/project-environment.ts | 92 ++---- 28 files changed, 429 insertions(+), 979 deletions(-) diff --git a/apps/cli/src/command-internal/migration-timestamp.format.ts b/apps/cli/src/command-internal/migration-timestamp.format.ts index 0db76141b0..9ab1f95010 100644 --- a/apps/cli/src/command-internal/migration-timestamp.format.ts +++ b/apps/cli/src/command-internal/migration-timestamp.format.ts @@ -1,11 +1,7 @@ /** - * Port of Go's `utils.FormatTimestampVersion` (`internal/utils/render.go:21`): - * parse a `YYYYMMDDHHMMSS` migration version with the strict `time.Parse` - * layout `20060102150405` and reformat it as `YYYY-MM-DD HH:MM:SS`. On any parse - * failure Go returns the input unchanged, so non-timestamp versions (`0`, `1`, - * non-numeric, out-of-range dates) pass through verbatim. - * - * Pure — no Effect / service dependencies. + * Parses a `YYYYMMDDHHMMSS` migration version and reformats it as `YYYY-MM-DD HH:MM:SS`. + * Returns the input unchanged when it isn't a valid timestamp (non-numeric, wrong length, + * or an impossible calendar date). */ export function formatTimestampVersion(version: string): string { const match = /^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/u.exec(version); @@ -23,9 +19,8 @@ export function formatTimestampVersion(version: string): string { const hour = Number(hh); const minute = Number(min); const second = Number(ss); - // Range-check each field, then a calendar round-trip so impossible dates - // (e.g. Feb 30, month 13) fall back to passthrough exactly like Go's - // `time.Parse`, which errors rather than normalising overflow. + // Reject out-of-range fields, then round-trip through Date.UTC so impossible dates + // (e.g. Feb 30) fall back to passthrough instead of silently normalizing overflow. if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) { return version; } @@ -40,26 +35,16 @@ export function formatTimestampVersion(version: string): string { return `${yyyy}-${mm}-${dd} ${hh}:${min}:${ss}`; } -/** - * Go's `math.MaxInt` on a 64-bit build (== `math.MaxInt64`) — the sentinel that - * pins the exhausted side of a migration-version two-pointer merge. - */ +/** Int64 max; the sentinel for the exhausted side of a migration-version merge. */ export const MIGRATION_VERSION_MAX = 9223372036854775807n; -/** Go's `math.MinInt64` — `strconv.Atoi`'s lower bound (`ParseInt(s, 10, 0)`). */ +/** Int64 min: the lower bound accepted for a migration version. */ const MIGRATION_VERSION_MIN = -9223372036854775808n; /** - * Parses a migration version like Go's `strconv.Atoi` (`makeTable` / - * `assertRemoteInSync`): `Atoi` == `ParseInt(s, 10, 0)`, so it accepts an optional - * leading `+`/`-` sign and base-10 digits within the int64 range, and rejects empty, - * whitespace, floats, and `0x`/`0b` forms. A non-parseable or out-of-range version - * returns `undefined` (Go's `Atoi` error → `continue`). Signs only ever appear on - * malformed history rows (e.g. `-1`); Go still validates and orders them by signed - * int — so `migration repair -1 --status reverted` can delete that text row, and the - * two-pointer merge sorts `-1` before `0`. BigInt keeps the full int64 range exact: - * `Number` loses precision above `Number.MAX_SAFE_INTEGER` (e.g. `Number("9999999999999999")` - * rounds to 1e16), which would mis-order versions Go accepts. + * Parses a migration version as a signed base-10 integer within the int64 range; returns + * `undefined` for anything else. Uses `BigInt`, since `Number` loses precision above + * `Number.MAX_SAFE_INTEGER` and would mis-order large version numbers. */ export const parseMigrationVersion = (value: string): bigint | undefined => { if (!/^[+-]?\d+$/u.test(value)) return undefined; @@ -72,11 +57,8 @@ export const compareMigrationVersions = (a: string, b: string): number => a < b ? -1 : a > b ? 1 : 0; /** - * Orders bare version strings the way `ORDER BY version` returns them, for the - * walks that compare version lists rather than paths. `version` is a `text` - * column, so Postgres orders it lexically and a prefix always precedes its - * extension — exactly what `sortMigrationPathsByVersion` - * (`migration-history.ts`) reproduces for the path-shaped walks. + * Orders bare version strings the way Postgres's `ORDER BY version` does: lexically, so a + * version string's extension always sorts after its prefix. */ export function sortMigrationVersions(versions: ReadonlyArray<string>): ReadonlyArray<string> { return [...versions].sort(compareMigrationVersions); diff --git a/apps/cli/src/command-internal/migration.errors.ts b/apps/cli/src/command-internal/migration.errors.ts index 132074ee81..30d610f9af 100644 --- a/apps/cli/src/command-internal/migration.errors.ts +++ b/apps/cli/src/command-internal/migration.errors.ts @@ -6,15 +6,9 @@ import { } from "../shared/telemetry/error-actionability.ts"; /** - * Listing or reading migrations failed for a reason other than the directory - * being absent. Byte-matches Go's `migration.ListLocalMigrations` - * (`apps/cli-go/pkg/migration/list.go:34-37`), which returns - * `"failed to read directory: " + err` for anything but `os.ErrNotExist` rather - * than treating an unreadable `supabase/migrations` as "no migrations". - * - * Lives in `command-internal/` (not the `db`-command-scoped `pgdelta.errors`) - * because it is raised by the shared migration-history module and consumed by - * both the `db` and `migration` command families. + * Listing or reading migrations failed for a reason other than the directory being absent + * (e.g. a permissions error) — an unreadable `supabase/migrations` is not treated as "no + * migrations". Shared by both the `db` and `migration` command families. */ export class MigrationsReadError extends Data.TaggedError("MigrationsReadError")<{ readonly message: string; diff --git a/apps/cli/src/command-internal/param-introspection.ts b/apps/cli/src/command-internal/param-introspection.ts index 7de0442808..ed8560b84b 100644 --- a/apps/cli/src/command-internal/param-introspection.ts +++ b/apps/cli/src/command-internal/param-introspection.ts @@ -2,24 +2,9 @@ import { Option } from "effect"; import { Param } from "effect/unstable/cli"; /** - * `effect/unstable/cli`'s own `Param.extractSingleParams`/`Param.getParamMetadata` - * (`.repos/effect/packages/effect/src/unstable/cli/Param.ts`) already implement - * exactly this unwrap — the same functions `--help` rendering uses internally — - * but both carry an `@internal` JSDoc tag and are confirmed ABSENT from this - * package's published `.d.ts` (present only in the compiled `.js`; verified - * against the pinned `effect@4.0.0-beta.97` under `node_modules`), so calling - * them would only type-check via an `as` cast, which this repo forbids. - * - * This module reimplements the same unwrap using only type-visible public - * fields. A `Map`/`Transform`/`Optional`/`Variadic` param wraps an inner - * `.param` of the same shape (e.g. `.pipe(Flag.optional)`, - * `.pipe(Flag.withDefault(...))`, which composes as `Map(Optional(Single))`), - * and every non-`Single` variant publicly declares `.param` per its own - * interface. The variant union is closed as of this effect version, so an - * unrecognized future variant fails *closed* (the walk stops and returns - * `undefined`) rather than open. Delete this in favor of - * `Param.extractSingleParams`/`Param.getParamMetadata` if effect ever - * publishes them. + * Reimplements `effect/unstable/cli`'s internal `Param.extractSingleParams`/ + * `getParamMetadata` unwrap using only public fields, since the originals are `@internal` and + * would need a forbidden `as` cast. Fails closed (`undefined`) on an unrecognized variant. */ interface WrappedParam { readonly param: Param.Any; @@ -44,24 +29,17 @@ export interface UnwrappedParam { readonly isOptional: boolean; readonly isVariadic: boolean; /** - * The `Param.variadic`/`Flag.atLeast`/`Flag.between` minimum occurrence - * count, or `0` when the param isn't variadic at all. A variadic param with - * `min === 0` (e.g. `Flag.atLeast(0)`, what `stringSliceFlag` uses) - * can legitimately be omitted entirely — `Param.ts`'s `parseOptionVariadic` - * only fails with `MissingOption` when `count < min` and `min > 0` — so - * "variadic" alone does NOT imply "optional" the way wrapping in - * `Param.Optional` does. Callers computing required-ness must check this, - * not just `isVariadic`. + * The variadic minimum occurrence count (`0` when not variadic, or when variadic with no + * minimum, e.g. `Flag.atLeast(0)`). A variadic param isn't necessarily optional — check + * this instead of `isVariadic` when determining whether a flag is required. */ readonly variadicMin: number; } /** - * Unwraps a possibly-wrapped `Param` down to its underlying `Single` leaf, - * alongside whether the param passed through `Param.optional`/`Flag.optional` - * (or `Flag.withDefault`, which composes as `Map(Optional(Single))`) and/or - * `Param.variadic`/`Flag.between`/`Flag.atLeast`/`Flag.atMost`. Returns - * `undefined` only if the variant union gains an unrecognized future case. + * Unwraps a possibly-wrapped `Param` down to its underlying `Single` leaf, tracking whether it + * passed through `Param.optional`/`Flag.optional`/`Flag.withDefault` and/or a variadic wrapper. + * Returns `undefined` for an unrecognized param variant. */ export function unwrapParam(param: Param.Any): UnwrappedParam | undefined { let current: Param.Any = param; @@ -85,11 +63,9 @@ export function unwrapParam(param: Param.Any): UnwrappedParam | undefined { } /** - * Unwraps down to the underlying `Single` param only, discarding the - * optional/variadic metadata `unwrapParam` also computes. Shared by - * `telemetry/command-telemetry.ts` (telemetry flag - * redaction) and `cli/complete.ts` (shell completion), both of - * which only need the leaf `Single`'s `name`/`aliases`/`primitiveType` fields. + * Unwraps down to the underlying `Single` param only, discarding the optional/variadic + * metadata `unwrapParam` also computes. Shared by telemetry flag redaction and shell + * completion, which only need the leaf's `name`/`aliases`/`primitiveType` fields. */ export function unwrapToSingleParam( param: Param.Any, diff --git a/apps/cli/src/command-internal/param-introspection.unit.test.ts b/apps/cli/src/command-internal/param-introspection.unit.test.ts index 463426eb13..9244518425 100644 --- a/apps/cli/src/command-internal/param-introspection.unit.test.ts +++ b/apps/cli/src/command-internal/param-introspection.unit.test.ts @@ -32,12 +32,6 @@ describe("unwrapParam", () => { }); it("marks a zero-minimum variadic flag (Flag.atLeast(0)) as variadic but NOT optional, with variadicMin 0", () => { - // This is the exact shape `stringSliceFlag` builds on - // (`string-slice-flag.ts`) — a real bug (CLI-1965 review) treated - // this as "required" for shell-completion purposes because it isn't - // `Optional`-wrapped, even though `Param.ts`'s `parseOptionVariadic` only - // fails with `MissingOption` when `count < min` and `min > 0`, so a - // zero-minimum variadic flag can legitimately be omitted entirely. const result = unwrapParam(Flag.string("domains").pipe(Flag.atLeast(0))); expect(result?.single.name).toBe("domains"); expect(result?.isOptional).toBe(false); diff --git a/apps/cli/src/command-internal/parent-project-ref.ts b/apps/cli/src/command-internal/parent-project-ref.ts index 1de9d010a0..ed14c356fd 100644 --- a/apps/cli/src/command-internal/parent-project-ref.ts +++ b/apps/cli/src/command-internal/parent-project-ref.ts @@ -9,10 +9,11 @@ export type ParentRefResolution = | { readonly kind: "invalid" } | { readonly kind: "absent" }; -/** The subset of `<workdir>/supabase/.temp/linked-project.json` callers care - * about: the parent project's `ref`, its `name`, and its - * `organization_slug`/`organization_id` — all optional, present only when the - * cache carries a usable (non-empty string) value for each. */ +/** + * The subset of `<workdir>/supabase/.temp/linked-project.json` callers care about: the + * parent project's `ref`, `name`, `organization_slug`, and `organization_id`. The latter + * three are optional, present only when the cache holds a non-empty string value. + */ export interface CachedLinkedProject { readonly ref: string; readonly name?: string; @@ -29,10 +30,11 @@ function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null; } -/** Best-effort parse of `<workdir>/supabase/.temp/linked-project.json`'s `ref` - * (and, when present, `name`/`organization_slug`/`organization_id`) fields — - * a missing file, unreadable file, malformed JSON, or non-string/empty `ref` - * all degrade to `None` rather than failing the caller. */ +/** + * Best-effort parse of `<workdir>/supabase/.temp/linked-project.json`'s `ref` (and, when + * present, `name`/`organization_slug`/`organization_id`) fields. A missing/unreadable file, + * malformed JSON, or non-string/empty `ref` all degrade to `None` rather than failing. + */ export function parseCachedLinkedProject(content: string): Option.Option<CachedLinkedProject> { try { const parsed: unknown = JSON.parse(content); @@ -63,12 +65,9 @@ function parseCachedParentRef(content: string): Option.Option<string> { function classifyParentCandidates( candidates: ReadonlyArray<Option.Option<string>>, ): ParentRefResolution { - // The FIRST PRESENT candidate decides (PR #6168 review): a present-but- - // malformed higher-priority candidate (a typo'd SUPABASE_PROJECT_ID) must - // hard-classify as invalid rather than silently falling through to a - // lower-priority project — otherwise a mutation could run against the - // previously linked project the user explicitly tried to override away - // from. This also restores the pre-CLI-2167 resolver's env validation. + // The first present candidate decides: a malformed higher-priority candidate (e.g. a + // typo'd SUPABASE_PROJECT_ID) hard-classifies as invalid rather than falling through to a + // lower-priority project, which could otherwise run a mutation against the wrong project. for (const candidate of candidates) { if (Option.isSome(candidate)) { return PROJECT_REF_PATTERN.test(candidate.value) @@ -80,40 +79,12 @@ function classifyParentCandidates( } /** - * Resolves the currently-linked PARENT project ref. Deliberately NOT - * `ProjectRefResolver.resolveOptional`/`resolve` (which resolve the FINAL - * linked ref): right after linking a branch, that would return the branch's - * OWN ref, breaking any subsequent command that needs the PARENT (a second - * `link <other-branch>`, or any `branches` subcommand — CLI-2167 follow-up). - * Candidate order; the FIRST PRESENT candidate decides — valid resolves, - * malformed hard-classifies as `"invalid"` (never silently skipped): - * - * 1. `SUPABASE_PROJECT_ID` (env, via `CommandSettings`) — UNLESS it merely - * RESTATES candidate 3's own value. A CI workflow commonly exports - * `SUPABASE_PROJECT_ID=<branch-ref>` right after `link <branch>` - * (mirroring what `project-ref` already holds) — that adds no parent - * information, so treat it exactly as absent and let the cache (the - * REAL parent) win instead of self-referentially "resolving" to the - * branch's own ref again (PR #6168 review). A genuinely different env - * value (a deliberate override to a DIFFERENT project) still wins - * outright, same as always. - * 2. `ref` in `<workdir>/supabase/.temp/linked-project.json` — ONLY when - * candidate 3 below yielded a value. KEY INVARIANT: the cache alone is - * NOT proof of a completed link — `link`'s handler writes it via - * `Effect.ensuring` on BOTH success and failure (mirroring Go's - * `PersistentPostRun`), so a FAILED `link --project-ref B` can leave a - * cache entry for a `B` that was never actually linked (`getProject` - * returned 200 — e.g. for a paused project — but a later link step - * failed before `project-ref` itself was ever written). It's a - * telemetry artifact, not linked-state evidence; it's trusted only as - * PARENT RECOVERY for a link that has already actually happened, which - * candidate 3 is exactly the proof of (PR #6168 review). - * 3. `<workdir>/supabase/.temp/project-ref`. - * - * A present-but-malformed first candidate is corrupt/stale linked state or a - * typo'd override (`"invalid"` — callers surface an error rather than acting - * on a lower-priority project); if no candidate exists at all, the workdir - * was never linked (`"absent"`). + * Resolves the currently-linked PARENT project ref, not the branch's own ref that + * `ProjectRefResolver.resolveOptional`/`resolve` would return right after `link <branch>`. + * The first present candidate wins, in order: `SUPABASE_PROJECT_ID` env, the + * `linked-project.json` cache, then the temp `project-ref` file. A malformed candidate + * hard-classifies as `"invalid"` rather than falling through to a lower-priority one; no + * candidate at all means the workdir was never linked (`"absent"`). */ export const resolveLinkedParentRef = Effect.fnUntraced(function* () { const cliSettings = yield* CommandSettings; @@ -124,6 +95,8 @@ export const resolveLinkedParentRef = Effect.fnUntraced(function* () { const fileRef = yield* readProjectRefFile(fs, path, cliSettings.workdir).pipe( Effect.orElseSucceed(() => Option.none<string>()), ); + // The cache alone isn't proof a link completed — `link` writes it on both success and + // failure — so only read it as parent recovery when the temp file confirms a link happened. const cachedRef = Option.isSome(fileRef) ? yield* fs.readFileString(paths.linkedProjectCache).pipe( Effect.map(parseCachedParentRef), @@ -131,11 +104,10 @@ export const resolveLinkedParentRef = Effect.fnUntraced(function* () { ) : Option.none<string>(); - // Dedup rule (PR #6168 review): an env candidate that merely restates the - // file candidate's own (valid) value carries no parent information — drop - // it so the cache gets a chance to win instead. Restricted to pattern-valid - // values so a garbage env that happens to equal a garbage file still - // hard-classifies as invalid below instead of sneaking past to the cache. + // An env candidate that merely restates the file candidate's own (valid) value carries no + // parent information; drop it so the cache gets a chance to win instead. Restricted to + // pattern-valid values so a garbage env equal to a garbage file still hard-classifies as + // invalid below instead of sneaking past to the cache. const envRef = Option.isSome(cliSettings.projectId) && Option.isSome(fileRef) && @@ -148,32 +120,11 @@ export const resolveLinkedParentRef = Effect.fnUntraced(function* () { }); /** - * Project-ref resolver for the PARENT-SCOPED `branches` command family. TS-only - * divergence (CLI-2167 follow-up, no Go counterpart): after `supabase link - * <branch>`, `supabase/.temp/project-ref` holds the BRANCH's own ref, and the - * Management API returns 403 for branch refs on every branches-management - * endpoint (they're parent-project-scoped). Every `branches` subcommand must - * keep resolving the PARENT, not whatever `link` last wrote there. - * - * Semantics: - * - * 1. `flagValue` present and non-empty → delegate to - * `ProjectRefResolver.resolve(flagValue)` unchanged (an explicit - * `--project-ref` always wins, with its existing validation/error - * behavior reproduced exactly). - * 2. Otherwise, run `resolveLinkedParentRef`: - * - `"resolved"` → return that ref. This is the fix: it prefers the - * cached parent (env / `linked-project.json`) over a branch ref sitting - * in `project-ref`. - * - `"invalid"` or `"absent"` → fall through to - * `ProjectRefResolver.resolve(Option.none())`, so the existing - * env/prompt/not-linked error behavior is reproduced exactly — no new - * error types; the error channel is identical to `resolver.resolve`. - * - * No-op property: when linked to a real (non-branch) project, the cache and - * the `project-ref` file hold the same ref, so every result here is identical - * to calling `resolver.resolve` directly today. Behavior only changes in the - * previously-403ing state where `project-ref` holds a branch ref. + * Project-ref resolver for the PARENT-SCOPED `branches` command family: after `link <branch>`, + * the temp `project-ref` file holds the branch's own ref, but branch-management endpoints + * return 403 for branch refs, so this resolves the parent instead. An explicit `flagValue` + * always wins; otherwise it prefers `resolveLinkedParentRef`'s cached parent, falling back to + * `resolver.resolve` unchanged when no parent is resolvable. */ export const resolveParentScopedProjectRef = Effect.fnUntraced(function* ( flagValue: Option.Option<string>, @@ -193,11 +144,9 @@ export const resolveParentScopedProjectRef = Effect.fnUntraced(function* ( }); /** - * A value made entirely of lowercase letters (but not 20 of them, or it would - * already have been treated as a ref) is a plausible ref typo. Appended to - * both {@link parentNotLinkedMessage} and `link`'s own - * branch-not-found message — shared by every command that resolves a branch - * name/UUID under the currently-linked PARENT project (CLI-2167). + * A value made entirely of lowercase letters (but not 20 of them, or it would already have + * been treated as a ref) is a plausible ref typo. Appended to both {@link parentNotLinkedMessage} + * and `link`'s own branch-not-found message. */ export function parentRefTypoHint(value: string): string { if (!/^[a-z]+$/.test(value)) return ""; @@ -207,8 +156,7 @@ export function parentRefTypoHint(value: string): string { /** * Shared "no project is linked to search for branches" message, produced when * {@link resolveLinkedParentRef} reports `"absent"` for a non-ref-shaped - * `--project-ref`/positional value. Used by both `link` and `config diff` - * (Hoist Before You Duplicate — ≥2 commands across families). + * `--project-ref`/positional value. Used by both `link` and `config diff`. */ export function parentNotLinkedMessage(value: string): string { return ( @@ -223,8 +171,7 @@ export function parentNotLinkedMessage(value: string): string { /** * Shared "the linked project ref is invalid" message, produced when * {@link resolveLinkedParentRef} reports `"invalid"` for a non-ref-shaped - * `--project-ref`/positional value. Used by both `link` and `config diff` - * (Hoist Before You Duplicate — ≥2 commands across families). + * `--project-ref`/positional value. Used by both `link` and `config diff`. */ export function parentRefInvalidMessage(value: string): string { return `Cannot resolve branch "${value}": the linked project ref is invalid (checked SUPABASE_PROJECT_ID, supabase/.temp/linked-project.json, supabase/.temp/project-ref). Relink the parent project first: supabase link --project-ref <parent-ref>`; diff --git a/apps/cli/src/command-internal/parse-uint.ts b/apps/cli/src/command-internal/parse-uint.ts index 0dd6a54525..e70a46761b 100644 --- a/apps/cli/src/command-internal/parse-uint.ts +++ b/apps/cli/src/command-internal/parse-uint.ts @@ -1,43 +1,16 @@ /** - * Faithful port of Go's `strconv.ParseUint(s, 0, 64)` (`parseUintBase0`) - * and `strconv.ParseInt(s, 0, 64)` (`isValidBase0Int64`) — the exact - * parsers pflag runs for `UintVarP`/`UintVar` and `Int64VarP`/`Int64Var` - * flags respectively (`uintValue.Set`/`int64Value.Set`, `pflag/{uint,int64}.go`). - * Hoisted here (from its original home under `commands/storage/cp/`, CLI-1965 - * review) once a second family needed it: `complete.ts` validates - * `functions deploy --jobs`/`migration down --last`/`db reset --last` (uint) and - * `backups restore --timestamp` (int64) — all declared `Flag.integer` in TS but - * a Go pflag numeric type with a narrower, sign-and-range-sensitive parser — - * the same way `storage cp --jobs` already does at parse time. Operating on - * the RAW flag token (instead of a pre-normalized number) is load-bearing for - * parity: - * - * - every sign prefix is rejected, including `-0` and `+1` (a numeric - * normalization turns `-0` into negative zero, for which `value < 0` is - * false, silently accepting what Go rejects); - * - error messages carry the ORIGINAL spelling (`-01`, not `-1`); - * - base 0 enables Go's prefix/underscore forms: `0x10` → 16, `0o10`/`010` → - * 8 (octal!), `0b10` → 2, and `1_0` → 10 — all of which Go accepts. - * - * All verdicts below are verified against go1.26 (`strconv.ParseUint(s, 0, 64)`): - * `-0`/`-01`/`+1`/`3.5`/`abc`/`09`/`0x`/`_1`/`1_`/`1__0`/` 1` → invalid - * syntax; `0x_10` → 16; `18446744073709551616` → value out of range. - * - * Go iterates bytes where this iterates UTF-16 code units, but every non-ASCII - * unit (and every byte of a multibyte rune) falls outside the digit/letter - * ranges in both, so the verdict is identical. - * - * Known residual: values above 2^53 lose precision in the `Number` conversion - * (Go carries the exact uint64). They still PARSE identically; only the - * resulting parallel-job count differs, in territory where Go's own behavior - * (an `int` conversion of a near-2^64 uint) is already degenerate. + * Parses a numeric flag token the way `pflag`'s `uint64`/`int64` types do: base-0 prefixes + * (`0x`, `0o`/leading `0`, `0b`), underscore digit separators, and strict int64/uint64 range + * checks, rejecting any sign on an unsigned value (including `-0`, which a naive numeric parse + * would treat as non-negative) and preserving the original token spelling. Used by shell + * completion so candidate values are accepted/rejected the same way these flags always have. */ const MAX_UINT64 = (1n << 64n) - 1n; const MAX_INT64 = (1n << 63n) - 1n; -// `strconv.ParseInt`'s negative bound has one MORE representable magnitude -// than the positive bound (two's complement) — `-9223372036854775808` is a -// valid `int64`, but `9223372036854775808` (its positive magnitude) is not. +// int64's negative bound has one more representable magnitude than the positive bound +// (two's complement): -9223372036854775808 is valid, but its positive magnitude, +// 9223372036854775808, is not. const MAX_INT64_NEGATIVE_MAGNITUDE = 1n << 63n; export type ParseUintResult = @@ -45,25 +18,11 @@ export type ParseUintResult = | { readonly cause: "invalid syntax" | "value out of range" }; /** - * The base-0 digit grammar shared by `parseUintBase0` (`ParseUint`, - * unsigned) and `isValidBase0Int64` (`ParseInt`, signed) — base - * detection (`0x`/`0o`/`0b` prefixes, else a leading `0` for octal, else - * decimal), digit accumulation, and underscore placement, all per - * `strconv/atoi.go`. Bounds the accumulated magnitude at `MAX_UINT64` — the - * widest of the two callers' limits, and therefore a safe SUPERSET bound for - * both (`MAX_INT64`/`MAX_INT64_NEGATIVE_MAGNITUDE` are both smaller): a - * magnitude that already exceeds `MAX_UINT64` is "value out of range" for - * either caller, so the exit can live here once. A value between the int64 - * bound and `MAX_UINT64` (this finding's own repro, - * `9223372036854775808` — one past int64 max, comfortably under uint64 max) - * parses successfully here and is bounded by `isValidBase0Int64`'s - * OWN, narrower check afterward instead. - * - * `token` is the value with any sign prefix already stripped by the caller - * (a sign character reaching this loop directly would fail as a non-digit, - * exactly like Go's own digit loop) — `originalToken` (WITH the sign, when - * the caller has one to give) is threaded through only for `underscoreOk`'s - * separator check, which inspects the full original spelling. + * Base-0 digit grammar shared by `parseUintBase0` (unsigned) and `isValidBase0Int64` (signed): + * detects the base (`0x`/`0o`/`0b`, else leading `0` for octal, else decimal), accumulates + * digits, and checks underscore placement. Bounds the magnitude at `MAX_UINT64` so the + * range-check exit lives here once; `isValidBase0Int64` applies its own narrower int64 bound + * afterward. `originalToken` keeps the sign (stripped from `token`) for the underscore check. */ function parseBase0Digits( token: string, @@ -71,9 +30,8 @@ function parseBase0Digits( ): { readonly n: bigint } | { readonly cause: "invalid syntax" | "value out of range" } { if (token.length === 0) return { cause: "invalid syntax" }; - // Base detection for base 0 (`strconv/atoi.go`): `0x`/`0b`/`0o` prefixes - // (only when at least one more character follows), else a leading `0` means - // octal, else decimal. + // Base detection: `0x`/`0b`/`0o` prefixes (only when at least one more character follows), + // else a leading `0` means octal, else decimal. let s = token; let base = 10n; if (s[0] === "0") { @@ -119,27 +77,17 @@ function parseBase0Digits( export function parseUintBase0(token: string): ParseUintResult { const parsed = parseBase0Digits(token, token); + // Magnitudes above 2^53 lose precision when converted to Number; only the accept/reject + // verdict is guaranteed exact for values that large. return "cause" in parsed ? parsed : { value: Number(parsed.n) }; } /** - * Faithful port of Go's `strconv.ParseInt(s, 0, 64)` — the exact parser - * pflag runs for an `Int64VarP`/`Int64Var` flag (`int64Value.Set`, - * `pflag/int64.go`), e.g. `backups restore --timestamp`. Only a syntax-and-range VERDICT is - * needed for completion (not the parsed value), so this returns a boolean - * rather than mirroring `ParseUintResult`'s shape. - * - * Reuses {@link parseBase0Digits} for the base/digit grammar (the - * same one `parseUintBase0` runs) on the sign-stripped remainder, then - * applies `ParseInt`'s own two-step design: strip an optional leading - * `+`/`-`, parse the magnitude, and bound it against `int64`'s asymmetric - * two's-complement range — `-9223372036854775808` is valid, but that same - * magnitude, `9223372036854775808`, is NOT (it is one past `int64`'s - * positive bound, `9223372036854775807`) — verified empirically: `backups restore --timestamp - * 9223372036854775808 --p` returns zero candidates with the Default - * directive, while `--timestamp 9223372036854775807` (`int64` max) and - * `--timestamp -9223372036854775808` (`int64` min) both still offer - * `--profile`/`--project-ref` — CLI-1965 review finding. + * Parses a numeric flag token as a signed base-0 int64, returning only the accept/reject + * verdict (used by shell completion for e.g. `backups restore --timestamp`). Reuses + * {@link parseBase0Digits} on the sign-stripped remainder, then bounds the magnitude against + * int64's asymmetric two's-complement range: `-9223372036854775808` is valid, but that same + * magnitude on the positive side, `9223372036854775808`, is one past int64 max. */ export function isValidBase0Int64(token: string): boolean { const isNegative = token[0] === "-"; @@ -150,14 +98,10 @@ export function isValidBase0Int64(token: string): boolean { } /** - * `underscoreOK` (`strconv/atoi.go`): underscores must sit between - * digits, or between the base prefix and the first digit (`0x_10` is valid). - * The sign skip is unreachable through `parseUintBase0` (a sign already - * fails the digit loop before `underscoreOk` is ever reached) but IS reachable - * through `isValidBase0Int64`, which passes the original, still-signed - * token through for this check specifically (see that function's doc - * comment) — kept unconditionally rather than split per-caller so both stay - * governed by one port of Go's source. + * Underscores must sit between digits, or between the base prefix and the first digit + * (`0x_10` is valid). Takes the original, possibly-signed token so `isValidBase0Int64`'s + * leading sign doesn't throw off the prefix/digit boundary check; `parseUintBase0` never + * reaches a sign here since one already fails the digit loop first. */ function underscoreOk(token: string): boolean { // `saw` tracks the class of the previous character: `^` start-of-number, diff --git a/apps/cli/src/command-internal/parse-uint.unit.test.ts b/apps/cli/src/command-internal/parse-uint.unit.test.ts index aada13fbc0..c110eb1e9e 100644 --- a/apps/cli/src/command-internal/parse-uint.unit.test.ts +++ b/apps/cli/src/command-internal/parse-uint.unit.test.ts @@ -2,9 +2,6 @@ import { describe, expect, it } from "vitest"; import { isValidBase0Int64, parseUintBase0 } from "./parse-uint.ts"; -// Every expectation in this file is ground truth captured from go1.26: -// `strconv.ParseUint(s, 0, 64)` — the exact call pflag makes for a `UintVarP` -// flag (`uintValue.Set`, `pflag/uint.go`). describe("parseUintBase0 (Go strconv.ParseUint(s, 0, 64) parity)", () => { it("parses plain decimal", () => { expect(parseUintBase0("0")).toEqual({ value: 0 }); @@ -55,18 +52,12 @@ describe("parseUintBase0 (Go strconv.ParseUint(s, 0, 64) parity)", () => { it("reports uint64 overflow as `value out of range`, accepting max uint64", () => { expect(parseUintBase0("18446744073709551616")).toEqual({ cause: "value out of range" }); expect(parseUintBase0("0x10000000000000000")).toEqual({ cause: "value out of range" }); - // Max uint64 parses (the Number conversion is lossy up there — documented - // residual in parse-uint.ts — but the accept/reject verdict matches Go). expect(parseUintBase0("18446744073709551615")).toEqual({ value: Number(18446744073709551615n), }); }); }); -// Every expectation in this file is ground truth captured from go1.26: -// `strconv.ParseInt(s, 0, 64)` — the exact call pflag makes for an -// `Int64VarP` flag (`int64Value.Set`, `pflag/int64.go`), e.g. `backups -// restore --timestamp`. describe("isValidBase0Int64 (Go strconv.ParseInt(s, 0, 64) parity)", () => { it("accepts int64's exact bounds, both signs", () => { expect(isValidBase0Int64("9223372036854775807")).toBe(true); // int64 max @@ -74,11 +65,7 @@ describe("isValidBase0Int64 (Go strconv.ParseInt(s, 0, 64) parity)", () => { }); it("rejects a magnitude one past int64's bound on each side — the asymmetric two's-complement range", () => { - // 9223372036854775808 is a syntactically valid uint64 (well under - // MAX_UINT64) but exceeds int64's positive bound by exactly one. expect(isValidBase0Int64("9223372036854775808")).toBe(false); - // -9223372036854775809's magnitude, 9223372036854775809, exceeds int64's - // negative-side bound (9223372036854775808) by one too. expect(isValidBase0Int64("-9223372036854775809")).toBe(false); }); diff --git a/apps/cli/src/command-internal/path-match.ts b/apps/cli/src/command-internal/path-match.ts index c6b428960c..58898ffcb1 100644 --- a/apps/cli/src/command-internal/path-match.ts +++ b/apps/cli/src/command-internal/path-match.ts @@ -1,39 +1,20 @@ /** - * Faithful, BYTE-level port of Go's stdlib `path.Match` (`$GOROOT/src/path/match.go`), - * used by the seed-file globber to expand `[db.seed] sql_paths` exactly like the Go - * CLI's `config.Glob.Files` → `io/fs.Glob` → `path.Match` chain. + * Matches a glob pattern against a path the way Go's `path.Match` does, for the seed-file + * globber's `[db.seed] sql_paths` expansion. Hand-ported rather than compiled to a `RegExp`, + * since glob character classes diverge from JS regex classes and a malformed pattern must + * report `badPattern` instead of being silently reinterpreted. * - * Why byte-level, not code-point-level: Go strings are raw byte slices — every index, - * slice, and length in `path.Match` operates on UTF-8 BYTES, not decoded characters. - * This matters most in the `*`-retry loop (`pathMatch`'s inner `for` below): - * Go retries the starred chunk at every BYTE offset of `name`, including offsets that - * land in the middle of a multibyte UTF-8 character. When that happens, Go's - * `unicode/utf8.DecodeRuneInString` decodes the LEADING (invalid, mid-character) - * continuation byte as a single-byte `U+FFFD` "rune" — it never throws and never - * consumes more than one byte for invalid input — so a `?` operator in the retried - * chunk can advance past exactly one such byte and let the retry succeed where a - * code-point-stepping port would not. Verified empirically against `apps/cli-go` - * (a `path.Match` scratch probe): `Match("*??.sql", "!.sql")` — a single fullwidth - * exclamation mark, U+FF01, 3 UTF-8 bytes — returns `true`: the second `?` in the - * retried chunk lands on the fullwidth character's 2nd and 3rd bytes (both mid-character - * continuation bytes, each decoded as one `U+FFFD` "rune"), not on a real code point. A - * prior code-point-based port of this file returned `false` for that same case. - * - * Why a hand port instead of a JS `RegExp`: Go's glob grammar and JS regex character - * classes diverge — POSIX classes (`[[:alpha:]]`), `\d`/`\w`, and a leading `^` mean - * different things, and Go reports a malformed class as an error (`path.ErrBadPattern`) - * where JS would silently reinterpret it. Compiling each segment to a `RegExp` leaked - * those JS-only semantics; porting the algorithm keeps seed globbing byte-compatible - * with Go, including the malformed-pattern handling and the byte-offset retry above. + * Byte-wise (UTF-8), not UTF-16 code units: the `*`-retry loop can land mid-character, whose + * byte decodes as a single invalid rune that a `?` in the retried chunk can then consume. */ -/** Mirrors Go's `path.Match` return `(matched bool, err error)`; `badPattern` ↔ `path.ErrBadPattern`. */ +/** `badPattern` reports a malformed pattern instead of throwing. */ export interface PathMatchResult { readonly matched: boolean; readonly badPattern: boolean; } -/** Go's `path.ErrBadPattern.Error()` text, surfaced verbatim in seed glob warnings. */ +/** Error message surfaced verbatim in seed glob warnings for a malformed pattern. */ export const BAD_PATTERN_MESSAGE = "syntax error in pattern"; const BAD_PATTERN: PathMatchResult = { matched: false, badPattern: true }; @@ -41,10 +22,9 @@ const BAD_PATTERN: PathMatchResult = { matched: false, badPattern: true }; const UTF8_ENCODER = new TextEncoder(); /** - * `TextEncoder.encode` returns `Uint8Array<ArrayBuffer>` (never a - * `SharedArrayBuffer`-backed view) — naming that explicitly so every `.subarray()` - * slice threaded through this module's helpers keeps that narrower type instead of - * widening to the generic `Uint8Array<ArrayBufferLike>` default. + * `TextEncoder.encode` never returns a `SharedArrayBuffer`-backed view; naming that keeps + * every `.subarray()` slice threaded through this module's helpers narrowly typed instead of + * widening to `Uint8Array<ArrayBufferLike>`. */ type Bytes = Uint8Array<ArrayBuffer>; @@ -64,14 +44,9 @@ interface DecodedRune { } /** - * Port of Go's `unicode/utf8.DecodeRuneInString`, decoding the rune starting at byte - * offset `i` of `b`. Any invalid or truncated sequence decodes as `(RuneError, 1)` — - * never throws, never consumes more than the single invalid lead byte — matching Go's - * documented behaviour exactly (`$GOROOT/src/unicode/utf8/utf8.go`'s `first` table and - * `acceptRanges`, transcribed here as explicit range checks per lead byte rather than - * the table itself, for readability; verified to agree with the table for every lead - * byte class, including the overlong/surrogate/out-of-range exclusions on `0xE0`, - * `0xED`, `0xF0`, and `0xF4`). + * Decodes the UTF-8 rune starting at byte offset `i` of `b`. Any invalid or truncated + * sequence decodes as `(RUNE_ERROR, 1)` — never throws, and never consumes more than the + * single invalid lead byte. */ const decodeRune = (b: Bytes, i: number): DecodedRune => { const n = b.length - i; @@ -135,7 +110,7 @@ interface ScanChunk { readonly rest: Bytes; } -/** Go's `scanChunk`: the next non-`*` segment, possibly preceded by a `*`. */ +/** The next non-`*` segment of the pattern, possibly preceded by a `*`. */ const scanChunk = (pattern: Bytes): ScanChunk => { let star = false; let p = pattern; @@ -165,7 +140,7 @@ interface GetEsc { readonly bad: boolean; } -/** Go's `getEsc`: a possibly-escaped character from inside a class. */ +/** A possibly-escaped character from inside a bracket class. */ const getEsc = (chunk: Bytes): GetEsc => { if (chunk.length === 0 || chunk[0] === HYPHEN || chunk[0] === RBRACKET) { return { r: 0, rest: chunk, bad: true }; @@ -176,8 +151,7 @@ const getEsc = (chunk: Bytes): GetEsc => { if (c.length === 0) return { r: 0, rest: c, bad: true }; } const { r, size } = decodeRune(c, 0); - // Go: `if r == utf8.RuneError && n == 1 { err = ErrBadPattern }` — a genuinely - // invalid byte, not a literal (valid, 3-byte-encoded) U+FFFD character. + // A genuinely invalid byte, not a literal (valid, 3-byte-encoded) U+FFFD character. if (r === RUNE_ERROR && size === 1) return { r, rest: c.subarray(1), bad: true }; const rest = c.subarray(size); return { r, rest, bad: rest.length === 0 }; @@ -193,9 +167,9 @@ const EMPTY_BYTES = new Uint8Array(0); const BAD_CHUNK: MatchChunk = { rest: EMPTY_BYTES, ok: false, bad: true }; /** - * Go's `matchChunk`: match the all-single-char-operators `chunk` against the - * start of `s`. Once the match fails the loop keeps walking `chunk` (no longer - * reading `s`) so a malformed pattern is still reported. + * Matches the all-single-char-operators `chunk` against the start of `s`. Once the match + * fails, the loop keeps walking `chunk` (no longer reading `s`) so a malformed pattern is + * still reported. */ const matchChunk = (chunkIn: Bytes, sIn: Bytes): MatchChunk => { let chunk = chunkIn; @@ -265,9 +239,8 @@ const matchChunk = (chunkIn: Bytes, sIn: Bytes): MatchChunk => { }; /** - * Reports whether `name` matches the shell pattern `pattern`, using Go's - * `path.Match` semantics. `badPattern` is set (instead of throwing) when the - * pattern is malformed, mirroring Go's `path.ErrBadPattern`. + * Reports whether `name` matches the shell pattern `pattern`, using Go's `path.Match` + * semantics. `badPattern` is set (instead of throwing) for a malformed pattern. */ export const pathMatch = (pattern: string, name: string): PathMatchResult => { let pat = UTF8_ENCODER.encode(pattern); @@ -276,9 +249,8 @@ export const pathMatch = (pattern: string, name: string): PathMatchResult => { const scan = scanChunk(pat); pat = scan.rest; if (scan.star && scan.chunk.length === 0) { - // Trailing `*` matches the rest of the name unless it contains a `/`. `/` is - // never a UTF-8 continuation byte, so a raw byte scan is safe here regardless - // of any multibyte characters elsewhere in `nm`. + // A trailing `*` matches the rest of the name unless it contains a `/`; a raw byte + // scan for `/` is safe since it's never a UTF-8 continuation byte. return { matched: !nm.includes(SLASH), badPattern: false }; } const m = matchChunk(scan.chunk, nm); @@ -290,8 +262,8 @@ export const pathMatch = (pattern: string, name: string): PathMatchResult => { continue; } if (scan.star) { - // Look for a match skipping one BYTE at a time (see this file's top comment - // for why byte-, not code-point-, stepping matters here); `*` cannot cross `/`. + // Retries at every byte offset (not code point) so a `?` can land on a mid-character + // byte; `*` cannot cross `/`. let advanced = false; for (let i = 0; i < nm.length && nm[i] !== SLASH; i++) { const skip = matchChunk(scan.chunk, nm.subarray(i + 1)); @@ -305,7 +277,7 @@ export const pathMatch = (pattern: string, name: string): PathMatchResult => { } if (advanced) continue; } - // No match: still verify the rest of the pattern is well-formed (Go does). + // No match: still verify the rest of the pattern is well-formed. while (pat.length > 0) { const tail = scanChunk(pat); pat = tail.rest; diff --git a/apps/cli/src/command-internal/path-match.unit.test.ts b/apps/cli/src/command-internal/path-match.unit.test.ts index d8148e2972..6d0f8ab3d4 100644 --- a/apps/cli/src/command-internal/path-match.unit.test.ts +++ b/apps/cli/src/command-internal/path-match.unit.test.ts @@ -41,8 +41,6 @@ describe("pathMatch", () => { }); it("treats a leading `!` as a literal class member, NOT negation (Go parity)", () => { - // Go's `path.Match` negates only with a leading `^`; `!` is an ordinary - // member. So `[!a]` matches `!` and `a`, and rejects anything else. expect(pathMatch("[!a].sql", "!.sql").matched).toBe(true); expect(pathMatch("[!a].sql", "a.sql").matched).toBe(true); expect(pathMatch("[!a].sql", "b.sql").matched).toBe(false); @@ -51,11 +49,7 @@ describe("pathMatch", () => { describe("byte-offset `*` retry against multibyte characters (Go path.Match parity)", () => { it.each([ - // `path.Match`'s byte-offset `*`-retry loop can land mid-multibyte-character and have a `?` - // consume the resulting invalid continuation byte as a single-byte `U+FFFD` "rune" — - // producing matches a code-point-stepping port would miss. Verified empirically - // (a fullwidth exclamation mark, U+FF01, is a - // single character but 3 UTF-8 bytes; an emoji, U+1F600, is 4 UTF-8 bytes): + // U+FF01 (fullwidth `!`) is 3 UTF-8 bytes; U+1F600 (😀) is 4 UTF-8 bytes. ["*??.sql", "!.sql", true], ["*??.sql", "😀.sql", true], ["*?.sql", "!.sql", true], @@ -82,7 +76,7 @@ describe("pathMatch", () => { "[a", // unterminated class with member "[]", // empty class "[^]", // empty negated class - "[*!#@D#", // Go's config_test.go golden case + "[*!#@D#", "a\\", // trailing escape ])("%s => badPattern", (pattern) => { const result = pathMatch(pattern, "x"); @@ -91,10 +85,8 @@ describe("pathMatch", () => { }); it("does not interpret POSIX/JS-only class syntax as a regex", () => { - // `[[:alpha:]]` is NOT a POSIX class. Go parses it as a normal class - // `[[:alph]` (members `[ : a l p h`) followed by a literal `]`, so it - // matches a class member char followed by `]` — never a JS `\w`-style - // range, and never throws. + // Parses `[[:alpha:]]` as a normal class `[[:alph]` (members `[ : a l p h`) followed by + // a literal `]` — not a POSIX class. expect(pathMatch("[[:alpha:]]", "a]").matched).toBe(true); expect(pathMatch("[[:alpha:]]", "[]").matched).toBe(true); expect(pathMatch("[[:alpha:]]", "z]").matched).toBe(false); diff --git a/apps/cli/src/command-internal/pflag-reconcile.ts b/apps/cli/src/command-internal/pflag-reconcile.ts index 5efeee8572..da632556c7 100644 --- a/apps/cli/src/command-internal/pflag-reconcile.ts +++ b/apps/cli/src/command-internal/pflag-reconcile.ts @@ -15,27 +15,12 @@ import { parseStringSliceFlag } from "./string-slice-flag.ts"; import { validateWorkdirIsDirectory } from "./workdir-validation.ts"; /** - * Hoisted here ahead of a second command family landing on purpose: a human - * reviewer flagged in #5974 that the pflag-vs-Effect-parser divergence this - * module reconciles is CLI-wide, not sso-specific, and asked for it to live - * in a shared layer rather than be reimplemented per command family — - * https://github.com/supabase/cli/pull/5974#discussion_r3685149895 (CLI-1982). - */ - -/** - * `ChangeWorkDir`, run from the root - * `PersistentPreRunE` — after `ParseFlags` and - * `ValidateArgs`, before `ValidateRequiredFlags`, `ValidateFlagGroups`, and - * `RunE` — so a missing workdir directory aborts with no API call ever made. - * Emulated for the pflag/viper-effective `--workdir`/`SUPABASE_WORKDIR` the - * Effect layer never validates (and, when `--workdir` consumed a flag-shaped - * token, never even saw — PR #5974 review round 6). Shared across add + - * update; message byte-matches Go's template. + * A missing or non-directory `--workdir`/`SUPABASE_WORKDIR`, checked before any other flag + * validation or API call — the Effect CLI parser never validates this path itself, and can + * miss the value entirely when `--workdir` consumed a flag-shaped token. * - * Flows through {@link validatePflagWorkdir}'s inferred Effect error - * channel; no call site imports the class by name. - * - * @public + * Flows through {@link validatePflagWorkdir}'s inferred Effect error channel; no call site + * imports the class by name. */ export class PflagWorkdirError extends Data.TaggedError("PflagWorkdirError")<{ readonly message: string; @@ -46,20 +31,15 @@ export class PflagWorkdirError extends Data.TaggedError("PflagWorkdirError")<{ } /** - * Reconciles an Effect-parsed option flag with pflag semantics - * (`pflagArgvScan`): the flag is only set when the raw-argv scan - * says pflag would have set it, and its value is the scan's — for a pflag - * `StringVar`, the last occurrence wins. + * Reconciles an Effect-parsed option flag with pflag's raw-argv semantics: the flag is only + * set when the raw scan says pflag would have set it, using the scan's own value (last + * occurrence wins for a `StringVar`). * - * This matters because the vendored Effect parser refuses to consume a - * flag-shaped token as a value while pflag consumes it unconditionally - * (`run.unit.test.ts`, CLI-1982). In - * `--project-ref --metadata-file x.xml --metadata-url u`, pflag hands - * `--metadata-file` to `--project-ref` as its value and never sets - * `metadata-file`; acting on the parsed options there would suppress the - * mutex error yet still read the metadata file — an API call the Go CLI - * never makes. When the scan and the parser agree (every normal invocation), - * the scan's value is byte-identical to the parsed one. + * The vendored Effect parser refuses to consume a flag-shaped token as a value, while pflag + * consumes it unconditionally — in `--project-ref --metadata-file x.xml --metadata-url u`, + * pflag hands `--metadata-file` to `--project-ref` and never sets `metadata-file`, so acting + * on the parsed options there would still read the metadata file unexpectedly. The two agree + * on every normal invocation. */ export function pflagStringValue( occurrences: ReadonlyMap<string, ReadonlyArray<string>>, @@ -70,15 +50,13 @@ export function pflagStringValue( } /** - * Like `pflagStringValue`, but for pflag `StringSliceVar` flags: - * every occurrence is CSV-split and accumulated, matching pflag's - * `stringSliceValue.Set`. An absent flag reconciles to `[]` even when the - * Effect parser produced values (its tokens were consumed by another flag). + * Like `pflagStringValue`, but for CSV-accumulating slice flags: every occurrence is + * CSV-split and accumulated. An absent flag reconciles to `[]` even when the Effect parser + * produced values (its tokens were consumed by another flag). * - * `parsedFallback` is only returned if the scan's raw values are malformed - * CSV — unreachable through the real CLI, because the Effect parser sees the - * same raw values and rejects the command at parse time before the handler - * runs; the fallback just keeps a handler-level disagreement from crashing. + * `parsedFallback` only returns when the scan's raw values are malformed CSV — unreachable in + * practice, since the Effect parser rejects the same malformed input at parse time before the + * handler runs; it just keeps a handler-level disagreement from crashing. */ export function pflagSliceValue( occurrences: ReadonlyMap<string, ReadonlyArray<string>>, @@ -97,26 +75,20 @@ export function pflagSliceValue( } /** - * The workdir `ChangeWorkDir` would - * `os.Chdir` to: `viper.GetString("WORKDIR")` resolves the pflag-effective - * `--workdir` first (a changed flag wins even when its value is empty — - * `--workdir=` falls through to the always-existing project-root walk-up, - * never to the env var) and `SUPABASE_WORKDIR` otherwise. `Option.none` - * means Go would chdir to the walk-up default, which cannot fail. + * The workdir a changed `--workdir`/`SUPABASE_WORKDIR` resolves to: a changed `--workdir` + * wins even when its value is empty (`--workdir=` falls through to the project-root walk-up, + * never to the env var), otherwise `SUPABASE_WORKDIR`. `Option.none` means the walk-up + * default applies, which cannot fail. * - * Resolution order (binary-verified, PR #5974 review - * round 6): - * - the scan's last `--workdir` occurrence wins — pflag consumes flag-shaped - * tokens the Effect parser refuses (`--workdir --metadata-file` binds - * `"--metadata-file"`), so the parsed flag cannot be trusted; + * Resolution order: + * - the scan's last `--workdir` occurrence wins — pflag consumes flag-shaped tokens the + * Effect parser refuses (`--workdir --metadata-file` binds `"--metadata-file"`), so the + * parsed flag cannot be trusted; * - when the `--workdir` token itself was consumed as another flag's value - * (`--domains --workdir`), pflag never marks it changed and viper falls to - * the env var — the parsed flag (which read the following token as a - * normal value) must be ignored; - * - otherwise the Effect-parsed value covers what the anchored scan cannot - * see: `--workdir` placed before the command path (`supabase --workdir x - * sso add …`), which cobra's `Find`/`stripFlags` routes to the same - * persistent flag. + * (`--domains --workdir`), the parsed flag (which read the following token as a normal + * value) is ignored in favor of the env var; + * - otherwise the Effect-parsed value covers what the anchored scan cannot see: `--workdir` + * placed before the command path (`supabase --workdir x sso add …`). */ export function pflagWorkdirValue( scan: Pick<PflagArgvScan, "occurrences" | "consumedFlagNames" | "prePathOccurrences">, @@ -124,11 +96,8 @@ export function pflagWorkdirValue( envWorkdir: string | undefined, ): Option.Option<string> { const scanned = pflagStringValue(scan.occurrences, "workdir"); - // Same last-wins order as the profile resolver: post-path occurrence → - // pre-path occurrence (pflag parses persistent flags before the command - // path and repeats resolve last-wins, while the Effect parser is - // first-wins) → consumed-discard → parsed fallback (review r3690…, the - // pre-path workdir twin of r3686720491). + // Same last-wins order as the profile resolver: post-path occurrence, then pre-path + // occurrence, then consumed-discard, then the parsed fallback. const prePathValues = scan.prePathOccurrences.get("workdir"); const prePath = prePathValues !== undefined && prePathValues.length > 0 @@ -150,19 +119,14 @@ export function pflagWorkdirValue( } /** - * Emulates `ChangeWorkDir` for the workdir {@link pflagWorkdirValue} - * resolves: `os.Chdir` on a missing path or a non-directory aborts the - * command from the root `PersistentPreRunE` — after `ParseFlags` and - * `ValidateArgs`, before `ValidateRequiredFlags`, `ValidateFlagGroups`, and - * `RunE` — so no API call is ever made. The Effect layer neither validates - * the resolved workdir (`command-settings.layer.ts` only path-resolves it) - * nor sees the value at all when `--workdir` consumed a flag-shaped token, - * hence the emulation here (PR #5974 review round 6). + * Validates the workdir {@link pflagWorkdirValue} resolves, aborting before any API call when + * it's missing or not a directory — the config layer only path-resolves the workdir it sees + * and never validates this, and can miss the value entirely when `--workdir` consumed a + * flag-shaped token. * - * Accepted micro-divergence: when the pflag-bound workdir names a directory - * that EXISTS, Go chdir's into it (printing `Using workdir …`) while the - * config layer keeps the workdir it resolved from the parsed flag — both - * sides then issue the identical request for these inputs. + * Accepted divergence: when the resolved directory exists, this check and the config layer + * may have resolved the workdir from different sources, but they then issue the identical + * request regardless. */ export const validatePflagWorkdir = Effect.fnUntraced(function* ( scan: Pick<PflagArgvScan, "occurrences" | "consumedFlagNames" | "prePathOccurrences">, @@ -181,28 +145,22 @@ export const validatePflagWorkdir = Effect.fnUntraced(function* ( }); /** - * The explicit (flag-or-env) profile token viper's `GetString("PROFILE")` / - * `IsSet("PROFILE")` would resolve (`getProfileName`). - * `Option.none` means it would fall through to the persisted - * `~/.supabase/profile` file and then the `supabase` default. + * The explicit (flag-or-env) profile token this command would resolve. `Option.none` means + * it falls through to the persisted `~/.supabase/profile` file and then the `supabase` + * default. * - * Resolution order mirrors {@link pflagWorkdirValue} (same viper - * semantics, binary-verified for `--profile` in PR #5974 review round 7): - * - the scan's last `--profile` occurrence wins — pflag consumes flag-shaped - * tokens the Effect parser refuses (`--profile --metadata-url` binds - * `"--metadata-url"`), is last-wins where the parser is first-wins, and a - * scanned occurrence marks the flag changed even when its value is the - * `supabase` default or empty; + * Resolution order mirrors {@link pflagWorkdirValue}: + * - the scan's last `--profile` occurrence wins — pflag consumes flag-shaped tokens the + * Effect parser refuses (`--profile --metadata-url` binds `"--metadata-url"`), and a + * scanned occurrence marks the flag changed even when its value is the `supabase` default + * or empty; * - when the `--profile` token itself was consumed as another flag's value - * (`--domains --profile alternate.yml`), pflag never marks it changed and - * viper falls to `SUPABASE_PROFILE` — the parsed flag (which read the - * following token as a normal value) must be ignored; + * (`--domains --profile alternate.yml`), the parsed flag (which read the following token + * as a normal value) is ignored in favor of `SUPABASE_PROFILE`; * - otherwise the Effect-parsed value covers pre-command-path placement - * (`supabase --profile x sso add …`) the anchored scan cannot see. The - * parsed flag cannot distinguish an explicit `--profile supabase` from the - * flag's default, so that value is treated as unset (the config layer - * closes the same gap with its own argv scan, - * `command-settings.layer.ts`). + * (`supabase --profile x sso add …`) the anchored scan cannot see. The parsed flag can't + * distinguish an explicit `--profile supabase` from the flag's default, so that value is + * treated as unset. */ export function pflagProfileValue( scan: Pick<PflagArgvScan, "occurrences" | "consumedFlagNames" | "prePathOccurrences">, @@ -210,12 +168,9 @@ export function pflagProfileValue( envProfile: string | undefined, ): Option.Option<string> { const scanned = pflagStringValue(scan.occurrences, "profile"); - // pflag's effective value is the LAST parsed occurrence anywhere in argv: - // a post-path occurrence wins outright; otherwise a persistent pre-path - // occurrence (`--profile A sso add …`) stays effective even when a later - // profile-shaped token was CONSUMED as another flag's value — discarding - // it here fell through to env/file/default and targeted a host Go never - // contacts (review r3686720491). + // A post-path occurrence wins outright; otherwise a pre-path occurrence + // (`--profile A sso add …`) stays effective even when a later profile-shaped token was + // consumed as another flag's value. const prePathValues = scan.prePathOccurrences.get("profile"); const prePath = prePathValues !== undefined && prePathValues.length > 0 @@ -237,30 +192,20 @@ export function pflagProfileValue( } /** - * Emulates `LoadProfile` for - * the pflag/viper-effective profile, returning the API URL the request must - * target when it differs from the one the Effect config layer resolved — - * `Option.none` means the layer's `CommandSettings.apiUrl` already matches - * the established resolution. The profile loads immediately BEFORE `ChangeWorkDir`, so a load - * failure here must precede the workdir check (and, like it, the - * required-flag check, the mutex check, and any API request). + * Reconciles the profile the Effect config layer resolved with the {@link pflagProfileValue} + * semantics, returning the API URL a request must target when they disagree — `Option.none` + * means the layer's `CommandSettings.apiUrl` already matches. Loads before the workdir check + * and any other flag validation or API request, since it can change which host every + * subsequent request targets. * - * The emulation only takes over when the viper-effective token disagrees - * with the token the config layer resolved from the parsed flag — i.e. - * exactly where the Effect parser and pflag diverge (consumed tokens, - * flag-shaped values, repeat resolution, explicit `--profile supabase` - * shadowing the env, an untrimmed persisted-file token) — or when the token - * is empty, which Go deterministically rejects. Where the two agree (every - * normal invocation), the layer's resolution stands unchanged — it uses the - * same strict `loadProfile` and explicit-flag detection - * (supabase/cli#6091). Argv shapes where pflag's token consumption diverges - * from the Effect parser can still fail the layer build before this - * reconcile runs; those fail-closed on both sides, possibly with different - * detail text. + * Only takes over where the two resolutions genuinely diverge (consumed tokens, flag-shaped + * values, repeat resolution, an explicit `--profile supabase` shadowing the env, an untrimmed + * persisted-file token) or when the resolved token is empty. On every normal invocation the + * layer's resolution stands unchanged. * - * `serviceOption` throughout: outside the real CLI tree (handler-level tests - * provide argv via `Stdio.layerTest`) the flag settings and `RuntimeInfo` - * may be absent; the emulation then only acts on what the scan itself shows. + * `serviceOption` throughout: outside the real CLI tree (handler-level tests provide argv via + * `Stdio.layerTest`) the flag settings and `RuntimeInfo` may be absent, and the reconcile then + * only acts on what the scan itself shows. */ export const resolvePflagProfile = Effect.fnUntraced(function* ( scan: Pick<PflagArgvScan, "occurrences" | "consumedFlagNames" | "prePathOccurrences">, @@ -270,14 +215,10 @@ export const resolvePflagProfile = Effect.fnUntraced(function* ( const env = process.env["SUPABASE_PROFILE"]; const envProfile = env !== undefined && env.length > 0 ? env : undefined; - // viper-effective explicit token vs the config layer's explicit token. - // The layer-model MUST mirror `resolveProfile` exactly (raw argv scan → - // parsed flag ≠ default → env): the layer's scan treats the last raw - // `--profile` occurrence as explicit even when pflag consumed it as another - // flag's value, so omitting it here would make the comparison miss a layer - // that shadowed the env and silently target the wrong host. When both agree - // on a non-empty explicit token, the layer resolved the exact same profile - // the Go binary would target. + // The explicit token pflag-equivalent semantics resolve, vs. the one the config layer's own + // scan resolved (which treats the last raw `--profile` occurrence as explicit even when it + // was consumed as another flag's value). When both agree on a non-empty token, the layer + // already resolved the right profile. const scanExplicit = Option.match(yield* Effect.serviceOption(CliArgs), { onNone: () => undefined, onSome: ({ args }) => lastExplicitLongFlagValue(args, [], "profile"), @@ -307,12 +248,9 @@ export const resolvePflagProfile = Effect.fnUntraced(function* ( return Option.none<LoadedProfile>(); } - // Lowest precedence: the persisted `~/.supabase/profile` file. The reference - // resolution uses the - // raw bytes (`string(content)`); the config layer - // trims and maps empty to the default — a real divergence the token - // comparison below surfaces (e.g. a trailing newline fails with - // `Unsupported Config Type ""`, binary-verified). + // Lowest precedence: the persisted `~/.supabase/profile` file. This reads the raw bytes, + // while the config layer trims and maps empty to the default — a divergence the token + // comparison below surfaces (e.g. a trailing newline fails to load as a profile). const fileRaw = yield* fs.value .readFileString(profileFilePath(path.value, runtimeInfo.value.homeDir)) .pipe(Effect.option); @@ -338,7 +276,7 @@ export const resolvePflagProfile = Effect.fnUntraced(function* ( return Option.some(yield* loadProfile(goToken, fs.value)); }); -/** Go's `strconv.ParseBool` accepted literals. */ +/** Accepted literal spellings for a pflag boolean flag value (`strconv.ParseBool`). */ const GO_PARSE_BOOL: ReadonlyMap<string, boolean> = new Map([ ["1", true], ["t", true], @@ -355,30 +293,15 @@ const GO_PARSE_BOOL: ReadonlyMap<string, boolean> = new Map([ ]); /** - * Like `pflagStringValue`, but for pflag `BoolVar` flags. pflag - * calls `Value.Set` for every occurrence in argv order: a bare occurrence - * sets `NoOptDefVal` (`"true"`), an inline `=value` goes through - * `strconv.ParseBool`, an invalid literal aborts `ParseFlags` with - * `invalid argument …` before `ValidateArgs`, - * every hook, and `RunE` — the failure branch here must therefore win over - * every later handler check. The last occurrence wins; an absent flag is - * `false` (the default). - * - * This cannot be read off the Effect-parsed boolean for two reasons - * (binary-verified, PR #5974 review round 4): - * - the Effect parser resolves repeated flags first-wins while pflag is - * last-wins (`--skip-url-validation=false --skip-url-validation` is `true` - * to Go, `false` to the parser), and - * - the Effect parser accepts `yes`/`no`, which `strconv.ParseBool` rejects. + * Like `pflagStringValue`, but for boolean flags: every occurrence is applied in argv order + * (a bare occurrence sets `true`; `--flag=value` is checked against the literal set above), + * so the last occurrence wins and an absent flag is `false`. An occurrence with an + * unrecognized literal fails immediately, before any other flag validation or API call. * - * The scan records a *bare* occurrence as pflag's `NoOptDefVal` `"true"` - * and an inline-empty `--flag=` as `""`, so the - * two stay distinguishable here: `""` goes through the ParseBool table and - * fails the same way. Reachable despite the Effect parser rejecting an - * explicit empty boolean at parse time, because first-wins parsing never - * validates later occurrences (binary-verified, PR #5974 review round 5: - * `--skip-url-validation=false --skip-url-validation=` aborts - * ParseFlags before any request; the parser accepts the argv). + * This can't be read off the Effect-parsed boolean: the Effect parser resolves repeated + * flags first-wins (pflag is last-wins) and accepts `yes`/`no`, which the literal set above + * rejects. The scan records a bare occurrence as `"true"` and an inline-empty `--flag=` as + * `""`, so both go through the same literal-set check and fail consistently. */ export function pflagBoolValue( occurrences: ReadonlyMap<string, ReadonlyArray<string>>, @@ -402,16 +325,14 @@ export function pflagBoolValue( } /** - * Like `pflagStringValue`, but for enum-valued flags - * (`ssoProviderType`, `ssoNameIDFormat`), whose - * `Value.Set` rejects anything outside the allowed set. pflag Sets every - * occurrence in argv order and aborts `ParseFlags` on the first invalid one — - * reachable here because the Effect parser resolves repeats first-wins and - * never validates later occurrences (`--type saml --type bogus` parses). - * The last occurrence wins; an absent flag is `Option.none`. + * Like `pflagStringValue`, but for enum-valued flags: every occurrence must be in `allowed`, + * checked in argv order, and the first invalid one fails immediately — reachable here because + * the Effect parser resolves repeats first-wins and never validates later occurrences (e.g. + * `--type saml --type bogus` parses). The last occurrence wins; an absent flag is + * `Option.none`. * - * `flagLabel` is how pflag names the flag in the error: `--name` without a - * shorthand, `-s, --name` with one. + * `flagLabel` names the flag in the error message: `--name` without a shorthand, `-s, --name` + * with one. */ export function pflagEnumValue( occurrences: ReadonlyMap<string, ReadonlyArray<string>>, diff --git a/apps/cli/src/command-internal/pflag-reconcile.unit.test.ts b/apps/cli/src/command-internal/pflag-reconcile.unit.test.ts index 7d57a2a803..8b3e8f7d88 100644 --- a/apps/cli/src/command-internal/pflag-reconcile.unit.test.ts +++ b/apps/cli/src/command-internal/pflag-reconcile.unit.test.ts @@ -17,8 +17,7 @@ import { resolvePflagProfile, } from "./pflag-reconcile.ts"; -// Go's SAML `nameid-format` enum, reused here only -// as sample data for the generic enum-reconciliation helper under test. +// Sample enum values for the generic enum-reconciliation helper under test. const NAME_ID_FORMATS = [ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", @@ -41,21 +40,18 @@ describe("pflagBoolValue", () => { }); it("resolves repeats last-wins, not first-wins (pflag Sets every occurrence)", () => { - // `--skip-url-validation=false --skip-url-validation` — Go ends up true. + // --skip-url-validation=false --skip-url-validation expect( pflagBoolValue(occ([["skip-url-validation", ["false", "true"]]]), "skip-url-validation"), ).toEqual(Result.succeed(true)); - // `--skip-url-validation --skip-url-validation=false` — Go ends up false. + // --skip-url-validation --skip-url-validation=false expect( pflagBoolValue(occ([["skip-url-validation", ["true", "false"]]]), "skip-url-validation"), ).toEqual(Result.succeed(false)); }); it("fails on an inline-empty occurrence exactly like Go's ParseBool", () => { - // `--skip-url-validation=false --skip-url-validation=` — the Effect - // parser resolves repeats first-wins and never validates the second - // occurrence, but pflag hands `""` to strconv.ParseBool and aborts - // ParseFlags before any request (binary-verified, PR #5974 round 5). + // --skip-url-validation=false --skip-url-validation= expect( pflagBoolValue(occ([["skip-url-validation", ["false", ""]]]), "skip-url-validation"), ).toEqual( @@ -75,13 +71,11 @@ describe("pflagBoolValue", () => { }); it("fails with pflag's byte-exact invalid-argument message on the first bad occurrence", () => { - // The Effect parser accepts `yes`/`no`; Go's strconv.ParseBool does not. expect(pflagBoolValue(occ([["skip-url-validation", ["yes"]]]), "skip-url-validation")).toEqual( Result.fail( `invalid argument "yes" for "--skip-url-validation" flag: strconv.ParseBool: parsing "yes": invalid syntax`, ), ); - // A later invalid occurrence still fails — pflag Sets each one in order. expect( pflagBoolValue(occ([["skip-url-validation", ["true", "no"]]]), "skip-url-validation"), ).toEqual( @@ -145,10 +139,7 @@ describe("pflagWorkdirValue", () => { }); it("resolves pre-path repeats last-wins, like pflag (the parser is first-wins)", () => { - // `--workdir /existing --workdir /missing sso add …`: pflag uses the - // LAST pre-path occurrence (/missing → Go's chdir aborts) while the - // Effect parser bound the first (pre-path workdir twin of the profile - // fix, PR #5974 review round 11). + // --workdir /existing --workdir /missing sso add … expect( pflagWorkdirValue( scan([], [], [["workdir", ["/existing", "/missing"]]]), @@ -183,8 +174,7 @@ describe("pflagWorkdirValue", () => { }); it("prefers the scan's occurrence over the parsed flag and the env var", () => { - // `--workdir --metadata-file …`: pflag binds the flag-shaped token; the - // Effect parser refused it and left the flag unset (PR #5974 round 6). + // --workdir --metadata-file … expect( pflagWorkdirValue(scan([["workdir", ["--metadata-file"]]]), Option.none(), "/env"), ).toEqual(Option.some("--metadata-file")); @@ -203,9 +193,7 @@ describe("pflagWorkdirValue", () => { }); it("ignores the parsed flag when the --workdir token was consumed by another flag, falling to the env var", () => { - // `--domains --workdir /x`: pflag hands `--workdir` to `--domains` and - // never marks workdir changed, so viper falls to SUPABASE_WORKDIR - // (binary-verified, PR #5974 round 6). + // --domains --workdir /x expect(pflagWorkdirValue(scan([], ["workdir"]), Option.some("/x"), "/env")).toEqual( Option.some("/env"), ); @@ -219,9 +207,7 @@ describe("pflagWorkdirValue", () => { }); it("treats a changed-but-empty flag as the walk-up default, shadowing the env var (viper precedence)", () => { - // `--workdir=`: viper returns the changed flag's empty value and Go falls - // through to the always-existing project root, never to SUPABASE_WORKDIR - // (binary-verified: the command proceeds to POST). + // --workdir= expect(pflagWorkdirValue(scan([["workdir", [""]]]), Option.none(), "/env")).toEqual( Option.none(), ); @@ -245,10 +231,7 @@ describe("pflagProfileValue", () => { }); it("keeps a pre-path occurrence when the only post-path profile token was consumed", () => { - // `--profile A sso add --type saml --domains --profile`: pflag parsed A - // pre-path (cobra Find strips persistent flags while routing) and never - // parsed the consumed token, so A stays effective — falling through to - // env/default targeted a host Go never contacts (review r3686720491). + // --profile A sso add --type saml --domains --profile expect( pflagProfileValue( scan([], ["profile"], [["profile", ["a.yml"]]]), @@ -283,9 +266,7 @@ describe("pflagProfileValue", () => { }); it("prefers the scan's occurrence over the parsed flag and the env var", () => { - // `--profile --metadata-url …`: pflag binds the flag-shaped token; the - // Effect parser refused it and left the flag at its default (PR #5974 - // round 7). + // --profile --metadata-url … expect( pflagProfileValue(scan([["profile", ["--metadata-url"]]]), Option.none(), "env.yml"), ).toEqual(Option.some("--metadata-url")); @@ -298,9 +279,6 @@ describe("pflagProfileValue", () => { }); it("keeps an explicit scanned `supabase` — pflag marks it changed, shadowing the env var", () => { - // viper: a changed flag wins even at its default value; the config layer - // cannot see this (its parsed flag can't distinguish default from - // explicit), so the scan is authoritative post-command-path. expect(pflagProfileValue(scan([["profile", ["supabase"]]]), Option.none(), "env.yml")).toEqual( Option.some("supabase"), ); @@ -319,10 +297,7 @@ describe("pflagProfileValue", () => { }); it("ignores the parsed flag when the --profile token was consumed by another flag, falling to the env var", () => { - // `--domains --profile alternate.yml`: pflag hands `--profile` to - // `--domains` and never marks profile changed, so viper falls to - // SUPABASE_PROFILE (binary-verified, PR #5974 - // round 7 — the demonstrated divergent input). + // --domains --profile alternate.yml expect( pflagProfileValue(scan([], ["profile"]), Option.some("alternate.yml"), "env.yml"), ).toEqual(Option.some("env.yml")); @@ -362,10 +337,9 @@ describe("resolvePflagProfile", () => { mockRuntimeInfo({ homeDir }), ); - // `--domains --profile supabase`: pflag consumes the `--profile` token, so - // Go keeps SUPABASE_PROFILE — but the config layer's raw scan shadowed the - // env with the swallowed occurrence. The reconcile must see the mismatch and - // re-run LoadProfile on the env profile, not return none. + // --domains --profile supabase: the config layer's raw scan shadows the env with the + // swallowed --profile token; the reconcile must see the mismatch and re-load on the env + // profile instead of returning none. it.effect( "re-loads the env profile when the layer's scan wrongly shadowed a consumed token", () => { diff --git a/apps/cli/src/command-internal/pg-dump.env.ts b/apps/cli/src/command-internal/pg-dump.env.ts index 266420a524..2ec6e70a90 100644 --- a/apps/cli/src/command-internal/pg-dump.env.ts +++ b/apps/cli/src/command-internal/pg-dump.env.ts @@ -1,15 +1,12 @@ import type { PgConnInput } from "./db-connection.service.ts"; /** - * Pure pg_dump environment builders, ported 1:1 from `pkg/migration/dump.go`. - * No Effect or service dependencies, so the schema/role/config lists and the - * `os.Expand` dry-run expansion stay unit-testable in isolation. Shared by `db - * dump`, `db pull`'s initial-migra schema dump, and (CLI-1969) `migration - * squash`'s before/after/full dumps — the third consumer is why this module - * lives in `command-internal/` rather than `commands/db/shared/`. + * Pure pg_dump environment builders — no Effect or service dependencies, so the schema/role/ + * config lists and the dry-run expansion stay unit-testable in isolation. Shared by `db dump`, + * `db pull`'s initial-migra schema dump, and `migration squash`'s before/after/full dumps. */ -/** `migration.InternalSchemas`. Used by schema dumps. */ +/** Schemas excluded from a schema dump. */ export const INTERNAL_SCHEMAS: ReadonlyArray<string> = [ "information_schema", "pg_*", // Wildcard pattern follows pg_dump @@ -44,7 +41,7 @@ export const INTERNAL_SCHEMAS: ReadonlyArray<string> = [ "vault", ]; -/** `migration.excludedSchemas`. Used by data dumps. */ +/** Schemas excluded from a data dump. */ export const EXCLUDED_SCHEMAS: ReadonlyArray<string> = [ "information_schema", "pg_*", // Wildcard pattern follows pg_dump @@ -79,7 +76,7 @@ export const EXCLUDED_SCHEMAS: ReadonlyArray<string> = [ "_supavisor", ]; -/** `migration.reservedRoles`. Used by role dumps. */ +/** Roles preserved verbatim by a role dump. */ export const RESERVED_ROLES: ReadonlyArray<string> = [ "anon", "authenticated", @@ -97,7 +94,7 @@ export const RESERVED_ROLES: ReadonlyArray<string> = [ "pgtle_admin", ]; -/** `migration.allowedConfigs`. Used by role dumps. */ +/** Config settings preserved verbatim by a role dump. */ export const ALLOWED_CONFIGS: ReadonlyArray<string> = [ // Ref: https://github.com/supabase/postgres/blob/develop/ansible/files/postgresql_config/supautils.conf.j2#L10 "pgaudit.*", @@ -112,11 +109,10 @@ export interface DumpOptions { readonly schema: ReadonlyArray<string>; readonly keepComments: boolean; readonly excludeTable: ReadonlyArray<string>; - /** `WithColumnInsert(!useCopy)` — true means emit `--column-inserts`. */ + /** `true` emits `--column-inserts` instead of `COPY` statements. */ readonly columnInsert: boolean; } -/** `migration.toEnv`. */ export function toDumpEnv(conn: PgConnInput): Record<string, string> { return { PGHOST: conn.host, @@ -127,7 +123,7 @@ export function toDumpEnv(conn: PgConnInput): Record<string, string> { }; } -/** `migration.DumpSchema` env assembly. */ +/** Env assembly for a schema-only dump. */ export function buildSchemaDumpEnv(conn: PgConnInput, opt: DumpOptions): Record<string, string> { const env = toDumpEnv(conn); if (opt.schema.length > 0) { @@ -142,7 +138,7 @@ export function buildSchemaDumpEnv(conn: PgConnInput, opt: DumpOptions): Record< return env; } -/** `migration.DumpData` env assembly. */ +/** Env assembly for a data-only dump. */ export function buildDataDumpEnv(conn: PgConnInput, opt: DumpOptions): Record<string, string> { const env = toDumpEnv(conn); if (opt.schema.length > 0) { @@ -166,13 +162,13 @@ export function buildDataDumpEnv(conn: PgConnInput, opt: DumpOptions): Record<st return env; } -/** `migration.quoteUpperCase`. */ +/** Double-quotes each dot-separated identifier segment (e.g. `public.foo` → `"public"."foo"`). */ export function quoteUpperCase(table: string): string { const escaped = table.replaceAll(".", `"."`); return `"${escaped}"`; } -/** `migration.DumpRole` env assembly. */ +/** Env assembly for a role dump. */ export function buildRoleDumpEnv(conn: PgConnInput, opt: DumpOptions): Record<string, string> { const env = toDumpEnv(conn); env["RESERVED_ROLES"] = RESERVED_ROLES.join("|"); @@ -186,21 +182,16 @@ export function buildRoleDumpEnv(conn: PgConnInput, opt: DumpOptions): Record<st const isAlphaNum = (c: string): boolean => c === "_" || (c >= "0" && c <= "9") || (c >= "a" && c <= "z") || (c >= "A" && c <= "Z"); -// `os.isShellSpecialVar`: `*#$@!?-` and the single digits 0-9. +// Shell special variable names: `*#$@!?-` and the single digits 0-9. const isShellSpecialVar = (c: string): boolean => "*#$@!?-0123456789".includes(c); -/** - * Port of `os.getShellName` (`src/os/env.go`): returns the variable name - * referenced by `$`-syntax at the start of `s`, plus the number of bytes - * consumed. - */ +/** Returns the variable name referenced by `$`-syntax at the start of `s`, plus the number of characters consumed. */ function getShellName(s: string): { name: string; width: number } { if (s.length === 0) return { name: "", width: 0 }; if (s[0] === "{") { if (s.length > 2 && isShellSpecialVar(s[1]!) && s[2] === "}") { return { name: s.slice(1, 2), width: 3 }; } - // Scan to the closing brace, copying the var name. for (let i = 1; i < s.length; i++) { if (s[i] === "}") { if (i === 1) return { name: "", width: 2 }; // bad syntax: `${}` @@ -218,15 +209,14 @@ function getShellName(s: string): { name: string; width: number } { } /** - * Port of `dump.noExec` expansion: expands - * `$VAR` / `${VAR}` references in `script` from `env`, ignoring bash default - * syntax (`${VAR:-x}` resolves `VAR` only) and escaping double quotes in the - * substituted values. Used to render the `--dry-run` script byte-for-byte. + * Expands `$VAR`/`${VAR}` references in `script` from `env`, ignoring bash default syntax + * (`${VAR:-x}` resolves `VAR` only) and escaping double quotes in substituted values. Used to + * render the `--dry-run` script exactly as it will run. */ export function expandScript(script: string, env: Record<string, string>): string { const mapping = (key: string): string => { - // Bash variable expansion is unsupported (golang/go#47187): only the name - // before the first ":" is honored. + // Only the name before the first ":" is honored; bash default-value syntax + // (`${VAR:-x}`) is not otherwise supported. const name = key.split(":")[0] ?? ""; const value = env[name] ?? ""; return value.replaceAll('"', '\\"'); diff --git a/apps/cli/src/command-internal/pg-dump.run.ts b/apps/cli/src/command-internal/pg-dump.run.ts index ba1307ab90..f8fbf9f5de 100644 --- a/apps/cli/src/command-internal/pg-dump.run.ts +++ b/apps/cli/src/command-internal/pg-dump.run.ts @@ -7,22 +7,15 @@ import { getRegistryImageUrl } from "./docker-registry.ts"; import { DockerRun } from "./docker-run.service.ts"; /** - * Runs a pg_dump / pg_dumpall bash script in a one-shot container, streaming its - * stdout chunk-by-chunk to `onStdout` and teeing stderr live, returning the exit - * code + captured stderr for failure classification. Mirrors `dockerExec`: - * host networking by default (overridden - * by the global `--network-id` flag, the ambient `SUPABASE_NETWORK_ID` env var, or - * a project `supabase/.env` value, in that precedence), no security-opt, and the - * Linux-only `host.docker.internal:host-gateway` extra host. + * Runs a pg_dump/pg_dumpall bash script in a one-shot container, streaming stdout + * chunk-by-chunk to `onStdout` and teeing stderr live, and returning the exit code and + * captured stderr for the caller to classify (e.g. with `isIPv6ConnectivityError`). Host + * networking by default, overridden by `--network-id`, `SUPABASE_NETWORK_ID`, or a project + * `supabase/.env` value, in that precedence. * - * Shared by `db dump` (streams to `--file`/stdout), `db pull`'s initial-migra - * schema dump (streams to the migration file), and (CLI-1969) `migration - * squash`'s three one-shot dumps (before/after `auth`/`storage` diff buffers, - * plus the full dump streamed straight into the target migration file) — the - * third consumer is why this module lives in `command-internal/` rather than - * `commands/db/shared/`. The pooler-fallback *decision* stays with the caller — - * this helper runs a single attempt and surfaces its exit/stderr so the caller - * can classify with `isIPv6ConnectivityError`. + * Shared by `db dump`, `db pull`'s initial-migra schema dump, and `migration squash`'s + * before/after/full dumps. Runs a single attempt only; the pooler-fallback decision stays + * with the caller. */ export const streamPgDump = Effect.fnUntraced(function* <E>(params: { /** Resolved Postgres image tag (pre-registry-URL); the helper applies the registry mirror. */ @@ -43,14 +36,9 @@ export const streamPgDump = Effect.fnUntraced(function* <E>(params: { const runtimeInfo = yield* RuntimeInfo; const networkIdFlag = yield* NetworkIdFlag; - // `dockerExec` sets `NetworkMode` to host, but - // `DockerStart` then overrides it with `viper.GetString("network-id")` whenever - // that resolves non-empty — a bound flag/env value wins, - // flag > ambient env > project-`.env` (`viperEnvStringWithProjectFallback` - // precedence). Only when NEITHER the flag nor the env resolves does Go fall back - // to `NetId` — but that branch only fires when the caller - // left `NetworkMode` empty, which the dump path never does, so the effective - // pg_dump fallback is host networking, not the generated `supabase_network_*`. + // Host networking by default; a resolved flag/env/project-env value wins in that + // precedence order. The generated `supabase_network_*` fallback used elsewhere never + // applies here, since this path always sets a NetworkMode. const networkId = Option.getOrUndefined(networkIdFlag); const envNetworkId = viperEnvStringWithProjectFallback( "SUPABASE_NETWORK_ID", diff --git a/apps/cli/src/command-internal/pg-net-guidance.ts b/apps/cli/src/command-internal/pg-net-guidance.ts index fa39c7917b..600dfa1714 100644 --- a/apps/cli/src/command-internal/pg-net-guidance.ts +++ b/apps/cli/src/command-internal/pg-net-guidance.ts @@ -21,17 +21,12 @@ export const isPgNetUnavailableError = (error: Pick<DbExecError, "code" | "messa const CREATE_PG_NET_EXTENSION_PATTERN = /\bcreate\s+extension\b[\s\S]*?\bpg_net\b/iu; /** - * Whether a recorded `supabase_migrations.schema_migrations` statement installs - * pg_net. + * Whether a recorded `supabase_migrations.schema_migrations` statement installs pg_net. * - * Deliberately a loose, over-matching scan (no SQL parse, no schema/quoting - * awareness) because of the direction the answer is used in: it only ever gates - * AWAY from dropping the extension. A false positive leaves a user's pg_net - * installed, which is harmless; a false negative would drop an extension the user's - * OWN migrations created — and `supabase start` does not replay migrations on an - * existing volume, so nothing would put it back. The constraint this exists to - * enforce: the webhooks-disabled convergence drop must never remove - * migration-owned pg_net. + * A loose, over-matching scan is safe here because the answer only ever gates away from + * dropping the extension: a false positive leaves pg_net installed (harmless), while a false + * negative would drop an extension the user's own migrations created, with nothing left to + * restore it. */ export const statementInstallsPgNet = (statement: string): boolean => CREATE_PG_NET_EXTENSION_PATTERN.test(statement); diff --git a/apps/cli/src/command-internal/pg-net-guidance.unit.test.ts b/apps/cli/src/command-internal/pg-net-guidance.unit.test.ts index 77f0474582..b0e52875de 100644 --- a/apps/cli/src/command-internal/pg-net-guidance.unit.test.ts +++ b/apps/cli/src/command-internal/pg-net-guidance.unit.test.ts @@ -13,11 +13,9 @@ describe("isPgNetUnavailableError", () => { message: "function net.http_post(url => text) does not exist", }), ).toBe(true); - // Right message, wrong SQLSTATE — a client-side echo, not a server verdict. expect(isPgNetUnavailableError({ code: "42P01", message: 'schema "net" does not exist' })).toBe( false, ); - // Right SQLSTATE, unrelated object. expect( isPgNetUnavailableError({ code: "3F000", message: 'schema "audit" does not exist' }), ).toBe(false); @@ -25,11 +23,6 @@ describe("isPgNetUnavailableError", () => { }); }); -/** - * This predicate only ever gates AWAY from dropping pg_net, so it must be - * generous: every plausible spelling of an install counts, and no false negative - * is acceptable. - */ describe("statementInstallsPgNet", () => { it.each([ "create extension pg_net", diff --git a/apps/cli/src/command-internal/pgdelta-ssl-probe.layer.ts b/apps/cli/src/command-internal/pgdelta-ssl-probe.layer.ts index f92dfdffdb..a8821702ca 100644 --- a/apps/cli/src/command-internal/pgdelta-ssl-probe.layer.ts +++ b/apps/cli/src/command-internal/pgdelta-ssl-probe.layer.ts @@ -5,13 +5,12 @@ import { PgDeltaSslProbe, PgDeltaSslProbeError } from "./pgdelta-ssl-probe.servi /** * The Postgres `SSLRequest` startup message (`int32 length=8`, `int32 code=80877103`). - * The server replies with a single byte: `S` (`0x53`) if it speaks TLS, `N` (`0x4E`) - * if it refuses. This is exactly the negotiation pgx performs for `sslmode=require` - * before deciding whether to fail with `"server refused TLS connection"`. + * The server replies with a single byte: `S` (`0x53`) if it speaks TLS, `N` (`0x4E`) if it + * refuses. */ const SSL_REQUEST_PACKET = new Uint8Array([0, 0, 0, 8, 0x04, 0xd2, 0x16, 0x2f]); -/** Default connect timeout when the URL carries no `connect_timeout` (Go's remote 10s). */ +/** Default connect timeout when the URL carries no `connect_timeout`. */ const DEFAULT_PROBE_TIMEOUT_MS = 10_000; /** Parsed dial target for the probe. */ @@ -22,9 +21,8 @@ export interface SslProbeTarget { } /** - * Parses a `postgresql://` URL into the probe's dial target. Mirrors how Go's - * `ConnectByUrl` reads `host`/`port`/`connect_timeout`: port defaults to 5432, and - * the timeout is the URL's `connect_timeout` (seconds) or the 10s remote default. + * Parses a `postgresql://` URL into the probe's dial target: port defaults to 5432, and the + * timeout is the URL's `connect_timeout` (seconds) or the default. */ export function parseSslProbeTarget(dbUrl: string): SslProbeTarget { const parsed = new URL(dbUrl); @@ -35,10 +33,9 @@ export function parseSslProbeTarget(dbUrl: string): SslProbeTarget { Number.isFinite(timeoutSeconds) && timeoutSeconds > 0 ? timeoutSeconds * 1000 : DEFAULT_PROBE_TIMEOUT_MS; - // `URL.hostname` keeps the brackets around an IPv6 literal (`[::1]`), and - // `net.connect` then treats `[::1]` as a DNS name (`getaddrinfo ENOTFOUND`) - // instead of dialing the address. Go's pgx path dials the bare `::1` (via - // `url.Hostname()`), so strip the surrounding brackets to match. + // `URL.hostname` keeps the brackets around an IPv6 literal (`[::1]`), and `net.connect` + // then treats it as a DNS name (`getaddrinfo ENOTFOUND`) instead of dialing the address — + // strip the brackets so it dials the bare address. const hostname = parsed.hostname; const host = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname; @@ -50,9 +47,8 @@ function sslProbeTargetForHost(host: string, port: number): SslProbeTarget { } /** - * Interprets the server's single-byte `SSLRequest` reply: `S` → speaks TLS, - * `N` → refused TLS (Go's `"server refused TLS connection"`). Any other byte is a - * protocol violation and surfaces as a probe error (Go propagates the connect error). + * Interprets the server's single-byte `SSLRequest` reply: `S` → speaks TLS, `N` → refused + * TLS. Any other byte is a protocol violation and surfaces as a probe error. */ export function interpretSslProbeByte(byte: number | undefined): "tls" | "refused" { if (byte === 0x53) return "tls"; // 'S' @@ -63,12 +59,9 @@ export function interpretSslProbeByte(byte: number | undefined): "tls" | "refuse } /** - * Live SSL-capability probe for pg-delta endpoints. Performs a raw Postgres - * `SSLRequest` negotiation over a TCP socket — the same question Go's `isRequireSSL` - * answers via `ConnectByUrl(dbUrl+"&sslmode=require")` — without completing the TLS - * handshake or authenticating (Go defers cert validation to the downstream Deno - * script). A `connect`/timeout/socket error propagates as a probe failure, matching - * Go's `return false, err` for non-TLS-refusal errors. + * Live SSL-capability probe for pg-delta endpoints: performs a raw Postgres `SSLRequest` + * negotiation over a TCP socket, without completing the TLS handshake or authenticating. A + * connect/timeout/socket error propagates as a probe failure. */ export const pgDeltaSslProbeLayer = Layer.effect( PgDeltaSslProbe, diff --git a/apps/cli/src/command-internal/pgdelta-ssl-probe.service.ts b/apps/cli/src/command-internal/pgdelta-ssl-probe.service.ts index 83e16ea010..7cff754fcc 100644 --- a/apps/cli/src/command-internal/pgdelta-ssl-probe.service.ts +++ b/apps/cli/src/command-internal/pgdelta-ssl-probe.service.ts @@ -6,26 +6,18 @@ import { } from "../shared/telemetry/error-actionability.ts"; /** - * A live TLS-capability probe for pg-delta SOURCE/TARGET endpoints, mirroring Go's - * `isRequireSSL` (`apps/cli-go/internal/gen/types/types.go:150`). Go opens a real - * connection with `sslmode=require` and treats a `"(server refused TLS connection)"` - * error as "TLS not required"; any other connection error propagates; a successful - * connection means "TLS required" (unless `--debug`, which disables SSL). - * - * The probe answers only the documented question — *does the server speak TLS?* — - * which Go performs via a raw Postgres `SSLRequest` negotiation. Certificate - * validation is intentionally NOT done here (Go's comment: "Cert validation happens - * downstream in the migra/pgdelta Deno scripts using GetRootCA"); the embedded CA - * bundle injected by `preparePgDeltaRef` is what the Deno script verifies - * against. Splitting this behind a service keeps the network side effect injectable - * so the pg-delta env-builder stays testable. + * A live TLS-capability probe for pg-delta source/target endpoints: opens a raw Postgres + * `SSLRequest` negotiation to determine whether the server speaks TLS, without completing the + * handshake or validating certificates — that happens downstream in the migra/pgdelta Deno + * scripts, using the embedded CA bundle `preparePgDeltaRef` injects. A refused negotiation + * means SSL isn't required; any other connection error propagates. A service so the network + * side effect stays injectable for tests. */ export interface PgDeltaSslProbeShape { /** - * Resolves `true` when the server at `dbUrl` speaks TLS and SSL should be required - * (Go's `isRequireSSL`). Resolves `false` when the server refuses TLS (Go's - * "server refused TLS connection") or when `--debug` is set (Go disables SSL in - * debug mode). Fails for any other connection error, matching Go's `return false, err`. + * Resolves `true` when the server at `dbUrl` speaks TLS and SSL should be required. + * Resolves `false` when the server refuses TLS, or when `--debug` is set. Fails for any + * other connection error. */ readonly requireSsl: (dbUrl: string) => Effect.Effect<boolean, PgDeltaSslProbeError>; readonly requireSslForHost: ( @@ -34,7 +26,7 @@ export interface PgDeltaSslProbeShape { ) => Effect.Effect<boolean, PgDeltaSslProbeError>; } -/** A non-TLS-refusal connection failure during the SSL probe (Go's propagated `err`). */ +/** A non-TLS-refusal connection failure during the SSL probe. */ export class PgDeltaSslProbeError extends Data.TaggedError("PgDeltaSslProbeError")<{ readonly message: string; readonly cause?: unknown; diff --git a/apps/cli/src/command-internal/pgdelta-ssl.ts b/apps/cli/src/command-internal/pgdelta-ssl.ts index 384f3847e0..9d981842c0 100644 --- a/apps/cli/src/command-internal/pgdelta-ssl.ts +++ b/apps/cli/src/command-internal/pgdelta-ssl.ts @@ -1,8 +1,7 @@ /** - * The Supabase CA bundle for TLS-requiring remote Postgres endpoints - * (concatenation of Go's embedded `caStaging + caProd + caSnap` bundles, - * verbatim). The migra engine passes it to its edge-runtime script as `SSL_CA` - * when the `PgDeltaSslProbe` reports the server requires TLS. + * The Supabase CA bundle for TLS-requiring remote Postgres endpoints (staging + production + + * snapshot roots, concatenated). The migra engine passes it to its edge-runtime script as + * `SSL_CA` when the SSL probe reports the server requires TLS. */ export const PG_DELTA_CA_BUNDLE = "-----BEGIN CERTIFICATE-----\nMIID1DCCArygAwIBAgIUbYRdq/8/uNq8G9stMCdOFSBgA2MwDQYJKoZIhvcNAQEL\nBQAwczELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l\ndyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEmMCQGA1UEAwwdU3VwYWJh\nc2UgU3RhZ2luZyBSb290IDIwMjEgQ0EwHhcNMjEwNDI4MTAzNjEzWhcNMzEwNDI2\nMTAzNjEzWjBzMQswCQYDVQQGEwJVUzEQMA4GA1UECAwHRGVsd2FyZTETMBEGA1UE\nBwwKTmV3IENhc3RsZTEVMBMGA1UECgwMU3VwYWJhc2UgSW5jMSYwJAYDVQQDDB1T\ndXBhYmFzZSBTdGFnaW5nIFJvb3QgMjAyMSBDQTCCASIwDQYJKoZIhvcNAQEBBQAD\nggEPADCCAQoCggEBAN0AKRE8a56O8LaZxiOAcHFUFnwiKUvPoXPq26Ifw+Nv+7zg\nN2V5WnMZbbw24q61Os60ZUn0XmbVtuIeJ+stPHsO7qxxuL+bmPR+qU5tkDrIOyEe\nYD/2u8/q6ssVv42k4XcXbhM6RVz7CkCDY0TiBm1bMtRZso3xB6E9wAjxDf43XfV5\nPAGs3JI+Zo/vyqCDlN0hHOrB/aBl01JXqQWI84Gia5ooucq4SjA1CyawBcQ2IAvG\nrXuy1BouY+xM3zRuNvtfFP6rb5Mta+jCYEMh1AZ8yP8sYUWAyhxX6k9EbOb009wQ\naZljbUCh/UglGWuBxdzePavx+zPjzWXB1NyVkpkCAwEAAaNgMF4wCwYDVR0PBAQD\nAgEGMB0GA1UdDgQWBBQFx+PHLf27iIo/PMfIfGqXF7Zb+DAfBgNVHSMEGDAWgBQF\nx+PHLf27iIo/PMfIfGqXF7Zb+DAPBgNVHRMBAf8EBTADAQH/MA0GCSqGSIb3DQEB\nCwUAA4IBAQB/xIiz5dDqzGXjqYqXZYx4iSfSxsVayeOPDMfmaiCfSMJEUG4cUiwG\nOvMPGztaUEYeip5SCvSKuAAjVkXyP7ahKR7t7lZ9mErVXyxSZoVLbOd578CuYiZk\nOgT17UjPv66WMzEKEr8wGpomTYWWfEkuqt8ENdiM1Z4LNFahdKj36+jm6/a+9R8K\n25VIL68DTaQpBxFWG6ixC1HRMHJ12lDhKsshIi099BVpkGibESlxPrQOdKKqBB/J\nvIX+/Hb+mS4H5zYMeK2wX0onp+GBcD6X9L1UJuXMVd+BRan8RFidXL5s3++xXjQq\nNzbc6lnA69urKffvcT07YwMsY/OmHzVa\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIDxDCCAqygAwIBAgIUbLxMod62P2ktCiAkxnKJwtE9VPYwDQYJKoZIhvcNAQEL\nBQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l\ndyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh\nc2UgUm9vdCAyMDIxIENBMB4XDTIxMDQyODEwNTY1M1oXDTMxMDQyNjEwNTY1M1ow\nazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD\nYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug\nUm9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqQXW\nQyHOB+qR2GJobCq/CBmQ40G0oDmCC3mzVnn8sv4XNeWtE5XcEL0uVih7Jo4Dkx1Q\nDmGHBH1zDfgs2qXiLb6xpw/CKQPypZW1JssOTMIfQppNQ87K75Ya0p25Y3ePS2t2\nGtvHxNjUV6kjOZjEn2yWEcBdpOVCUYBVFBNMB4YBHkNRDa/+S4uywAoaTWnCJLUi\ncvTlHmMw6xSQQn1UfRQHk50DMCEJ7Cy1RxrZJrkXXRP3LqQL2ijJ6F4yMfh+Gyb4\nO4XajoVj/+R4GwywKYrrS8PrSNtwxr5StlQO8zIQUSMiq26wM8mgELFlS/32Uclt\nNaQ1xBRizkzpZct9DwIDAQABo2AwXjALBgNVHQ8EBAMCAQYwHQYDVR0OBBYEFKjX\nuXY32CztkhImng4yJNUtaUYsMB8GA1UdIwQYMBaAFKjXuXY32CztkhImng4yJNUt\naUYsMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAB8spzNn+4VU\ntVxbdMaX+39Z50sc7uATmus16jmmHjhIHz+l/9GlJ5KqAMOx26mPZgfzG7oneL2b\nVW+WgYUkTT3XEPFWnTp2RJwQao8/tYPXWEJDc0WVQHrpmnWOFKU/d3MqBgBm5y+6\njB81TU/RG2rVerPDWP+1MMcNNy0491CTL5XQZ7JfDJJ9CCmXSdtTl4uUQnSuv/Qx\nCea13BX2ZgJc7Au30vihLhub52De4P/4gonKsNHYdbWjg7OWKwNv/zitGDVDB9Y2\nCMTyZKG3XEu5Ghl1LEnI3QmEKsqaCLv12BnVjbkSeZsMnevJPs1Ye6TjjJwdik5P\no/bKiIz+Fq8=\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nMIIDxzCCAq+gAwIBAgIUeX+gpfmsRW9asFkRvjyXjHxbfgcwDQYJKoZIhvcNAQEL\nBQAwazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5l\ndyBDYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJh\nc2UgUm9vdCAyMDIxIENBMB4XDTI1MDkwMzA4MDEyNVoXDTM1MDkwMTA4MDEyNVow\nazELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0RlbHdhcmUxEzARBgNVBAcMCk5ldyBD\nYXN0bGUxFTATBgNVBAoMDFN1cGFiYXNlIEluYzEeMBwGA1UEAwwVU3VwYWJhc2Ug\nUm9vdCAyMDIxIENBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA5Ve7\ni9UAmc7luUilELPtqzEk8nGHxg7nY0aCStr625M7+K4OPO6RUllTsHh47k1jWyzm\nLXLlyYwCsYCjQp+3vn06H+F/HRUxBt6CK2B7bNng230exTunk0xFvfkX6YgHR7B3\n1B7L25Rq3PhuRFPV4hnGYRam2XBZC4UNPqoAgrhV0HOYzXXAVoTr2yaBTMnB331Z\nRwOmINh7eqTCk/JRZbb6vfZOhZRAVAe9AoRLoG8aKwmeoLGwlu0UuFx6z3E+6bmA\nfSNa8Lx02GEoCdPLw9IRKUFq/SgBpQUKm44H1fDwTjH2CMM0N4p0mL/6wXnNeHvt\nC40MmKZ0RcVmHE5wBwIDAQABo2MwYTAdBgNVHQ4EFgQUjvEE541toZcwtXQlZlcB\nYOBRTnowHwYDVR0jBBgwFoAUjvEE541toZcwtXQlZlcBYOBRTnowDwYDVR0TAQH/\nBAUwAwEB/zAOBgNVHQ8BAf8EBAMCAYYwDQYJKoZIhvcNAQELBQADggEBACD5IcGP\nXKvS9qg0CgEQPFqYavt5c7P+0xxFgiZe+xoG8fUw58yNeK2APtgGPRpxEOGfAlNx\nz9HDt4gcyHEE00B3qAVDm49pqNxioFWzNqU2LGfM/HL1QmN6urR7hCOkVCJddvOc\nFhFX4nZDuRfaBboDvS5HlK3Pzxddp9hvrJi2bemr8HLqYc3HzmVckgPGSLML6t+h\n4LRCXSlQsDgQ1LZ4KHsl4cq7K51N6FOXQBLB5q4lMKhs0VUhCT8Pdsj12+84laCV\nc22q6p2mdT9SaernCSRnWazXWisgpjv3H7Ex4S1DCYjJIwn3PUToGFv1r8YRN2/S\nO19yVSxxCIf64Sg=\n-----END CERTIFICATE-----\n"; diff --git a/apps/cli/src/command-internal/pgdelta.paths.ts b/apps/cli/src/command-internal/pgdelta.paths.ts index 30bdbb0e13..f63d028826 100644 --- a/apps/cli/src/command-internal/pgdelta.paths.ts +++ b/apps/cli/src/command-internal/pgdelta.paths.ts @@ -14,7 +14,7 @@ import type { Path } from "effect"; import { resolveSupabaseHome } from "../shared/config/supabase-home.ts"; -/** `supabase/.temp/pgdelta` — catalog snapshots and debug bundles (`declarative.go:44`). */ +/** `supabase/.temp/pgdelta` — catalog snapshots and debug bundles. */ export function pgDeltaTempPath(path: Path.Path, workdir: string): string { return path.join(workdir, "supabase", ".temp", "pgdelta"); } diff --git a/apps/cli/src/command-internal/pgdelta.ts b/apps/cli/src/command-internal/pgdelta.ts index 40648781ed..350fff55ba 100644 --- a/apps/cli/src/command-internal/pgdelta.ts +++ b/apps/cli/src/command-internal/pgdelta.ts @@ -12,10 +12,8 @@ export interface PgDeltaContext { readonly projectId: string; readonly cwd: string; /** - * Effective `edge_runtime.deno_version` from the (remote-merged on `--linked`) - * config, forwarded to the edge-runtime container so migra runs under the - * configured Deno image. Mirrors Go, which resolves the image from the loaded - * config the command operates on rather than the base `config.toml`. + * Effective `edge_runtime.deno_version` from the (remote-merged on `--linked`) config, + * forwarded to the edge-runtime container so migra runs under the configured Deno image. */ readonly denoVersion: number; /** The project's parsed `supabase/.env` (`readDbToml`'s `projectEnv`). */ @@ -23,26 +21,14 @@ export interface PgDeltaContext { } /** - * Resolves {@link PgDeltaContext.projectId}: Go's `Config.ProjectId` singleton - * (`SUPABASE_PROJECT_ID` env → config.toml's `project_id` → sanitized workdir basename, - * `pkg/config/config.go:563-570` + `Validate` :989-996), sanitized the same way - * `UpdateDockerIds` derives `EdgeRuntimeId` from it (`internal/utils/config.go:57-76`) — - * NOT `CommandSettings.projectId` alone, which is env-only and resolves to `""` for a - * project that relies on config.toml's `project_id` or the workdir-basename default, - * mounting the WRONG `supabase_edge_runtime_` Deno-cache volume (review: - * PRRT_kwDOErm0O86XAlIw). Hoisted here — the single home for every pg-delta context - * builder (`db diff`, `db pull`, `db schema declarative generate`/`sync`) — per - * `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" rule. + * Resolves {@link PgDeltaContext.projectId} — not `CommandSettings.projectId` alone, which is + * env-only and resolves to `""` for a project relying on config.toml's `project_id` or the + * workdir-basename default, mounting the wrong `supabase_edge_runtime_` Deno-cache volume. + * Shared by every pg-delta context builder (`db diff`, `db pull`, declarative generate/sync). * - * `toml.appliedRemote !== undefined` suppresses the raw `cliProjectId` argument entirely: - * `toml.projectId` already reflects the matched `[remotes.<ref>]` block's own `project_id` - * at viper's override tier (`readDbToml`'s `remoteOverrideKeys.has("project_id")` - * gate, review: PRRT_kwDOErm0O86XHGDL) — but `resolveLocalProjectId` tries its FIRST - * argument before its second, so passing the raw, ungated `cliProjectId` through would let - * an unrelated ambient `SUPABASE_PROJECT_ID` win back over the matched remote's own id, - * mounting the wrong Deno-cache volume for a linked run. Mirrors the same - * suppression `local-project-context.ts`'s own `loadLocalProjectContext` - * already applies (review: PRRT_kwDOErm0O86XI1w8). + * When a matched `[remotes.<ref>]` block already resolved its own `project_id` into + * `toml.projectId`, the raw `cliProjectId` argument is suppressed entirely — otherwise an + * unrelated ambient `SUPABASE_PROJECT_ID` could win back over the matched remote's id. */ export function resolvePgDeltaProjectId( cliProjectId: Option.Option<string>, @@ -58,17 +44,15 @@ export function resolvePgDeltaProjectId( ); } -/** Mirrors Go's `isPostgresURL` (`internal/db/diff/pgdelta.go:46`). */ export function isPostgresURL(ref: string): boolean { return ref.startsWith("postgres://") || ref.startsWith("postgresql://"); } -/** Mirrors Go's `utils.EdgeRuntimeId` = `GetId("edge_runtime")` = `supabase_edge_runtime_<projectId>`. */ export function edgeRuntimeId(projectId: string): string { return `supabase_edge_runtime_${projectId}`; } -/** Mirrors Go's `IsPgDeltaDebugEnabled` (`internal/db/diff/pgdelta_debug.go:11`). */ +/** Recognizes `PGDELTA_DEBUG=1`/`true`/`yes` (case-insensitive). */ export function isPgDeltaDebugEnabled(): boolean { const value = (process.env["PGDELTA_DEBUG"] ?? "").trim().toLowerCase(); return value === "1" || value === "true" || value === "yes"; diff --git a/apps/cli/src/command-internal/pgpass.ts b/apps/cli/src/command-internal/pgpass.ts index b5f3e5b10d..431e38c80c 100644 --- a/apps/cli/src/command-internal/pgpass.ts +++ b/apps/cli/src/command-internal/pgpass.ts @@ -3,11 +3,9 @@ import { homedir } from "node:os"; import { join } from "node:path"; /** - * libpq `.pgpass` password lookup, a 1:1 port of `jackc/pgpassfile` - * (`ParsePassfile` + `FindPassword`) as used by `pgconn.ParseConfig`: - * when a connection string omits the password, pgconn - * reads the passfile and returns the first entry matching host/port/database/ - * user (with `*` wildcards). For a unix-socket host pgconn matches `localhost`. + * libpq `.pgpass` password lookup: when a connection string omits the password, this reads + * the passfile and returns the first entry matching host/port/database/user (with `*` + * wildcards). A unix-socket host matches `localhost`. */ const TMP_BACKSLASH = "\r"; @@ -81,15 +79,13 @@ type PassfileEnv = (name: string) => string | undefined; const processEnv: PassfileEnv = (name) => process.env[name]; /** - * Resolve the passfile path with pgconn's precedence: an - * explicit `passfile=` connection-string setting wins, then `PGPASSFILE`, then the - * libpq per-OS default (`~/.pgpass`, or `%APPDATA%/postgresql/pgpass.conf`). + * Resolves the passfile path with libpq precedence: an explicit `passfile=` + * connection-string setting wins, then `PGPASSFILE`, then the per-OS default (`~/.pgpass`, or + * `%APPDATA%/postgresql/pgpass.conf`). * - * A *present* `passfile` (even an empty string) is authoritative: an empty value - * resolves to no usable passfile (`undefined`) rather than falling back to - * `PGPASSFILE`/the default, mirroring pgconn calling `ReadPassfile("")` (→ - * `os.Open("")` fails → no `.pgpass` lookup → empty password). Only an *absent* - * (`undefined`) setting falls through to `PGPASSFILE`/the default. + * A *present* `passfile` (even an empty string) is authoritative: an empty value resolves to + * no usable passfile (`undefined`) rather than falling back to `PGPASSFILE`/the default. Only + * an *absent* (`undefined`) setting falls through. */ function pgpassFilePath(env: PassfileEnv, passfile: string | undefined): string | undefined { if (passfile !== undefined) { @@ -110,12 +106,12 @@ function pgpassFilePath(env: PassfileEnv, passfile: string | undefined): string } /** - * Resolve a password from the `.pgpass` file for the given connection, or `""` - * when the file is absent/unreadable or has no matching entry. A unix-socket - * host (a path) matches `localhost`, mirroring pgconn's `NetworkAddress`. + * Resolves a password from the `.pgpass` file for the given connection, or `""` when the + * file is absent/unreadable or has no matching entry. A unix-socket host (a path) matches + * `localhost`. * - * `env` supplies `PGPASSFILE`/`APPDATA` (defaults to `process.env`); `passfile` is - * an explicit connection-string `passfile=` setting that takes precedence. + * `env` supplies `PGPASSFILE`/`APPDATA` (defaults to `process.env`); `passfile` is an + * explicit connection-string `passfile=` setting that takes precedence. */ export function pgpassPassword( host: string, diff --git a/apps/cli/src/command-internal/pgservicefile.ts b/apps/cli/src/command-internal/pgservicefile.ts index 308dde531b..66461954a2 100644 --- a/apps/cli/src/command-internal/pgservicefile.ts +++ b/apps/cli/src/command-internal/pgservicefile.ts @@ -1,21 +1,17 @@ import { readFileSync } from "node:fs"; /** - * PostgreSQL service file (`pg_service.conf`) support, a 1:1 port of - * `jackc/pgservicefile` as used by `pgconn.ParseConfig`: - * when a connection's - * `service` is set (via `service=` or `PGSERVICE`), pgconn reads the service file - * and merges the named section's settings between the env and connection-string - * layers. A `dbname` key is remapped to `database` to match pgconn's `nameMap`. + * PostgreSQL service file (`pg_service.conf`) support: when a connection's `service` is set + * (via `service=` or `PGSERVICE`), this reads the service file and merges the named section's + * settings between the env and connection-string layers. A `dbname` key is remapped to + * `database`. */ /** - * Parse a service file into a `section → settings` map. Mirrors - * `pgservicefile.ParseServicefile`: INI-style `[name]` sections of `key=value` - * pairs (split on the first `=`, both sides trimmed); blank and `#` lines are - * ignored. Throws on a `key=value` line before any section, or a line that is - * neither a section, comment, nor `key=value` — matching pgconn, which surfaces - * those as a `failed to read service` parse error. + * Parses a service file into a `section → settings` map: INI-style `[name]` sections of + * `key=value` pairs (split on the first `=`, both sides trimmed); blank and `#` lines are + * ignored. Throws on a `key=value` line before any section, or a line that is neither a + * section, comment, nor `key=value`. */ export function parseServicefile(contents: string): Map<string, Map<string, string>> { const services = new Map<string, Map<string, string>>(); @@ -43,12 +39,11 @@ export function parseServicefile(contents: string): Map<string, Map<string, stri } /** - * Resolve a named service's settings from the service file at `servicefilePath`, - * remapping `dbname` → `database` like pgconn's `parseServiceSettings`. Returns - * `undefined` when the file is missing/unreadable, malformed, or has no matching - * section — pgconn treats all three as a hard parse error, so the caller surfaces - * a parse failure rather than silently falling through to defaults. The returned - * map may be empty (a section with no keys), which is distinct from `undefined`. + * Resolves a named service's settings from the service file at `servicefilePath`, remapping + * `dbname` → `database`. Returns `undefined` when the file is missing/unreadable, malformed, + * or has no matching section, so the caller can surface a parse failure rather than silently + * falling through to defaults. The returned map may be empty (a section with no keys), which + * is distinct from `undefined`. */ export function pgServiceSettings( serviceName: string, diff --git a/apps/cli/src/command-internal/postgres-url.ts b/apps/cli/src/command-internal/postgres-url.ts index 1d8a06385f..a489f8cd0a 100644 --- a/apps/cli/src/command-internal/postgres-url.ts +++ b/apps/cli/src/command-internal/postgres-url.ts @@ -1,20 +1,17 @@ /** - * Build a `postgresql://` URL from a resolved connection, mirroring Go's - * `utils.ToPostgresURL`. TLS (`sslmode`) is omitted: `ToPostgresURL` - * serializes only `RuntimeParams` (`sslmode` lives on `pgconn.Config.TLSConfig`). + * Builds a `postgresql://` URL from a resolved connection. TLS (`sslmode`) is intentionally + * omitted; it's negotiated separately, not serialized into the URL's query string. */ -/** Mirrors Go's IPv6 check (`net.ParseIP(host) != nil && ip.To4() == nil`). */ function isIPv6Host(host: string): boolean { // Hostnames never contain ':'; a bare IPv6 literal always does. return host.includes(":"); } /** - * Mirrors `url.QueryEscape`: every byte outside the unreserved set - * `A-Za-z0-9-_.~` is percent-encoded from its UTF-8 bytes, and space becomes `+`. - * Used for `RuntimeParams` values so the serialized query string is byte-identical - * to `ToPostgresURL` (`encodeURIComponent` differs on space and `!*'()`). + * Percent-encodes every byte outside the unreserved set `A-Za-z0-9-_.~` from its UTF-8 bytes, + * and turns space into `+` (`application/x-www-form-urlencoded` query-escaping, not + * `encodeURIComponent`, which differs on space and `!*'()`). */ function goQueryEscape(value: string): string { let out = ""; @@ -38,18 +35,13 @@ export interface PostgresUrlInput { readonly user: string; readonly password: string; readonly database: string; - /** `pgconn.Config.ConnectTimeout` in seconds; defaults to 10 when 0/absent. */ + /** Connect timeout in seconds; defaults to 10 when 0/absent. */ readonly connectTimeoutSeconds?: number; - /** - * libpq `options` startup parameter (Go's `pgconn.Config.RuntimeParams["options"]`, - * e.g. `reference=<ref>` for Supavisor pooler tenant routing). - */ + /** libpq `options` startup parameter, e.g. `reference=<ref>` for Supavisor pooler tenant routing. */ readonly options?: string; /** - * The remaining libpq startup `RuntimeParams` (e.g. `search_path`, - * `statement_timeout`). `ToPostgresURL` appends every `RuntimeParams` entry, so - * a custom `--db-url`'s session settings reach pg-delta. Emitted in sorted key order - * (Go iterates a map, so the exact order is not a parity contract). + * The remaining libpq startup parameters (e.g. `search_path`, `statement_timeout`) so a + * custom `--db-url`'s session settings reach pg-delta. Emitted in sorted key order. */ readonly runtimeParams?: Readonly<Record<string, string>>; } @@ -60,19 +52,17 @@ export function toPostgresURL(conn: PostgresUrlInput): string { ? conn.connectTimeoutSeconds : 10; const host = isIPv6Host(conn.host) ? `[${conn.host}]` : conn.host; - // Go uses url.UserPassword (userinfo escaping) + url.PathEscape (database). - // encodeURIComponent is a strict superset of those escape sets, so the decoded - // value pg-delta sees is identical for any input. + // encodeURIComponent is a strict superset of libpq's userinfo/path escaping, so the + // decoded value pg-delta sees is identical for any input. const userinfo = `${encodeURIComponent(conn.user)}:${encodeURIComponent(conn.password)}`; - // Mirror `connect_timeout` + `RuntimeParams` loop: the - // pooler tenant-routing `options` must reach pg-delta or the connection misses - // the tenant on pooler fallback. + // The pooler tenant-routing `options` must reach pg-delta, or the connection misses the + // tenant on pooler fallback. const optionsParam = conn.options !== undefined && conn.options.length > 0 ? `&options=${goQueryEscape(conn.options)}` : ""; - // Every other runtime param (search_path, statement_timeout, …), sorted for a stable - // serialization (Go iterates a map, so order is not a parity contract). + // Every other runtime param (search_path, statement_timeout, …), sorted for stable + // serialization. const extraParams = conn.runtimeParams === undefined ? "" diff --git a/apps/cli/src/command-internal/profile-load.ts b/apps/cli/src/command-internal/profile-load.ts index 3d6fc3473e..ffe2f3d897 100644 --- a/apps/cli/src/command-internal/profile-load.ts +++ b/apps/cli/src/command-internal/profile-load.ts @@ -8,14 +8,10 @@ import { } from "../shared/telemetry/error-actionability.ts"; import { apiUrl, dashboardUrl, isBuiltinProfileName, poolerHost, projectHost } from "./profile.ts"; -// `LoadProfile`, run from the root -// `PersistentPreRunE` immediately BEFORE -// `ChangeWorkDir` — so a profile Go cannot load aborts before the workdir -// check, `ValidateRequiredFlags`, `ValidateFlagGroups`, and `RunE`, with no -// API call ever made. Raised by `commandSettingsLayer` on every command's -// profile resolution (supabase/cli#6091) and by the sso pflag reconciliation -// (PR #5974 round 7). Message byte-matches Go for the deterministic failure -// classes. +/** + * A profile that fails to load, checked before the workdir check and any other flag + * validation or API call. Raised during every command's profile resolution. + */ export class ProfileLoadError extends Data.TaggedError("ProfileLoadError")<{ readonly message: string; }> { @@ -25,79 +21,26 @@ export class ProfileLoadError extends Data.TaggedError("ProfileLoadError")<{ } /** - * Emulates `LoadProfile` - * for a resolved profile token, returning the profile's endpoint set - * (`LoadedProfile`) or failing exactly where — and, for the - * deterministic classes, byte-for-byte how — the Go binary fails. Go runs - * this from the root `PersistentPreRunE` BEFORE - * `ChangeWorkDir`, so a load failure aborts the command before the workdir - * check, the required-flag check, the mutex check, and any API request. - * - * Resolution, mirroring Go (binary-verified, PR #5974 review round 7): - * - * 1. A token that case-insensitively (`strings.EqualFold`) matches a built-in - * profile name resolves to that profile's API URL. - * 2. Anything else is a config-file path handed to viper (`SetConfigFile` + - * `ReadInConfig`): - * - viper ignores an empty path and falls into search mode, which fails - * with `Config File "config" Not Found in "[]"`; - * - a path whose extension (Go `filepath.Ext` semantics: last `.` in the - * final path segment, ANY position — `.yml` alone is extension `yml`) - * is not in viper's `SupportedExts` fails with - * `Unsupported Config Type "<ext>"`; - * - an unreadable path fails with the OS error - * (`open <path>: no such file or directory`, `read <path>: is a - * directory`, …). - * 3. The content is parsed and decoded into `Profile` struct with - * `UnmarshalExact` — unknown keys fail with mapstructure's - * `'utils.Profile' has invalid keys: <sorted keys>` block. Viper decodes - * with `WeaklyTypedInput`, so scalar YAML values (numbers, booleans) are - * stringified, not rejected (binary-verified: `api_url: 123` reaches the - * validator and fails the `http_url` tag, not decoding). - * 4. `validator.StructCtx` checks the struct tags in field order and fails - * with one `Key: 'Profile.<Field>' Error:Field validation for '<Field>' - * failed on the '<tag>' tag` line per failing field. - * - * Multi-line Go errors are rendered with every line padded to the longest - * line's width (lipgloss block layout, binary-verified) — the padding is baked - * into the message so stderr matches the Go binary byte-for-byte. One caveat: - * the shared error normalizer (`normalize-error.ts` `readString`) trims the - * message ends, so the FINAL line's trailing padding is stripped before - * rendering; interior lines (including blank ones) keep it. Binary-diffed: - * only that final-line whitespace differs, on inputs where both CLIs already - * exit 1 with zero requests. - * - * Accepted micro-divergences (all fail-closed: both sides exit 1 with no API - * request; only the detail text can differ): - * - YAML parse-failure detail text comes from the JS `yaml` package, not - * go-yaml (the `failed to read profile: While parsing config: ` prefix - * matches). - * - Non-YAML/JSON `SupportedExts` contents (`.toml`, `.env`, `.ini`, …) are - * parsed as YAML rather than with their native viper codecs. - * - Array/object values on string fields render the offending value - * approximately (`%v` formatting emulated, not guaranteed). - * - The `http_url`/`hostname_rfc1123`/`uuid4` tag checks approximate - * go-playground/validator with WHATWG `URL` parsing and the validator's own - * published regexes. + * Resolves a profile token to its endpoint set, or fails with the CLI's established error + * text for each deterministic failure class (config-file-not-found, unsupported extension, + * unreadable file, unknown keys, missing/malformed required fields) — checked before the + * workdir check, the required-flag check, the mutex check, and any API request. A token that + * case-insensitively matches a built-in profile name resolves directly; anything else is + * read as a YAML config-file path. */ export interface LoadedProfile { readonly apiUrl: string; /** - * `CurrentProfile.Name` — the canonical built-in name (EqualFold - * match) or the file's required `name:` field. Credential resolution keys - * the keyring account on this name, so the - * reconciled request must read the reconciled profile's token, not the - * config layer's (review r3684153345). + * The canonical built-in name or the file's required `name:` field. Credential resolution + * keys the keyring account on this name. */ readonly name: string; - /** `Profile.ProjectHost` (`required`). */ readonly projectHost: string; /** - * `Profile.PoolerHost` (`omitempty`): "" when absent, disabling the - * linked pooler MITM domain assertion — never falls back to `supabase.com`. + * Empty when absent, disabling the linked pooler MITM domain assertion (never falls back + * to `supabase.com`). */ readonly poolerHost: string; - /** `Profile.DashboardURL` (`required`). */ readonly dashboardUrl: string; } @@ -106,8 +49,8 @@ export function loadProfile( fs: FileSystem.FileSystem, ): Effect.Effect<LoadedProfile, ProfileLoadError> { return Effect.gen(function* () { - // Go: `strings.EqualFold(p.Name, prof)` — the built-in names are all - // ASCII lower-case, so folding is plain lower-casing here. + // Built-in names are all ASCII lower-case, so case-insensitive matching is plain + // lower-casing. const folded = token.toLowerCase(); if (isBuiltinProfileName(folded)) { return { @@ -119,8 +62,7 @@ export function loadProfile( }; } - // viper `SetConfigFile("")` is a no-op, so `ReadInConfig` falls back to - // its (empty) search-path mode — byte-exact per the Go binary. + // An empty token falls back to the established "no config file" search-path error. if (token === "") { return yield* failRead(`Config File "config" Not Found in "[]"`); } @@ -156,25 +98,21 @@ export function loadProfile( parsed = {}; } if (typeof parsed !== "object" || Array.isArray(parsed)) { - // Go: yaml unmarshals into `map[string]interface{}` and rejects - // non-mapping documents. Detail text is best-effort (see doc comment). + // A non-mapping YAML document (e.g. a scalar or list) is rejected; detail text is + // best-effort. return yield* failRead( `While parsing config: yaml: unmarshal errors:\n cannot unmarshal into map[string]interface {}`, ); } - // Viper lowercases configuration keys before decoding - // (`insensitiviseMap`), so `API_URL:` / `Name:` decode exactly like - // their lowercase spellings, and UnmarshalExact reports unknown keys - // LOWERCASED (probed: `BOGUS_KEY` → `bogus_key`; review r3689635101). - // A same-key case collision is nondeterministic in Go (map iteration - // order) — document order (last wins) is used here. + // Configuration keys are lowercased before decoding, so `API_URL:`/`Name:` behave like + // their lowercase spellings, and unknown-key errors report the lowercased form. A + // same-key case collision uses document order (last wins). const config: Record<string, unknown> = {}; for (const [key, value] of Object.entries(parsed as Record<string, unknown>)) { config[key.toLowerCase()] = value; } - // `UnmarshalExact` — unknown keys abort decoding. mapstructure reports - // them sorted, in a padded multi-line block (binary-verified). + // Unknown keys abort decoding, reported sorted in a padded multi-line block. const invalidKeys = Object.keys(config) .filter((key) => !PROFILE_STRUCT_KEYS.has(key)) .sort(); @@ -213,8 +151,7 @@ export function loadProfile( return yield* fail(padGoErrorBlock(`invalid profile: ${validationErrors.join("\n")}`)); } - // All `required` fields passed validation above; `pooler_host` stays "" - // when absent (omitempty). + // All required fields passed validation above; pooler_host stays "" when absent. return { apiUrl: values.get("api_url") ?? "", name: values.get("name") ?? "", @@ -229,7 +166,7 @@ const fail = (message: string) => Effect.fail(new ProfileLoadError({ message })) const failRead = (detail: string) => fail(`failed to read profile: ${detail}`); -/** mapstructure's aggregate error template (`Decode` → `joinedError`). */ +/** Aggregate decode-error template: multiple failing fields render as one block. */ const failDecode = (detail: string) => fail( padGoErrorBlock( @@ -237,7 +174,7 @@ const failDecode = (detail: string) => ), ); -/** viper v1.21 `SupportedExts` (checked case-sensitively, like viper). */ +/** Recognized config file extensions, checked case-sensitively. */ const VIPER_SUPPORTED_EXTS: ReadonlySet<string> = new Set([ "json", "toml", @@ -254,9 +191,8 @@ const VIPER_SUPPORTED_EXTS: ReadonlySet<string> = new Set([ ]); /** - * Go `filepath.Ext` minus the leading dot: scans the final path segment from - * the end and returns everything after the last `.` — including for - * dot-files (`.yml` → `yml`), where Node's `path.extname` returns `""`. + * Returns everything after the last `.` in the final path segment, including for dot-files + * (`.yml` → `yml`), where Node's `path.extname` returns `""`. */ function goFilepathExt(token: string): string { for (let i = token.length - 1; i >= 0 && token[i] !== "/"; i--) { @@ -267,7 +203,7 @@ function goFilepathExt(token: string): string { return ""; } -/** Every mapstructure key of `Profile` struct. */ +/** Every recognized profile config key. */ const PROFILE_STRUCT_KEYS: ReadonlySet<string> = new Set([ "name", "api_url", @@ -290,10 +226,8 @@ interface ProfileStringField { } /** - * `Profile` string fields in struct order with - * their `validate:` tags — the order determines the order of the validator's - * error lines. `regions` (a slice, no validate tag) is exempt from weak - * string decoding and never validated, matching Go. + * Profile string fields in validation order, which determines the order of error lines. + * `regions` (a slice) is exempt from weak string decoding and never validated. */ const PROFILE_STRING_FIELDS: ReadonlyArray<ProfileStringField> = [ { key: "name", goName: "Name", required: true }, @@ -310,10 +244,10 @@ function validatorLine(goName: string, tag: string): string { return `Key: 'Profile.${goName}' Error:Field validation for '${goName}' failed on the '${tag}' tag`; } -/** validator v10's `hostnameRegexRFC1123`, copied verbatim. */ +/** RFC 1123 hostname pattern. */ const HOSTNAME_RFC1123 = /^([a-zA-Z0-9][a-zA-Z0-9-]{0,62})(\.[a-zA-Z0-9][a-zA-Z0-9-]{0,62})*?$/; -/** validator v10's `uuid4Regex`, copied verbatim (lower-case only). */ +/** Lower-case-only UUIDv4 pattern. */ const UUID4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/; const FORMAT_TAG_CHECKS: Record<FormatTag, (value: string) => boolean> = { @@ -331,9 +265,9 @@ const FORMAT_TAG_CHECKS: Record<FormatTag, (value: string) => boolean> = { }; /** - * mapstructure `WeaklyTypedInput` string decoding: strings pass through, - * bools become `"1"`/`"0"`, numbers are stringified, `null` decodes to the - * zero value. Arrays/objects are unconvertible → `undefined` (decode error). + * Weak string coercion: strings pass through, booleans become `"1"`/`"0"`, numbers are + * stringified, and `null`/`undefined` decode to `""`. Arrays/objects are unconvertible + * (`undefined`, a decode error). */ function weakString(value: unknown): string | undefined { if (value === null || value === undefined) return ""; @@ -349,7 +283,7 @@ function goTypeName(value: unknown): string { return typeof value; } -/** Approximates `%v` for the YAML values reachable here. */ +/** Best-effort rendering of an invalid field's value for the error message. */ function goValueString(value: unknown): string { if (Array.isArray(value)) return `[${value.map(goValueString).join(" ")}]`; if (typeof value === "object" && value !== null) { @@ -362,10 +296,8 @@ function goValueString(value: unknown): string { } /** - * Go renders these multi-line errors through a lipgloss block, which pads - * every line (including blank ones) with trailing spaces to the longest - * line's width (binary-verified via `od`). Baked into the message so the - * final stderr bytes match the Go binary exactly. + * Pads every line (including blank ones) with trailing spaces to the longest line's width, to + * match the CLI's established multi-line error rendering exactly. */ export function padGoErrorBlock(message: string): string { const lines = message.split("\n"); diff --git a/apps/cli/src/command-internal/profile-load.unit.test.ts b/apps/cli/src/command-internal/profile-load.unit.test.ts index 5421c16526..d4978e6521 100644 --- a/apps/cli/src/command-internal/profile-load.unit.test.ts +++ b/apps/cli/src/command-internal/profile-load.unit.test.ts @@ -32,7 +32,6 @@ const writeProfile = (name: string, content: string): string => { describe("loadProfile", () => { it.effect("resolves built-in profile names case-insensitively (Go strings.EqualFold)", () => Effect.gen(function* () { - // Binary-verified: `--profile SUPABASE-LOCAL` targets localhost:8080. expect(yield* load("SUPABASE-LOCAL")).toBe("http://localhost:8080"); expect(yield* load("supabase")).toBe("https://api.supabase.com"); expect(yield* load("supabase-staging")).toBe("https://api.supabase.green"); @@ -50,8 +49,7 @@ describe("loadProfile", () => { it.effect("fails on a token without a supported extension (flag-shaped tokens)", () => Effect.gen(function* () { - // `--profile --metadata-url …`: pflag binds the flag-shaped token and Go - // fails viper's extension gate (binary-verified, PR #5974 round 7). + // --profile --metadata-url … expect(yield* loadError("--metadata-url")).toBe( `failed to read profile: Unsupported Config Type ""`, ); @@ -63,8 +61,6 @@ describe("loadProfile", () => { it.effect("uses Go filepath.Ext semantics for dot-files (`.yml` IS extension `yml`)", () => Effect.gen(function* () { - // Node's `path.extname(".yml")` is "" — Go's filepath.Ext is ".yml", so - // viper accepts the type and fails at the open() instead. expect(yield* loadError(join(tempRoot, ".yml"))).toBe( `failed to read profile: open ${join(tempRoot, ".yml")}: no such file or directory`, ); @@ -105,8 +101,6 @@ describe("loadProfile", () => { it.effect("accepts mixed-case keys like viper's insensitive decode (probed on go1.26)", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - // `Name:` / `API_URL:` decode exactly like their lowercase spellings - // (viper `insensitiviseMap`; review r3689635101). const file = writeProfile( "mixed-case.yml", [ @@ -125,13 +119,12 @@ describe("loadProfile", () => { it.effect("returns the full endpoint set for built-in and YAML profiles", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - // Built-in: endpoints come from the `allProfiles` table. const builtin = yield* loadProfile("supabase-staging", fs); expect(builtin.projectHost).toBe("supabase.red"); expect(builtin.poolerHost).toBe("supabase.green"); expect(builtin.dashboardUrl).toBe("https://supabase.green/dashboard"); - // YAML: `project_host`/`dashboard_url` are required, `pooler_host` is - // `omitempty` and stays empty when absent (disables the MITM assertion). + // pooler_host is omitted below; it's optional and stays empty (disables the MITM + // assertion). const file = writeProfile( "endpoints.yml", [ @@ -169,11 +162,7 @@ describe("loadProfile", () => { () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - // Built-in: EqualFold match resolves to the canonical (lower-case) - // table name — the keyring account Go reads. expect((yield* loadProfile("SUPABASE-LOCAL", fs)).name).toBe("supabase-local"); - // File profile: `UnmarshalExact` populates Name from the required - // `name:` key, NOT from the file path. const file = writeProfile( "named.yml", [ @@ -189,8 +178,6 @@ describe("loadProfile", () => { it.effect("rejects unknown keys with mapstructure's padded UnmarshalExact block", () => Effect.gen(function* () { - // Byte-captured from the Go binary (`od -c`, PR #5974 round 7): keys - // sorted, every line padded with spaces to the longest line's width. const file = writeProfile( "extra-keys.yml", [ @@ -214,8 +201,6 @@ describe("loadProfile", () => { "reports missing required fields with the validator's padded lines, in struct order", () => Effect.gen(function* () { - // Byte-captured from the Go binary: `invalid profile: ` + one line per - // failing field (struct order), padded to the longest line's width. const file = writeProfile("incomplete.yml", "name: incomplete\n"); const lines = [ "invalid profile: Key: 'Profile.APIURL' Error:Field validation for 'APIURL' failed on the 'required' tag", @@ -247,8 +232,6 @@ describe("loadProfile", () => { "weakly stringifies scalars like viper, so `api_url: 123` fails http_url, not decoding", () => Effect.gen(function* () { - // Binary-verified: viper decodes with WeaklyTypedInput, so the int - // reaches go-playground/validator and fails the `http_url` tag. const file = writeProfile( "typebad.yml", [ @@ -286,8 +269,6 @@ describe("loadProfile", () => { it.effect("fails a malformed YAML file closed with viper's parse prefix", () => Effect.gen(function* () { - // Detail text comes from the JS yaml package (documented micro- - // divergence); the class — abort before any request — matches Go. const file = writeProfile("malformed.yml", "name: [broken\n api_url"); const message = yield* loadError(file); expect(message).toMatch(/^failed to read profile: While parsing config: /); diff --git a/apps/cli/src/command-internal/profile.ts b/apps/cli/src/command-internal/profile.ts index 1df9d67371..8de4e181c5 100644 --- a/apps/cli/src/command-internal/profile.ts +++ b/apps/cli/src/command-internal/profile.ts @@ -1,28 +1,24 @@ /** - * Built-in profile → environment endpoints. Mirrors the `allProfiles` table. - * Both `project_host` (used - * to build `SUPABASE_URL = https://<ref>.<host>` for `branches get`) and - * `dashboard_url` (used by `suggestUpgrade` to build the billing URL) - * live here so we have a single source of truth. + * Built-in profile → environment endpoints, the single source of truth for `project_host` + * (used to build `SUPABASE_URL = https://<ref>.<host>`) and `dashboard_url` (used to build the + * billing URL). * - * YAML-mode profiles carry their own `project_host` and `dashboard_url` - * (both `required`, loaded by `loadProfile`). The `?? DEFAULT_ENDPOINTS` - * fallbacks below only serve callers that key a lookup on an already-resolved - * profile NAME, which for a YAML profile is its `name:` field, not a table key. + * YAML-mode profiles carry their own `project_host`/`dashboard_url` (loaded by `loadProfile`). + * The `?? DEFAULT_ENDPOINTS` fallbacks below only serve callers keying a lookup on an + * already-resolved profile name, which for a YAML profile is its `name:` field, not a table key. */ import type { ProfileName } from "../config/command-settings.service.ts"; interface ProfileEndpoints { - /** Management API base URL (`Profile.APIURL`). */ + /** Management API base URL. */ readonly apiUrl: string; readonly projectHost: string; readonly dashboardUrl: string; /** - * eTLD+1 the connection pooler hostname must belong to - * (`Profile.PoolerHost`). Empty string means "no pooler-domain - * assertion" (`supabase-local`). Used by the linked db-config resolver's - * MITM domain check. + * eTLD+1 the connection pooler hostname must belong to. Empty string means no + * pooler-domain assertion (`supabase-local`). Used by the linked db-config resolver's MITM + * domain check. */ readonly poolerHost: string; } @@ -55,10 +51,9 @@ const BUILT_IN: Readonly<Record<string, ProfileEndpoints>> = { }; /** - * Exact-match (case-sensitive) built-in profile-name guard. Go matches - * built-in names with `strings.EqualFold`; callers that - * need Go's fold semantics lower-case the candidate first (all four built-in - * names are already lower-case). + * Exact-match (case-sensitive) built-in profile-name guard. Callers that need + * case-insensitive matching lower-case the candidate first (all four built-in names are + * already lower-case). */ export function isBuiltinProfileName(profile: string): profile is ProfileName { return profile in BUILT_IN; diff --git a/apps/cli/src/command-internal/project-create-core.ts b/apps/cli/src/command-internal/project-create-core.ts index e54f4eb3d9..3c7cd8f5c4 100644 --- a/apps/cli/src/command-internal/project-create-core.ts +++ b/apps/cli/src/command-internal/project-create-core.ts @@ -26,11 +26,7 @@ import { type CreateInput = typeof V1CreateAProjectInput.Type; -/** - * Mirror of Go's `api.V1ProjectResponse` (`apps/cli-go/pkg/api/types.gen.go`) - * — `projects create -o yaml|toml` encodes the raw struct, so keys derive - * from the Go field names (CLI-1975). - */ +/** Struct spec driving `-o yaml|toml` key casing for `projects create`'s raw response. */ const GO_PROJECT_RESPONSE = goStruct([ ["created_at", goString], ["id", goString], @@ -53,28 +49,24 @@ export interface ProjectCreateInput { readonly postgresEngine: CreateInput["postgres_engine"]; readonly templateUrl: string | undefined; /** - * Standalone `projects create` emits a `--output-format` json/stream-json - * success result; `bootstrap` suppresses it (it emits its own top-level - * result), matching Go's `create.Run` which only ever echoes via `-o`. + * Standalone `projects create` emits a `--output-format` json/stream-json success result; + * `bootstrap` suppresses it since it emits its own top-level result instead. */ readonly emitStructuredResult: boolean; } -/** Go's `printKeyValue` (`create.go`): `key` + `:` + pad to width 20 + value. */ +/** Formats `key: value`, padding `key:` to width 20. */ function printKeyValue(key: string, value: string): string { return `${key}:${" ".repeat(Math.max(0, 20 - key.length))}${value}`; } /** - * Ports Go's `create.Run` (`apps/cli-go/internal/projects/create/create.go:16-50`, - * deleted in CLI-1970; last present at commit 7b469f5b3): - * `promptMissingParams` (prompt for / echo each empty field), `POST /v1/projects`, - * and the project echo (`Created a new project at …` plus the `-o`/pretty render). + * Prompts for any missing project fields, creates the project via `POST /v1/projects`, and + * echoes the result (`Created a new project at …` plus the `-o`/pretty render). * - * Returns the created ref + the resolved db password (Go stores both globally via - * `flags.ProjectRef` / `viper.Set("DB_PASSWORD", …)`). Does NOT validate required - * flags (that is the standalone command's cobra PreRunE) and does NOT write the - * linked-project cache (the caller owns that via `Effect.ensuring`). + * Returns the created ref and the resolved db password. Does not validate required flags + * (the standalone command's own pre-run does that) and does not write the linked-project + * cache (the caller owns that via `Effect.ensuring`). */ export const projectCreateCore = Effect.fnUntraced(function* (input: ProjectCreateInput) { const output = yield* Output; @@ -91,8 +83,7 @@ export const projectCreateCore = Effect.fnUntraced(function* (input: ProjectCrea const releaseChannel = input.releaseChannel; const postgresEngine = input.postgresEngine; - // promptMissingParams (`create.go:58-85`): prompt for each empty value and - // echo the resolved value to stderr in text mode. + // Prompt for each empty value and echo the resolved value to stderr in text mode. if (name.length === 0) { name = yield* promptProjectName(); } else if (output.format === "text") { @@ -129,9 +120,8 @@ export const projectCreateCore = Effect.fnUntraced(function* (input: ProjectCrea const creating = output.format === "text" ? yield* output.task("Creating project...") : undefined; - // `executeRaw` sends the body with Go-sorted keys (matching `json.Marshal`) - // and skips output decoding: the 201 response's `ref` can be the cli-e2e - // `__PROJECT_REF__` placeholder, which the generated schema rejects. + // `executeRaw` skips output decoding: the 201 response's `ref` can be the cli-e2e + // `__PROJECT_REF__` placeholder, which the generated schema would reject. const response = yield* api.executeRaw(operationDefinitions.v1CreateAProject, body).pipe( Effect.tapError(() => creating?.fail() ?? Effect.void), Effect.mapError( @@ -154,7 +144,7 @@ export const projectCreateCore = Effect.fnUntraced(function* (input: ProjectCrea const id = readProjectField(created, "id"); - // Go prints this to stderr for every output format (`create.go:33-34`). + // Printed to stderr for every output format. const projectUrl = `${dashboardUrlForProfile(cliSettings.profile)}/project/${id}`; yield* output.raw(`Created a new project at ${projectUrl}\n`, "stderr"); diff --git a/apps/cli/src/command-internal/project-environment.ts b/apps/cli/src/command-internal/project-environment.ts index e99a2873aa..f21b583cd3 100644 --- a/apps/cli/src/command-internal/project-environment.ts +++ b/apps/cli/src/command-internal/project-environment.ts @@ -6,54 +6,25 @@ import type { CliProjectEnvironment } from "@supabase/config"; import { parseDotEnv } from "./dotenv.ts"; /** - * Fills the gap between `@supabase/config`'s `loadCliProjectEnvironment` and - * `loadNestedEnv`. The established - * behavior walks not just `supabase/` but one directory further, up to the project - * root/workdir (the loop stops once `cwd == filepath.Dir(repoDir)`, i.e. after - * exactly two directories: `supabase/`, then its parent), and at each - * directory calls `loadDefaultEnv`, which loads dotenv - * files chosen by `SUPABASE_ENV` (empty/unset defaults to `"development"`): - * `.env.<env>.local`, `.env.local` (skipped when - * `env === "test"`), `.env.<env>`, `.env` — via `godotenv.Load`, which only - * sets a key if it isn't already present in the process environment - * (`overload: false`). Because - * `godotenv.Load` writes straight into the process env as it goes, the net - * precedence (highest first) is: ambient shell env > `supabase/`-dir dotenv - * files (`.local` variant before non-local, env-specific before bare `.env`) - * > project-root dotenv files (same internal order). + * Dotenv filenames to check, in precedence order, for a given `SUPABASE_ENV` value + * (`.env.<env>.local`, `.env.local` — skipped for `"test"` — `.env.<env>`, `.env`). * - * `loadCliProjectEnvironment` only implements the `supabase/`-dir, plain - * `.env`/`.env.local` half of this (no project-root pass, no `SUPABASE_ENV` - * filename selection) — and it's shared infrastructure used well beyond - * `stop`/`status` (`packages/stack`, `secrets set`), so extending its - * file-resolution semantics is out of scope here. - * Instead, this fills in the missing project-root + `SUPABASE_ENV`-selected - * files locally: `loadCliProjectEnvironment`'s already-resolved `values` (its - * ambient-wins-over-`supabase/.env`(.local) result) always takes precedence - * over anything discovered here, since it's already correct for the keys it - * knows about. + * Fills a gap in `@supabase/config`'s `loadCliProjectEnvironment`, which only resolves this + * order for the `supabase/` directory: this module also checks the project root, and applies + * `SUPABASE_ENV`-selected filenames there too. Its own resolved values still take precedence + * over anything found here, since it's already correct for the keys it knows about. */ export function candidateDotenvFilenames(env: string): ReadonlyArray<string> { return [`.env.${env}.local`, ...(env === "test" ? [] : [".env.local"]), `.env.${env}`, ".env"]; } /** - * Minimal dotenv reader for the project-root and `SUPABASE_ENV`-selected extra - * files this module resolves, intentionally not reusing `@supabase/config`'s - * Effect-based `FileSystem` parser: this module stays a plain synchronous - * helper (like `local-config-values.ts`'s `loadFirstSigningKey`) since - * it only needs a handful of extra files read once per `stop`/`status` - * invocation. Delegates to {@link parseDotEnv} — the same `godotenv`-faithful, - * cursor-based parser `bootstrap`/`readDbToml` already use — rather than - * a hand-rolled line-by-line scan, so a quoted value spanning physical lines - * (a PEM/private key) parses correctly instead of aborting on what looks like - * a malformed continuation line. + * Reads and parses a dotenv file, or `undefined` if it doesn't exist. Delegates to + * {@link parseDotEnv} rather than a hand-rolled line scanner, so a quoted value spanning + * physical lines (a PEM/private key) parses correctly. * - * @throws on a line that isn't blank, a comment, or a `KEY=VALUE`/`KEY: VALUE` - * assignment — matching `loadEnvIfExists`, - * which propagates `godotenv.Load`'s parse error up through `loadNestedEnv` and - * fails `Config.Load` before `stop`/`status` touch Docker, rather than silently - * skipping the bad line. + * @throws on a malformed line (not blank, a comment, or a `KEY=VALUE`/`KEY: VALUE` + * assignment) — the caller must fail rather than silently skip it. */ function readDotEnvFile(path: string): Record<string, string> | undefined { if (!existsSync(path)) return undefined; @@ -69,33 +40,17 @@ function readDotEnvFile(path: string): Record<string, string> | undefined { } /** - * Returns the merged env-var map `stop`/`status` should read `SUPABASE_*` - * overrides (project id, auth fields) from — the project-root and - * `SUPABASE_ENV`-selected files `loadCliProjectEnvironment` doesn't cover, layered - * under only the truly ambient-sourced entries of `projectEnv.values`. + * Merged env-var map for `stop`/`status` to read `SUPABASE_*` overrides from, covering the + * project-root and `SUPABASE_ENV`-selected files {@link candidateDotenvFilenames} adds beyond + * `loadCliProjectEnvironment`. * - * Only `projectEnv`'s AMBIENT entries outrank `merged`: `projectEnv.values` - * also carries plain `supabase/.env`/`.env.local` values it read itself, and - * those are not necessarily higher Go precedence than an env-specific file - * (`.env.<env>.local`/`.env.<env>`) `merged` resolved — `loadCliProjectEnvironment` - * has no notion of `SUPABASE_ENV`-selected filenames, so it can't tell the two - * apart itself. `merged`'s own walk below already re-derives the full file - * precedence, including `supabase/.env`(.local), so only ambient needs to be - * layered back on top (`projectEnv.sources[key] === "ambient"` marks exactly - * those entries — see `loadCliProjectEnvironment`'s `CliProjectEnvironment` shape). + * Only `projectEnv`'s ambient-sourced entries are layered back on top: its other, file-derived + * entries aren't necessarily higher-precedence than the env-specific files resolved here, and + * it has no notion of those filenames to tell the two apart itself. * - * `projectEnv` is `null` whenever `@supabase/config` found no - * `supabase/config.toml`/`config.json` (searching ancestors, or at exactly - * `workdir` when the caller passed `search: false`) — but dotenv loading - * doesn't share that precondition: `Config.Load` calls - * `loadNestedEnv(builder.SupabaseDirPath)` BEFORE it ever opens `config.toml`, - * and `SupabaseDirPath` is a pure string - * join with no existence check (`NewPathBuilder`). - * So a missing/absent config file must not skip dotenv loading — fall back to - * deriving the same two directories directly from `workdir` - * (`<workdir>/supabase` and `workdir` itself) and read `process.env` itself as - * the ambient layer, since there's no `loadCliProjectEnvironment` result to - * consult for it in this branch. + * `projectEnv` is `null` when no config file was found, but dotenv loading isn't gated on + * that — fall back to deriving `<workdir>/supabase` and `workdir` directly, with `process.env` + * as the ambient layer. */ export function resolveProjectEnvironmentValues( projectEnv: CliProjectEnvironment | null, @@ -108,10 +63,9 @@ export function resolveProjectEnvironmentValues( const supabaseDir = projectEnv?.paths.supabaseDir ?? join(workdir, "supabase"); const projectRoot = projectEnv?.paths.projectRoot ?? workdir; - // supabase/ dir first, then its parent (the project root) — matching Go's - // directory walk order. Within a directory, `godotenv.Load`'s "never - // override an already-set var" means first-processed-wins, so the plain - // merge below (skip keys already present) reproduces both orderings at once. + // supabase/ dir first, then its parent (the project root). Within a directory, + // "never override an already-set var" means first-processed-wins, so skipping keys + // already present reproduces both orderings at once. for (const dir of [supabaseDir, projectRoot]) { for (const filename of filenames) { const parsed = readDotEnvFile(join(dir, filename)); From 7bb39a4590a01e61f9597115b851957a87201374 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 12:37:44 +0100 Subject: [PATCH 13/56] chore(repo): trim comments, shard 13 (1568 -> 480 comment lines) --- .../command-internal/local-config-values.ts | 1770 ++++------------- 1 file changed, 341 insertions(+), 1429 deletions(-) diff --git a/apps/cli/src/command-internal/local-config-values.ts b/apps/cli/src/command-internal/local-config-values.ts index adba638b96..8d4cad40ab 100644 --- a/apps/cli/src/command-internal/local-config-values.ts +++ b/apps/cli/src/command-internal/local-config-values.ts @@ -57,36 +57,25 @@ import { DEFAULT_SIGNING_KEY, generateAsymmetricGoJwt, generateGoJwt, type Jwk } import { collectDotenvPrivateKeys, decryptSecret, isEncryptedSecret } from "./vault-decrypt.ts"; /** - * Go-parity derived local-dev config values, ported from `utils.Config`'s - * post-load defaulting and - * `utils.GetApiUrl`/status's `toValues()`. `@supabase/config`'s schema has no field for - * a handful of Go constants (`db.password`, the S3 credential triple) — those are - * Go-hardcoded literals, reproduced here rather than added to the shared schema. - * - * Kept generic (no `status`-specific shaping) so a future native `start`/`restart` - * port can reuse it instead of re-deriving these values — see the plan's - * "Files to create" note. Do not fold this into `storage-credentials.ts`; - * that module resolves credentials through a different (HTTP/tenant-aware) path - * for the remote-project branch, which this pure resolver does not need (the - * shared `<scheme>://<host>:<port>` derivation itself lives in - * `api-url.ts`, used by both). + * Resolves local-dev config values (URLs, ports, keys) for `status`/`stop`, filling in + * literal defaults for fields `@supabase/config`'s schema doesn't model (`db.password`, + * the S3 credential triple). Kept separate from `storage-credentials.ts`, which resolves + * credentials for remote projects over HTTP instead. */ -/** `Db.Password` default — never present in config.toml. */ +/** Local Postgres password; not configurable via config.toml. */ const DEFAULT_DB_PASSWORD = "postgres"; -/** Go's hardcoded local S3 credentials. */ +/** Local S3 credentials; not configurable via config.toml. */ const DEFAULT_S3_ACCESS_KEY_ID = "625729a08b95bf1b7ff351a663f3a23c"; const DEFAULT_S3_SECRET_ACCESS_KEY = "850181e4652dd023b7a98c58ae0d2d34bd487ee0cc3254aed6eda37307425907"; const DEFAULT_S3_REGION = "local"; /** - * `Db.RootKey` default. - * Exported (not just a local default) so `start`'s Postgres container-spec - * builder (`postgres.service.ts`) shares this one literal instead of a second - * copy — `db.root_key` isn't modeled in `@supabase/config`'s schema, so it's - * resolved below off the raw document the same way `jwtSecret` is resolved. + * Default Postgres root key. Exported so `start`'s Postgres container-spec builder can + * reuse this literal instead of duplicating it; `db.root_key` isn't modeled in + * `@supabase/config`'s schema, so it's resolved from the raw document like `jwtSecret`. */ export const POSTGRES_DEFAULT_ROOT_KEY = "d4dc5b6d4a1d6a10b2c1e76112c994d65db7cec380572cc1839624d4be3fa275"; @@ -98,12 +87,7 @@ export interface LocalConfigValues { /** Already env-overridden `studio.port` (`SUPABASE_STUDIO_PORT`) — see `apiPort`/`dbPort` for the same pattern. */ readonly studioPort: number; readonly rootKey: string; - /** - * Already-resolved (env-overridden, decrypted-if-`encrypted:`) `studio. - * openai_api_key`, decrypted for both the TOML value and any - * `SUPABASE_STUDIO_OPENAI_API_KEY` override — same treatment as - * `jwtSecret`/the API keys below, via the same `decryptAuthSecret` helper. - */ + /** Already-resolved `studio.openai_api_key`: env-overridden, then decrypted if `encrypted:`. */ readonly openaiApiKey: string | undefined; readonly authSiteUrl: string; readonly authJwtIssuer: string | undefined; @@ -143,36 +127,22 @@ export interface LocalConfigValues { /** Already env-overridden `analytics.gcp_jwt_path` (`SUPABASE_ANALYTICS_GCP_JWT_PATH`). */ readonly gcpJwtPath: string; /** - * `Config.ProjectId`, sanitized — the SAME - * env-overridden, `projectIdFallback`-aware value this function already - * validates internally (see `resolvedProjectId` above), just also - * returned so callers that need it for Docker resource naming (`functions` - * deploy`/`download`/`serve`) don't re-derive it with a second - * implementation that could drift from this one. + * Sanitized, env-overridden project ID — the same value already validated internally, + * exposed so callers needing it for Docker resource naming don't re-derive it separately. */ readonly projectId: string; /** Already env-overridden `edge_runtime.deno_version` (`SUPABASE_EDGE_RUNTIME_DENO_VERSION`). */ readonly edgeRuntimeDenoVersion: number; } -/** - * `utils.GetApiUrl(path)`: appends - * `path` to the resolved external URL. Go's own fallback branch (building a bare - * `http://host:port` when `Config.Api.ExternalUrl` is empty) is unreachable in - * practice because `config.Load` already defaults `ExternalUrl` before `status` - * runs — `resolveApiExternalUrl` reproduces that same default, so `apiExternalUrl` - * passed in here is never empty. - */ +/** Appends `path` to the resolved external URL; `apiExternalUrl` is always already defaulted. */ function apiUrlWithPath(apiExternalUrl: string, path: string): string { return `${apiExternalUrl}${path}`; } /** - * Thrown by {@link resolveLocalConfigValues} when `auth.jwt_secret` is - * configured but too short to sign with, mirroring `Config.Validate` — - * that check runs at config-load time, before - * any command renders output, so no local dev stack can even start with a - * short secret. + * Thrown by {@link resolveLocalConfigValues} when `auth.jwt_secret` is configured but too + * short to sign with. Validated at config-load time, before any command renders output. */ export class InvalidJwtSecretError extends Error { static readonly [ErrorActionabilityFingerprintId] = "InvalidJwtSecretError"; @@ -185,20 +155,13 @@ export class InvalidJwtSecretError extends Error { } } -/** Go's minimum `auth.jwt_secret` length. */ +/** Minimum `auth.jwt_secret` length. */ const MIN_JWT_SECRET_LENGTH = 16; /** - * Thrown by {@link envOverridePort} when a `SUPABASE_*_PORT` env/dotenv - * override doesn't parse as a valid port, mirroring `Config.Load`: - * `v.UnmarshalExact` decodes with - * `WeaklyTypedInput` on (viper's `defaultDecoderConfig`, never reset by our - * decoder options), so mapstructure's `decodeUint` runs `strconv.ParseUint` - * on the override string and hard-fails config loading on a bad value — - * there is no Go code path that reaches `status`/`stop` with a malformed - * port override. The message text isn't a byte-match for mapstructure's - * internal error (that's viper/mapstructure library text, not a Go-authored - * string), but the parity-relevant part — hard-fail, same field name — is. + * Thrown by {@link envOverridePort} when a `SUPABASE_*_PORT` env/dotenv override doesn't + * parse as a valid port. A malformed port override always hard-fails config loading; there's + * no path that reaches `status`/`stop` with one. */ export class InvalidPortEnvOverrideError extends Error { static readonly [ErrorActionabilityFingerprintId] = "InvalidPortEnvOverrideError"; @@ -211,21 +174,13 @@ export class InvalidPortEnvOverrideError extends Error { } } -/** `uint16` port fields' valid range. */ +/** Maximum valid port number. */ const MAX_PORT = 65535; /** - * Port-flavored sibling of {@link envOverride}/{@link envOverrideBool} - * for `SUPABASE_*_PORT` fields Go decodes as `uint16` rather than a plain - * string. Unlike the boolean sibling — which intentionally falls back to - * `configured` on a malformed override — a bad port override is a genuine - * Go-parity hard failure (see {@link InvalidPortEnvOverrideError}), not - * a leniency case: Go never proceeds with the pre-override value on a decode - * error, it fails config loading outright. Parses with - * {@link parseGoBaseZeroUint} (Go's base-0 grammar, same as - * {@link envOverrideUint}), then applies {@link MAX_PORT} as this - * field's own `uint16` bound afterward — the same "parse the literal, then - * check it fits the bit width" split `strconv.ParseUint` itself uses. + * Port-flavored sibling of {@link envOverride}/{@link envOverrideBool}. Unlike the boolean + * sibling, which falls back to the configured value on a malformed override, a bad port + * override throws {@link InvalidPortEnvOverrideError} instead. */ export function envOverridePort( name: string, @@ -243,27 +198,12 @@ export function envOverridePort( } /** - * ANY config field can be overridden by a `SUPABASE_<DOTTED_KEY>` env var, - * generically across the whole struct — not just auth fields (every - * already-overridden field is automatically reflected in `status`'s output). - * This resolves it for every field this module derives a URL/port from, at - * the same higher-than-config.toml precedence env vars get. An empty env var - * is treated as unset. + * Resolves a `SUPABASE_<DOTTED_KEY>` override for a config field: checks `projectEnvValues` + * (the project's already-resolved dotenv values) before falling back to `process.env`, + * treating an empty value as unset. * - * The env-override binding is resolved AFTER the project's `supabase/.env`(.local) - * and project-root dotenv files are loaded into the process env — so a value - * that lives only in one of those files, not the ambient shell, must still be - * visible here. `projectEnvValues` is that already-resolved map (see - * `resolveProjectEnvironmentValues`); falling back to `process.env` - * covers the "no `supabase/` project found" case, where `projectEnvValues` is - * `undefined`. - * - * The resolved override string itself can be a further `env(VAR)` indirection - * (e.g. `SUPABASE_API_ENABLED=env(API_ENABLED)`), resolved on every string - * decoded into the config regardless of whether it came from `config.toml` or - * a `SUPABASE_*` override. Resolved with the same `projectEnvValues ?? - * process.env` precedence and non-empty gate as the outer lookup; an - * unresolved/empty indirection leaves the `env(VAR)` literal untouched. + * The resolved value can itself be an `env(VAR)` indirection (e.g. `env(API_ENABLED)`), + * resolved with the same precedence; an unresolved indirection is returned as-is. */ export function envOverride( name: string, @@ -279,15 +219,9 @@ export function envOverride( } /** - * Thrown by {@link envOverrideBool} when a `SUPABASE_*_ENABLED` (or other - * bool-typed) env/dotenv override doesn't parse as one of Go's accepted bool - * spellings, mirroring `Config.Load`: - * `v.UnmarshalExact` decodes with `WeaklyTypedInput` on (viper's - * `defaultDecoderConfig`, never reset by our decoder options — same mechanism - * as {@link InvalidPortEnvOverrideError}), so mapstructure's `decodeBool` - * runs `strconv.ParseBool` on the override string and hard-fails config - * loading on a bad value — there is no Go code path that reaches `status`/ - * `stop` with a malformed bool override. + * Thrown by {@link envOverrideBool} when a `SUPABASE_*_ENABLED` (or other bool-typed) + * env/dotenv override doesn't parse as an accepted bool spelling. A malformed bool override + * always hard-fails config loading, same as {@link InvalidPortEnvOverrideError}. */ export class InvalidBoolEnvOverrideError extends Error { static readonly [ErrorActionabilityFingerprintId] = "InvalidBoolEnvOverrideError"; @@ -301,21 +235,10 @@ export class InvalidBoolEnvOverrideError extends Error { } /** - * Boolean-flavored sibling of {@link envOverride} for `SUPABASE_*` fields - * decoded as a native bool (`api.tls.enabled`, `auth.enabled`, and every other - * `<section>.enabled` gate `status`/`stop` read — see `status-values.ts`) - * rather than a string/number, but the override string must be decoded with the - * accepted bool-spelling set ({@link parseGoBool}) instead of used - * verbatim. Unlike a plain string override — where an unparsed value has no - * observable failure mode — a malformed bool override is a genuine hard - * failure (see {@link InvalidBoolEnvOverrideError}), same as - * {@link InvalidPortEnvOverrideError} for ports: never proceed with the - * pre-override value on a decode error, fail config loading outright. - * - * Exported (not just used internally) because `status-values.ts`'s own - * `<section>.enabled` gates need this same override treatment — Go's - * `status.toValues()` reads `utils.Config.*.Enabled` post-Viper-override for - * every gated service, not only auth. + * Boolean-flavored sibling of {@link envOverride} for `SUPABASE_*` fields decoded as a + * native bool (`api.tls.enabled`, `auth.enabled`, and other `<section>.enabled` gates). A + * malformed override throws {@link InvalidBoolEnvOverrideError} rather than falling back + * to `configured`. */ export function envOverrideBool( name: string, @@ -332,17 +255,7 @@ export function envOverrideBool( return parsed; } -/** - * Thrown by {@link envOverrideAnalyticsBackend} when `SUPABASE_ANALYTICS_BACKEND` - * doesn't match one of `LogflareBackend` values. `Analytics.Backend` is - * typed `LogflareBackend`, and - * `LogflareBackend.UnmarshalText` hard-rejects anything - * outside `{postgres, bigquery}` — that runs inside the same - * `v.UnmarshalExact` decode call every other - * `SUPABASE_*` override goes through, so a malformed override fails config - * loading outright, same mechanism as {@link InvalidPortEnvOverrideError}/ - * {@link InvalidBoolEnvOverrideError}. - */ +/** Thrown by {@link envOverrideAnalyticsBackend} when `SUPABASE_ANALYTICS_BACKEND` isn't `"postgres"` or `"bigquery"`. */ export class InvalidAnalyticsBackendEnvOverrideError extends Error { static readonly [ErrorActionabilityFingerprintId] = "InvalidAnalyticsBackendEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { @@ -357,29 +270,10 @@ export class InvalidAnalyticsBackendEnvOverrideError extends Error { } /** - * `analytics.backend`-flavored sibling of {@link envOverridePort}/ - * {@link envOverrideBool} for the one `SUPABASE_*` override this file - * decodes as a Go text-unmarshalled enum rather than a string/number/bool — - * see {@link InvalidAnalyticsBackendEnvOverrideError}. Validates the - * override-or-configured value with a SINGLE check (rather than only - * validating the override, trusting the schema for the configured value), - * matching Go more closely: viper merges the config.toml value and any env - * override into one string BEFORE `UnmarshalExact` calls `UnmarshalText` - * exactly once on the resolved value, not once per - * source. `@supabase/config`'s `stringEnum` (`packages/config/src/ - * analytics.ts:31-39`) already guards the `config.toml`-sourced value at - * decode time, so this is belt-and-suspenders for that source and the sole - * guard for the env-override one, which bypasses that schema entirely. - * - * `skipEnvOverride` (default `false`) is `resolveLocalConfigValues`'s `remoteWins - * ("analytics.backend")` — `analytics.backend` is in `ENV_OVERRIDABLE_KEYS` - * (`db-config.toml-read.ts`), so a matched remote block's value must win over a - * conflicting `SUPABASE_ANALYTICS_BACKEND` the same way every other gated field in that function - * does (review: PRRT_kwDOErm0O86W30n6). Threaded as a parameter (rather than gating at the call - * site with a bare ternary) so the single validation check below still narrows `configured` - * itself to the return type on the remote-wins path — `CliConfig["analytics"]["backend"]`'s - * declared type is a plain `string`, not the literal union, so a call-site ternary would - * re-widen the result. + * Validates the override-or-configured value against the closed `postgres`/`bigquery` set, + * checking both sources with a single check so the return type narrows correctly. + * `skipEnvOverride` lets a matched remote-config value win over a conflicting + * `SUPABASE_ANALYTICS_BACKEND`. */ export function envOverrideAnalyticsBackend( configured: string, @@ -395,13 +289,7 @@ export function envOverrideAnalyticsBackend( return value; } -/** - * Thrown by {@link envOverrideRealtimeIpVersion} when - * `SUPABASE_REALTIME_IP_VERSION` doesn't match `AddressFamily` — - * `UnmarshalText` hard-rejects anything - * outside `{IPv4, IPv6}`, same mechanism as - * {@link InvalidAnalyticsBackendEnvOverrideError}. - */ +/** Thrown by {@link envOverrideRealtimeIpVersion} when `SUPABASE_REALTIME_IP_VERSION` isn't `"IPv4"` or `"IPv6"`. */ export class InvalidRealtimeIpVersionEnvOverrideError extends Error { static readonly [ErrorActionabilityFingerprintId] = "InvalidRealtimeIpVersionEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { @@ -416,12 +304,6 @@ export class InvalidRealtimeIpVersionEnvOverrideError extends Error { } } -/** - * `realtime.ip_version`-flavored sibling of {@link envOverrideAnalyticsBackend} — - * `Realtime.IpVersion` is `AddressFamily`, text-unmarshalled the same - * way `Analytics.Backend` is, so the override-or-configured value is - * validated with a single check to match Go's one-shot `UnmarshalText` call. - */ export function envOverrideRealtimeIpVersion( configured: string, projectEnvValues: Readonly<Record<string, string>> | undefined, @@ -455,12 +337,7 @@ export function envOverrideApiMaxRows( return envOverrideUint("SUPABASE_API_MAX_ROWS", "api.max_rows", configured, projectEnvValues); } -/** - * Thrown by {@link envOverridePoolMode} when `SUPABASE_DB_POOLER_POOL_MODE` - * doesn't match `PoolMode` — `UnmarshalText` - * hard-rejects anything outside `{transaction, session}`, same mechanism as - * {@link InvalidRealtimeIpVersionEnvOverrideError}. - */ +/** Thrown by {@link envOverridePoolMode} when `SUPABASE_DB_POOLER_POOL_MODE` isn't `"transaction"` or `"session"`. */ export class InvalidPoolModeEnvOverrideError extends Error { static readonly [ErrorActionabilityFingerprintId] = "InvalidPoolModeEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { @@ -475,12 +352,6 @@ export class InvalidPoolModeEnvOverrideError extends Error { } } -/** - * `db.pooler.pool_mode`-flavored sibling of {@link envOverrideRealtimeIpVersion} — - * `Pooler.PoolMode` is `PoolMode`, text-unmarshalled the same way - * `Realtime.IpVersion` is, so the override-or-configured value is validated - * with a single check to match Go's one-shot `UnmarshalText` call. - */ export function envOverridePoolMode( configured: string, projectEnvValues: Readonly<Record<string, string>> | undefined, @@ -493,13 +364,7 @@ export function envOverridePoolMode( return value; } -/** - * Thrown by {@link envOverrideEdgeRuntimePolicy} when - * `SUPABASE_EDGE_RUNTIME_POLICY` doesn't match `RequestPolicy` — - * `UnmarshalText` hard-rejects anything - * outside `{per_worker, oneshot}`, same mechanism as - * {@link InvalidPoolModeEnvOverrideError}. - */ +/** Thrown by {@link envOverrideEdgeRuntimePolicy} when `SUPABASE_EDGE_RUNTIME_POLICY` isn't `"per_worker"` or `"oneshot"`. */ export class InvalidEdgeRuntimePolicyEnvOverrideError extends Error { static readonly [ErrorActionabilityFingerprintId] = "InvalidEdgeRuntimePolicyEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { @@ -514,12 +379,6 @@ export class InvalidEdgeRuntimePolicyEnvOverrideError extends Error { } } -/** - * `edge_runtime.policy`-flavored sibling of {@link envOverridePoolMode} — - * `EdgeRuntime.Policy` is `RequestPolicy`, text-unmarshalled the same - * way `Pooler.PoolMode` is, so the override-or-configured value is validated - * with a single check to match Go's one-shot `UnmarshalText` call. - */ export function envOverrideEdgeRuntimePolicy( configured: string, projectEnvValues: Readonly<Record<string, string>> | undefined, @@ -559,22 +418,10 @@ export function envOverrideMaxClientConn( } /** - * Decrypts a resolved auth identity-key field (`jwt_secret`, `publishable_key`, - * `secret_key`, `anon_key`, `service_role_key`) when it's a dotenvx `encrypted:` - * value — an undecryptable value aborts config loading with - * `failed to parse config: <error>` before `status`/`stop` - * continue. `@supabase/config`'s schema only tags these fields for later - * `Redacted` wrapping (`packages/config/src/lib/env.ts`) and never decrypts, so - * without this step a valid `encrypted:` secret would be used as literal (wrong) - * key material and a malformed one would silently pass through instead of - * failing. - * - * Applied AFTER {@link envOverride}: an env-sourced override lands on the - * same field and goes through the same decrypt step as a TOML-sourced value, - * so `SUPABASE_AUTH_JWT_SECRET=encrypted:...` is decrypted too, not just the - * config.toml value. Exported for the storage-credentials resolver - * (`resolveLocalServiceRoleKey`), which applies the same composition to - * `auth.{jwt_secret,service_role_key}` for the local Storage gateway. + * Decrypts a resolved auth identity-key field when it's a dotenvx `encrypted:` value; an + * undecryptable value fails config loading rather than passing through unusable key material. + * Applied after {@link envOverride} so an env-sourced override is decrypted too, not just + * the config.toml value. */ export function decryptAuthSecret( value: string | undefined, @@ -590,41 +437,19 @@ export function decryptAuthSecret( } /** - * `[auth.email.smtp]`'s full resolved field set, including its - * presence-based `enabled` default. Exported so any caller that needs GoTrue's - * actual SMTP-vs-Mailpit decision (not just validation) can reuse this instead - * of re-deriving it from the schema-decoded, always-`enabled: false`-when- - * absent `config.auth.email.smtp` — see `start.handler.ts`'s - * `resolveGotrueEnvInput`, which used to do exactly that. - * - * `[auth.email.smtp]` presence-based `enabled` default: when the TOML table is - * present but omits `enabled`, treat it as `true` — a genuinely presence-based - * default `@supabase/config`'s schema can't see (it always decodes - * `smtp.enabled` to `false` when the key is absent), so this reads the raw - * `authDocument` too. `auth.email.smtp.*` is env-bindable like every other - * nested field once `[auth.email.smtp]` is present in config.toml, so - * `SUPABASE_AUTH_EMAIL_SMTP_ENABLED`/`_HOST`/`_PORT`/`_USER`/`_PASS`/ - * `_ADMIN_EMAIL`/`_SENDER_NAME` overrides apply before validation runs — - * layered on top of the presence-aware raw-document read above, same - * `envOverride`/`envOverridePort` precedent as every other field in this file. - * `sender_name` is an equally env-bindable regular field, just not needed by - * validation (hence absent from {@link SmtpInput}). + * Resolves `[auth.email.smtp]`'s full field set, including a presence-based `enabled` + * default `@supabase/config`'s schema can't express (it always decodes `enabled: false` + * when the key is absent from a present table). Exported so `start.handler.ts`'s GoTrue env + * resolution reuses this instead of re-deriving it. */ export function resolveAuthEmailSmtp( authDocument: Readonly<Record<string, unknown>> | undefined, projectEnvValues: Readonly<Record<string, string>> | undefined, /** - * Same remote-over-env precedence as {@link resolveConfiguredSigningKeys}'s own - * parameter — `auth.email.smtp.enabled`/`.port`/`.pass` are in `ENV_OVERRIDABLE_KEYS` - * (`db-config.toml-read.ts`) because their ungated `envOverrideBool`/ - * `envOverridePort`/`envOverride` calls below THROW (directly, or via - * `decryptAuthSecret` for `.pass`) on a malformed override even when a matched remote - * block already set them, which would abort the whole caller (`resolveLocalConfigValues`, - * and the shadow it feeds) on an env value the override tier should silently ignore. `host`/`user`/`admin_email`/ - * `sender_name` are also in the allowlist: their `envOverride` reads can't throw, but - * leaving them ungated is still a precedence bug, same reasoning as `auth.external.*`'s - * `client_id`/`url`/`redirect_uri`. Defaults to empty for `start.handler.ts`'s callers, which - * never resolve a `[remotes.<ref>]` block for this read. + * Fields whose `envOverride`/`envOverrideBool`/`envOverridePort` calls below can throw on + * a malformed value (directly, or via {@link decryptAuthSecret}) belong in this set so a + * matched remote block's value wins instead of aborting on an unrelated bad env var. + * Defaults to empty for callers with no `[remotes.<ref>]` block to match against. */ remoteOverrideKeys: ReadonlySet<string> = new Set(), ): (SmtpInput & { readonly senderName: string | undefined }) | undefined { @@ -670,14 +495,8 @@ export function resolveAuthEmailSmtp( typeof smtpDoc["user"] === "string" ? smtpDoc["user"] : "", projectEnvValues, ) ?? ""), - // `Auth.Email.Smtp.Pass` is a `config.Secret`, - // decrypted by `DecryptSecretHookFunc` at decode time for both the TOML - // value and any env override — same treatment as `jwt_secret`/the API - // keys below, via the same `decryptAuthSecret` helper. Same remote-over-env - // precedence as `.enabled`/`.port` above — `auth.email.smtp.pass` is now in - // `ENV_OVERRIDABLE_KEYS` because an ungated `envOverride` call here let a - // malformed ambient `SUPABASE_AUTH_EMAIL_SMTP_PASS` outrank a matched remote's own valid - // `pass` and throw during decryption, aborting the whole caller (review: PRRT_kwDOErm0O86XJYol). + // Decrypted like other secrets. `auth.email.smtp.pass` is in `ENV_OVERRIDABLE_KEYS` so a + // malformed ambient override can't abort decryption when a remote block already set it. pass: remoteWins("auth.email.smtp.pass") ? (decryptAuthSecret( typeof smtpDoc["pass"] === "string" ? smtpDoc["pass"] : "", @@ -713,42 +532,21 @@ export function resolveAuthEmailSmtp( } /** - * `auth.captcha` validation requires both `provider` and `secret` when the - * section is enabled. `auth.captcha.*` is env-bindable like every other nested - * field once `[auth.captcha]` is present in config.toml, so - * `SUPABASE_AUTH_CAPTCHA_ENABLED`/`_PROVIDER`/`_SECRET` overrides apply before - * this validation runs. Unlike the flat `auth.site_url` field, `config.auth.captcha` - * does NOT decode to `undefined` when `[auth.captcha]` is absent from config.toml — - * `captcha.ts`'s own `withDecodingDefaultKey` fills in `{ enabled: false }` even - * through the outer `Schema.optionalKey` wrapper (`packages/config/src/auth/index.ts`), - * confirmed empirically; there is no schema-level presence signal here, unlike - * `auth.passkey`/`auth.webauthn`. So presence is read from the raw `authDocument` - * instead — an absent `[auth.captcha]` section never picks up an env override - * alone (only keys already present in the merged config are env-bindable). - * - * Hoisted (like {@link resolveAuthEmailSmtp}) so both - * `resolveLocalConfigValues` and `start.handler.ts`'s - * `resolveGotrueEnvInput` (the actual GoTrue env) resolve the SAME effective - * value — `secret` is decrypted the same way `jwt_secret`/API keys/`smtp.pass` - * are. + * `auth.captcha` validation requires both `provider` and `secret` when the section is + * enabled. Unlike `auth.passkey`/`auth.webauthn`, `config.auth.captcha` never decodes to + * `undefined` when `[auth.captcha]` is absent, so presence is read from the raw + * `authDocument` instead. Hoisted so `start.handler.ts`'s GoTrue env resolution shares this + * same resolved value. */ export function resolveAuthCaptcha( authDocument: Readonly<Record<string, unknown>> | undefined, captcha: CliConfig["auth"]["captcha"], projectEnvValues: Readonly<Record<string, string>> | undefined, /** - * Same remote-over-env precedence as {@link resolveConfiguredSigningKeys}'s own - * parameter — `auth.captcha.enabled`/`.secret` are in `ENV_OVERRIDABLE_KEYS` - * (`db-config.toml-read.ts`) because their ungated `envOverrideBool`/ - * `envOverride` calls below THROW (directly, or via `decryptAuthSecret` for - * `.secret`) on a malformed override even when a matched remote block already set them, which - * would abort the whole caller (`resolveLocalConfigValues`, and the shadow it feeds) on - * an env value the override tier should silently ignore. `auth.captcha.provider` can't throw the same way - * (`envOverride` is a plain string read), but `validateResolvedConfig`'s enum check - * downstream rejects anything other than `hcaptcha`/`turnstile` — same "non-throwing read, - * throwing downstream consumer" class as `studio.api_url` (review: PRRT_kwDOErm0O86XLAYn). - * Defaults to empty for `start.handler.ts`'s callers, which never resolve a `[remotes.<ref>]` - * block for this config read. + * Fields whose `envOverride`/`envOverrideBool` calls below can throw on a malformed value + * (directly, or via {@link decryptAuthSecret}) belong in this set so a matched remote + * block's value wins instead of aborting on an unrelated bad env var. Defaults to empty for + * callers with no `[remotes.<ref>]` block to match against. */ remoteOverrideKeys: ReadonlySet<string> = new Set(), ): CaptchaInput | undefined { @@ -771,13 +569,9 @@ export function resolveAuthCaptcha( : captchaDoc !== undefined ? envOverride("SUPABASE_AUTH_CAPTCHA_PROVIDER", captcha.provider, projectEnvValues) : captcha.provider, - // `Auth.Captcha.Secret` is a `config.Secret`, decrypted - // by `DecryptSecretHookFunc` at decode time — same treatment as `auth.email.smtp.pass` - // above. Same remote-over-env precedence as `.enabled` above — `auth.captcha.secret` is - // in `ENV_OVERRIDABLE_KEYS` because an ungated `envOverride` call here let a - // malformed ambient `SUPABASE_AUTH_CAPTCHA_SECRET` outrank a matched remote's own valid - // `secret` and throw during decryption, aborting the whole caller - // (review: PRRT_kwDOErm0O86XJ4HR). + // Decrypted like `auth.email.smtp.pass`. `auth.captcha.secret` is in + // `ENV_OVERRIDABLE_KEYS` so a malformed ambient override can't abort decryption when a + // remote block already set it. secret: decryptAuthSecret( remoteWins("auth.captcha.secret") ? captcha.secret @@ -791,11 +585,9 @@ export function resolveAuthCaptcha( } /** - * Resolve the signing secret from the (override-applied, decrypted) - * `auth.jwt_secret`: empty falls back to `defaultJwtSecret`, shorter than - * {@link MIN_JWT_SECRET_LENGTH} throws {@link InvalidJwtSecretError}. - * Exported for the storage-credentials resolver (`resolveLocalServiceRoleKey`), - * which derives the local Storage gateway's service-role key from it. + * Resolves the signing secret from the already-resolved `auth.jwt_secret`: empty falls back + * to {@link defaultJwtSecret}, shorter than {@link MIN_JWT_SECRET_LENGTH} throws + * {@link InvalidJwtSecretError}. */ export function resolveJwtSecret(configured: string | undefined): string { if (configured === undefined || configured.length === 0) return defaultJwtSecret; @@ -821,7 +613,7 @@ function resolveSignedKey( : generateGoJwt(jwtSecret, role); } -/** Matches `JWK` struct fields — see `Jwk`. */ +/** JWK fields, matching {@link Jwk}. */ const JwkSchema = Schema.Struct({ kty: Schema.String, kid: Schema.optionalKey(Schema.String), @@ -844,31 +636,9 @@ const JwkSchema = Schema.Struct({ const decodeJwks = Schema.decodeUnknownSync(Schema.Array(JwkSchema)); /** - * `Config.Validate`: a relative - * `signing_keys_path` resolves against `<workdir>/supabase`, then the file is - * read and JSON-decoded into `[]JWK`. Used via {@link resolveConfiguredSigningKeys}'s - * {@link loadSigningKeys} call, both by `resolveLocalConfigValues` (which only needs the - * first key to sign anon/service_role, matching `generateJWT`'s `a.SigningKeys[0]` — see - * {@link resolveSignedKey}) and {@link resolveLocalJwks} (the full array, matching - * `ResolveJWKS`'s `a.SigningKeys` loop). - * - * Uses `node:fs` directly (not the `FileSystem` Effect service other Go-parity - * resolvers use for file reads) so this function — and its large - * existing test surface — can stay a plain synchronous resolver; this is an - * optional, rarely-configured field, not worth threading Effect dependencies - * through `statusValues`/`status.handler.ts` for. - * - * Error wording matches Go's two `Validate` failure branches exactly - * (`"failed to read signing keys: %w"` for an open failure, `"failed to decode - * signing keys: %w"` for a parse failure) rather than letting `readFileSync`/ - * `JSON.parse`'s raw Node error text through unwrapped. - * - * Callers must only invoke this when auth is enabled (the `SUPABASE_AUTH_ENABLED`- - * overridden value, not necessarily raw `config.auth.enabled` — see - * {@link envOverrideBool}) — `Validate` nests the entire signing-keys read - * inside `if c.Auth.Enabled`, reading - * that same post-override value, so a disabled auth section never touches - * `signing_keys_path`, however stale or missing that file is. + * Reads and JSON-decodes `signingKeysPath` into an array of {@link Jwk}, using `node:fs` + * directly (not the `FileSystem` Effect service) to keep this a plain synchronous resolver + * for a rarely-configured field. Callers must only invoke this when auth is enabled. */ function readSigningKeysFile(workdir: string, signingKeysPath: string): ReadonlyArray<Jwk> { const absolutePath = resolveSigningKeysPath(workdir, signingKeysPath); @@ -881,10 +651,8 @@ function readSigningKeysFile(workdir: string, signingKeysPath: string): Readonly } try { - // `Schema.Array` decodes `key_ops` as `ReadonlyArray<string>`, but `Jwk.key_ops` is a - // mutable `string[]` (required for assignability into Node's `createPrivateKey`/`JsonWebKey` - // input — see that type's own doc comment), so each key's `key_ops` is copied into a fresh - // mutable array here rather than widening the schema's own (correctly readonly) output type. + // `Jwk.key_ops` is mutable (required for Node's `createPrivateKey`/`JsonWebKey` input), so + // it's copied into a fresh array rather than widening the schema's readonly output type. return decodeJwks(JSON.parse(contents)).map((jwk) => ({ ...jwk, key_ops: jwk.key_ops === undefined ? undefined : [...jwk.key_ops], @@ -900,34 +668,11 @@ function loadSigningKeys(workdir: string, signingKeysPath: string): ReadonlyArra } /** - * `Auth.SigningKeys` config-load gating: `auth.signing_keys_path`'s file is read only when auth is enabled - * (the `SUPABASE_AUTH_ENABLED`-overridden value) AND a path is actually - * configured. Returns `undefined` in every other case — `Auth.SigningKeys` - * then keeps its `NewConfig()`-seeded single default ES256 key - * ({@link DEFAULT_SIGNING_KEY}); callers fall back to their own default - * instead of this function choosing one, since `resolveLocalJwks` and - * `gotrue.service.ts`'s `GOTRUE_JWT_KEYS` each spell their own default slightly - * differently (a bare JWKS-shaped key vs. a full `GotrueSigningKey`). - * - * Shared by {@link resolveLocalJwks} (the full JWKS document) and - * `start.handler.ts`'s `GOTRUE_JWT_KEYS` env (Go's `GOTRUE_JWT_KEYS = - * utils.Config.Auth.SigningKeys`) so the two resolvers can never disagree on - * which signing key(s) apply — a prerequisite for GoTrue-issued tokens to - * verify against the published JWKS at all. - * - * `remoteOverrideKeys` (default empty, so `supabase start`/`resolveLocalConfigValues`'s - * OTHER callers see exactly the same behavior as before): `auth.signing_keys_path` set at - * viper's OVERRIDE tier by a matched remote block must win over a conflicting - * `SUPABASE_AUTH_SIGNING_KEYS_PATH` — this resolver's caller `resolveLocalJwks` feeds the - * shadow's PG15+ one-shot auth-migration job on the `db diff --linked`/`db pull` path (CLI-1956, - * review: PRRT_kwDOErm0O86W3Ox_), and `resolveLocalConfigValues`'s own `signingKey` (used - * to sign `anonKey`/`serviceRoleKey`, already remote-gated fields) reaches the same shadow. - * `auth.enabled` itself needs the identical gate: it's in `ENV_OVERRIDABLE_KEYS` - * (`db-config.toml-read.ts`), and an ungated `envOverrideBool` call THROWS on a - * malformed `SUPABASE_AUTH_ENABLED` even when a matched remote block already set `auth.enabled` - * at viper's OVERRIDE tier — a value `Validate` never even evaluates the env var for in - * that case — which would otherwise abort this whole resolver (and the shadow it feeds) on an - * env value the override tier should silently ignore (review: PRRT_kwDOErm0O86W30n6). + * Returns the parsed signing keys only when auth is enabled and a path is configured; + * `undefined` otherwise, so callers fall back to their own default key shape. Shared by + * {@link resolveLocalJwks} and `start.handler.ts`'s `GOTRUE_JWT_KEYS` so both resolvers agree + * on which key(s) apply. `remoteOverrideKeys` lets a matched remote block win over a + * conflicting env override on `auth.enabled`/`auth.signing_keys_path`. */ export function resolveConfiguredSigningKeys( config: CliConfig, @@ -957,27 +702,9 @@ export function resolveConfiguredSigningKeys( } /** - * `Config.Validate` TLS branch file reads: gated on - * `api.enabled && api.tls.enabled` same as the caller, each configured path is read to confirm - * it's actually reachable, matching `fs.ReadFile` calls (Go caches the bytes for `start` to - * serve as `CertContent`/`KeyContent` — `status`/`stop` have no use for the bytes, only the same - * validation outcome, so they're discarded here). The "exactly one of cert/key set" presence - * check now lives in `validateResolvedConfig`'s `api.tls` step - * (`config-validate.ts`) — this function only runs the reads, and only when BOTH paths - * are actually present: neither path set, or only one, never reaches a `fs.ReadFile` call here, - * since the presence check (run later, as part of the single consolidated validation call) owns - * rejecting the one-but-not-the-other case. - * - * Both paths join unconditionally with the `supabase/` dir — no `filepath.IsAbs` guard - * (`path.Join` absorbs a leading `/`) — unlike {@link readSigningKeysFile}'s - * `signing_keys_path`, which IS guarded with an absolute-path check. - * See `resolveApiTlsPath`. Matches the identical Kong-side - * validation already ported for `seed buckets`/`storage` in - * `storage-credentials.ts`'s `validateLocalKongTls`. - * - * Uses `node:fs` directly for the same reason as {@link readSigningKeysFile}: this stays a plain - * synchronous resolver rather than threading the Effect `FileSystem` service through - * `statusValues`/`status.handler.ts`. + * Confirms `api.tls.cert`/`.key` are readable when both are configured; the "exactly one set" + * check lives in `validateResolvedConfig`. Unlike {@link readSigningKeysFile}'s + * `signing_keys_path`, both paths join with the `supabase/` dir without an absolute-path guard. */ function readApiTlsFiles( workdir: string, @@ -1029,49 +756,22 @@ export type ResolvedAuthEmail = Omit<CliConfig["auth"]["email"], "template" | "n }; /** - * `Auth.Email` is a value-typed (non-pointer) struct, - * always Viper/`AutomaticEnv`-bound regardless of `[auth.email]` presence in config.toml — - * same reasoning as {@link resolveGotrueRateLimit}/ - * {@link resolveGotrueSessions} elsewhere in this module, just hoisted here since - * `readAuthEmailTemplateContent`'s validation-only - * file-read ALSO needs the override-aware `template`/`notification` maps, not just - * `start.handler.ts`'s GoTrue env builder — same single-source/two-consumer shape as - * {@link resolveAuthExternalProviders}. - * - * `template`/`notification` THEMSELVES need no raw-document presence gate: they're - * `Schema.Record`s (`packages/config/src/auth/email.ts`), which — unlike a fixed-shape struct - * with `withDecodingDefaultKey` — only ever contain a key when the TOML section was actually - * present, so `Object.entries(email.template)` already reflects presence. Each entry's OWN - * `subject` field is a narrower case, though: an explicit `subject = ""` - * is a real state distinct from an absent key — but both decode to the SAME `""` in - * `@supabase/config`'s plain-string schema (`packages/config/src/auth/email.ts`), so `authDocument` - * (the raw TOML) is read per-entry to recover which case applies, same as the - * `auth.captcha`/`auth.passkey`/`auth.webauthn`/`auth.email.smtp` presence gaps elsewhere in this - * file. An env override always wins outright when set, regardless of the raw document. - * - * This resolves the SAME effective value for both `resolveKongEmailTemplateMounts` and - * `resolveGotrueEnvInput` in `start.handler.ts`, which both need the post-override email config. + * Resolves `auth.email`'s full field set, including per-`template`/`notification` overrides. + * Each entry's `subject` needs a raw-document read to tell an explicit `subject = ""` apart + * from an absent key, since both decode to the same `""`; an env override always wins over + * either case. Shared by `start.handler.ts`'s GoTrue env builder and its email-template + * content read. */ export function resolveAuthEmail( email: CliConfig["auth"]["email"], authDocument: Record<string, unknown> | undefined, projectEnvValues: Readonly<Record<string, string>> | undefined, - // `remoteOverrideKeys` (default empty, so `start.handler.ts`/`db/start/start.handler.ts` — - // which never resolve a matched `[remotes.*]` block — see identical behavior to before this - // parameter existed): a matched remote's override-tier `auth.email.*` leaf must win over a - // conflicting `SUPABASE_AUTH_EMAIL_*` env var the same way every other gated field in this - // file does, and — same "throws before a value the caller needs is resolved" bug class as - // `auth.enabled`/`api.enabled` — an ungated malformed override here aborts the WHOLE - // `resolveLocalConfigValues` call for the `db diff --linked`/`db pull` shadow-provisioning - // path (CLI-1956), denying the shadow every field, not just this one (review: PRRT_kwDOErm0O86XHvYh). - // Per-entry `template.<name>.*`/`notification.<name>.*` leaves (dynamically keyed, tracked via - // `AUTH_EMAIL_TEMPLATE_FIELDS`/`AUTH_EMAIL_NOTIFICATION_FIELDS`, same shape as - // `auth.external.<name>.*`) need the identical gating: `content_path` is the field that can - // actually abort resolution (a stale/missing ambient `_CONTENT_PATH` env var wins over a - // matched remote's own valid path and makes the caller-side file read below throw); - // `subject`/`content`/notification's `enabled` can't throw the same way, but leaving them - // ungated is still a precedence bug, same reasoning as `auth.external.*`'s non-throwing fields - // (review: PRRT_kwDOErm0O86XLAYn, PRRT_kwDOErm0O86XLAYo). + /** + * Fields whose `envOverride`/`envOverrideBool` calls (here or in the caller-side file read + * for `content_path`) can throw on a malformed value belong in this set so a matched remote + * block's value wins instead of aborting on an unrelated bad env var. Covers top-level + * `auth.email.*` leaves as well as each `template.<name>.*`/`notification.<name>.*` leaf. + */ remoteOverrideKeys: ReadonlySet<string> = new Set(), ): ResolvedAuthEmail { const remoteWins = makeRemoteWins(remoteOverrideKeys); @@ -1092,10 +792,8 @@ export function resolveAuthEmail( ? tmpl.content_path : (envOverride(`${envPrefix}_CONTENT_PATH`, tmpl.content_path, projectEnvValues) ?? tmpl.content_path), - // `content` is folded from `${envPrefix}_CONTENT` by the same generic env-bind as every - // other field — so an env override makes `content` "present" here exactly like a raw - // TOML `content = "..."` would, and {@link readAuthEmailTemplateContent} rejects it below - // unless `content_path` is also set. + // `content` counts as present when set via TOML or the `_CONTENT` env override; + // {@link readAuthEmailTemplateContent} rejects it below unless `content_path` is also set. content_present: asRecord(templateDoc?.[name])?.["content"] !== undefined || (remoteWins(`auth.email.template.${name}.content`) @@ -1194,22 +892,10 @@ export function resolveAuthEmail( } /** - * Template/notification content read, run only when auth is enabled. Every - * template is checked unconditionally; a notification only when that - * notification is itself enabled. Uses the same `readFileSync`-based pattern - * as {@link readSigningKeysFile}/`readApiTlsFiles` in this file, not an Effect - * `FileSystem` service. - * - * The `content`-vs-`content_path` exclusivity decision and project-root-relative path resolution - * live in `resolveEmailTemplateContentPath` (`config-validate.ts`); this function - * only feeds it each entry's already-resolved - * `content_present` (see {@link resolveAuthEmail}, which folds both the raw TOML `content` - * key AND a `${envPrefix}_CONTENT` env override into that flag) and performs the read when a path - * comes back. - * - * Takes the ALREADY env-override-resolved `email` (from {@link resolveAuthEmail}) so this - * only performs the file-existence read, matching the other validators' "resolve once, validate - * the resolved value" shape. + * Reads each template/notification's content file, run only when auth is enabled. Every + * template is checked unconditionally; a notification only when it's itself enabled. The + * `content`-vs-`content_path` exclusivity and path resolution live in + * `resolveEmailTemplateContentPath`; this only performs the read once a path comes back. */ function readAuthEmailTemplateContent(email: ResolvedAuthEmail, workdir: string): void { for (const [name, tmpl] of Object.entries(email.template)) { @@ -1245,27 +931,15 @@ function readAuthEmailTemplateContent(email: ResolvedAuthEmail, workdir: string) } } -// Decode-time overflow bound for every field routed through {@link envOverrideUint}: -// they are all a plain 64-bit `uint`, decoded via `strconv.ParseUint(str, 0, 64)`. -// `BigInt`, not `Number`, comparison: a JS `Number` can't exactly represent values -// this close to `2^64`, so a `Number.MAX_SAFE_INTEGER`-based check would incorrectly reject -// legitimate large `uint64` values that are otherwise still accepted. +// Every field routed through {@link envOverrideUint} is an unsigned 64-bit value; comparing +// as `BigInt` (not `Number`) avoids precision loss near `2^64`. const UINT_MAX = 18446744073709551615n; // 2^64 - 1 /** - * Base-0 unsigned integer literal parsing, matching `strconv.ParseUint(str, 0, bitSize)` — - * the same call {@link envOverrideUint}'s callers all decode through. Base 0 - * auto-detects from a prefix: `0b`/`0B` → binary, `0o`/`0O` → octal, `0x`/`0X` - * → hex, and a BARE leading `0` followed by more digits is ALSO octal (so `"010"` parses as `8`, not - * `10` — a real footgun for a user zero-padding a value expecting decimal semantics). Underscores - * are permitted between digits (Go 1.13+, e.g. `"1_000"`), but ONLY in base-0 mode. `ParseUint` - * never accepts a leading `+`/`-` sign (unlike `ParseInt`) — verified against a real Go binary - * across prefix casing, underscore placement, invalid-octal-digit rejection (`"08"`/`"09"` do NOT - * fall back to decimal — Go rejects them outright), and sign rejection. Returns `undefined` (not a - * thrown error) for anything that isn't a valid literal, so the caller can produce its own - * consistently-shaped error message; deliberately does NOT bound-check the result against any - * particular bit width — that stays the caller's job (see {@link UINT_MAX}), matching - * `strconv.ParseUint`'s own separation of "parse the literal" from "does it fit in `bitSize` bits". + * Base-0 unsigned integer literal parsing (`0b`/`0o`/`0x` prefixes, and a bare leading zero + * also meaning octal — so `"010"` parses as `8`), matching Go's `strconv.ParseUint(str, 0, …)` + * grammar. Underscores between digits are allowed; a leading sign is never accepted. Returns + * `undefined` for anything invalid instead of throwing, leaving bit-width bounds to the caller. */ function parseGoBaseZeroUint(value: string): bigint | undefined { if (value.length === 0 || value.startsWith("+") || value.startsWith("-")) return undefined; @@ -1278,8 +952,8 @@ function parseGoBaseZeroUint(value: string): bigint | undefined { } else if (/^0[xX](_?[0-9a-fA-F])+$/.test(value)) { literal = `0x${value.slice(2).replaceAll("_", "")}`; } else if (value.startsWith("0") && value.length > 1) { - // Go's default branch for a bare leading zero: always octal, with no fallback to decimal even - // when a later digit isn't valid octal (`"08"`/`"09"` are rejected, not read as decimal 8/9). + // A bare leading zero is always octal, with no fallback to decimal — `"08"`/`"09"` are + // rejected, not read as decimal 8/9. literal = /^[0-7](_?[0-7])*$/.test(value) ? `0o${value.replaceAll("_", "")}` : undefined; } else { literal = /^[0-9](_?[0-9])*$/.test(value) ? value.replaceAll("_", "") : undefined; @@ -1287,8 +961,7 @@ function parseGoBaseZeroUint(value: string): bigint | undefined { if (literal === undefined) return undefined; try { - // `BigInt(...)` natively parses `0b`/`0o`/`0x`-prefixed string literals in the corresponding - // base — exactly the normalized, underscore-free literal built above. + // `BigInt` natively parses `0b`/`0o`/`0x`-prefixed literals in the corresponding base. return BigInt(literal); } catch { return undefined; @@ -1296,20 +969,10 @@ function parseGoBaseZeroUint(value: string): bigint | undefined { } /** - * `SUPABASE_<NAME>` sibling of {@link envOverridePort} for `uint`-typed config - * fields with no upper-bound cap (`db.major_version`, `edge_runtime. - * deno_version`, `auth.jwt_expiry`, `auth.refresh_token_reuse_interval`, - * `auth.minimum_password_length`, …) — same generic Viper `AutomaticEnv` - * binding, same mapstructure hard-fail-on-bad-value - * semantics as the capped `uint16` port fields, but without `MAX_PORT`. Parses - * with {@link parseGoBaseZeroUint} (Go's base-0 grammar — hex/octal/binary - * prefixes and a bare leading zero all parse differently than plain decimal), - * not plain decimal, since Go's own decode does the same. A non-parsing or - * out-of-{@link UINT_MAX} override folds into the same generic - * "Invalid <field>" message `validateResolvedConfig` produces for an - * out-of-set numeric value, since Go's own decode failure and `Validate` - * failure for these fields aren't independently distinguishable from the - * CLI's output the way ports/bools are. + * `SUPABASE_<NAME>` sibling of {@link envOverridePort} for uncapped `uint`-typed fields + * (`db.major_version`, `auth.jwt_expiry`, …). Parses with {@link parseGoBaseZeroUint} and + * folds an invalid or out-of-{@link UINT_MAX} override into the generic "Invalid <field>" + * error message. */ export function envOverrideUint( name: string, @@ -1353,16 +1016,10 @@ export function envOverrideDenoVersion( } /** - * Optional-uint sibling of {@link envOverrideUint} for `db.settings.*` number - * fields (`max_connections`, `max_wal_senders`, …) — each one is a genuine Go - * nil pointer (`*uint`) when unset in `config.toml`, unlike `db.major_version`/ - * `edge_runtime.deno_version`, which always have a real default. `configured` - * (and the return value) stay `number | undefined` to preserve that - * "not configured" state through an override miss, matching Go's nil pointer - * staying nil when no `SUPABASE_DB_SETTINGS_*` override is set either. Parses - * with {@link parseGoBaseZeroUint}/{@link UINT_MAX}, same as - * {@link envOverrideUint}, since these fields decode through the same - * `strconv.ParseUint(str, 0, 64)` call on the Go side. + * Optional-uint sibling of {@link envOverrideUint} for `db.settings.*` fields + * (`max_connections`, `max_wal_senders`, …) that are left unset, not defaulted, when + * absent from config.toml. `configured`/the return value stay `number | undefined` to + * preserve that state through an override miss. */ function envOverrideOptionalUint( name: string, @@ -1380,10 +1037,8 @@ function envOverrideOptionalUint( } /** - * Optional-bool sibling of {@link envOverrideBool} for - * `db.settings.track_commit_timestamp` — a genuine Go nil pointer (`*bool`) - * when unset, unlike every other bool this file overrides (all of which have - * a real default and can never be "not configured"). + * Optional-bool sibling of {@link envOverrideBool} for `db.settings.track_commit_timestamp`, + * the only bool field in this file left unset (not defaulted) when absent. */ function envOverrideOptionalBool( name: string, @@ -1400,14 +1055,7 @@ function envOverrideOptionalBool( return parsed; } -/** - * Thrown by {@link resolveDbSettingsEnvOverrides} when - * `SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE` doesn't match Go's - * `SessionReplicationRole` — `UnmarshalText` - * hard-rejects anything outside `{origin, replica, local}`, same mechanism as - * {@link InvalidAnalyticsBackendEnvOverrideError}/ - * {@link InvalidRealtimeIpVersionEnvOverrideError}. - */ +/** Thrown by {@link resolveDbSettingsEnvOverrides} when `SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE` isn't `"origin"`, `"replica"`, or `"local"`. */ export class InvalidSessionReplicationRoleEnvOverrideError extends Error { static readonly [ErrorActionabilityFingerprintId] = "InvalidSessionReplicationRoleEnvOverrideError"; @@ -1424,13 +1072,9 @@ export class InvalidSessionReplicationRoleEnvOverrideError extends Error { } /** - * `db.settings.session_replication_role`-flavored sibling of - * {@link envOverrideAnalyticsBackend}/{@link envOverrideRealtimeIpVersion} — - * the one `db.settings.*` field Go decodes as a text-unmarshalled enum - * rather than a string/number/bool. Unlike those two siblings, `configured` - * (and the return value) may genuinely be `undefined` (Go's nil pointer, "not - * configured" — never written to `postgresql.conf`), so validation only runs - * once the merged override-or-configured value is actually present. + * Enum-flavored sibling of {@link envOverride} for `db.settings.session_replication_role`. + * Unlike {@link envOverrideAnalyticsBackend}, `configured` (and the return value) may + * be `undefined` — validation only runs once a value is actually present. */ function envOverrideSessionReplicationRole( configured: string | undefined, @@ -1452,22 +1096,10 @@ function envOverrideSessionReplicationRole( } /** - * Every `SUPABASE_DB_SETTINGS_*` override applies generically before - * `postgresql.conf` is serialized — so an override for, say, `shared_buffers` - * changes what actually configures Postgres. This resolves all 23 - * `db.settings.*` sub-fields (`packages/config/src/db.ts`) to their - * env-overridden value, for `postgresSettingsToPostgresConfig` to - * serialize — mirroring the `db.port`/`db.major_version`-style fix already - * applied at this same `start` call site, just fanned out across every - * `[db.settings]` field instead of one. - * - * `remoteOverrideKeys` (default empty, so `db start`/`db reset` — which never resolve a - * `[remotes.<ref>]` block for this config read — see exactly the same behavior as - * before): the `db.settings.*` keys a matched remote block set at override tier — - * a remote value for, say, `max_connections` must beat a conflicting `SUPABASE_DB_SETTINGS_MAX_ - * CONNECTIONS`, exactly like `db-config.toml-read.ts`'s own `db.major_version` - * gate. `db diff --linked`/`db pull` (CLI-1956) pass the set their sibling `readDbToml` - * call already computed, via `buildLocalDbContainerInputs`. + * Resolves every `db.settings.*` sub-field to its env-overridden value before + * `postgresSettingsToPostgresConfig` serializes `postgresql.conf`. `remoteOverrideKeys` + * lets a matched remote block's value win over a conflicting `SUPABASE_DB_SETTINGS_*` var, + * same as elsewhere in this file. */ export function resolveDbSettingsEnvOverrides( settings: CliConfig["db"]["settings"], @@ -1646,15 +1278,9 @@ const PASSWORD_REQUIREMENTS_VALUES = new Set([ ]); /** - * `auth.password_requirements`-flavored sibling of {@link envOverrideEdgeRuntimePolicy} — - * `PasswordRequirements.UnmarshalText` hard-fails config loading - * on a value outside this fixed set, same decode-time-failure semantics as the other `UnmarshalText` - * enums above. Extracted to its own exported function (rather than left inline in - * {@link resolveLocalConfigValues}) so `db start`'s own eager-validation battery - * (`commands/db/start/start.handler.ts`) can call it directly instead of duplicating the check — - * mirroring how that battery already calls `envOverrideBool`/`envOverrideUint` directly - * for `auth.enable_signup`/`auth.refresh_token_reuse_interval` rather than going through the full - * resolver (review: PRRT_kwDOErm0O86VnEV6). + * Enum-flavored sibling of {@link envOverride} for `auth.password_requirements`. Exported so + * `db start`'s own eager-validation battery can call it directly instead of duplicating the + * check. */ export function envOverrideAuthPasswordRequirements( configured: string, @@ -1667,7 +1293,7 @@ export function envOverrideAuthPasswordRequirements( return override ?? configured; } -/** Narrows an unknown value to a plain object, mirroring `db-config.toml-read.ts`'s `asRecord`. */ +/** Narrows an unknown value to a plain object. */ function asRecord(value: unknown): Record<string, unknown> | undefined { return typeof value === "object" && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) @@ -1675,24 +1301,10 @@ function asRecord(value: unknown): Record<string, unknown> | undefined { } /** - * `auth.external_url` isn't modeled in `@supabase/config`'s schema, so it's read off the raw - * document — same presence-based pattern as passkey/webauthn/external. `auth.GetExternalURL` - * prefers this explicit value over deriving from `apiUrl`, and feeds - * it into `API_EXTERNAL_URL`, the mailer verify URL, the default JWT issuer, and OAuth redirect-URI - * fallbacks for `supabase start`'s long-running GoTrue container AND `db start`'s/`supabase - * start`'s fresh-DB one-shot auth migration job — every caller must resolve the SAME value, hence - * this single standalone helper instead of independent per-caller derivations. Hoisted here (was - * private to `start/start.handler.ts`) once `db/start/start.handler.ts`'s own native container - * bootstrap became a third caller — see `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" rule. - * - * `remoteOverrideKeys` (default empty, so `db start`/`supabase start` — which never resolve a - * `[remotes.<ref>]` block for this config read — see exactly the same behavior as before): - * `auth.external_url` set at viper's OVERRIDE tier by a matched remote block - * must win over a conflicting - * `SUPABASE_AUTH_EXTERNAL_URL`, matching the `db.root_key`/`auth.jwt_secret`-style gates already - * applied elsewhere in this file — `db diff --linked`/`db pull` (CLI-1956) pass the set their - * sibling `readDbToml` call already computed, via `buildLocalDbContainerInputs` - * (review: PRRT_kwDOErm0O86W3Ox_). + * Resolves `auth.external_url`, not modeled in `@supabase/config`'s schema, from the raw + * document. Shared by `supabase start`'s long-running GoTrue container and `db start`'s + * one-shot auth migration job so both resolve the same value. `remoteOverrideKeys` lets a + * matched remote block win over a conflicting `SUPABASE_AUTH_EXTERNAL_URL`. */ export function resolveAuthExternalUrl( document: Readonly<Record<string, unknown>> | undefined, @@ -1707,9 +1319,7 @@ export function resolveAuthExternalUrl( return envOverride("SUPABASE_AUTH_EXTERNAL_URL", configuredAuthExternalUrl, projectEnvValues); } -/** `hook.validate()` hook-type iteration order, used - * only to build {@link resolveLocalConfigValues}'s `hooks` input in the right order — - * the actual per-hook validation now lives in `validateResolvedConfig`. */ +/** Hook-type iteration order for {@link resolveAuthHooks}'s output. */ const HOOK_TYPE_ORDER = [ "mfa_verification_attempt", "password_verification_attempt", @@ -1719,7 +1329,7 @@ const HOOK_TYPE_ORDER = [ "before_user_created", ] as const; -/** camelCase key {@link resolveAuthHooks} exposes per {@link HOOK_TYPE_ORDER} entry — matches `BuildGotrueEnvInput.hooks`'s field names (`gotrue.service.ts`). */ +/** camelCase key {@link resolveAuthHooks} exposes per {@link HOOK_TYPE_ORDER} entry, matching `gotrue.service.ts`'s env-input field names. */ const HOOK_TYPE_TO_CAMEL = { mfa_verification_attempt: "mfaVerificationAttempt", password_verification_attempt: "passwordVerificationAttempt", @@ -1740,45 +1350,21 @@ export type ResolvedAuthHooks = { }; /** - * Fixed hook iteration order. - * `auth.hook.<type>.*` is env-bindable like every other nested field, so - * `SUPABASE_AUTH_HOOK_<TYPE>_ENABLED`/`_URI`/`_SECRETS` overrides apply before - * `GOTRUE_HOOK_*` is built — there is no separate "raw" vs. "effective" hook - * value, so a hook enabled/retargeted purely through env vars must reach - * GoTrue too, not just validation. - * `@supabase/config`'s hook schema always decodes a `{ enabled: false }` - * default per type regardless of file presence (`packages/config/src/auth/ - * hooks.ts`'s `withDecodingDefaultKey`), which erases the presence signal - * `AutomaticEnv` needs (it only intercepts keys already present in the - * merged config) — so, like the passkey/webauthn/captcha overrides, this - * reads the raw `[auth.hook.<type>]` document to gate the override on the - * section actually being present. - * - * Hoisted (like {@link resolveAuthEmailSmtp}/{@link resolveAuthCaptcha}) - * so both `resolveLocalConfigValues` (which derives its filtered, - * enabled-only `HookInput[]` for `Config.Validate` parity from this same - * unfiltered result) and `start.handler.ts`'s `resolveGotrueEnvInput` (the - * actual GoTrue env) resolve the SAME effective values. + * Resolves `auth.hook.<type>.*` overrides in {@link HOOK_TYPE_ORDER}. Each type's presence in + * the raw `authDocument` gates whether an env override applies, since the schema always + * decodes a `{ enabled: false }` default regardless of file presence. Hoisted so both + * `resolveLocalConfigValues` and `start.handler.ts`'s GoTrue env resolution share this same + * result. */ export function resolveAuthHooks( authDocument: Readonly<Record<string, unknown>> | undefined, hook: CliConfig["auth"]["hook"], projectEnvValues: Readonly<Record<string, string>> | undefined, /** - * Same remote-over-env precedence as {@link resolveConfiguredSigningKeys}'s own - * parameter — every `auth.hook.<type>.{enabled,uri,secrets}` leaf is in - * `ENV_OVERRIDABLE_KEYS` (`db-config.toml-read.ts`) because a matched remote - * block flattens the WHOLE block and applies every leaf — not just `enabled` — at override - * tier. `enabled`'s ungated `envOverrideBool` - * call additionally THROWS on a malformed override even when a matched remote block already - * set it, which would abort the whole caller (`resolveLocalConfigValues`, and the shadow - * it feeds) on an env value that should otherwise be silently ignored. `uri`/`secrets` can't - * throw the same way (plain `envOverride`), but leaving them ungated is still a - * precedence bug: a remote's valid `uri` must beat a stale/malformed - * `SUPABASE_AUTH_HOOK_<TYPE>_URI`, otherwise `validateResolvedConfig`'s scheme check can - * reject a linked diff/pull that should otherwise succeed (review: PRRT_kwDOErm0O86XGTq5). - * Defaults to empty for `start.handler.ts`'s callers, which never resolve a - * `[remotes.<ref>]` block for this config read. + * Fields whose overrides below can throw or otherwise cause a downstream rejection on a + * malformed value belong in this set so a matched remote block's value wins instead. A + * matched remote flattens the whole `auth.hook.<type>` block, so every leaf + * (`enabled`/`uri`/`secrets`) is included, not just `enabled`. */ remoteOverrideKeys: ReadonlySet<string> = new Set(), ): ResolvedAuthHooks { @@ -1817,35 +1403,18 @@ export function resolveAuthHooks( } /** - * `Auth.MFA` factor fields (`TOTP`/`Phone`/`WebAuthn`) are value-typed - * structs, never `nil` — unlike `Auth.Hook`'s - * pointer-typed fields above, Viper's `AutomaticEnv` always binds to them - * regardless of whether `[auth.mfa.<factor>]` is present in config.toml, so - * `SUPABASE_AUTH_MFA_*` overrides always apply before `Auth.MFA.validate()` - * runs — no raw-document presence gate needed, unlike - * hooks/smtp above. - * - * Hoisted (like {@link resolveAuthHooks}/{@link resolveAuthCaptcha}) - * so both `resolveLocalConfigValues` (which derives its `enrollEnabled`/ - * `verifyEnabled` pairs for `Config.Validate` parity from this same unfiltered - * result) and `start.handler.ts`'s `resolveGotrueEnvInput` (the actual GoTrue - * env) resolve the SAME effective values. + * Resolves `auth.mfa`'s per-factor fields. Unlike hooks/smtp, `auth.mfa.<factor>` is always + * bound (no schema presence gap), so overrides always apply. Hoisted so both + * `resolveLocalConfigValues` and `start.handler.ts`'s GoTrue env resolution share this same + * result. */ export function resolveAuthMfa( mfa: CliConfig["auth"]["mfa"], projectEnvValues: Readonly<Record<string, string>> | undefined, /** - * Same remote-over-env precedence as {@link resolveConfiguredSigningKeys}'s own - * parameter — every throw-capable `auth.mfa.*` leaf below (`enroll_enabled`/`verify_enabled` - * per factor, `phone.otp_length`, `max_enrolled_factors`) is in `ENV_OVERRIDABLE_KEYS` - * (`db-config.toml-read.ts`) because its ungated `envOverrideBool`/ - * `envOverrideUint` call THROWS on a malformed override even when a matched remote block - * already set it, which would abort the whole caller (`resolveLocalConfigValues`, and the - * shadow it feeds) on an env value the override tier should silently ignore. `phone.template`/`.max_frequency` are - * also in the allowlist: their `envOverride` reads can't throw, but leaving them ungated - * is still a precedence bug, same reasoning as `auth.external.*`'s `client_id`/`url`/ - * `redirect_uri`. Defaults to empty for `start.handler.ts`'s callers, which never resolve a - * `[remotes.<ref>]` block for this read. + * Fields whose `envOverrideBool`/`envOverrideUint`/`envOverride` calls below can throw or + * otherwise cause a downstream rejection on a malformed value belong in this set so a + * matched remote block's value wins instead of aborting on an unrelated bad env var. */ remoteOverrideKeys: ReadonlySet<string> = new Set(), ): CliConfig["auth"]["mfa"] { @@ -1936,18 +1505,8 @@ export function resolveAuthMfa( } /** - * `auth.rate_limit.*` are plain `uint`s, always env-bindable regardless of - * `[auth.rate_limit]` presence, so every `SUPABASE_AUTH_RATE_LIMIT_*` override - * applies before `GOTRUE_RATE_LIMIT_*` is built — no raw-document presence - * gate needed, matching the existing `db.pooler`/SMS numeric-field precedent. - * Unlike `auth.sms`/`auth.mfa`, `rateLimit` has no `Enabled`-gated validation - * branch at all — the only check is the unconditional `uint` decode, so - * callers resolve it eagerly and unconditionally, with no `authEnabled` gate. - * - * Hoisted here (originally private to `commands/start/start.handler.ts`) once - * `commands/db/start/start.handler.ts` became a second caller — both need the same eager, - * unconditional `auth.rate_limit.*` resolution, per - * `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate". + * Resolves `auth.rate_limit.*`, all plain `uint`s with no presence gate: every + * `SUPABASE_AUTH_RATE_LIMIT_*` override applies unconditionally. */ export function resolveGotrueRateLimit( rateLimit: CliConfig["auth"]["rate_limit"], @@ -2000,23 +1559,9 @@ export function resolveGotrueRateLimit( } /** - * `Auth.Sessions` is a value-typed struct, - * always merged with a Viper default (empty durations) regardless of - * `[auth.sessions]` presence in config.toml — so - * `SUPABASE_AUTH_SESSIONS_{TIMEBOX,INACTIVITY_TIMEOUT}` overrides always apply - * before `start.go` builds `GOTRUE_SESSIONS_*`, no raw-document presence gate - * needed (same reasoning as {@link resolveAuthMfa} above). - * `@supabase/config`'s `sessions` schema is `Schema.optionalKey` at the - * `auth` level though (`config.auth.sessions` can be `undefined`), unlike - * Go's always-present struct — an env override must still be able to - * introduce a value even when the section was never in config.toml at all, - * matching Go's real behavior. - * - * Hoisted here (originally private to `commands/start/start.handler.ts`) once - * `commands/db/start/start.handler.ts` became a second caller — both need the - * same eager `auth.sessions.{timebox,inactivity_timeout}` resolution to - * reproduce Go's unconditional `Config.Load` duration decode, per - * `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate". + * Resolves `auth.sessions.{timebox,inactivity_timeout}`. An env override can introduce a + * value even when `[auth.sessions]` was never in config.toml, since `config.auth.sessions` + * can be `undefined` here unlike other always-bound sections. */ export function resolveGotrueSessions( sessions: CliConfig["auth"]["sessions"], @@ -2037,28 +1582,9 @@ export function resolveGotrueSessions( } /** - * `appendGotruePasskeyEnv`/`auth.passkey`/`auth.webauthn` presence gate: - * `@supabase/config` has no `auth.passkey`/`auth.webauthn` schema fields at - * all, so presence and every field must come from the raw, pre-schema TOML - * document instead — same document-based approach this file's own validation - * resolution (inside {@link resolveLocalConfigValues}) already uses for - * these two sections. - */ -/** - * `auth.passkey.enabled`/`auth.webauthn.*` are env-bindable like every other - * nested field once `[auth.passkey]`/`[auth.webauthn]` are present in - * config.toml, so `SUPABASE_AUTH_PASSKEY_ENABLED`/`SUPABASE_AUTH_WEBAUTHN_{RP_ID, - * RP_DISPLAY_NAME,RP_ORIGINS}` overrides apply before `appendGotruePasskeyEnv` - * builds GoTrue's env — same reasoning, and same - * presence-gating (an absent section is never synthesized from an env - * override alone), as this file's identical validation resolution for this - * raw-document pair. `rp_display_name` has no validation-path precedent, but - * GoTrue's env does consume it, so it gets the same treatment here. - * - * Hoisted here (originally private to `commands/start/start.handler.ts`) once - * `commands/db/start/start.handler.ts` became a second caller — both need the - * same eager `auth.passkey`/`auth.webauthn` resolution, per - * `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate". + * Resolves `auth.passkey`/`auth.webauthn`, which have no `@supabase/config` schema fields at + * all: presence and every field come from the raw TOML document. An absent section is never + * synthesized from an env override alone. */ export function resolveGotruePasskeyWebauthn( document: Readonly<Record<string, unknown>> | undefined, @@ -2106,10 +1632,7 @@ export function resolveGotruePasskeyWebauthn( : "", projectEnvValues, ) ?? "", - // Go's mapstructure decode chain applies `StringToSliceHookFunc(",")` - // unconditionally to every `[]string`-typed field — a raw or - // `env(...)`-resolved `rp_origins` string (this section has no `@supabase/config` - // schema at all) is comma-split, not silently dropped to `[]`. + // A raw or `env(...)`-resolved `rp_origins` string is comma-split, not dropped. rpOrigins: (() => { if (rpOriginsOverride !== undefined) return strToArr(rpOriginsOverride); const raw = webauthnDoc["rp_origins"]; @@ -2123,16 +1646,7 @@ export function resolveGotruePasskeyWebauthn( return { passkeyEnabled, webauthn }; } -/** - * `Auth.Web3` is a value-typed struct — - * same no-presence-gate reasoning as {@link resolveGotrueRateLimit}. - * - * Hoisted here (originally private to `commands/start/start.handler.ts`) once - * `commands/db/start/start.handler.ts` became a second caller — both need the - * same eager `auth.web3.*.enabled` resolution to reproduce Go's unconditional - * `Config.Load` decode, per `apps/cli/CLAUDE.md`'s "Hoist Before You - * Duplicate". - */ +/** Resolves `auth.web3.*.enabled`, both value-typed with no presence gate — overrides always apply. */ export function resolveGotrueWeb3( web3: CliConfig["auth"]["web3"], projectEnvValues: Readonly<Record<string, string>> | undefined, @@ -2157,16 +1671,7 @@ export function resolveGotrueWeb3( }; } -/** - * `Auth.OAuthServer` is a value-typed - * struct — same no-presence-gate reasoning as {@link resolveGotrueRateLimit}. - * - * Hoisted here (originally private to `commands/start/start.handler.ts`) once - * `commands/db/start/start.handler.ts` became a second caller — both need the - * same eager `auth.oauth_server.*` resolution to reproduce Go's unconditional - * `Config.Load` decode, per `apps/cli/CLAUDE.md`'s "Hoist Before You - * Duplicate". - */ +/** Resolves `auth.oauth_server.*`, value-typed with no presence gate — overrides always apply. */ export function resolveGotrueOAuthServer( oauthServer: CliConfig["auth"]["oauth_server"], projectEnvValues: Readonly<Record<string, string>> | undefined, @@ -2194,26 +1699,10 @@ export function resolveGotrueOAuthServer( } /** - * Fixed provider validation order — only enabled providers are forwarded, in - * that order. Each provider struct is value-typed, same no-presence-gate - * reasoning as {@link resolveGotrueWeb3} — so - * `SUPABASE_AUTH_THIRD_PARTY_<PROVIDER>_*` overrides always apply, including `workos`, whose - * default template omits `[auth.third_party.workos]` entirely, before third-party - * validation runs. - * - * Hoisted here (originally private to {@link resolveLocalConfigValues}) once - * `commands/db/start/start.handler.ts`'s eager pre-probe battery became a second caller — both - * need the same eager `auth.third_party.*` resolution to reproduce Go's unconditional - * `Config.Load` decode, per `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" (review: - * PRRT_kwDOErm0O86WXFqj). - * - * `remoteOverrideKeys` (default empty, so neither existing caller's behavior changes): each - * `auth.third_party.<provider>.*` field is in `ENV_OVERRIDABLE_KEYS` - * (`db-config.toml-read.ts`) and `envOverrideBool` THROWS on a malformed override, - * so an ungated call here would abort this whole function (and the shadow it feeds via - * `buildLocalDbContainerInputs`) on a malformed `SUPABASE_AUTH_THIRD_PARTY_*_ENABLED` even - * when a matched remote block already set that provider's field at viper's OVERRIDE tier — same - * `auth.enabled` bug class (review: PRRT_kwDOErm0O86W30n6). + * Resolves enabled `auth.third_party.<provider>` entries in a fixed order, forwarding only + * the enabled ones. `remoteOverrideKeys` lets a matched remote block's value win over a + * conflicting `SUPABASE_AUTH_THIRD_PARTY_*_ENABLED`, since `envOverrideBool` would otherwise + * throw and abort the whole call. */ export function resolveThirdPartyProviders( thirdParty: CliConfig["auth"]["third_party"], @@ -2342,8 +1831,7 @@ export function resolveThirdPartyProviders( return resolved; } -/** `(s *sms) validate()` fixed provider priority — a - * `switch` that validates ONLY the first enabled provider in this order. */ +/** Fixed SMS provider priority — validation stops at the first enabled provider. */ const SMS_PROVIDER_ORDER = [ "twilio", "twilio_verify", @@ -2353,57 +1841,20 @@ const SMS_PROVIDER_ORDER = [ ] as const; /** - * `auth.sms.<provider>.*` is env-bindable like every other nested field once - * `[auth.sms.<provider>]` is present in config.toml, so - * `SUPABASE_AUTH_SMS_<PROVIDER>_ENABLED`/`_<FIELD>` overrides must reach - * GoTrue's actual container env, not - * just validation — same - * "validates but doesn't use" gap already fixed for `auth.hook`/`auth.captcha`/`auth.external`/ - * `auth.mfa`. Hoisted so both {@link validateAuthSmsProviders} and `start.handler.ts`'s - * `resolveGotrueEnvInput` resolve the SAME effective per-provider values — same precedent as - * {@link resolveAuthExternalProviders}. - * - * Presence-gated per provider (the raw `[auth.sms.<provider>]` table must exist) — same - * gate {@link validateAuthSmsProviders} used before this was hoisted out of it. `twilio` is the - * one exception: the default `config.toml` template unconditionally emits an UNCOMMENTED - * `[auth.sms.twilio]` table, merged in before the user's own file — so `auth.sms.twilio.*` - * is always registered and `SUPABASE_AUTH_SMS_TWILIO_*` overrides apply with no - * user-declared table, confirmed empirically. The other 4 providers get - * no default template entry at all (not even commented out), so they keep the presence gate. When - * a (non-twilio) provider's table is absent, its decoded (schema-default) values pass through - * unchanged, still decrypting - * the one secret-typed field per provider for - * parity with this function's (now-superseded) `resolveGotrueSms` precursor, which decrypted all - * 5 providers unconditionally. - * - * The 4 top-level scalars (`enable_signup`/`enable_confirmations`/`template`/`max_frequency`) - * get NO presence gate, unlike the providers above — they're - * unconditionally emitted (uncommented) in the default ejected config.toml, - * so they're always registered - * regardless of whether the user's own config.toml even has an `[auth.sms]` section, - * same reasoning already applied to `resolveAuthEmail`'s scalars. `test_otp` (a - * `map[string]string`) is deliberately left unresolved: it's commented out of the default - * template, so it's never known by default, and there's no `SUPABASE_AUTH_SMS_TEST_OTP*` - * env var at all — env binding covers static struct fields, not arbitrary map keys. + * Resolves `auth.sms`'s full field set so `SUPABASE_AUTH_SMS_*` overrides reach GoTrue's env, + * not just validation. Presence-gated per provider except `twilio`, whose default template + * always includes an uncommented `[auth.sms.twilio]` table; the 4 top-level scalars have no + * gate for the same reason. `test_otp` (a map) is left unresolved: it has no env var. */ export function resolveAuthSms( authDocument: Readonly<Record<string, unknown>> | undefined, sms: CliConfig["auth"]["sms"], projectEnvValues: Readonly<Record<string, string>> | undefined, /** - * Same remote-over-env precedence as every other gated resolver in this file. Reachable from - * the `db diff --linked`/`db pull` shadow path via `validateAuthSmsProviders`, called - * unconditionally from `resolveLocalConfigValues` whenever `authEnabled` — a prior review - * (PRRT_kwDOErm0O86XFmjZ) rejected this gap as "unreachable," having only grepped direct - * `resolveAuthSms(` call sites in `start.handler.ts`/`db/start/start.handler.ts` and - * missed this file's own `validateAuthSmsProviders` wrapper. `enable_signup`/ - * `enable_confirmations`/each provider's `enabled` THROW via `envOverrideBool`, and each - * provider's secret-typed field (`auth_token`/`access_key`/`api_key`/`api_secret`) - * THROWS via `decryptAuthSecret` — either can abort - * this whole call (and the shadow it feeds) on a malformed ambient `SUPABASE_AUTH_SMS_*` - * override even when a matched remote block already set that field at override tier. - * Defaults to empty for `start.handler.ts`/`db/start/start.handler.ts`'s callers, which never - * resolve a `[remotes.<ref>]` block for this config read. + * Fields whose `envOverrideBool`/`decryptAuthSecret` calls below can throw on a malformed + * value belong in this set so a matched remote block's value wins instead of aborting on an + * unrelated bad env var. Reachable from the `db diff --linked`/`db pull` shadow path via + * `validateAuthSmsProviders`, not just this function's direct callers. */ remoteOverrideKeys: ReadonlySet<string> = new Set(), ): CliConfig["auth"]["sms"] { @@ -2430,12 +1881,8 @@ export function resolveAuthSms( ); } - // `remoteOverrideKey` is passed in explicitly (rather than reconstructed from - // `providerName`/`field` internally, the way `resolveEnabled` does for the fixed `.enabled` - // suffix) because `field` here ranges over a different, non-uniform set per provider — a - // reconstructed template type would have to admit every provider × field combination, most of - // which aren't real config keys, defeating the point of typing this against - // `RemoteOverridableKey` at all. + // `remoteOverrideKey` is explicit (not reconstructed from `providerName`/`field`) since + // `field`'s shape differs per provider, and not every combination is a real config key. function resolveField( providerName: (typeof SMS_PROVIDER_ORDER)[number], field: string, @@ -2484,10 +1931,7 @@ export function resolveAuthSms( return { ...sms, - // `(s *sms) validate()` takes the `case s.EnableSignup:` switch - // branch — reached only when every named provider above is disabled — and mutates - // `EnableSignup = false` before `buildGotrueEnv` ever reads it, so phone signup is never - // enabled with no provider configured to actually deliver an OTP. + // Phone signup is never enabled when no provider is configured to deliver an OTP. enable_signup: anyProviderEnabled ? enableSignupConfigured : false, enable_confirmations: remoteWins("auth.sms.enable_confirmations") ? sms.enable_confirmations @@ -2593,11 +2037,9 @@ export function resolveAuthSms( } /** - * `(s *sms) validate()`: a boolean `switch` that inspects - * providers in the FIXED priority order above and validates ONLY the first one whose `enabled` is - * true — a later enabled-but-incomplete provider is never even looked at. Runs against - * {@link resolveAuthSms}'s env-override-aware result, same document-based, post-override - * pattern as {@link validateAuthExternalProviders} below. + * Validates only the first enabled provider in {@link SMS_PROVIDER_ORDER}; a later + * enabled-but-incomplete provider is never checked. Runs against {@link resolveAuthSms}'s + * env-override-aware result. */ function validateAuthSmsProviders( authDocument: Record<string, unknown> | undefined, @@ -2649,13 +2091,10 @@ function validateAuthSmsProviders( } } -/** `external.validate()` deprecated-provider skip — `linkedin`/ - * `slack` are deleted (and warned on, if enabled) before the required-field loop runs, so they - * are never validated here. Mirrors `db-config.toml-read.ts`'s identical "B5: external - * providers" skip list. */ +/** Deleted external providers, warned on if still enabled, never validated. */ const DEPRECATED_EXTERNAL_PROVIDERS = new Set(["linkedin", "slack"]); -/** Matches `GotrueExternalProviderInput` (`start/services/gotrue.service.ts`) field-for-field — kept as its own type here rather than importing that command-specific one, same precedent as {@link ResolvedAuthHooks}. */ +/** Matches `GotrueExternalProviderInput`'s fields, kept separate to avoid a command-specific import. */ export interface ResolvedAuthExternalProvider { readonly enabled: boolean; readonly clientId: string; @@ -2667,57 +2106,11 @@ export interface ResolvedAuthExternalProvider { } /** - * `appendGotrueExternalProviderEnv` presence-filtering: `auth.external` only - * ever contains the providers a user's `config.toml` actually mentions, but - * `@supabase/config`'s schema always decodes a fixed set of ~19 known - * providers, each defaulting `enabled: false` regardless of TOML presence — so - * presence must be read from the raw document, same approach - * {@link validateAuthExternalProviders} below uses. `apple` is the one - * exception, unioned into the iterated provider set unconditionally — see the - * inline comment at the iteration below for why. - * - * `auth.external.<name>.*` is env-bindable like every other nested field once - * `[auth.external.<name>]` is present in config.toml, so - * `SUPABASE_AUTH_EXTERNAL_<NAME>_*` overrides apply before - * `appendGotrueExternalProviderEnv` runs — there is - * no separate "raw" vs. "effective" provider value, so this must be reflected - * in GoTrue's actual container env, not just validation. - * - * Hoisted (like {@link resolveAuthHooks}/{@link resolveAuthCaptcha}) - * so both {@link validateAuthExternalProviders} (which derives its - * enabled/client_id/secret checks from this same unfiltered result) and - * `start.handler.ts`'s `resolveGotrueEnvInput` (the actual GoTrue env) resolve - * the SAME effective values. - */ -/** - * ANY unmodeled/raw-document boolean field — a custom external-provider `enabled` entry, - * `auth.passkey.enabled`, etc. — must decode with the same weak-bool coercion as a - * schema-modeled field: an `env(VAR)`-substituted string decodes the same as a raw one. - * `@supabase/config` has no schema at all for `auth.passkey`/`auth.webauthn`, and only - * recognizes the ~19 known provider ids for `auth.external` (`packages/config/src/auth/ - * providers.ts`) — so for any of these unmodeled paths, the pre-decode `env(...)` walker - * substitutes the env value but skips type coercion (no schema AST at that path — - * `packages/config/src/lib/env.ts:308-314`), leaving e.g. `enabled = "env(CUSTOM_OAUTH_ENABLED)"` - * as the literal string `"true"`/`"false"` instead of a real boolean. A native TOML `true`/`false` - * literal still decodes to an actual `boolean` even for an unmodeled key (only `env(...)` - * substitution is schema-blind), so this must accept both. Used by - * {@link resolveAuthExternalProviders} below AND by - * {@link resolveGotruePasskeyWebauthn}/this file's own passkey-validation read, since both - * are unmodeled-document reads of the identical shape. - * - * An unparsable STRING (e.g. a typo, or a still-literal `"env(VAR)"` when the referenced var was - * never set) is a hard config-load failure, not a silent `false`, same as - * {@link envOverrideBool}'s identical treatment for - * schema-modeled bool fields. Silently defaulting to `false` here would both misreport a broken - * config as "section disabled" AND skip the required-field validation an enabled section should - * trigger. An absent value (`undefined` — key genuinely not present) is NOT an error: that's the - * zero-value bool default, unchanged. - * - * A raw NUMBER for a `bool` field is NOT an error either — it weakly coerces via a - * truthiness check (int/uint/float `!= 0`), e.g. `enabled = 123` decodes - * as `true`, `enabled = 0` as `false`. Only a genuinely unconvertible type — an array or inline - * table (TOML's only other value kinds) — is a hard failure regardless of the weak-typing rule. So - * this function must weakly-coerce a JS `number` the same way, and only throw for anything else. + * Weakly coerces an unmodeled raw-document value (no `@supabase/config` schema, e.g. custom + * `auth.external` providers, `auth.passkey`/`auth.webauthn`) to a bool, since an `env(VAR)` + * substitution there skips normal type coercion and leaves a literal `"true"`/`"false"` + * string. A number coerces via truthiness (`!= 0`); an unparsable string or any other type + * throws rather than silently defaulting to `false`. */ export function rawUnmodeledBool(value: unknown, dottedFieldPath: string): boolean { if (value === undefined) return false; @@ -2734,36 +2127,30 @@ export function rawUnmodeledBool(value: unknown, dottedFieldPath: string): boole } /** - * `strToArr`: empty string → `[]`, else a plain comma-split, no - * trimming — the same semantic `mapstructure.StringToSliceHookFunc(",")` applies unconditionally to - * every `[]string`-typed config field during decode, so a raw or - * `env(VAR)`-resolved string destined for a slice field (e.g. `auth.webauthn.rp_origins`, which - * `@supabase/config` has no schema for at all) must be split the same way, not just accepted when - * it's already a JS array. + * Comma-splits a string into an array (empty string → `[]`), the same rule config decode + * applies to a raw or `env(VAR)`-resolved value destined for an unmodeled `[]string` field + * (e.g. `auth.webauthn.rp_origins`). */ export function strToArr(value: string): Array<string> { return value.length === 0 ? [] : value.split(","); } +/** + * Resolves `auth.external.<name>` overrides, iterating the raw document's provider names + * (not just the schema's fixed ~19) since custom providers decode with no schema at all. + * `apple` is always included, since the default config.toml template registers it + * uncommented. Hoisted so both {@link validateAuthExternalProviders} and `start.handler.ts`'s + * GoTrue env resolution share this same result. + */ export function resolveAuthExternalProviders( authDocument: Readonly<Record<string, unknown>> | undefined, external: CliConfig["auth"]["external"], projectEnvValues: Readonly<Record<string, string>> | undefined, /** - * Same remote-over-env precedence as every other gated resolver in this file — - * `auth.external.<name>.*` leaves are tracked dynamically in `applyRemoteOverride` - * (`db-config.toml-read.ts`), not via a fixed `ENV_OVERRIDABLE_KEYS` entry, - * since provider names are an arbitrary/custom-keyed map (see this function's own doc comment - * above). `enabled`/`skip_nonce_check`/`email_optional` THROW via `envOverrideBool` and - * `secret` THROWS via `decryptAuthSecret` on a - * malformed override even when a matched remote block already set that field, which would abort - * the whole caller (`resolveLocalConfigValues`, and the shadow it feeds) on a value the - * override tier should otherwise win; `client_id`/`url`/`redirect_uri` can't throw the - * same way, but leaving them ungated is still a precedence bug — a remote's valid value must - * beat a stale `SUPABASE_AUTH_EXTERNAL_<NAME>_*` env var, same reasoning as - * `resolveAuthHooks`'s `uri`/`secrets` (review: PRRT_kwDOErm0O86XKYiF). Defaults to empty - * for `start.handler.ts`/`db/start/start.handler.ts`'s callers, which never resolve a - * `[remotes.<ref>]` block for this config read. + * Fields whose `envOverrideBool`/`decryptAuthSecret` calls below can throw on a malformed + * value belong in this set so a matched remote block's value wins instead of aborting on an + * unrelated bad env var. Provider names are dynamic, so these keys are tracked via + * `applyRemoteOverride` rather than a fixed `ENV_OVERRIDABLE_KEYS` entry. */ remoteOverrideKeys: ReadonlySet<string> = new Set(), ): Record<string, ResolvedAuthExternalProvider> { @@ -2772,15 +2159,8 @@ export function resolveAuthExternalProviders( const result: Record<string, ResolvedAuthExternalProvider> = {}; const decodedProviders = new Map(Object.entries(external)); - // Iterate the RAW document's keys, not `Object.entries(external)` — see this - // function's doc comment above for why (unmodeled/custom provider names). - // `apple` is unioned in unconditionally: the default ejected config.toml - // unconditionally emits an UNCOMMENTED `[auth.external.apple]` table, merged in - // before the user's own file — so `auth.external.apple.*` is always registered and - // `SUPABASE_AUTH_EXTERNAL_APPLE_*` overrides apply with no user-declared table, - // confirmed empirically. Every other provider has no default - // template entry (just named in a comment), so they keep the raw-document - // presence gate below. + // Iterates the raw document's keys (not `Object.entries(external)`) to catch custom + // provider names; `apple` is unioned in since the default template always registers it. const providerNames = new Set([...Object.keys(externalDoc ?? {}), "apple"]); for (const name of providerNames) { if (DEPRECATED_EXTERNAL_PROVIDERS.has(name)) continue; @@ -2857,30 +2237,10 @@ export function resolveAuthExternalProviders( } /** - * `(e external) validate()` — D-only per - * `config-validate.ts`'s module header ("`auth.external` ... stays 100% inline in D"), so - * this ports the identical inline block D already has (`db-config.toml-read.ts`'s "B5: - * external providers") to close the same gap for L. `auth.external` is a genuine Go - * `map[string]provider`, so an arbitrary/unmodeled - * provider name (e.g. `[auth.external.custom]`) is a legitimate config shape — Go validates - * every enabled entry regardless of name. `@supabase/config`'s `external` schema only models the - * ~20 known provider ids and silently drops anything else at decode time - * (`packages/config/src/auth/providers.ts`), so an unmodeled provider's required-field check - * must run against the RAW `authDocument` instead of the decoded `CliConfig` — same - * document-based approach as {@link readAuthEmailTemplateContent}/the passkey/smtp checks above. - * Known providers are already covered by the schema's own `requiredWhenEnabled` check at decode - * time, so in practice this only ever fires for a name the schema doesn't model, but it runs - * over every raw key unconditionally, matching Go's own map iteration rather than special-casing - * "unknown" a different way. `authDocument`'s values are already post-`env()`-interpolation (see - * `LoadedCliConfig.document`), so no `expandEnv`-style resolution is needed here, - * unlike D's raw pre-interpolation document. - * - * `auth.external.<name>.*` is env-bindable like every other nested field once - * `[auth.external.<name>]` is present in config.toml, so - * `SUPABASE_AUTH_EXTERNAL_<NAME>_ENABLED`/`_CLIENT_ID`/`_SECRET` - * overrides apply before this validation runs — same gap this schema's own `requiredWhenEnabled` - * check has for KNOWN providers too (that check only sees the decoded, pre-override TOML value), - * so this now covers both known and unmodeled provider names uniformly. + * Validates required fields for every enabled `auth.external.<name>` provider, including + * custom names `@supabase/config`'s schema silently drops at decode time. Runs against + * {@link resolveAuthExternalProviders}'s env-override-aware result, since the schema's own + * `requiredWhenEnabled` check only sees the pre-override, known-providers-only value. */ function validateAuthExternalProviders( authDocument: Record<string, unknown> | undefined, @@ -2888,9 +2248,6 @@ function validateAuthExternalProviders( projectEnvValues: Readonly<Record<string, string>> | undefined, remoteOverrideKeys: ReadonlySet<string> = new Set(), ): void { - // Derived from `resolveAuthExternalProviders`'s unfiltered result so this validation - // path and `start.handler.ts`'s GoTrue env builder can't drift — same precedent as - // `resolveAuthHooks`'s validation caller above. const resolved = resolveAuthExternalProviders( authDocument, external, @@ -2917,48 +2274,26 @@ function validateAuthExternalProviders( } /** - * @throws when `project_id` (post-override, post-workdir-basename-fallback) is - * an explicit empty string. This is checked FIRST, before - * every other field: the sanitized workdir basename is merged in as a - * default value BEFORE `config.toml` is merged — so `project_id` is NEVER - * empty by the time validation runs; it's always at least this sanitized - * basename. A workdir whose basename sanitizes to the empty string (e.g. - * `!!!`) therefore fails config loading even with NO `project_id` key in the - * file at all. An explicit `project_id = ""` IN the file overwrites that - * default with the literal empty string the same way (rather than being - * treated as absent) — this fails outright rather than falling back to the - * basename either way. - * `sanitizeProjectId` is only applied to the BASENAME fallback here, - * matching the pre-sanitized default — an explicit non-empty - * `config.project_id`/`SUPABASE_PROJECT_ID` value is intentionally NOT - * re-sanitized at this point; the auto-fix branch is a WARN-only rewrite with - * no throwing equivalent, same precedent as this module's other WARN-only - * omissions (`auth.captcha.secret`/`assertEnvLoaded`, SMS's `EnableSignup` case). + * @throws when `project_id` (post-override, post-workdir-basename-fallback) is an explicit + * empty string. Checked first: the sanitized workdir basename is merged in as a default + * before `config.toml`, so `project_id` is never empty by the time validation runs — a + * workdir whose basename sanitizes to `""` fails even with no `project_id` key at all. * @throws {InvalidJwtSecretError} when `auth.jwt_secret` is set but too short. - * @throws {InvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv - * override doesn't parse as a valid port. - * @throws {InvalidBoolEnvOverrideError} when a `SUPABASE_*_ENABLED` env/dotenv - * override doesn't parse as a valid bool. + * @throws {InvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv override doesn't + * parse as a valid port. + * @throws {InvalidBoolEnvOverrideError} when a `SUPABASE_*_ENABLED` env/dotenv override + * doesn't parse as a valid bool. * @throws when a configured `api.tls` cert/key file can't be read — see - * {@link readApiTlsFiles}. The "exactly one of cert/key set" presence check - * runs later, as part of {@link validateResolvedConfig}. + * {@link readApiTlsFiles}. * @throws when `auth.signing_keys_path` is set, auth is enabled, and the file is missing, * malformed, or its first key uses an unsupported algorithm — see * {@link resolveConfiguredSigningKeys} and {@link generateAsymmetricGoJwt}. * @throws when an email template's `content` is present without `content_path`, or a * configured `content_path` file can't be read — see {@link readAuthEmailTemplateContent}. - * @throws {InvalidAnalyticsBackendEnvOverrideError} when `SUPABASE_ANALYTICS_BACKEND` - * doesn't parse as one of `LogflareBackend` values. - * @throws {ConfigValidateError} for every other `Config.Validate` branch this module - * and `config-validate.ts` jointly own — project_id emptiness aside (checked above, - * inline, since the value is also needed for the throw's own message-free early-exit shape), - * every REMAINING pure check (api.port/tls presence, db.port/major_version, storage bucket - * names, studio, local_smtp, auth.site_url/captcha/passkey/hooks/mfa/smtp/third_party, - * function slugs, edge_runtime.deno_version, analytics.gcp_*, experimental.*) is deferred to a - * SINGLE call to {@link validateResolvedConfig} at the end of this function, in Go's exact - * relative order — see that module's header for the full table and the accepted ordering - * tradeoff this introduces against the I/O checks listed above (which keep running at their - * original position, per-caller, rather than being folded into that single call). + * @throws {InvalidAnalyticsBackendEnvOverrideError} when `SUPABASE_ANALYTICS_BACKEND` doesn't + * parse as one of its accepted values. + * @throws {ConfigValidateError} for every other validation branch, deferred to a single call + * to {@link validateResolvedConfig} at the end of this function. */ export function resolveLocalConfigValues( config: CliConfig, @@ -2966,119 +2301,33 @@ export function resolveLocalConfigValues( workdir: string, projectEnvValues?: Readonly<Record<string, string>>, /** - * `LoadedCliConfig.document` (`packages/config/src/io.ts`) — the raw, - * pre-schema-default TOML document `config` was decoded from. Lets checks - * that hinge on TOML-section PRESENCE (not the decoded, always-defaulted - * value) inspect the file directly — see `validateResolvedConfig`'s - * `experimental.webhooks`/`auth.passkey`/`auth.email.smtp` steps. - * `undefined` for callers that haven't threaded it through yet (e.g. most - * existing unit tests); those checks are then simply skipped rather than - * guessed at. + * The raw, pre-schema-default TOML document `config` was decoded from, letting checks that + * hinge on section presence (not the always-defaulted decoded value) inspect the file + * directly. `undefined` callers simply skip those checks. */ document?: Readonly<Record<string, unknown>>, /** - * Config keys a matched `[remotes.<ref>]` block contributed at override tier (applied ABOVE - * the ambient env tier) — see - * `db-config.toml-read.ts`'s `RemoteOverride.remoteOverrideKeys` doc comment for - * the full precedence rationale. Every `envOverride*` call below that resolves a field - * this function's shadow-consuming caller (`buildLocalDbContainerInputs`) actually - * threads onward (`dbPort`/`rootKey`/`jwtSecret`/`authJwtExpiry`/`authSiteUrl`/`anonKey`/ - * `serviceRoleKey`, plus `apiUrl`'s own `api.port`/`api.tls.enabled`/`api.external_url` - * inputs, plus `signingKeysPath`'s `auth.signing_keys_path` gate feeding the `signingKey` that - * signs `anonKey`/`serviceRoleKey` — review: PRRT_kwDOErm0O86W3Ox_) must NOT re-apply a - * `SUPABASE_*` value for a field the remote block already set — same gate - * `resolveDbBootstrapConfig`/`resolveDbSettingsEnvOverrides` already apply for the - * OTHER shadow-bootstrap fields (review: PRRT_kwDOErm0O86W2tRi, following on from - * PRRT_kwDOErm0O86W2LL4's fix to those two). Defaults to empty: `db start`/`db reset`/ - * `status`/`stop` never resolve a remote block for this config read (they never pass a - * `projectRef`), so they are unaffected. `api.enabled`/`auth.enabled`/ - * `edge_runtime.deno_version`/`analytics.enabled`/`analytics.backend`/every - * `auth.third_party.*.enabled` are ALSO gated below even though their resolved values are - * never part of the returned `LocalConfigValues` — each one's `envOverride*` call - * THROWS on a malformed override - * (`envOverrideBool`/`envOverrideDenoVersion`/`envOverrideAnalyticsBackend`) - * even when the remote block already set that field, which would abort this entire function - * (and every field it DOES return) on an env value the override tier should silently ignore (review: - * PRRT_kwDOErm0O86W30n6 for `auth.enabled`/`analytics.*`, PRRT_kwDOErm0O86W4gCk for - * `edge_runtime.deno_version`, PRRT_kwDOErm0O86W5UlV for `api.enabled`) — "not read by the - * caller" is not the same as "cannot abort the caller." An earlier version of this comment - * claimed the remaining `studio`/`local_smtp`/`passkey`/`mfa`/hooks/`captcha`/`auth.email.smtp`/ - * `experimental.webhooks`/the auth `enable_signup`/`enable_anonymous_sign_ins`/refresh-token/ - * manual-linking/password-length/-requirements group could stay ungated because their own - * `envOverride*` calls "cannot throw before a value the caller needs has already been - * resolved" — that reasoning doesn't hold: this function is a single synchronous call that - * either returns its whole object or throws, so ANY unconditional throw anywhere in its body - * aborts the entire call and denies the shadow every field, including ones already computed as - * local variables earlier in the function — textual position relative to a caller-needed field - * is irrelevant. All of those fields are now gated the same way as `api.enabled` above and - * tracked in `ENV_OVERRIDABLE_KEYS` (review: PRRT_kwDOErm0O86W6R-G). An earlier version - * of this comment also claimed `jwtIssuer`/`additionalRedirectUrls`/the mfa phone factor's - * `template`/`max_frequency`/the webauthn `rp_id`/`rp_origins`/the sms `template`/`max_frequency`/ - * the GCP analytics fields could stay ungated because their own reads genuinely cannot throw — - * that reasoning doesn't hold either: a non-throwing read is still a precedence bug when a - * matched remote's own value loses to a stale/differently-scoped ambient env var, same "cannot - * throw" vs. "no Go-observable consequence" distinction already drawn for `auth.external.*`'s - * `client_id`/`url`/`redirect_uri` above. All of those are now gated too and tracked in - * `ENV_OVERRIDABLE_KEYS`. `studioApiUrl` is gated for a related but distinct reason - * (below) — a "non-throwing read, throwing downstream - * consumer" case like the third_party required fields just below: `goUrlParse` inside - * `validateResolvedConfig` throws on a malformed URL even though `envOverride` - * itself never does (review: PRRT_kwDOErm0O86XKYiF's sibling gap). `studio.openai_api_key`/ - * `auth.publishable_key`/`auth.secret_key` are `config.Secret`-typed exactly like `anon_key`/ - * `service_role_key` below and are now gated the same way, having been missed when that pair - * was fixed. `auth.sms.*` (`resolveAuthSms`, reached via `validateAuthSmsProviders` - * below) and `auth.external.*` (`resolveAuthExternalProviders`, reached via - * `validateAuthExternalProviders` below) are threaded through and gated in their own resolvers - * now too — see those functions' own doc comments (review: PRRT_kwDOErm0O86XFmjZ, - * PRRT_kwDOErm0O86XKYiF). This function's OWN validation-only `thirdParty` - * block's non-`enabled` leaves (`requiredField`/`cognitoUserPoolRegion`) are gated too, despite - * `envOverride` itself never throwing: each provider's per-field validation - * (domain/tenant/user_pool_id/issuer_url emptiness, plus Clerk's domain - * regex) runs inside the single {@link validateResolvedConfig} call below, so an - * ungated read that picks up a stale/differently-invalid env override over a remote's own - * valid value can flip that provider's validation verdict — accepting a config Go would - * reject, or (as the reported case) rejecting one Go would accept — even though nothing - * actually throws during resolution itself (review: PRRT_kwDOErm0O86W93Ex). "Cannot throw" and - * "has no Go-observable failure mode" are different properties; this block has the former but - * not the latter. This is NOT the same `third_party` as {@link resolveLocalJwks}'s/ - * {@link resolveConfiguredSigningKeys}'s own, SEPARATE third-party/signing-keys - * resolution, which DOES feed the shadow's JWKS document and IS gated (see those functions' - * own doc comments). + * Config keys a matched `[remotes.<ref>]` block set at override tier. Every field whose + * `envOverride*` call (or a downstream {@link validateResolvedConfig} check) can throw on a + * malformed value belongs in this set — this function returns its whole object or throws, so + * an unconditional throw anywhere aborts the whole call, even for a field not part of the + * returned `LocalConfigValues`. Defaults to empty for callers with no `[remotes.<ref>]` block. */ remoteOverrideKeys: ReadonlySet<string> = new Set(), /** - * `Eject` default: `flags.LoadConfig` - * pre-sets `Config.ProjectId` to the resolved `--project-ref`/linked project - * ref BEFORE merging the file, so `Eject`'s own basename fallback only - * triggers when that default is itself empty. `undefined` for `status`/ - * `stop`, which have no such flag and fall straight to the basename, same - * as before this parameter existed. + * Default `project_id` supplied by `--project-ref`/the linked project ref before the + * basename fallback applies. `undefined` for `status`/`stop`, which have no such flag. */ projectIdFallback?: string, ): LocalConfigValues { const remoteWins = makeRemoteWins(remoteOverrideKeys); - // `Config.Validate` checks `ProjectId` FIRST, before every other field — - // see this function's `@throws` doc above - // for why a workdir basename that sanitizes to `""` fails here even when - // `project_id` is absent from the file entirely. `config.project_id` is - // `undefined` only when the key is genuinely absent (`optionalKey`, see - // `packages/config/src/base.ts`) — that's the ONE case where Go's own - // sanitized-basename-or-`projectIdFallback` viper default shows through - // instead of a file value, so the fallback belongs here, not as a third - // branch after `envOverride`. - // `SUPABASE_PROJECT_ID` is checked via the same `envOverride` precedence - // every other field here uses, since Viper's `AutomaticEnv` binds it too - // and it can turn an explicit-empty file value (or an - // unsanitizable basename fallback) back into a valid override. Deliberately NOT - // gated by `remoteWins("project_id")` (unlike the fields below): the ONLY consumer - // of this value is `validateResolvedConfig`'s emptiness check - // (`config-validate.ts:336`), and `envOverride` (a plain, non-throwing - // string read) can never turn an already non-empty remote-merged `project_id` into - // an empty one, nor vice versa — so gating here would change no observable - // accept/reject outcome. The real "shadow's network id/labels resolve the wrong - // project id" bug this pattern otherwise guards against lives in - // `local-project-context.ts`'s OWN, separately-consumed project id (see its - // doc comment — review: PRRT_kwDOErm0O86XHGDL), not this validation-only field. + // Checked first, before every other field: `config.project_id` is `undefined` only when the + // key is absent, in which case the sanitized workdir basename (or + // `projectIdFallback`) applies instead of a file value. + // + // Not gated by `remoteWins("project_id")` like the fields below: `envOverride` never turns + // an already non-empty remote-merged value empty or vice versa, so gating here couldn't + // change the accept/reject outcome. const resolvedProjectId = envOverride( "SUPABASE_PROJECT_ID", config.project_id ?? @@ -3088,17 +2337,6 @@ export function resolveLocalConfigValues( projectEnvValues, ); - // `status` reads `utils.Config.Api.Port`/`ExternalUrl`/`Tls.Enabled` - // after Viper's AutomaticEnv has already applied any `SUPABASE_API_PORT`/ - // `SUPABASE_API_EXTERNAL_URL`/`SUPABASE_API_TLS_ENABLED` override, - // so the values fed into - // `resolveApiExternalUrl`'s own `external_url`-wins-else- - // `scheme://host:port` derivation (which picks `https` vs `http` from - // `tls.enabled`) must be the overridden ones too. - // A matched remote block's `api.tls.enabled` was installed at viper's OVERRIDE tier (above - // `AutomaticEnv`), so it must win over a conflicting `SUPABASE_API_TLS_ENABLED` — this field - // reaches `apiUrl`/`restUrl`/etc, which the shadow's own `db diff --linked`/`db pull` setup - // input consumes (`buildLocalDbContainerInputs`). const apiTlsEnabled = remoteWins("api.tls.enabled") ? config.api.tls.enabled : envOverrideBool( @@ -3107,30 +2345,11 @@ export function resolveLocalConfigValues( "api.tls.enabled", projectEnvValues, ); - // Go's TLS cert/key validation nests entirely inside `if c.Api.Enabled` — - // mirroring `authEnabled` below, gate on the - // POST-`SUPABASE_API_ENABLED`-override value, not raw `config.api.enabled`. - // Same remote-over-env precedence as `apiTlsEnabled`/`apiPort` above and `authEnabled` below — - // `api.enabled` is now in `ENV_OVERRIDABLE_KEYS` (`db-config.toml-read.ts`) - // and that reader's own resolver already gates it (`blockProvidesKey(block, - // "api.enabled")`); this resolver must match, since an ungated `envOverrideBool` call - // THROWS on a malformed `SUPABASE_API_ENABLED` even when a matched remote block already set - // `api.enabled` at viper's OVERRIDE tier — a value `Validate` never even evaluates the - // env var for in that case — which would otherwise abort this whole function (and the shadow - // it feeds via `buildLocalDbContainerInputs`, denying it `apiPort`/`apiUrl`/`dbPort`/ - // `rootKey`/etc.) on an env value the override tier should silently ignore. `apiEnabled`'s own resolved value is - // never part of the returned `LocalConfigValues` — same "throws before caller-needed - // fields are resolved" rationale as `authEnabled`/`analytics.*`/`edge_runtime.deno_version` - // below, not the "value is consumed downstream" rationale `apiTlsEnabled`/`apiPort` above have. + // TLS cert/key validation only runs while `api.enabled`, so this gates on the post-override + // value, not raw `config.api.enabled`. const apiEnabled = remoteWins("api.enabled") ? config.api.enabled : envOverrideBool("SUPABASE_API_ENABLED", config.api.enabled, "api.enabled", projectEnvValues); - // Same remote-over-env precedence as `apiTlsEnabled`/`apiPort` above: a matched remote - // block's `api.tls.cert_path`/`key_path` were installed at viper's OVERRIDE tier (above - // `AutomaticEnv`), so they must win over a conflicting `SUPABASE_API_TLS_CERT_PATH`/ - // `SUPABASE_API_TLS_KEY_PATH` — otherwise a stale/missing ambient env path can fail - // `readApiTlsFiles` below even though the remote block already supplied a valid path - // Go would actually use (review: PRRT_kwDOErm0O86W8ZYk). const apiTlsCertPath = remoteWins("api.tls.cert_path") ? config.api.tls.cert_path : envOverride("SUPABASE_API_TLS_CERT_PATH", config.api.tls.cert_path, projectEnvValues); @@ -3140,18 +2359,13 @@ export function resolveLocalConfigValues( if (apiEnabled && apiTlsEnabled) { readApiTlsFiles(workdir, apiTlsCertPath, apiTlsKeyPath); } - // `Config.Validate` rejects `api.port === 0`/`SUPABASE_API_PORT=0` ONLY - // when `api.enabled` — unlike `db.port` - // below, which has no `enabled` gate. Resolved once into a named const so the - // check and the URL derivation below share the same overridden value instead - // of calling `envOverridePort` twice. - // Same remote-over-env precedence as `apiTlsEnabled` above. + // `api.port === 0` is rejected only when `api.enabled`, unlike `db.port` below. Resolved once + // so the check and the URL derivation below share the same overridden value. const apiPort = remoteWins("api.port") ? config.api.port : envOverridePort("SUPABASE_API_PORT", config.api.port, "api.port", projectEnvValues); const apiExternalUrl = resolveApiExternalUrl( { - // Same remote-over-env precedence as `apiTlsEnabled`/`apiPort` above. external_url: remoteWins("api.external_url") ? config.api.external_url : envOverride("SUPABASE_API_EXTERNAL_URL", config.api.external_url, projectEnvValues), @@ -3160,45 +2374,21 @@ export function resolveLocalConfigValues( }, hostname, ); - // Unlike `api.port`/`studio.port`/`local_smtp.port` below, `db.port` has no - // `enabled` gate in `Config.Validate` — it's unconditionally required, - // and a decoded `0` (e.g. `SUPABASE_DB_PORT=0`) fails validation with this - // exact message before `status`/`stop` - // render anything, same wording already used for the `db query`/`test db` - // path (`db-config.toml-read.ts:1380`). - // Same remote-over-env precedence as `apiPort`/`apiTlsEnabled` above — `dbPort` also reaches - // `dbUrl`, consumed by the shadow's own `db diff --linked`/`db pull` setup input. + // Unlike `api.port`/`studio.port`/`local_smtp.port` below, `db.port` has no `enabled` gate — + // it's unconditionally required, and a decoded `0` fails validation. const dbPort = remoteWins("db.port") ? config.db.port : envOverridePort("SUPABASE_DB_PORT", config.db.port, "db.port", projectEnvValues); - // `Config.Validate` checks `db.major_version` right after `db.port`, - // unconditionally (no `enabled` gate). Validate-only here - // (this function's return type has no `majorVersion` field — the shadow's own resolved value - // comes from `resolveDbBootstrapConfig`, which already gates it) — but a matched - // remote's `db.major_version` must still suppress a conflicting `SUPABASE_DB_MAJOR_VERSION` - // here too, otherwise a malformed env value the remote block should have made irrelevant - // fails this validate-only read outright before the (correctly gated) real value is ever - // reached (review: PRRT_kwDOErm0O86W2tRi). + // Validate-only: the shadow's own resolved `majorVersion` comes from + // `resolveDbBootstrapConfig`, which gates it separately. const majorVersion = remoteWins("db.major_version") ? config.db.major_version : envOverrideMajorVersion(config.db.major_version, projectEnvValues); - // Config load applies every `SUPABASE_DB_SETTINGS_*` override unconditionally, - // BEFORE `start`/`status`/`stop` do anything else — so a malformed override must fail here, the same - // point `majorVersion`/`denoVersion`/`orioledbVersion` are already validated, not deep inside - // `start.handler.ts`'s `bringUp` after Postgres may already be created. Validate-only: the - // actual resolved settings `start` needs are recomputed at their own call site (same - // "validate early, recompute at point of use" split already used for those three fields). - // `remoteOverrideKeys` threaded through so a matched remote's `db.settings.*` value doesn't - // fail this validate-only read the same way `majorVersion` above doesn't. + // Validate-only: the actual resolved settings `start` needs are recomputed at their own call + // site. resolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues, remoteOverrideKeys); - // Same gap for `db.network_restrictions.enabled` — `[db.network_restrictions]` ships - // uncommented in the default template (unlike the commented-out `[db.ssl_enforcement]`) and - // `network_restrictions` is a plain, always-registered field, so a malformed - // `SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED` override decodes - // unconditionally — same bucket as `db.port`/`db.major_version` above, not - // the presence-gated `db.ssl_enforcement`/`auth.sms.twilio`/`auth.external.apple` cases. - // Validate-only: `start` doesn't otherwise consume this field (only `config push` does). Same - // remote-over-env precedence as `majorVersion` above. + // Validate-only: `start` doesn't otherwise consume `db.network_restrictions.enabled` (only + // `config push` does). if (!remoteWins("db.network_restrictions.enabled")) { envOverrideBool( "SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED", @@ -3207,28 +2397,15 @@ export function resolveLocalConfigValues( projectEnvValues, ); } - // `db.root_key` isn't modeled in `@supabase/config`'s schema (every other - // `db.*` field is), so it's read off the raw pre-schema document — same - // presence-based pattern as `authDocument` below. The - // default-or-configured, decrypted-if-`encrypted:` value is written verbatim into - // `/etc/postgresql-custom/pgsodium_root.key` on every start, - // going through the same secret-decrypt step every other secret field gets. + // `db.root_key` isn't modeled in `@supabase/config`'s schema, so it's read off the raw + // pre-schema document. The resolved, decrypted-if-`encrypted:` value is written verbatim into + // `/etc/postgresql-custom/pgsodium_root.key` on every start. const rawRootKeyValue = asRecord(document?.["db"])?.["root_key"]; - // The secret decrypt step only intercepts a STRING source value; - // any other raw TOML kind (integer, bool, array, ...) - // falls through untouched, and decoding that scalar into a secret-shaped struct is - // then rejected with exactly this message — same "decoding failed due to the following - // error(s):" wrapper already used for `auth.captcha.provider`/`analytics.backend` above. if (rawRootKeyValue !== undefined && typeof rawRootKeyValue !== "string") { throw new ConfigValidateError( "failed to parse config: decoding failed due to the following error(s):\n\n'db.root_key' expected a map or struct", ); } - // Same remote-over-env precedence as `apiPort`/`dbPort` above — `rootKey` reaches the - // shadow's own Postgres container spec (`buildLocalDbContainerInputs`). `rawRootKeyValue` - // already reflects a matched remote's `db.root_key` (`document` is the remote-merged raw doc — - // see `LoadedCliConfig.document`'s own doc comment), so `remoteWins` here just means - // "don't let a conflicting `SUPABASE_DB_ROOT_KEY` clobber that already-merged value." const rawRootKey = remoteWins("db.root_key") ? rawRootKeyValue : envOverride("SUPABASE_DB_ROOT_KEY", rawRootKeyValue, projectEnvValues); @@ -3236,19 +2413,10 @@ export function resolveLocalConfigValues( rawRootKey === undefined || rawRootKey.length === 0 ? POSTGRES_DEFAULT_ROOT_KEY : (decryptAuthSecret(rawRootKey, projectEnvValues) ?? POSTGRES_DEFAULT_ROOT_KEY); - // `Config.Validate` runs `ValidateBucketName` over every `[storage.buckets.*]` - // key right after `db.major_version`, unconditionally. const storageBucketNames = config.storage.buckets !== undefined ? Object.keys(config.storage.buckets) : []; - // `Config.Validate` rejects `studio.port === 0`/`SUPABASE_STUDIO_PORT=0` - // ONLY when `studio.enabled` — same - // enabled-gated pattern as `api.port` above. - // Same remote-over-env precedence as `apiEnabled`/`apiPort` above — `studio.enabled`/ - // `studio.port` are now in `ENV_OVERRIDABLE_KEYS` (`db-config.toml-read.ts`): - // an ungated `envOverrideBool`/`envOverridePort` call here THROWS on a malformed - // `SUPABASE_STUDIO_ENABLED`/`SUPABASE_STUDIO_PORT` even when a matched remote block already - // set that field at viper's OVERRIDE tier, which would abort this whole function — and the - // shadow it feeds — on an env value the override tier should silently ignore (review: PRRT_kwDOErm0O86W6R-G). + // `studio.port === 0` is rejected only when `studio.enabled`, same enabled-gated pattern as + // `api.port` above. const studioEnabled = remoteWins("studio.enabled") ? config.studio.enabled : envOverrideBool( @@ -3260,30 +2428,15 @@ export function resolveLocalConfigValues( const studioPort = remoteWins("studio.port") ? config.studio.port : envOverridePort("SUPABASE_STUDIO_PORT", config.studio.port, "studio.port", projectEnvValues); - // `Config.Validate` parses `studio.api_url` with `net/url.Parse` right - // after the port check, still inside `if c.Studio.Enabled`. - // `config.studio.api_url` is a required - // (defaulted) field, so `envOverride` can only return `undefined` here if - // that default itself were somehow undefined — the `??` fallback just - // satisfies that generic signature. - // `envOverride` itself never throws, but `studio.api_url` feeds - // `validateResolvedConfig`'s `goUrlParse` check below, which DOES throw on a - // malformed URL — same "non-throwing read, throwing downstream consumer" bug class already - // fixed for `resolveAuthHooks`'s `uri`/`secrets` (review: PRRT_kwDOErm0O86XGTq5). An - // ungated read here can flip that validate() outcome even though nothing in this read itself - // throws, so `studio.api_url` is gated the same way as `studio.enabled`/`studio.port` above. + // `envOverride` itself never throws, but `studio.api_url` feeds `validateResolvedConfig`'s + // URL-parse check below, which does throw on a malformed URL, so this is gated the same way + // as `studio.enabled`/`studio.port` above. const studioApiUrl = remoteWins("studio.api_url") ? config.studio.api_url : (envOverride("SUPABASE_STUDIO_API_URL", config.studio.api_url, projectEnvValues) ?? config.studio.api_url); - // `Config.Validate` rejects `local_smtp.port === 0`/ - // `SUPABASE_LOCAL_SMTP_PORT=0` ONLY when `local_smtp.enabled` — Go's struct - // field is still named `Inbucket` for the `[local_smtp]` TOML section, - // so `local_smtp.enabled` and the - // deprecated `inbucket.enabled` alias are the same underlying flag, not two - // independent ones. - // Same remote-over-env precedence as `studioEnabled`/`studioPort` above — `local_smtp.enabled`/ - // `local_smtp.port` are now in `ENV_OVERRIDABLE_KEYS` for the identical reason. + // `local_smtp.port === 0` is rejected only when `local_smtp.enabled`; the deprecated + // `inbucket.enabled` alias is the same underlying flag, not a second one. const mailpitEnabled = remoteWins("local_smtp.enabled") ? config.local_smtp.enabled : envOverrideBool( @@ -3300,8 +2453,6 @@ export function resolveLocalConfigValues( "local_smtp.port", projectEnvValues, ); - // Same remote-over-env precedence as `apiPort`/`dbPort`/`rootKey` above — `jwtSecret` reaches - // the shadow's own Postgres/fresh-DB-setup spec (`buildLocalDbContainerInputs`). const jwtSecret = resolveJwtSecret( decryptAuthSecret( remoteWins("auth.jwt_secret") @@ -3310,9 +2461,6 @@ export function resolveLocalConfigValues( projectEnvValues, ), ); - // Same remote-over-env precedence as `jwtSecret` above — `signingKeysPath` gates whether - // {@link resolveConfiguredSigningKeys} below produces an asymmetric `signingKey`, which - // feeds `anonKey`/`serviceRoleKey` (already remote-gated fields the shadow's setup consumes). const signingKeysPath = remoteWins("auth.signing_keys_path") ? config.auth.signing_keys_path : envOverride( @@ -3320,24 +2468,9 @@ export function resolveLocalConfigValues( config.auth.signing_keys_path, projectEnvValues, ); - // Gated on `auth.enabled`: - // the signing-keys file read only runs when auth is enabled, so a - // disabled auth section never opens/parses `signing_keys_path`, even a stale - // or missing one. JWT-secret validation and anon/service_role key generation - // run unconditionally either way, so - // only this file read is gated. `auth.enabled` is itself env-bindable like - // any other field, so this gate reads the - // POST-`SUPABASE_AUTH_ENABLED`-override value, not the raw TOML one — hence - // `envOverrideBool` here instead of `config.auth.enabled` directly. - // Same remote-over-env precedence as every other gated field above — `auth.enabled` IS in - // `ENV_OVERRIDABLE_KEYS` (`db-config.toml-read.ts`) and that reader's own - // resolver already gates it (`remoteOverrideKeys.has("auth.enabled")`); this resolver must - // match, since an ungated `envOverrideBool` call THROWS on a malformed - // `SUPABASE_AUTH_ENABLED` even when a matched remote block already set `auth.enabled` at - // override tier — a value validation never even evaluates the env var for in - // that case — which would otherwise abort this whole function (and the shadow it feeds via - // `buildLocalDbContainerInputs`) on an env value the override tier should silently ignore - // (review: PRRT_kwDOErm0O86W30n6). + // The signing-keys file read only runs when auth is enabled, so a disabled auth section + // never opens/parses `signing_keys_path`, even a stale or missing one. JWT-secret validation + // and anon/service_role key generation run unconditionally either way. const authEnabled = remoteWins("auth.enabled") ? config.auth.enabled : envOverrideBool( @@ -3346,30 +2479,16 @@ export function resolveLocalConfigValues( "auth.enabled", projectEnvValues, ); - // `Config.Validate` checks `auth.site_url` first inside `if c.Auth.Enabled`, - // before the signing-keys read below — - // `@supabase/config`'s schema only defaults `site_url` when the key is ABSENT - // (`Schema.withDecodingDefaultKey`), so an explicit `site_url = ""` decodes as - // `""` with no schema-level error, same gap as `db.port === 0` above. - // Same remote-over-env precedence as `jwtSecret` above — `siteUrl` reaches the shadow's own - // fresh-DB-setup spec (`buildLocalDbContainerInputs`'s `authSiteUrl`). + // `@supabase/config`'s schema only defaults `site_url` when the key is absent, so an + // explicit `site_url = ""` decodes as `""` with no schema-level error, same gap as + // `db.port === 0` above. const siteUrl = remoteWins("auth.site_url") ? config.auth.site_url : (envOverride("SUPABASE_AUTH_SITE_URL", config.auth.site_url, projectEnvValues) ?? config.auth.site_url); - // GoTrue's env is built straight off the resolved auth config, with no local - // override logic of its own — the override happens earlier, generically, so - // every flat `auth.*` scalar fed into - // GoTrue's env must go through the same override resolution `siteUrl` - // above already gets, not just the fields validation happens to check. - // `jwtIssuer` is a plain, non-throwing `envOverride` string read, but leaving it ungated - // is still a precedence bug, same reasoning as `auth.external.*`'s `client_id`/`url`/ - // `redirect_uri` above — `auth.jwt_issuer` is in `ENV_OVERRIDABLE_KEYS`. const jwtIssuer = remoteWins("auth.jwt_issuer") ? config.auth.jwt_issuer : envOverride("SUPABASE_AUTH_JWT_ISSUER", config.auth.jwt_issuer, projectEnvValues); - // Same remote-over-env precedence as `siteUrl` above — `jwtExpiry` reaches the shadow's own - // Postgres container spec (`buildLocalDbContainerInputs`'s `authJwtExpiry`). const jwtExpiry = remoteWins("auth.jwt_expiry") ? config.auth.jwt_expiry : envOverrideUint( @@ -3378,12 +2497,6 @@ export function resolveLocalConfigValues( config.auth.jwt_expiry, projectEnvValues, ); - // Go decodes `additional_redirect_urls` (a `[]string`) through the same - // `StringToSliceHookFunc(",")` mapstructure hook as every other Go - // string-slice field — same comma-split-override - // pattern as `auth.webauthn.rp_origins` below. Same "non-throwing read is still a precedence - // bug" reasoning as `jwtIssuer` above — `auth.additional_redirect_urls` is also in - // `ENV_OVERRIDABLE_KEYS`. const additionalRedirectUrlsOverride = remoteWins("auth.additional_redirect_urls") ? undefined : envOverride("SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", undefined, projectEnvValues); @@ -3391,9 +2504,6 @@ export function resolveLocalConfigValues( additionalRedirectUrlsOverride !== undefined ? additionalRedirectUrlsOverride.split(",") : config.auth.additional_redirect_urls; - // Same remote-over-env precedence as `studioEnabled`/`mailpitEnabled` above, for the exact same - // "throws before a value the caller needs is resolved" reason — every field in this group is - // now in `ENV_OVERRIDABLE_KEYS`. const enableSignup = remoteWins("auth.enable_signup") ? config.auth.enable_signup : envOverrideBool( @@ -3445,10 +2555,6 @@ export function resolveLocalConfigValues( const passwordRequirements = remoteWins("auth.password_requirements") ? config.auth.password_requirements : envOverrideAuthPasswordRequirements(config.auth.password_requirements, projectEnvValues); - // `LoadedCliConfig.document` (the raw, pre-schema-default TOML `config` was decoded from) — - // hoisted here (rather than inside the `authEnabled` block below, where it used to live) because - // the captcha presence check right below needs it too. `undefined` for callers that haven't - // threaded `document` through yet, in which case presence-based checks are simply skipped. const authDocument = asRecord(document?.["auth"]); const captchaInput = resolveAuthCaptcha( authDocument, @@ -3456,59 +2562,30 @@ export function resolveLocalConfigValues( projectEnvValues, remoteOverrideKeys, ); - // Go's `generateJWT` signs asymmetrically whenever - // `len(a.SigningKeysPath) > 0 && len(a.SigningKeys) > 0` — NOT gated on `auth.enabled`. Since - // `a.SigningKeys` is unconditionally seeded with the default ES256 key at `NewConfig()` time - // and only ever replaced by the file's keys (when the read above actually runs), it's never - // empty either way — so this reduces to "does `signing_keys_path` resolve to a key at all," - // matching {@link resolveLocalJwks}'s identical `signingKeysPath`-only condition. Reuses - // {@link resolveConfiguredSigningKeys} (which already gates the actual file read on - // `authEnabled` internally, matching the file-read gating above) rather than duplicating that - // gate here — a disabled-auth config with a configured path must still sign asymmetrically - // with the default key, not silently fall back to symmetric HS256. + // Reuses {@link resolveConfiguredSigningKeys}, which already gates the file read on + // `authEnabled` internally, rather than duplicating that gate here: a disabled-auth config + // with a configured path must still sign asymmetrically with the default key, not fall back + // to symmetric HS256. const signingKey = signingKeysPath !== undefined && signingKeysPath.length > 0 ? (resolveConfiguredSigningKeys(config, workdir, projectEnvValues, remoteOverrideKeys) ?? [ DEFAULT_SIGNING_KEY, ])[0] : undefined; - // Validation runs passkey/webauthn, hook, mfa, email, then sms/third-party checks (skipping - // the D-only `external` step, ported separately below), all right after the signing-keys read - // and only while auth is enabled. Sms - // is enforced at decode time by `@supabase/config`'s `sms` - // schema (`packages/config/src/auth/sms.ts`'s provider-switch check) for the TOML-only case, - // AND re-checked here post-env-override by {@link validateAuthSmsProviders} (called alongside - // {@link validateAuthExternalProviders}, after the single `validateResolvedConfig` call - // below) — see that function's doc comment for why both are needed. External - // is D-only per `config-validate.ts`'s module - // header; {@link validateAuthExternalProviders} ports D's identical inline check. This block - // only ACCUMULATES the inputs those checks need — the checks themselves run once, later, as - // part of the single `validateResolvedConfig` call below. + // This block only accumulates the inputs passkey/webauthn/hook/mfa/email/smtp/third_party + // validation needs; the checks themselves run once, later, in the single + // `validateResolvedConfig` call below (sms/external run separately after it). let authInput: AuthInput | undefined; if (authEnabled) { - // `@supabase/config`'s auth schema has no `passkey`/`webauthn` fields at all (see - // `registry-auth.ts:717-724`'s "deliberately unmapped" note — there is no `../auth/*.ts` - // section for either), so passkey/webauthn are read from the RAW, post-`env()`-interpolation - // TOML document (`authDocument`, hoisted above) instead of the decoded `CliConfig` — same - // document-based approach already used on the `db`/migration config-load path - // (`db-config.toml-read.ts`'s - // `validateAuthConfig`, section A6). `authDocument` is `undefined` when a caller hasn't - // threaded `document` through yet, in which case passkey/smtp presence-based checks are - // simply skipped rather than guessed at. + // `@supabase/config`'s auth schema has no `passkey`/`webauthn` fields at all, so they're + // read from the raw, post-`env()`-interpolation TOML document instead of the decoded + // `CliConfig`. `authDocument` is `undefined` when a caller hasn't threaded `document` + // through yet, in which case these presence-based checks are simply skipped. const passkeyDoc = asRecord(authDocument?.["passkey"]); const webauthnDoc = asRecord(authDocument?.["webauthn"]); - // `auth.passkey.enabled`/`auth.webauthn.*` are env-bindable like every other nested field once - // `[auth.passkey]`/`[auth.webauthn]` are present in config.toml, so - // `SUPABASE_AUTH_PASSKEY_ENABLED` and - // `SUPABASE_AUTH_WEBAUTHN_RP_ID`/`_RP_ORIGINS` overrides apply before passkey/webauthn - // validation runs. Gated on the raw section already - // being present (`passkeyDoc`/`webauthnDoc !== undefined`) — only keys already present in the - // merged config are env-bindable, so an absent - // `[auth.passkey]`/`[auth.webauthn]` section is never synthesized from an env override alone. - // Same remote-over-env precedence as `studioEnabled`/`authEnabled` above — `auth.passkey.enabled` - // is in `ENV_OVERRIDABLE_KEYS` because the ungated `envOverrideBool` call below - // THROWS on a malformed override even when a matched remote block already set it, which would - // abort this whole function (and the shadow it feeds) on an env value the override tier should silently ignore. + // Gated on the raw section already being present: only keys already present in the merged + // config are env-bindable, so an absent `[auth.passkey]`/`[auth.webauthn]` section is never + // synthesized from an env override alone. const passkeyEnabled = remoteWins("auth.passkey.enabled") ? rawUnmodeledBool(passkeyDoc?.["enabled"], "auth.passkey.enabled") : passkeyDoc !== undefined @@ -3519,10 +2596,6 @@ export function resolveLocalConfigValues( projectEnvValues, ) : false; - // `rp_id`/`rp_origins` are plain, non-throwing `envOverride` reads, but leaving them - // ungated is still a precedence bug, same reasoning as `auth.external.*`'s `client_id`/`url`/ - // `redirect_uri` above — `auth.webauthn.rp_id`/`.rp_origins` are in - // `ENV_OVERRIDABLE_KEYS`. const configuredRpId = typeof webauthnDoc?.["rp_id"] === "string" ? webauthnDoc["rp_id"] : undefined; const rpId = remoteWins("auth.webauthn.rp_id") @@ -3530,18 +2603,12 @@ export function resolveLocalConfigValues( : webauthnDoc !== undefined ? envOverride("SUPABASE_AUTH_WEBAUTHN_RP_ID", configuredRpId, projectEnvValues) : undefined; - // Go decodes `rp_origins` (a `[]string`) through the same `StringToSliceHookFunc(",")` - // mapstructure hook as every other Go string-slice field, so a - // `SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS` override is comma-split the same way. const rpOriginsOverride = remoteWins("auth.webauthn.rp_origins") ? undefined : webauthnDoc !== undefined ? envOverride("SUPABASE_AUTH_WEBAUTHN_RP_ORIGINS", undefined, projectEnvValues) : undefined; - // Go's mapstructure decode chain applies `StringToSliceHookFunc(",")` unconditionally to - // every `[]string`-typed field — a raw or `env(...)`-resolved - // `rp_origins` string (this section has no `@supabase/config` schema at all) must be - // comma-split, not silently dropped when it isn't already a JS array. + // A raw or `env(...)`-resolved `rp_origins` string is comma-split, not dropped. const rawRpOrigins = webauthnDoc?.["rp_origins"]; const rpOrigins = rpOriginsOverride !== undefined @@ -3555,11 +2622,7 @@ export function resolveLocalConfigValues( ? { webauthnPresent: webauthnDoc !== undefined, rpId, rpOrigins } : undefined; - // Only enabled hooks are forwarded to `Config.Validate` parity, in Go's - // fixed iteration order — derived from - // `resolveAuthHooks`'s unfiltered result so this validation path and - // `resolveGotrueEnvInput`'s actual GoTrue env resolve the exact same - // per-hook override values (see that function's doc comment). + // Only enabled hooks are forwarded, in {@link HOOK_TYPE_ORDER}. const resolvedHooks = resolveAuthHooks( authDocument, config.auth.hook, @@ -3573,9 +2636,6 @@ export function resolveLocalConfigValues( return { type: hookType, uri: resolved.uri, secrets: resolved.secrets }; }); - // Derived from `resolveAuthMfa`'s unfiltered result so this validation path and - // `resolveGotrueEnvInput`'s actual GoTrue env resolve the exact same per-factor override - // values (see that function's doc comment) — same precedent as `hooks` above. const resolvedMfa = resolveAuthMfa(config.auth.mfa, projectEnvValues, remoteOverrideKeys); const mfa: ReadonlyArray<MfaFactorInput> = [ { @@ -3595,16 +2655,11 @@ export function resolveLocalConfigValues( }, ]; - // `Config.Validate` runs the email template/notification content read right after - // `Auth.MFA.validate()`, still inside `if c.Auth.Enabled` — this I/O read - // stays at this exact textual position (see this function's `@throws` doc for why). readAuthEmailTemplateContent( resolveAuthEmail(config.auth.email, authDocument, projectEnvValues, remoteOverrideKeys), workdir, ); - // `[auth.email.smtp]` presence-based `enabled` default — see - // {@link resolveAuthEmailSmtp}'s doc comment. const resolvedSmtp = resolveAuthEmailSmtp(authDocument, projectEnvValues, remoteOverrideKeys); const smtp: SmtpInput | undefined = resolvedSmtp === undefined @@ -3618,13 +2673,6 @@ export function resolveLocalConfigValues( adminEmail: resolvedSmtp.adminEmail, }; - // `(tpa *thirdParty) validate()` fixed provider order — - // only enabled providers are forwarded, in that order. {@link resolveThirdPartyProviders} - // is the SAME hoisted resolver `commands/db/start/start.handler.ts`'s eager pre-probe battery - // calls, so both callers apply identical `SUPABASE_AUTH_THIRD_PARTY_<PROVIDER>_*` overrides — - // `remoteOverrideKeys` is threaded through so a matched remote's `auth.third_party.*` value - // doesn't lose to a malformed `SUPABASE_AUTH_THIRD_PARTY_*` override (review: - // PRRT_kwDOErm0O86W30n6), same reasoning as every other `remoteWins`-gated field above. const thirdParty = resolveThirdPartyProviders( config.auth.third_party, projectEnvValues, @@ -3641,46 +2689,17 @@ export function resolveLocalConfigValues( thirdParty, }; } - // `Config.Validate` runs `ValidateFunctionSlug` over every `[functions.*]` - // key right after the auth block/`generateAPIKeys`, unconditionally. const functionSlugs = Object.keys(config.functions); - // `Config.Validate` checks `edge_runtime.deno_version` after the auth - // block and the functions loop, and — - // unlike `studio.port`/`local_smtp.port` above — unconditionally, with no - // `edge_runtime.enabled` gate. `edge_runtime.deno_version` is in - // `ENV_OVERRIDABLE_KEYS` (`db-config.toml-read.ts`) and - // `envOverrideDenoVersion` THROWS on a malformed override — same - // `auth.enabled`/`analytics.enabled` bug class (review: PRRT_kwDOErm0O86W30n6, - // PRRT_kwDOErm0O86W4gCk): an ungated call here would abort this whole - // resolver (and the shadow it feeds) on a malformed `SUPABASE_EDGE_RUNTIME_ - // DENO_VERSION` even when a matched remote block already set - // `edge_runtime.deno_version` at viper's OVERRIDE tier, a value Go's - // `Validate` never evaluates the env var for in that case. + // `edge_runtime.deno_version` is checked unconditionally, with no `edge_runtime.enabled` + // gate, unlike `studio.port`/`local_smtp.port` above. const denoVersion = remoteWins("edge_runtime.deno_version") ? config.edge_runtime.deno_version : envOverrideDenoVersion(config.edge_runtime.deno_version, projectEnvValues); - // `Config.Validate` validates `[analytics]` right after - // `edge_runtime.deno_version`: when - // `analytics.enabled` and `analytics.backend == "bigquery"`, all three GCP - // fields are required, checked in that order, each with its own message. - // Backend-enum validation (rejecting a non-postgres/bigquery value) is - // covered at decode time for the `config.toml`-sourced value by - // `@supabase/config`'s `stringEnum` (`packages/config/src/analytics.ts:17-41`), - // but that schema doesn't see the `SUPABASE_ANALYTICS_BACKEND` env-override - // path — see {@link envOverrideAnalyticsBackend} for that case. - // `analytics.enabled`/`analytics.backend` are both in `ENV_OVERRIDABLE_KEYS` - // (`db-config.toml-read.ts`) and both THROW on a malformed override - // (`InvalidBoolEnvOverrideError`/`InvalidAnalyticsBackendEnvOverrideError`) — same - // `auth.enabled` bug class (review: PRRT_kwDOErm0O86W30n6): an ungated call here would abort - // this whole function (and the shadow it feeds) on a malformed `SUPABASE_ANALYTICS_*` env var - // even when a matched remote block already set the field at viper's OVERRIDE tier, a value - // `Validate` never evaluates the env var for in that case. `gcpProjectId`/ - // `gcpProjectNumber`/`gcpJwtPath` below can't throw either (`envOverride` is a plain - // string read), but leaving them ungated is still a precedence bug, same reasoning as - // `auth.external.*`'s `client_id`/`url`/`redirect_uri` — all three are in - // `ENV_OVERRIDABLE_KEYS` and already gated on the `db-config.toml-read.ts` side - // (`analyticsString`); this resolver's own copy just never got the matching gate. + // When `analytics.enabled` and `analytics.backend == "bigquery"`, all three GCP fields are + // required. Backend-enum validation is covered at decode time for the config.toml-sourced + // value by `@supabase/config`'s schema, but not for the `SUPABASE_ANALYTICS_BACKEND` + // env-override path — see {@link envOverrideAnalyticsBackend} for that case. const analyticsEnabled = remoteWins("analytics.enabled") ? config.analytics.enabled : envOverrideBool( @@ -3716,32 +2735,15 @@ export function resolveLocalConfigValues( projectEnvValues, ); - // `Config.Validate` calls `c.Experimental.validate()` right after the - // analytics/bigquery block and right before returning. The webhooks check is NOT "the user - // disabled a feature" — Go's bool zero-value is `false`, so `e.Webhooks != nil && - // !e.Webhooks.Enabled` rejects ANY present `[experimental.webhooks]` section whose `enabled` - // isn't explicitly `true`, including one where the key is simply omitted; the section exists - // only so it can be turned on, never explicitly off. This hinges on PRESENCE of the TOML - // section, not the decoded `enabled` value — `@supabase/config`'s decode-time default - // (`packages/config/src/experimental.ts`'s `withDecodingDefaultKey(Effect.succeed({}))`) fills - // in `experimental.webhooks = { enabled: false }` on the DECODED `CliConfig` even when the - // TOML section is entirely absent — verified empirically, this default-fill erases exactly the - // presence signal this check needs. So this reads `LoadedCliConfig.document` (the raw, - // pre-default TOML) instead, same as the passkey/smtp checks above. + // The webhooks check isn't "the user disabled a feature": an omitted `enabled` key in a + // present `[experimental.webhooks]` section is rejected too — the section exists only so it + // can be turned on, never explicitly off. This hinges on TOML-section presence, which the + // schema's decode-time default erases (`experimental.webhooks` always decodes to + // `{ enabled: false }` even when absent), so this reads the raw document instead. const experimentalDocument = asRecord(document?.["experimental"]); const webhooksPresent = asRecord(experimentalDocument?.["webhooks"]) !== undefined; - // `SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED`/`SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS` are - // env-bindable like every other leaf field before experimental validation runs — same - // mechanism the db/migration - // loader (`db-config.toml-read.ts`) already applies for the `pgdelta` override; this - // resolver just never got the equivalent treatment. A malformed JSON override needs no separate - // error path here: it flows through unchanged and `validateResolvedConfig`'s existing - // `isValidJson` check reports it the same way it already reports a malformed TOML-sourced value. - // Same remote-over-env precedence as `studioEnabled`/`authEnabled` above — `experimental. - // webhooks.enabled` is in `ENV_OVERRIDABLE_KEYS` because the ungated - // `envOverrideBool` call below THROWS on a malformed override even when a matched remote - // block already set it, which would abort this whole function (and the shadow it feeds) on an - // env value the override tier should silently ignore. + // A malformed JSON override needs no separate error path: it flows through unchanged and + // `validateResolvedConfig`'s existing JSON-validity check reports it. const webhooksEnabled = remoteWins("experimental.webhooks.enabled") ? config.experimental.webhooks?.enabled === true : envOverrideBool( @@ -3750,14 +2752,6 @@ export function resolveLocalConfigValues( "experimental.webhooks.enabled", projectEnvValues, ); - // `experimental.pgdelta.format_options` is ALSO in `ENV_OVERRIDABLE_KEYS` - // (`db-config.toml-read.ts`), which already gates its OWN `format_options` read the - // same way (`remoteOverrideKeys.has("experimental.pgdelta.format_options")`) — this resolver's - // copy just never got the matching gate: an ungated `envOverride` here let ambient - // `SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS` beat a matched remote's own `format_options`, - // the opposite of `mergeRemoteConfig`, which installs the remote leaf with `v.Set` ABOVE - // `AutomaticEnv` — same remote-over-env precedence as `webhooksEnabled` - // immediately above. const pgdeltaFormatOptions = remoteWins("experimental.pgdelta.format_options") ? (config.experimental.pgdelta?.format_options ?? "") : (envOverride( @@ -3766,19 +2760,10 @@ export function resolveLocalConfigValues( projectEnvValues, ) ?? ""); - // Every PURE Config.Validate check this module/config-validate.ts jointly own is - // deferred to this single call, positioned here (where the last of those checks ran until - // this commit), in Go's exact relative order against every OTHER pure check. This means a - // config broken in TWO OR MORE independent pure-section ways reports whichever Go considers - // first among the ones broken — unchanged from before. The only real reordering risk is - // between a pure check and one of this function's 3 I/O reads (signing keys, api.tls - // cert/key, email template/notification content) that in THIS function's source sits between - // two pure sections (e.g. the signing-keys read sits between the captcha check above and the - // passkey/hooks/mfa/email/smtp/third_party checks folded into `authInput` above) — that I/O - // read now effectively runs BEFORE those later pure checks rather than interleaved at its - // original relative position. This is the same narrow, accepted, documented tradeoff recorded - // in `config-validate.ts`'s module header; every existing test constructs exactly one - // validation failure at a time, so it has zero effect on any real test. + // Every pure validation check runs in one place, here, rather than interleaved with this + // function's 3 I/O reads (signing keys, api.tls cert/key, email content) at their original + // relative positions — a config broken in two independent ways can report a different one of + // those errors than if checks ran interleaved. const apiInput: ApiInput = { enabled: apiEnabled, port: apiPort, @@ -3818,13 +2803,8 @@ export function resolveLocalConfigValues( experimental: experimentalInput, }; validateResolvedConfig(input); - // Both run after the single shared `validateResolvedConfig` call per the module's - // documented sms/external-vs-third_party ordering tradeoff (third_party is checked inside that - // call; sms/external run after it here) — in Go's own relative sms-then-external order. - // `validateAuthSmsProviders` re-runs `@supabase/config`'s schema-level - // switch with env overrides applied (see its doc comment); `validateAuthExternalProviders` is - // D-only per `config-validate.ts`'s module header ("auth.external ... stays 100% inline - // in D") — this is L's port of D's identical inline block. + // sms/external run after the shared validation call, since both need the env-override-aware + // resolved value rather than the raw decoded config it validates. if (authEnabled) { validateAuthSmsProviders(authDocument, config.auth.sms, projectEnvValues, remoteOverrideKeys); validateAuthExternalProviders( @@ -3835,11 +2815,7 @@ export function resolveLocalConfigValues( ); } - // `studio.openai_api_key` is a `config.Secret`, decrypted the same - // way `auth.email.smtp.pass`/`auth.captcha.secret` are — same remote-over-env precedence: an - // ungated `envOverride` here could let a malformed ambient `SUPABASE_STUDIO_OPENAI_API_KEY` - // outrank a matched remote's own valid value and throw during decryption, aborting the whole - // call (and the shadow it feeds) on a value `v.Set` (override tier) silently ignores. + // Decrypted like `auth.email.smtp.pass`/`auth.captcha.secret`. const openaiApiKey = remoteWins("studio.openai_api_key") ? decryptAuthSecret(config.studio.openai_api_key, projectEnvValues) : decryptAuthSecret( @@ -3876,11 +2852,7 @@ export function resolveLocalConfigValues( studioUrl: `http://${hostname}:${studioPort}`, mailpitUrl: `http://${hostname}:${mailpitPort}`, dbUrl: `postgresql://postgres:${DEFAULT_DB_PASSWORD}@${hostname}:${dbPort}/postgres`, - // `auth.publishable_key`/`auth.secret_key` are - // `config.Secret`-typed exactly like `anon_key`/`service_role_key` below — same - // remote-over-env precedence: an ungated `envOverride` here could let a malformed - // ambient `SUPABASE_AUTH_PUBLISHABLE_KEY`/`SUPABASE_AUTH_SECRET_KEY` outrank a matched - // remote's own valid value and throw during decryption, aborting the whole call. + // Decrypted like `anon_key`/`service_role_key` below. publishableKey: resolveOpaqueKey( remoteWins("auth.publishable_key") ? decryptAuthSecret(config.auth.publishable_key, projectEnvValues) @@ -3904,9 +2876,6 @@ export function resolveLocalConfigValues( defaultSecretKey, ), jwtSecret, - // Same remote-over-env precedence as `jwtSecret`/`siteUrl` above — `anonKey`/ - // `serviceRoleKey` reach the shadow's own fresh-DB-setup spec - // (`buildLocalDbContainerInputs`). anonKey: resolveSignedKey( decryptAuthSecret( remoteWins("auth.anon_key") @@ -3942,68 +2911,33 @@ export function resolveLocalConfigValues( gcpProjectId: gcpProjectId ?? "", gcpProjectNumber: gcpProjectNumber ?? "", gcpJwtPath: gcpJwtPath ?? "", - // Sanitized here (not above, in `input.projectId`) — `validateResolvedConfig`'s check is - // presence-only and must see the raw value to reject an explicit `project_id = ""` before any - // fallback; every OTHER reader of the project id (Docker resource naming, labels) needs the - // post-validation sanitized singleton. + // Sanitized here, not in `input.projectId` above: `validateResolvedConfig`'s check is + // presence-only and must see the raw value to reject an explicit `project_id = ""`. projectId: sanitizeProjectId(resolvedProjectId ?? ""), edgeRuntimeDenoVersion: denoVersion, }; } /** - * Resolves the local JWKS document — reached only - * from the future native `start` port (a fetch failure there - * fails the whole `start` command outright). Deliberately NOT folded into - * {@link LocalConfigValues}/{@link resolveLocalConfigValues}: that resolver is - * synchronous and runs on every `stop`/`status` invocation (see `status-values.ts`/ - * `stop.handler.ts`), so adding this function's network round-trip (the OIDC discovery + remote - * JWKS fetch) there would tax two commands that never render a JWKS. This is a standalone sibling - * `start`-only callers invoke separately, alongside (not instead of) `resolveLocalConfigValues`. - * - * Divergences from the structurally similar (but functionally unrelated) - * `resolveLocalAuthArtifacts`/`finalizeAuthArtifacts` pair in - * `shared/functions/serve.ts` — deliberately NOT copied here: - * - a remote-JWKS fetch failure is a hard, propagating error here (it fails the whole `start` - * command outright); `serve.ts` instead swallows the failure and continues with - * zero remote keys, a `functions serve`-only leniency with no equivalent here. - * - this never injects `serve.ts`'s `defaultSigningKey` EC key — that key exists only for - * `functions serve`'s own local-dev defaults and has no equivalent here. - * - * Reuses {@link toPublicJwk}/{@link resolveThirdPartyIssuerUrl}/{@link resolveRemoteJwks} - * (`shared/auth/jwks.ts`) rather than re-implementing them a second time — see that module's - * header. `jwtSecret` is accepted as a parameter (the same value already resolved onto - * {@link LocalConfigValues.jwtSecret} by {@link resolveLocalConfigValues}/ - * {@link resolveJwtSecret}) rather than recomputed, so the two functions never disagree on it. + * Resolves the local JWKS document for the future native `start` port; a fetch failure fails + * the whole `start` command. Kept separate from {@link resolveLocalConfigValues} — synchronous + * and run on every `stop`/`status` — so this function's network round-trip doesn't tax those + * two commands. * - * `authEnabled`/`signingKeysPath` ARE recomputed here (cheap, pure `envOverride`/ - * `envOverrideBool` calls, no I/O) rather than threaded through from the caller's own - * computation of the same values, keeping this function self-contained. Both this function and - * `resolveLocalConfigValues`'s own `signingKey` now share - * {@link resolveConfiguredSigningKeys} — this function needs the FULL key array (see - * {@link loadSigningKeys}), the other only the first key to sign anon/service_role. + * `jwtSecret` is accepted as a parameter (the same value {@link resolveLocalConfigValues} + * already resolves) rather than recomputed, so the two functions never disagree on it. + * `authEnabled`/`signingKeysPath` are recomputed here, keeping this function self-contained. * - * `signingKeysPath`'s effect on the oct-JWT-secret fallback below matches Go's literal field - * checks exactly, NOT "is auth enabled": `a.SigningKeysPath` - * is resolved to an absolute path unconditionally, regardless of `auth.enabled` — only the file - * read INTO `a.SigningKeys` is gated on `c.Auth.Enabled`. So a config - * with `auth.enabled = false` and a configured `signing_keys_path` resolves `signingKeys: []` - * (never read) with `signingKeysPath` still non-empty — Go's fallback check - * (`len(a.SigningKeysPath) == 0`) is then FALSE, so the oct fallback is skipped too, matching this - * function's `signingKeysPath` emptiness check below rather than `!authEnabled`. + * The oct-JWT-secret fallback below is gated on `signingKeysPath` emptiness, not `authEnabled`: + * a configured path with auth disabled still resolves the signing keys to the default (never + * reads the file), but `signingKeysPath` stays non-empty, so the fallback stays skipped. * - * @throws {ConfigValidateError} when more than one `auth.third_party.*` provider is - * enabled, an enabled provider is missing a required field, or the remote JWKS fetch (OIDC - * discovery or the JWKS document itself) fails — matching `ResolveJWKS` returning that error - * outright, propagated here as this file's own error type rather than a bare `Error`. + * @throws {ConfigValidateError} when more than one `auth.third_party.*` provider is enabled, + * an enabled provider is missing a required field, or the remote JWKS fetch fails. * - * `remoteOverrideKeys` (default empty, so `start.handler.ts`'s `supabase start` caller sees - * exactly the same behavior as before): every `auth.signing_keys_path`/`auth.third_party.*` - * field a matched `[remotes.<ref>]` block set at viper's OVERRIDE tier - * must win over a conflicting `SUPABASE_AUTH_*` - * value — this function feeds the shadow's PG15+ one-shot auth-migration job's `jwks` input on - * the `db diff --linked`/`db pull` path (CLI-1956), via `buildLocalDbContainerInputs` - * (review: PRRT_kwDOErm0O86W3Ox_). + * `remoteOverrideKeys` lets a matched remote block's `auth.signing_keys_path`/ + * `auth.third_party.*` value win over a conflicting `SUPABASE_AUTH_*` override, same as + * {@link resolveLocalConfigValues}'s parameter of the same name. */ export async function resolveLocalJwks( config: CliConfig, @@ -4020,16 +2954,11 @@ export async function resolveLocalJwks( config.auth.signing_keys_path, projectEnvValues, ); - // The signing keys are UNCONDITIONALLY seeded with the single default ES256 key — - // every resolved config carries it, - // regardless of `auth.enabled`. It is only ever REPLACED by a configured - // `signing_keys_path` file, and only when that file is actually read (gated on - // `auth.enabled && signing_keys_path set` — see - // {@link resolveConfiguredSigningKeys}). So JWKS resolution (which has no - // `auth.enabled` gate of its own) always publishes either the - // file's keys or this default — never neither. `GOTRUE_JWT_KEYS` signs with the same - // default (`services/gotrue.service.ts`'s `GOTRUE_DEFAULT_SIGNING_KEY`), so the - // two must never disagree on which key applies here. + // Every resolved config carries the default ES256 key, regardless of `auth.enabled`. It's + // only ever replaced by a configured `signing_keys_path` file, and only when that file is + // actually read (gated on `auth.enabled` — see {@link resolveConfiguredSigningKeys}). So JWKS + // resolution always publishes either the file's keys or this default, never neither — + // `GOTRUE_JWT_KEYS` signs with the same default, so the two must never disagree. const signingKeys: ReadonlyArray<Jwk> = resolveConfiguredSigningKeys( config, workdir, @@ -4037,13 +2966,9 @@ export async function resolveLocalJwks( remoteOverrideKeys, ) ?? [DEFAULT_SIGNING_KEY]; - // Same fixed provider order + `SUPABASE_AUTH_THIRD_PARTY_<PROVIDER>_*` overrides as the - // `thirdParty: Array<ThirdPartyInput>` block in `resolveLocalConfigValues` above, - // but built as a `ThirdPartyProvidersLike` (every provider's full field set, including auth0's - // `tenant_region`) rather than `ThirdPartyInput` (a validation-only shape with no - // `tenant_region` field) — {@link resolveThirdPartyIssuerUrl} needs the full set to build the - // issuer URL, not just validate presence. Each field below prefers the remote-set value over a - // conflicting env override, same as {@link resolveDbSettingsEnvOverrides}'s per-field gate. + // Built as a `ThirdPartyProvidersLike` (every provider's full field set, including auth0's + // `tenant_region`) rather than the validation-only `ThirdPartyInput`, since + // {@link resolveThirdPartyIssuerUrl} needs the full set to build the issuer URL. const thirdParty: ThirdPartyProvidersLike = { firebase: { enabled: remoteWins("auth.third_party.firebase.enabled") @@ -4146,22 +3071,11 @@ export async function resolveLocalJwks( }, }; - // The "at most one enabled" + required-field checks `resolveThirdPartyIssuerUrl` - // performs only run while auth is enabled — but this whole - // function is called UNCONDITIONALLY, regardless of - // `auth.enabled`. When auth is enabled, `resolveLocalConfigValues`'s own gated - // `validateAuthThirdPartyProviders`-equivalent check already ran first, so the validating - // resolver here is safe/redundant-but-harmless. When auth is disabled, that earlier validation - // is (correctly) skipped, so this function must NOT re-introduce it — using the unchecked, - // no-throw issuer-url builder instead. - // Same remote-over-env precedence as every other field above — `auth.enabled` is in - // `ENV_OVERRIDABLE_KEYS` (`db-config.toml-read.ts`) and an ungated - // `envOverrideBool` call THROWS on a malformed `SUPABASE_AUTH_ENABLED` even when a - // matched remote block already set `auth.enabled` at override tier — a value - // validation never even evaluates the env var for in that case — which would otherwise abort - // this whole function (and the shadow's PG15+ one-shot auth-migration job it feeds via - // `buildLocalDbContainerInputs`) on an env value the override tier should silently ignore - // (review: PRRT_kwDOErm0O86W30n6). + // This function is called unconditionally, regardless of `auth.enabled`, but + // `resolveThirdPartyIssuerUrl`'s "at most one enabled" + required-field checks are only + // meaningful while auth is enabled — `resolveLocalConfigValues` already ran the equivalent + // check in that case. When auth is disabled, that validation is (correctly) skipped, so this + // uses the unchecked, no-throw issuer-url builder instead. const authEnabled = remoteWins("auth.enabled") ? config.auth.enabled : envOverrideBool( @@ -4182,11 +3096,9 @@ export async function resolveLocalJwks( } const keys: unknown[] = []; - // Only attempt the remote fetch when the issuer URL is non-empty — - // a provider's own issuer-url resolution can return the - // empty string with no validation (e.g. workos's is a raw field read), so an - // enabled-but-unconfigured third-party provider with - // `auth.enabled = false` must be tolerated, not fetched. + // A provider's own issuer-url resolution can return the empty string with no validation + // (e.g. workos's is a raw field read), so an enabled-but-unconfigured provider must be + // tolerated, not fetched. if (issuerUrl !== undefined && issuerUrl.length > 0) { try { keys.push(...(await resolveRemoteJwks(issuerUrl))); From 48b4e0bfd7c0584dc1f0444624461a641ab4a583 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 12:38:53 +0100 Subject: [PATCH 14/56] chore(repo): trim comments, shard 17 (1260 -> 735 comment lines) --- .../command-internal/storage-content-type.ts | 38 ++--- .../storage-credentials.errors.ts | 37 +---- .../command-internal/storage-credentials.ts | 152 +++++------------- .../storage-gateway.errors.ts | 29 ++-- .../src/command-internal/storage-gateway.ts | 79 ++++----- .../command-internal/storage-runtime.layer.ts | 18 +-- apps/cli/src/command-internal/storage-url.ts | 125 +++++--------- .../command-internal/storage-url.unit.test.ts | 5 +- .../src/command-internal/string-slice-flag.ts | 130 +++++---------- .../string-slice-flag.unit.test.ts | 53 ++---- apps/cli/src/command-internal/temp-paths.ts | 33 ++-- apps/cli/src/command-internal/tenant-keys.ts | 10 +- .../src/command-internal/tenant-versions.ts | 43 ++--- .../test-db.command-handler.ts | 54 ++----- .../src/command-internal/test-db.errors.ts | 17 +- .../src/command-internal/test-db.handler.ts | 106 +++++------- .../test-db.integration.test.ts | 57 ++----- .../src/command-internal/test-db.layers.ts | 43 ++--- .../test-db.layers.unit.test.ts | 23 +-- .../command-internal/test-db.pg-prove-args.ts | 37 ++--- .../test-db.pg-prove-args.unit.test.ts | 14 +- .../src/command-internal/timestamp.format.ts | 12 +- .../src/command-internal/upgrade-notice.ts | 146 ++++++----------- .../upgrade-notice.unit.test.ts | 56 +++---- .../src/command-internal/upgrade-suggest.ts | 36 ++--- .../upgrade-suggest.unit.test.ts | 6 - .../cli/src/command-internal/vault-decrypt.ts | 49 +++--- .../vault-decrypt.unit.test.ts | 9 +- apps/cli/src/command-internal/vault.ts | 17 +- apps/cli/src/command-internal/viper-env.ts | 66 ++------ 30 files changed, 485 insertions(+), 1015 deletions(-) diff --git a/apps/cli/src/command-internal/storage-content-type.ts b/apps/cli/src/command-internal/storage-content-type.ts index 54c03746bb..64c5085195 100644 --- a/apps/cli/src/command-internal/storage-content-type.ts +++ b/apps/cli/src/command-internal/storage-content-type.ts @@ -5,22 +5,19 @@ import { Effect, FileSystem, Option } from "effect"; import { detectContentType } from "./detect-content-type.ts"; /** - * Upload content-type resolution, ported from `pkg/storage/objects.go` - * (`ParseFileOptions` + `UploadObject`) and shared by `seed buckets` and - * `storage cp`: run `http.DetectContentType` on the first ≤512 bytes (the bytes - * decide), then refine a generic `text/plain` by file extension. So a PNG named - * `.txt` stores as `image/png` (bytes win), while a JSON text file refines to - * `application/json`. + * Resolves the content-type for a file upload, shared by `seed buckets` and + * `storage cp`: sniff the first ≤512 bytes, then refine a generic `text/plain` + * result by file extension. Bytes win over extension, so a PNG named `.txt` + * still uploads as `image/png`. */ -// Content-type sniff window: Go reads the first 512 bytes (`io.LimitReader(f, 512)`). const SNIFF_LEN = 512; /** - * Read ONLY the first ≤512 bytes of a file for content-type sniffing, mirroring - * `io.LimitReader(f, 512)` — the file is - * NOT fully buffered. Returns an empty buffer on EOF or any read error (an - * unreadable file then fails at the streaming upload open, so the sniff is moot). + * Reads the first ≤512 bytes of a file for content-type sniffing without + * buffering the whole file. Returns an empty buffer on EOF or any read error; + * an unreadable file fails later at the streaming upload open, so the sniff + * failure is harmless. */ export const readSniffBytes = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, @@ -38,10 +35,9 @@ export const readSniffBytes = Effect.fnUntraced(function* ( }); /** - * Refine a content-type by file extension, but only when it is a generic - * `text/plain` (`if strings.Contains(fo.ContentType, "text/plain")` gate). - * Applied to both the sniffed type and an explicit - * `--content-type` value, matching the established file-options → upload flow. + * Refines a content-type by file extension, but only when it is a generic + * `text/plain`. Applied to both the sniffed type and an explicit + * `--content-type` value. */ export function refineUploadContentType(contentType: string, filePath: string): string { if (contentType.includes("text/plain")) { @@ -52,19 +48,13 @@ export function refineUploadContentType(contentType: string, filePath: string): return contentType; } -/** - * Content-type for an uploaded object from its sniffed bytes: detect, then refine - * a generic `text/plain` by extension. `sniff` is the first ≤512 bytes. - */ +/** Resolves the upload content-type from sniffed bytes, refining a generic `text/plain` by extension. */ export function contentTypeForUpload(sniff: Uint8Array, filePath: string): string { return refineUploadContentType(detectContentType(sniff), filePath); } -// Go's built-in `mime` extension table (`mime/type.go` `builtinTypesLower`), used -// only to refine a generic `text/plain` sniff result. NOTE: Go's -// `mime.TypeByExtension` also augments this from the OS MIME database -// (`/etc/mime.types`, the Windows registry), which is host-dependent and not -// reproduced here — the deterministic built-in table is the faithful baseline. +// Built-in extension-to-MIME table used only to refine a generic `text/plain` +// sniff result. Does not consult the OS MIME database, which is host-dependent. const MIME_BY_EXTENSION: Readonly<Record<string, string>> = { ".ai": "application/postscript", ".apk": "application/vnd.android.package-archive", diff --git a/apps/cli/src/command-internal/storage-credentials.errors.ts b/apps/cli/src/command-internal/storage-credentials.errors.ts index f088a07fed..59b943caab 100644 --- a/apps/cli/src/command-internal/storage-credentials.errors.ts +++ b/apps/cli/src/command-internal/storage-credentials.errors.ts @@ -8,19 +8,10 @@ import { } from "../shared/telemetry/error-actionability.ts"; /** - * Errors raised while deriving Storage connection credentials, shared by - * `seed buckets` and `storage ls/cp/mv/rm`. - * - * `StorageConfigError` covers the config-load-time validations run - * before the Storage API client is built (`auth.jwt_secret` length, an - * undecryptable `encrypted:` `auth.jwt_secret`/`auth.service_role_key` (or - * `SUPABASE_AUTH_*` override), Kong TLS cert/key pairing and readability, a - * malformed `SUPABASE_API_*` port/bool override, an enabled API whose resolved - * `api.port` is `0`, and an unreadable/malformed project dotenv file — see - * `resolveStorageCredentials`'s local branch, `resolveLocalApiConfig`, and - * `resolveLocalServiceRoleKey`). - * The remaining three mirror `tenant.GetApiKeys` failure - * modes on the `--linked` path. + * Config-load-time failure while deriving local Storage credentials, before + * the Storage API client is built: an invalid or undecryptable auth secret, + * a Kong TLS cert/key pairing or readability problem, a malformed + * `SUPABASE_API_*` override, or an unreadable project dotenv file. */ export class StorageConfigError extends Data.TaggedError("StorageConfigError")<{ readonly message: string; @@ -30,18 +21,12 @@ export class StorageConfigError extends Data.TaggedError("StorageConfigError")<{ } } -/** - * Raised on `--linked` when the project's api-keys response yields no keys, - * mirroring `tenant.GetApiKeys` → `errMissingKey` ("Anon key not found."), - * which aborts before the remote - * Storage client is built. - */ +/** Raised on `--linked` when the project's api-keys response yields no usable key. */ export class StorageMissingApiKeyError extends Data.TaggedError("StorageMissingApiKeyError")<{ readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // A 200 api-keys response with no usable key — an API response problem, not - // a raw status failure. + // A 200 response with no usable key, not a raw status failure. return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; } } @@ -58,20 +43,14 @@ export class StorageApiKeysNetworkError extends Data.TaggedError("StorageApiKeys } } -/** - * `GET /v1/projects/{ref}/api-keys?reveal=true` returned a non-200 on a - * `--linked` run. Byte-matches `tenant.GetApiKeys` → `ErrAuthToken`, - * `"Authorization failed for the access token and project ref pair: " + body`. - */ +/** Raised when `GET /v1/projects/{ref}/api-keys?reveal=true` returns a non-200 status on a `--linked` run. */ export class StorageAuthTokenError extends Data.TaggedError("StorageAuthTokenError")<{ readonly status: number; readonly body: string; readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // The shared mapper wraps any non-200 in this tag; the status policy maps - // 401 → re-login, 404 → user-supplied ref not found, everything else → - // API status. + // 401 → re-login, 404 → user-supplied ref not found, everything else → API status. return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); } } diff --git a/apps/cli/src/command-internal/storage-credentials.ts b/apps/cli/src/command-internal/storage-credentials.ts index c1e4752041..86212d6daf 100644 --- a/apps/cli/src/command-internal/storage-credentials.ts +++ b/apps/cli/src/command-internal/storage-credentials.ts @@ -25,22 +25,16 @@ import { } from "./storage-credentials.errors.ts"; /** - * Resolves the Storage gateway base URL + service-role key (+ local Kong CA), - * mirroring `client.NewStorageAPI`. - * Shared by `seed buckets` and `storage ls/cp/mv/rm`. + * Resolves Storage gateway credentials (base URL, service-role key, and local + * CA) for `seed buckets` and `storage ls/cp/mv/rm`. * - * - `projectRef === ""` (local): base URL from `api.external_url` (else - * `<scheme>://<host>:<api.port>`), with the `SUPABASE_API_*` env/dotenv - * overrides folded in first (see {@link resolveLocalApiConfig}), service-role - * key derived from `auth.{service_role_key,jwt_secret}` with their - * `SUPABASE_AUTH_*` env/dotenv overrides applied and decrypted (see - * {@link resolveLocalServiceRoleKey}), and the Kong CA when the URL is https. - * - remote: base URL `https://<ref>.<projectHost>`; key from - * `SUPABASE_AUTH_SERVICE_ROLE_KEY` else `tenant.GetApiKeys`. - * - * Requires `CommandSettings` (workdir, projectHost) and — only on the remote - * branch — `CommandPlatformApiFactory` (lazy, so the local path never touches the - * Management API). + * Local (`projectRef === ""`): URL from `api.external_url` or + * `<scheme>://<host>:<api.port>`, and key from + * `auth.service_role_key`/`auth.jwt_secret`, both after + * `SUPABASE_API_*`/`SUPABASE_AUTH_*` overrides (see + * {@link resolveLocalApiConfig} and {@link resolveLocalServiceRoleKey}). + * Remote: URL is `https://<ref>.<projectHost>`; key from + * `SUPABASE_AUTH_SERVICE_ROLE_KEY` or the project's api-keys endpoint. */ /** Structural subset of `@supabase/config`'s CliConfig used here. */ @@ -72,15 +66,9 @@ export const resolveStorageCredentials = Effect.fnUntraced(function* (opts: { readonly projectRef: string; readonly config: StorageConfigView; /** - * Already-resolved project env map for the `SUPABASE_API_*` fold and the - * local auth-key resolution, when the caller has one in scope - * (`seedBucketsRun`, `start`) — same passthrough idea as `seedBucketsRun`'s - * own `resolvedConfig`. Either walk's shape works — a map that omits - * ambient-shadowed keys (`loadProjectEnv`) or one that overlays ambient - * values (`resolveProjectEnvironmentValues`) — since the override helpers' - * `map[name] ?? process.env[name]` lookup resolves both identically. When - * omitted (the `storage` commands), the local branch loads the nested - * project dotenv walk itself. + * Already-resolved project env map for the `SUPABASE_API_*`/`SUPABASE_AUTH_*` + * overrides, when the caller has one in scope. When omitted, this loads the + * project dotenv itself. */ readonly projectEnvValues?: Readonly<Record<string, string>>; }) { @@ -88,14 +76,11 @@ export const resolveStorageCredentials = Effect.fnUntraced(function* (opts: { if (opts.projectRef !== "") { const baseUrl = `https://${opts.projectRef}.${cliSettings.projectHost}`; - // Go: `viper.IsSet("AUTH_SERVICE_ROLE_KEY")` → use the env-provided key and - // skip the tenant lookup. const envKey = process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"]; if (envKey !== undefined && envKey.length > 0) { return { baseUrl, apiKey: envKey, localKongCa: undefined } satisfies StorageCredentials; } - // Resolve the Management API client lazily so the local path never triggers - // auth (`tenant.GetApiKeys`). + // Resolved lazily so the local path never triggers auth. const api = yield* (yield* CommandPlatformApiFactory).make; const keys = extractServiceKeys( yield* api.v1.getProjectApiKeys({ ref: opts.projectRef, reveal: true }).pipe( @@ -107,8 +92,6 @@ export const resolveStorageCredentials = Effect.fnUntraced(function* (opts: { ), ), ); - // `tenant.GetApiKeys` fails with `errMissingKey` ("Anon key not found.") - // when the response yields nothing. if (keys.anon === "" && keys.serviceRole === "") { return yield* new StorageMissingApiKeyError({ message: "Anon key not found." }); } @@ -130,11 +113,8 @@ export const resolveStorageCredentials = Effect.fnUntraced(function* (opts: { const baseUrl = resolveApiExternalUrl(api, getHostname()); const apiKey = yield* resolveLocalServiceRoleKey(opts.config.auth, projectEnvValues); - // `status.NewKongClient` installs unconditionally for the local client; its - // embedded CA only matters for https. `(*api).Validate` resolves cert_path / - // key_path and validates the pairing only when `api.enabled && api.tls.enabled`. - // Inject a CA whenever the resolved URL is https - // (the scheme derives from `api.tls.enabled` alone). + // Validate the cert/key pairing only when the API and TLS are both enabled; + // inject a CA whenever the resolved URL is https. let localKongCa: string | undefined; const validatedCa = api.enabled && api.tls.enabled @@ -153,12 +133,9 @@ export const resolveStorageCredentials = Effect.fnUntraced(function* (opts: { }); /** - * The config-load helpers this module composes (`envOverride*`, - * `decryptAuthSecret`, `resolveJwtSecret`, `validateApi*`) report invalid - * config by throwing. Each throw collapses into the tagged storage config - * error with the helper's message preserved — the same collapse every other - * consumer of these helpers applies (`wrapDbConfigOverride` → - * `DbConfigLoadError`) — keeping this Effect error channel tagged. + * Converts a thrown config-load validation error (from `envOverride*`, + * `decryptAuthSecret`, `resolveJwtSecret`, `validateApi*`) into a tagged + * `StorageConfigError`, preserving the original message. */ const toStorageConfigError = (cause: unknown) => new StorageConfigError({ @@ -166,23 +143,9 @@ const toStorageConfigError = (cause: unknown) => }); /** - * Fold the `SUPABASE_API_*` env/dotenv overrides into the `[api]` fields the - * local gateway derives its base URL and TLS material from. Every other local - * consumer of these fields already reads them post-override - * (`local-config-values.ts`'s resolvers, `start.handler.ts`'s - * `effectiveLocalStorageConfig`); without this fold, a stack brought up with - * e.g. `SUPABASE_API_PORT=54331` is unreachable here because the gateway URL - * falls back to the raw `config.toml` port (#6452). `projectEnvValues` is the - * nested project dotenv map (caller-supplied or loaded by - * `resolveStorageCredentials`), so a value set only in - * `supabase/.env`(.local) counts; a caller that already folded these overrides - * (`start`) re-resolves the same map to the same values, so the fold is - * idempotent. A malformed port/bool override or an enabled API whose resolved - * port is `0` (`validateApiPort` — the canonical branch) is an - * invalid-config hard failure, same as the sibling resolvers. - * `[remotes.*]` never merges on the local path (`loadCliConfig` receives no - * `projectRef` here), so the remote-over-env precedence those resolvers apply - * does not arise. + * Folds `SUPABASE_API_*` overrides into `[api]` before deriving the gateway + * URL, so a stack started with an overridden port stays reachable here + * instead of falling back to the raw `config.toml` value. */ const resolveLocalApiConfig = ( api: StorageConfigView["api"], @@ -217,29 +180,13 @@ const resolveLocalApiConfig = ( }); /** - * Resolve the service-role key for the local Storage gateway: - * - jwt secret: `SUPABASE_AUTH_JWT_SECRET` (shell or project dotenv) → - * `auth.jwt_secret` → `defaultJwtSecret`; a resolved secret shorter than 16 - * chars is rejected (`resolveJwtSecret`); - * - service-role key: `SUPABASE_AUTH_SERVICE_ROLE_KEY` (shell or project - * dotenv) → `auth.service_role_key` → sign from the resolved secret. - * - * Both fields go through the same `envOverride` → `decryptAuthSecret` - * composition the status/stop resolver applies to them - * (`local-config-values.ts`), in the same order (jwt secret first, so a short - * secret is reported before a broken service-role key), so a value set only in - * `supabase/.env`(.local) counts and a dotenvx `encrypted:` value is decrypted - * instead of being used as literal key material. An undecryptable value is an - * invalid-config hard failure, same as those siblings. As with the `[api]` fold - * above, `[remotes.*]` never merges on the local path, so the remote-over-env - * precedence those siblings gate on does not arise. The derivation itself stays - * symmetric (`generateGoJwt` from the secret — the same signer those siblings - * use, so without `auth.signing_keys_path` the minted token is the one `status` - * prints); `start` pre-folds its signing-keys-aware key for the - * `auth.signing_keys_path` case. + * Resolves the service-role key for the local Storage gateway: + * - jwt secret: `SUPABASE_AUTH_JWT_SECRET` → `auth.jwt_secret` → + * `defaultJwtSecret`, rejected if shorter than 16 chars. + * - service-role key: `SUPABASE_AUTH_SERVICE_ROLE_KEY` → + * `auth.service_role_key` → signed from the resolved jwt secret. * - * Empty checks use length, so an explicit `service_role_key = ""` is - * regenerated (not sent as the empty string). + * An explicit `service_role_key = ""` is treated as unset and regenerated. */ const resolveLocalServiceRoleKey = Effect.fnUntraced(function* ( auth: StorageConfigView["auth"], @@ -269,13 +216,9 @@ const resolveLocalServiceRoleKey = Effect.fnUntraced(function* ( }); /** - * Validate-only entry point for `seedBucketsRun`'s empty-config short-circuit: - * runs the config-load checks of the local branch in the seeding path's order — - * the `SUPABASE_API_*` decode + `validateApiPort`, the auth override/decrypt + - * jwt-secret length, then the `validateApiTlsPresence` pairing rule — without - * building credentials. The cert/key file reads stay on the seeding path - * (`validateLocalKongTls`). The resolved values are discarded; the seeding path - * re-resolves through `resolveStorageCredentials`. + * Runs the local config-load validations (API overrides, auth secret + * decryption, TLS presence) without building credentials, for `seed + * buckets`'s empty-config short-circuit. */ export const validateLocalStorageConfig = Effect.fnUntraced(function* ( config: StorageConfigView, @@ -292,13 +235,9 @@ export const validateLocalStorageConfig = Effect.fnUntraced(function* ( }); /** - * Validate + resolve the local Kong TLS config, mirroring `(*api).Validate`: - * cert without key (or vice-versa) errors; both - * present and readable returns the cert PEM; neither returns the embedded CA. - * - * Only called when `api.enabled && api.tls.enabled` (Go gates both path - * resolution and validation on `c.Api.Enabled`). The CLI uses only the CA cert, - * but Go reads the key to validate the pairing, so this mirrors that. + * Validates the local Kong TLS cert/key pairing: cert without key (or vice + * versa) errors; both present and readable returns the cert PEM; neither + * returns the embedded CA. Only called when the API and TLS are both enabled. */ const validateLocalKongTls = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, @@ -307,16 +246,14 @@ const validateLocalKongTls = Effect.fnUntraced(function* ( certPath: string | undefined, keyPath: string | undefined, ) { - // The canonical presence rule lives in `config-validate.ts`; only the - // file reads below are this caller's own I/O. + // Presence validation lives in `config-validate.ts`; this only does the file I/O. yield* Effect.try({ try: () => validateApiTlsPresence(certPath, keyPath), catch: toStorageConfigError, }); if (certPath !== undefined && certPath.length > 0) { - // TLS paths join unconditionally with the supabase dir — NO IsAbs guard - // (`path.Join` absorbs a leading "/"). + // Cert/key paths join unconditionally with the supabase dir, even if they look absolute. const absCert = path.join(workdir, "supabase", certPath); const certContent = yield* fs.readFileString(absCert).pipe( Effect.catchTag( @@ -344,12 +281,10 @@ const validateLocalKongTls = Effect.fnUntraced(function* ( }); /** - * Builds a `typeof globalThis.fetch` that injects `tls.ca` into every request, - * trusting the provided CA PEM for HTTPS connections to the local Kong gateway. - * Mirrors `newLocalClient`. - * - * Bun's fetch accepts `{ tls: { ca: string } }` via `BunFetchRequestInit`, which - * extends `RequestInit`; no `as` cast is needed. + * Returns a fetch that injects `tls.ca` into every request, trusting the + * given CA PEM for HTTPS connections to the local Kong gateway. Bun's fetch + * accepts `{ tls: { ca } }` via `BunFetchRequestInit`, which extends + * `RequestInit`, so no `as` cast is needed. */ function kongCaFetch(ca: string): typeof globalThis.fetch { const fetchImpl = async ( @@ -363,11 +298,10 @@ function kongCaFetch(ca: string): typeof globalThis.fetch { } /** - * The `FetchHttpClient.Fetch` override to provide for Storage gateway calls: a - * CA-trusting fetch for a local https gateway, plain `globalThis.fetch` - * otherwise. Storage calls never use DoH in Go (`newLocalClient` / - * `newRemoteClient` use `status.NewKongClient` / `http.DefaultClient`), so the - * DoH-wrapped shared client is always overridden at the gateway scope. + * `FetchHttpClient.Fetch` override for Storage gateway calls: a CA-trusting + * fetch for a local https gateway, plain `globalThis.fetch` otherwise. Storage + * calls never use DNS-over-HTTPS, so this always replaces the shared + * DoH-wrapped client at the gateway scope. */ export function storageGatewayFetch(localKongCa: string | undefined): typeof globalThis.fetch { return localKongCa !== undefined ? kongCaFetch(localKongCa) : globalThis.fetch; diff --git a/apps/cli/src/command-internal/storage-gateway.errors.ts b/apps/cli/src/command-internal/storage-gateway.errors.ts index f4643306cd..6009e17ecb 100644 --- a/apps/cli/src/command-internal/storage-gateway.errors.ts +++ b/apps/cli/src/command-internal/storage-gateway.errors.ts @@ -7,24 +7,16 @@ import { } from "../shared/telemetry/error-actionability.ts"; /** - * Errors for the Supabase Storage **service gateway** (Kong), shared by every - * command that talks to Storage directly (`seed buckets`, `storage ls/cp/mv/rm`). - * Mirrors Go's `pkg/fetcher` error shapes: - * - transport failure (`failed to execute http request`) → - * `StorageGatewayNetworkError` - * - non-200 response (`Error status <d>: <body>`, `pkg/fetcher/http.go:112`) → - * `StorageGatewayStatusError` - * - * `message` reproduces Go's verbatim error text. `body` is carried on the status - * error so callers can classify it (e.g. `mv`'s `"error":"not_found"` and `rm`'s - * `"error":"Bucket not found"` substrings, and `seed`'s vector graceful-skip). + * Errors for the Supabase Storage service gateway (Kong): a transport + * failure (`StorageGatewayNetworkError`) or non-200 response + * (`StorageGatewayStatusError`), with `body` kept for error classification. */ export class StorageGatewayNetworkError extends Data.TaggedError("StorageGatewayNetworkError")<{ readonly message: string; /** - * Set when this failure is a 200-response body that failed to decode - * (`failParse`) rather than a transport failure — so a malformed-body decode - * classifies as an API response problem instead of a network problem. + * Set when this is a 200-response body that failed to decode, rather than a + * transport failure, so it classifies as an API response problem instead of + * a network problem. */ readonly decode?: boolean; }> { @@ -41,12 +33,9 @@ export class StorageGatewayStatusError extends Data.TaggedError("StorageGatewayS readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // The tenant Storage gateway is not the Management API: a 401/403 here - // means stale local service keys, which `supabase login` cannot fix, so - // the Management-API auth/permission policy must not apply. This tag also - // spans collection and capability-probe routes (including unsupported - // vector routes returning 404), so it cannot safely opt every 404 into the - // named-resource policy. + // A 401/403 here means stale local service keys, not a login problem, so + // the Management-API auth policy doesn't apply. This tag also covers + // capability-probe routes where a 404 isn't a named-resource lookup. if (this.status === 401 || this.status === 403) { return { ...actionability.apiStatus, fingerprint_suffix: "gateway_auth" }; } diff --git a/apps/cli/src/command-internal/storage-gateway.ts b/apps/cli/src/command-internal/storage-gateway.ts index 61a1ed405a..3fd73f6f1a 100644 --- a/apps/cli/src/command-internal/storage-gateway.ts +++ b/apps/cli/src/command-internal/storage-gateway.ts @@ -9,16 +9,13 @@ import { StorageGatewayNetworkError, StorageGatewayStatusError } from "./storage import { goPathSplit } from "./storage-url.ts"; /** - * Native TypeScript client for the Supabase Storage **service gateway** (Kong), - * mirroring `pkg/storage/{buckets,objects,vector,api}.go` and the - * `fetcher.NewServiceGateway` auth headers — see {@link kongAuthHeaders} - * for the exact `apikey`/`Authorization` header shape. + * Client for the Supabase Storage service gateway (Kong). See + * {@link kongAuthHeaders} for the auth header shape. * - * Shared by `seed buckets` (bucket/object/vector upsert against the local stack) - * and `storage ls/cp/mv/rm` (object list/download/move/delete + bucket delete). + * Shared by `seed buckets` (bucket/object/vector upsert) and `storage + * ls/cp/mv/rm` (object list/download/move/delete + bucket delete). */ -/** `pkg/storage/api.go`. */ export const PAGE_LIMIT = 100; export const DELETE_OBJECTS_LIMIT = 1000; @@ -27,25 +24,20 @@ interface BucketSummary { readonly id: string; } -/** A `/storage/v1/object/list/{bucket}` entry: a directory when `Id == nil`. */ +/** A `/storage/v1/object/list/{bucket}` entry: a directory when `id` is absent. */ interface StorageObject { readonly name: string; readonly isDir: boolean; } export interface UpsertBucketProps { - /** - * Tri-state to match `Public *bool` with `json:"public,omitempty"`: - * `undefined` when `public` is absent from the bucket's TOML (field omitted), - * otherwise the explicit value. - */ + /** `undefined` when `public` is absent from the bucket's TOML config; otherwise the explicit value. */ readonly public: boolean | undefined; - /** Byte count; omitted from the request body when 0 (Go `omitempty`). */ + /** Byte count; omitted from the request body when 0. */ readonly fileSizeLimit: number; readonly allowedMimeTypes: ReadonlyArray<string>; } -/** Upload headers, mirroring `FileOptions`. */ interface UploadObjectOptions { readonly contentType: string; readonly cacheControl: string; @@ -120,12 +112,9 @@ export interface StorageGateway { } /** - * Strict JSON decode mirroring `fetcher.ParseJSON[T]` - * (`pkg/fetcher/http.go` — `json.NewDecoder(r).Decode(&data)`): a body whose - * shape doesn't match the typed target aborts. Only missing fields, `null` - * (decoded as the zero value), empty arrays, and extra keys are tolerated; a - * non-matching top-level type, a non-null non-object element, or a - * present-but-wrong-typed string field fail. + * Decode errors from below share this permissive-but-strict policy: missing + * fields, `null`, empty arrays, and extra keys are tolerated; a non-matching + * top-level type or a wrong-typed field fails the decode. */ function failParse(detail: string): StorageGatewayNetworkError { return new StorageGatewayNetworkError({ @@ -135,9 +124,9 @@ function failParse(detail: string): StorageGatewayNetworkError { } /** - * Port for `localGatewayHint`: the - * port-conflict hint fires only for a loopback host with a port, reporting THAT - * URL's port (not `api.port`, which can differ when `api.external_url` is set). + * Returns the port for `localGatewayHint`'s port-conflict message: only for a + * loopback host, and using the resolved URL's port rather than `api.port` + * (which can differ when `api.external_url` is set). */ function localGatewayHintPort(baseUrl: string): string | undefined { try { @@ -152,7 +141,6 @@ function localGatewayHintPort(baseUrl: string): string | undefined { return undefined; } -/** Byte-identical to `localGatewayHint` message. */ function localGatewayHint(port: string): string { return ( "The local Supabase API gateway did not return a valid HTTP response. " + @@ -162,10 +150,9 @@ function localGatewayHint(port: string): string { } /** - * Whether a transport failure is a plain connection-refused. Go's - * `localGatewayHint` fires only for malformed-response / timeout — NOT - * `ECONNREFUSED` — so the port-conflict hint is suppressed for refused - * connections. + * Whether a transport failure is a plain connection-refused. The + * port-conflict hint is suppressed for these, since a refused connection + * means nothing is listening, not a malformed response. */ function isConnectionRefused(error: HttpClientError.TransportError): boolean { const detail = @@ -179,7 +166,7 @@ const parseJsonBody = (body: string): Effect.Effect<unknown, StorageGatewayNetwo catch: (cause) => failParse(String(cause)), }); -/** A JSON object → itself; `null` → `{}` (Go zero-value struct); other → `null`. */ +/** A JSON object → itself; `null` → `{}`; anything else → `null`. */ function asObject(entry: unknown): Record<string, unknown> | null { if (entry === null) return {}; return typeof entry === "object" && !Array.isArray(entry) @@ -187,7 +174,7 @@ function asObject(entry: unknown): Record<string, unknown> | null { : null; } -/** Go-struct string field: absent/`null` → ""; wrong type → `null` (decode failure). */ +/** A string field: absent or `null` decodes as `""`; a wrong type fails the decode (returns `null`). */ function decodeStringField(obj: Record<string, unknown>, key: string): string | null { const value = obj[key]; if (value === undefined || value === null) return ""; @@ -217,10 +204,8 @@ const decodeBucketSummaries = ( }); /** - * Decode `[]ObjectResponse`: `Id` is a `*string`, - * so an absent or `null` id marks a directory (`o.Id == nil`); - * a present id (any non-null) marks a file. A - * non-string non-null id fails the decode, matching `*string` unmarshal. + * Decodes a storage object list entry: an absent or `null` `id` marks a + * directory; any other non-string value fails the decode. */ const decodeStorageObjects = ( body: string, @@ -277,10 +262,9 @@ const decodeVectorBucketNames = ( }); /** - * Validate a `{<field>}` success body and return the field's value. Go's - * mutations decode the 200 body via `fetcher.ParseJSON` into `{name}`/`{message}` - * and fail on a non-JSON/empty body. `null` is tolerated (Go's zero value); a - * non-object top-level or a wrong-typed field fails. + * Validates a `{<field>}` success body and returns the field's value. `null` + * decodes as an empty result; a non-object top-level or a wrong-typed field + * fails. */ const decodeFieldResponse = ( body: string, @@ -340,9 +324,9 @@ const decodeAnalyticsBucketNames = ( }); /** - * Build the create/update bucket body with `omitempty` semantics: - * `public` (a `*bool`) is omitted when absent - * from the TOML, `file_size_limit` when 0, `allowed_mime_types` when empty. + * Builds the create/update bucket request body: `public` is omitted when + * absent from the TOML config, `file_size_limit` when 0, and + * `allowed_mime_types` when empty. */ export function bucketBody(props: UpsertBucketProps): Record<string, unknown> { const body: Record<string, unknown> = {}; @@ -391,9 +375,7 @@ export const makeStorageGateway = Effect.fnUntraced(function* (opts: { HttpClientRequest.setHeaders(kongAuthHeaders(opts.apiKey)), ); - // Sends a request and returns the response body text, reproducing Go's - // fetcher error shapes (`pkg/fetcher/http.go`). Go's service gateway installs - // `WithExpectedStatus(http.StatusOK)`, so only exactly 200 is a success. + // Sends a request and returns the response body text; only exactly 200 counts as success. const send = Effect.fnUntraced(function* (req: HttpClientRequest.HttpClientRequest) { const { status, body } = yield* Effect.gen(function* () { const response = yield* httpClient.execute(req); @@ -493,10 +475,9 @@ export const makeStorageGateway = Effect.fnUntraced(function* (opts: { if (options.overwrite) { req = req.pipe(HttpClientRequest.setHeader("x-upsert", "true")); } - // `bodyFile` stats the file for Content-Length and streams it via - // FileSystem rather than buffering — the analogue of Go's open-and-stream - // upload. The captured FileSystem is supplied here so the gateway's public - // Effect type stays free of a service requirement. + // `bodyFile` streams the file without buffering. FileSystem is provided + // here (not via the Effect type) so the gateway's public Effect stays + // free of a service requirement. const withBody = options.contentType.length > 0 ? HttpClientRequest.bodyFile(req, absPath, { contentType: options.contentType }) diff --git a/apps/cli/src/command-internal/storage-runtime.layer.ts b/apps/cli/src/command-internal/storage-runtime.layer.ts index 6c03b9876e..db42c1ad92 100644 --- a/apps/cli/src/command-internal/storage-runtime.layer.ts +++ b/apps/cli/src/command-internal/storage-runtime.layer.ts @@ -20,15 +20,11 @@ import { CommandRuntime } from "../shared/runtime/command-runtime.service.ts"; /** * Runtime layer for the commands that talk to the Storage gateway directly: - * `seed buckets` and `storage ls/cp/mv/rm`. The Management API client is **lazy** - * so the LOCAL path (no `--linked`) never resolves a token / requires a login: - * `commandPlatformApiFactoryLayer` defers token resolution to the first - * `factory.make` call, which only fires on the `--linked` branch (the remote - * service-role-key fetch). + * `seed buckets` and `storage ls/cp/mv/rm`. The Management API client is lazy, + * so the local path (no `--linked`) never resolves a token or requires login. * - * `HttpClient` is exposed at the top level because the Storage gateway requires - * an `HttpClient` service directly rather than going through the typed - * Management API client. + * `HttpClient` is exposed at the top level because the Storage gateway needs + * it directly, not through the typed Management API client. */ export function storageGatewayRuntimeLayer(subcommand: ReadonlyArray<string>) { const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); @@ -37,10 +33,8 @@ export function storageGatewayRuntimeLayer(subcommand: ReadonlyArray<string>) { Layer.provide(cliSettings), Layer.provide(debugLoggerLayer), ); - // Lazy factory: build does NOT resolve a token. Token resolution is deferred - // until `factory.make` is first called — i.e. when the `--linked` branch - // actually executes. The LOCAL path completes without touching the Management - // API. + // Token resolution is deferred until `factory.make` is first called, on the + // `--linked` branch. const platformApiFactory = commandPlatformApiFactoryLayer.pipe( Layer.provide(credentials), Layer.provide(cliSettings), diff --git a/apps/cli/src/command-internal/storage-url.ts b/apps/cli/src/command-internal/storage-url.ts index edfb510d5b..6bcc9c9447 100644 --- a/apps/cli/src/command-internal/storage-url.ts +++ b/apps/cli/src/command-internal/storage-url.ts @@ -6,32 +6,22 @@ import { } from "../shared/telemetry/error-actionability.ts"; /** - * Storage URL parsing, matching `url.Parse` semantics for the `ss://` scheme. - * - * Two layers: - * - `goUrlParse` reproduces `url.Parse` for the fields the storage - * commands read (`Scheme`, `Host`, `Path`) and the parse errors they surface - * (`missing protocol scheme`, malformed `%` escape, control byte). `cp` parses - * `src`/`dst` with this directly (Go uses `url.Parse`, not `ParseStorageURL`). - * - `parseStorageUrl` is `ParseStorageURL`: parse, then require + * Storage URL parsing, matching Go's `net/url.Parse` semantics for the `ss://` + * scheme. `goUrlParse` implements `net/url.Parse` for the fields storage + * commands need (scheme, host, path); `parseStorageUrl` additionally requires * scheme `ss` (case-insensitive), a non-empty path, and no host. * - * Kept pure (no Effect, no command-specific tagged errors) so the handlers map - * the thrown errors to their own tagged errors and the parser stays - * unit-testable against `scheme_test.go`. + * Kept pure (no Effect) so handlers can map the thrown errors to their own + * tagged errors. */ -/** Go `client.STORAGE_SCHEME`. */ export const STORAGE_SCHEME = "ss"; -/** Go `client.ErrInvalidURL` message. */ const STORAGE_INVALID_URL_MESSAGE = "URL must match pattern ss:///bucket/[prefix]"; /** - * Thrown when `goUrlParse` fails, mirroring `*url.Error`: - * `parse "<url>": <inner>` (`net/url.Error.Error`). Callers wrap `.message` in - * their own `failed to parse … url: <message>` text, matching Go's - * `errors.Errorf("failed to parse … url: %w", err)`. + * Thrown when `goUrlParse` fails: `parse "<url>": <inner>`. Callers wrap + * `.message` in their own `failed to parse … url: <message>` text. */ export class GoUrlParseError extends Error { static readonly [ErrorActionabilityFingerprintId] = "GoUrlParseError"; @@ -47,9 +37,8 @@ export class GoUrlParseError extends Error { /** * Thrown when a URL parses but does not match the `ss:///bucket/[prefix]` - * pattern (`ErrInvalidURL`). Distinct from `GoUrlParseError` so the - * handler can map it to `StorageUrlPatternError` rather than the - * parse-error tagged error. + * pattern. Distinct from `GoUrlParseError` so handlers can map it to a + * separate tagged error. */ export class StorageUrlPatternError extends Error { static readonly [ErrorActionabilityFingerprintId] = "StorageUrlPatternError"; @@ -64,11 +53,11 @@ export class StorageUrlPatternError extends Error { } export interface GoUrl { - /** Lowercased scheme (Go lowercases via `strings.ToLower`); `""` when none. */ + /** Lowercased scheme; `""` when none. */ readonly scheme: string; /** Authority host (after `//`); `""` when absent. */ readonly host: string; - /** Unescaped path (Go `url.Path`); `""` when the URL is opaque/host-only. */ + /** Unescaped path; `""` when the URL is opaque/host-only. */ readonly path: string; } @@ -80,7 +69,7 @@ function isDigit(c: number): boolean { return c >= 0x30 && c <= 0x39; } -/** Go `net/url.stringContainsCTLByte` (`url.go`). */ +/** Whether `s` contains a control byte (other than tab). */ function containsCtlByte(s: string): boolean { for (let i = 0; i < s.length; i++) { const b = s.charCodeAt(i); @@ -90,10 +79,9 @@ function containsCtlByte(s: string): boolean { } /** - * Go `net/url.getScheme`: a scheme is `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )` - * terminated by `:`. A leading non-alpha, or any out-of-grammar byte before the - * first `:`, means there is no scheme (the whole input is the rest). A leading - * `:` is the `missing protocol scheme` error. + * A scheme is `ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )` terminated by `:`. + * A leading non-alpha, or any out-of-grammar byte before the first `:`, means + * there is no scheme. A leading `:` is the `missing protocol scheme` error. */ function getScheme(rawURL: string): { scheme: string; rest: string } { for (let i = 0; i < rawURL.length; i++) { @@ -126,14 +114,9 @@ function unhex(c: number): number { } /** - * Go `net/url.unescape(s, encodePath)`: decode `%XX` sequences, erroring on a - * malformed escape (`invalid URL escape "%XY"`). In `encodePath` mode `+` is - * literal. - * - * Go decodes `%XX` to raw bytes and the resulting `Path` is a byte string that, - * for a UTF-8 source, reads as the Unicode runes. To match, consecutive `%XX` - * bytes are collected and decoded as a single UTF-8 run (`%E4%B8%AD` → `中`, not - * three per-byte code points); literal characters pass through unchanged. + * Decodes `%XX` sequences, erroring on a malformed escape (`invalid URL + * escape "%XY"`). Consecutive `%XX` bytes are collected and decoded together + * as a single UTF-8 run (`%E4%B8%AD` → `中`, not three separate code points). */ function unescapePath(s: string): string { if (!s.includes("%")) return s; @@ -165,13 +148,13 @@ function unescapePath(s: string): string { return out; } -/** Go `net/url` authority host extraction (userinfo split at the last `@`). */ +/** Authority host: userinfo split at the last `@`. */ function hostFromAuthority(authority: string): string { const at = authority.lastIndexOf("@"); return at === -1 ? authority : authority.slice(at + 1); } -/** Go `net/url.validOptionalPort`: `""`, or `:` followed by only digits. */ +/** `""`, or `:` followed by only digits. */ function isValidOptionalPort(port: string): boolean { if (port.length === 0) return true; if (port.charCodeAt(0) !== 0x3a /* : */) return false; @@ -182,22 +165,11 @@ function isValidOptionalPort(port: string): boolean { } /** - * Go `net/url.parseHost`, restricted to the branches this module's callers - * can actually hit: IP-literal (`[...]`) bracket/port validation, and the bare - * `host[:port]` port-digit check. Deliberately does NOT implement IPv6 - * zone-identifier percent-decoding or `netip.ParseAddr` address validation - * (Go additionally requires a bracketed literal to parse as a valid, - * non-IPv4 IP address) — callers only need to know whether validation - * THROWS, and a config author writing a bogus-but-bracket-balanced IPv6 - * literal is exotic enough that the narrower `netip.ParseAddr` check isn't - * worth the added surface; the common bracket-mismatch/invalid-port mistakes - * Go actually raises for realistic input (e.g. `http://[::1`, a truncated - * IPv6 literal) are still reproduced byte-for-byte. Also skips Go's - * `GODEBUG=urlstrictcolons=0` legacy multi-colon opt-out — - * an explicit, non-default env var this codebase's bundled Go binary doesn't - * set, so the http/https "first colon is the port separator" behavior always - * applies for those schemes; other schemes use the last colon, matching Go's - * unconditional `else` branch for non-http(s) schemes. + * Validates IP-literal (`[...]`) bracket/port syntax and `host[:port]` + * port-digit checks, matching Go's host validation for realistic inputs but + * skipping bracketed-literal IP-address checks and IPv6 zone-id decoding. + * http/https treat the first colon as the port separator; other schemes use + * the last. */ function validateGoUrlHost(scheme: string, host: string): void { if (host.length === 0) return; @@ -227,13 +199,11 @@ function validateGoUrlHost(scheme: string, host: string): void { } /** - * Port of `url.Parse` restricted to `Scheme`/`Host`/`Path`. Throws - * `GoUrlParseError` (`*url.Error`) on the failures the storage - * commands can hit. Query (`?…`) and fragment (`#…`) are stripped exactly as Go - * splits them, though storage URLs never use them. + * Parses a URL for `scheme`/`host`/`path`, matching Go's `net/url.Parse`. + * Throws `GoUrlParseError` on parse failure. Query and fragment are stripped, + * though storage URLs never use them. */ export function goUrlParse(rawURL: string): GoUrl { - // Go `Parse` cuts the fragment before calling the internal `parse`. const hashIdx = rawURL.indexOf("#"); const u = hashIdx === -1 ? rawURL : rawURL.slice(0, hashIdx); @@ -254,7 +224,7 @@ export function goUrlParse(rawURL: string): GoUrl { throw new GoUrlParseError(u, cause instanceof Error ? cause.message : String(cause)); } - // Strip the query (Go: `rest, RawQuery, _ = strings.Cut(rest, "?")`). + // Strip the query string. const qIdx = rest.indexOf("?"); if (qIdx !== -1) rest = rest.slice(0, qIdx); @@ -272,7 +242,8 @@ export function goUrlParse(rawURL: string): GoUrl { } let host = ""; - // Go: enter authority parsing only when (scheme set OR not "///"-prefixed) AND "//"-prefixed. + // Skip authority parsing for a schemeless triple-slash prefix ("///x") — + // that's a path, not an authority. if ((scheme !== "" || !rest.startsWith("///")) && rest.startsWith("//")) { const afterSlashes = rest.slice(2); const slash = afterSlashes.indexOf("/"); @@ -296,11 +267,10 @@ export function goUrlParse(rawURL: string): GoUrl { } /** - * Go `client.ParseStorageURL`: parse, then require scheme - * `ss` (case-insensitive), a non-empty path, and no host. Returns the path. - * Throws `GoUrlParseError` on a url-parse failure (wrapped by the caller - * as `failed to parse storage url: …`) or `StorageUrlPatternError` when - * the parsed URL doesn't match the `ss:///bucket/[prefix]` pattern. + * Parses a storage URL, requiring scheme `ss` (case-insensitive), a + * non-empty path, and no host, and returns the path. Throws + * `GoUrlParseError` on a parse failure, or `StorageUrlPatternError` when it + * doesn't match `ss:///bucket/[prefix]`. */ export function parseStorageUrl(objectURL: string): string { const parsed = goUrlParse(objectURL); @@ -311,9 +281,9 @@ export function parseStorageUrl(objectURL: string): string { } /** - * Go `client.SplitBucketPrefix`: `/bucket/folder/x` → - * `["bucket", "folder/x"]`; `/bucket/` / `/bucket` / `bucket` → `["bucket", ""]`; - * `""` / `"/"` → `["", ""]`. + * Splits an object path into `[bucket, prefix]`: `/bucket/folder/x` → + * `["bucket", "folder/x"]`; `/bucket/`, `/bucket`, `bucket` → `["bucket", ""]`; + * `""`, `"/"` → `["", ""]`. */ export function splitBucketPrefix(objectPath: string): readonly [string, string] { if (objectPath === "" || objectPath === "/") { @@ -328,29 +298,22 @@ export function splitBucketPrefix(objectPath: string): readonly [string, string] } /** - * Lowercased scheme for a raw URL, mirroring `cp`'s - * `strings.EqualFold(parsed.Scheme, STORAGE_SCHEME)` branch input. `""` when the - * URL has no scheme (a local path). Throws `GoUrlParseError` on a malformed - * URL (e.g. `:`), exactly as `cp`'s `url.Parse` does. + * Lowercased scheme for a raw URL; `""` when the URL has no scheme (a local + * path). Throws `GoUrlParseError` on a malformed URL (e.g. `:`). */ export function detectScheme(rawURL: string): string { return goUrlParse(rawURL).scheme; } -/** - * Go `cp.IsDir`: an object prefix is a directory when it is - * empty or ends with `/`. - */ +/** An object prefix is a directory when it is empty or ends with `/`. */ export function storageIsDir(objectPrefix: string): boolean { return objectPrefix.length === 0 || objectPrefix.endsWith("/"); } /** - * Go `path.Split`: split after the final slash into `[dir, file]`, where `dir` - * keeps its trailing slash. `folder/name.png` → `["folder/", "name.png"]`; - * `dir` → `["", "dir"]`; `tmp/` → `["tmp/", ""]`; `""` → `["", ""]`. Used by the - * gateway's `listObjects` query and the recursive - * walk's base-path computation. + * Splits after the final slash into `[dir, file]`, where `dir` keeps its + * trailing slash: `folder/name.png` → `["folder/", "name.png"]`; `dir` → + * `["", "dir"]`; `tmp/` → `["tmp/", ""]`; `""` → `["", ""]`. */ export function goPathSplit(p: string): readonly [string, string] { const i = p.lastIndexOf("/"); diff --git a/apps/cli/src/command-internal/storage-url.unit.test.ts b/apps/cli/src/command-internal/storage-url.unit.test.ts index ef98be2298..284b0ab346 100644 --- a/apps/cli/src/command-internal/storage-url.unit.test.ts +++ b/apps/cli/src/command-internal/storage-url.unit.test.ts @@ -54,7 +54,6 @@ describe("parseStorageUrl", () => { }); }); -// Oracle: SplitBucketPrefix sub-tests in scheme_test.go describe("splitBucketPrefix", () => { it.each([ ["", ["", ""]], @@ -85,9 +84,7 @@ describe("detectScheme", () => { }); }); -// Oracle: `go run` against Go 1.25's `net/url.Parse` directly (net/url/url.go's -// `parseHost`) — used by `studio.api_url` validation, which needs the Host, not -// just Scheme/Path, so the failures below matter beyond the storage commands. +// Host validation here also backs `studio.api_url` validation. describe("goUrlParse (host validation)", () => { it("rejects an unterminated IPv6 literal", () => { expect(() => goUrlParse("http://[::1")).toThrow(GoUrlParseError); diff --git a/apps/cli/src/command-internal/string-slice-flag.ts b/apps/cli/src/command-internal/string-slice-flag.ts index 00b92bd7df..d754c79305 100644 --- a/apps/cli/src/command-internal/string-slice-flag.ts +++ b/apps/cli/src/command-internal/string-slice-flag.ts @@ -6,15 +6,11 @@ import { } from "../shared/telemetry/error-actionability.ts"; /** - * Parses a pflag `StringSliceVar` flag: CSV-splits each occurrence via - * `encoding/csv` and accumulates across repeats, matching `readAsCSV` in - * `github.com/spf13/pflag/string_slice.go`'s `stringSliceValue.Set`. A naive - * `.split(",")` diverges on quoted/embedded commas (`'"a,b",c'`). Effect V4 - * CLI has no CSV/list primitive, so every Go `StringSliceVar` flag ported to - * the CLI needs this (e.g. `--domains`, `--config`). + * Parses a pflag `StringSliceVar` flag: CSV-splits each occurrence, matching + * `encoding/csv`, and accumulates across repeats. A naive `.split(",")` + * diverges on quoted/embedded commas (e.g. `'"a,b",c'`). * - * Whitespace is NOT trimmed and empty fields are NOT dropped: Go's csv.Reader - * returns raw field values; pflag appends them directly to the slice. + * Whitespace is not trimmed and empty fields are not dropped. */ import { Flag } from "effect/unstable/cli"; @@ -25,18 +21,15 @@ const CR = 0x0d; // \r const EMPTY = new Uint8Array(0); -/** Go `encoding/csv` `lengthNL`: number of bytes for the trailing `\n`. */ +/** Number of bytes for the trailing `\n`, if any. */ const lengthNL = (b: Uint8Array): number => (b.length > 0 && b[b.length - 1] === LF ? 1 : 0); /** * Thrown by `parseStringSliceFlag` when a value is not valid CSV. * - * `message` reproduces Go's error string byte-for-byte: - * - `csv.ParseError.Error()` for malformed CSV (with the - * `record on line %d; ` prefix when the record starts on an earlier - * physical line than the error — `encoding/csv/reader.go`), or - * - the literal `EOF` when the value contains only blank lines (pflag's - * `readAsCSV` propagates `csv.Reader.Read`'s `io.EOF` unchanged). + * `message` is either a `parse error on line N, column N: <detail>` string + * (with a `record on line N; ` prefix when the record started on an earlier + * line), or the literal `EOF` when the value is only blank lines. */ export class StringSliceFlagParseError extends Error { static readonly [ErrorActionabilityFingerprintId] = "StringSliceFlagParseError"; @@ -46,7 +39,7 @@ export class StringSliceFlagParseError extends Error { this.name = "StringSliceFlagParseError"; this.value = value; } - /** Mirrors Go `csv.ParseError.Error()`. Line/column are 1-based; column is a byte offset within the physical line. */ + /** Line/column are 1-based; column is a byte offset within the physical line. */ static parse( value: string, startLine: number, @@ -60,7 +53,7 @@ export class StringSliceFlagParseError extends Error { startLine !== line ? `record on line ${startLine}; ${location}` : location, ); } - /** Mirrors pflag propagating `io.EOF` (value is nothing but blank lines). */ + /** Value is nothing but blank lines. */ static eof(value: string): StringSliceFlagParseError { return new StringSliceFlagParseError(value, "EOF"); } @@ -71,45 +64,22 @@ export class StringSliceFlagParseError extends Error { } /** - * Parses one CSV record from `val`, a byte-faithful port of a single - * `csv.Reader.Read()` with `NewReader` defaults (comma delimiter, no - * comments, `LazyQuotes`/`TrimLeadingSpace` off) — the exact call pflag's - * `readAsCSV` makes (`string_slice.go`). Ported from `readRecord`/`readLine` - * in `encoding/csv/reader.go`; verified against the real Go CLI, - * including multiline and multibyte values. - * - * Semantics that only show up with `\r`/`\n` in the value (all Go-observed): - * - **Only the first record is read.** An unquoted newline ends the record - * and everything after it is silently dropped (`1.2.3.4\n5.6.7.8` → - * `["1.2.3.4"]`), because pflag calls `Read()` once. - * - Blank lines before the record are skipped; a value that is *only* blank - * lines makes `Read()` return `io.EOF`, which pflag surfaces as the error - * `EOF`. - * - `\r\n` is normalized to `\n` (so a quoted multiline field keeps `\n`, - * not `\r\n`); a lone `\r` is kept, except a trailing `\r` at EOF, which - * is dropped. - * - Parse errors report 1-based physical line numbers and 1-based **byte** - * columns within that line, with a `record on line N; ` prefix when the - * record started on an earlier line. - * - * **Throws `StringSliceFlagParseError`** on the malformed-CSV - * conditions `csv.Reader` rejects: - * - Quoted field with no closing quote (`"tenant`) → "extraneous or missing \" in quoted-field" (column = one past the last byte, Go hits EOF) - * - Extra non-comma bytes after a closing quote (`"a"b`) → "extraneous or missing \" in quoted-field" (column = the closing quote) - * - A bare `"` inside an unquoted field (`a"b`) → "bare \" in non-quoted-field" (column = the bare quote) + * Parses one CSV record from `val`, matching Go's `encoding/csv` reader as + * used by pflag's `readAsCSV`. Only the first record is read — an unquoted + * newline ends it and the rest is dropped. Blank lines before the record are + * skipped; blank-only input throws `EOF`. `\r\n` normalizes to `\n`, and + * parse errors report 1-based line/byte-column positions. */ function readAsCSVStrict(val: string): string[] { if (val === "") return []; - // Go's csv.Reader works on bytes; ASCII delimiters (`"`, `,`, `\r`, `\n`) - // never appear inside multibyte UTF-8 sequences, so byte scanning is exact - // and columns come out in bytes for free. + // ASCII delimiters never appear inside multibyte UTF-8 sequences, so byte + // scanning is exact and columns come out in bytes for free. const input = new TextEncoder().encode(val); let offset = 0; let numLine = 0; - // Port of `readLine`: returns one line INCLUDING its trailing `\n`, with - // `\r\n` normalized to `\n` and a trailing `\r` before EOF dropped. - // Returns null for `io.EOF` (nothing left to read). + // Returns one line including its trailing `\n`, with `\r\n` normalized to + // `\n` and a trailing `\r` before EOF dropped. Returns null at EOF. const readLine = (): Uint8Array | null => { if (offset >= input.length) return null; const nl = input.indexOf(LF, offset); @@ -120,14 +90,14 @@ function readAsCSVStrict(val: string): string[] { } else { line = input.subarray(offset); offset = input.length; - // For backwards compatibility, Go drops a trailing \r before EOF. + // A trailing \r before EOF is dropped. if (line.length > 0 && line[line.length - 1] === CR) { line = line.subarray(0, line.length - 1); } } numLine++; - // Normalize \r\n to \n on all input lines. Mutating is safe: `input` is - // our own copy and these bytes are never re-read. + // Normalize \r\n to \n. Mutating is safe: `input` is our own copy and + // these bytes are never re-read. const n = line.length; if (n >= 2 && line[n - 2] === CR && line[n - 1] === LF) { line[n - 2] = LF; @@ -136,8 +106,8 @@ function readAsCSVStrict(val: string): string[] { return line; }; - // Read the record's first line, skipping past blank lines (Go's - // `readRecord` empty-line loop). EOF here is pflag's `EOF` error. + // Read the record's first line, skipping past blank lines. EOF here + // throws the `EOF` error. let line: Uint8Array; for (;;) { const next = readLine(); @@ -147,7 +117,6 @@ function readAsCSVStrict(val: string): string[] { break; } - // Port of `readRecord`'s parseField loop (LazyQuotes/TrimLeadingSpace off). const recLine = numLine; // Starting line for record const pos = { line: numLine, col: 1 }; const recordBuffer: number[] = []; @@ -204,13 +173,11 @@ function readAsCSVStrict(val: string): string[] { fieldIndexes.push(recordBuffer.length); continue parseField; } else if (lengthNL(line) === line.length) { - // `"\n` sequence (end of line — pflag reads ONE record, so any - // remaining input is dropped). + // `"\n` sequence: end of line; remaining input is dropped (only one record is read). fieldIndexes.push(recordBuffer.length); break parseField; } else { - // `"*` sequence (invalid non-escaped quote). Go reports the byte - // position of the closing quote itself (`pos.col - quoteLen`). + // `"*` sequence: invalid non-escaped quote; reports the byte position of the closing quote. throw StringSliceFlagParseError.parse( val, recLine, @@ -220,8 +187,8 @@ function readAsCSVStrict(val: string): string[] { ); } } else if (line.length > 0) { - // Hit end of line (copy all data so far, INCLUDING the `\n` — this - // is how a quoted multiline field keeps its newline). + // Hit end of line: copy all data so far, including the `\n`, since a + // quoted multiline field keeps its newline. append(line); pos.col += line.length; const next = readLine(); @@ -245,7 +212,6 @@ function readAsCSVStrict(val: string): string[] { } } - // Create the field strings out of the accumulated record bytes. const record = new Uint8Array(recordBuffer); const decoder = new TextDecoder(); const fields: string[] = []; @@ -258,18 +224,12 @@ function readAsCSVStrict(val: string): string[] { } /** - * CSV-parses and flattens all raw occurrences of a repeated pflag `StringSlice` flag. + * CSV-parses and flattens all raw occurrences of a repeated `StringSlice` + * flag. Throws `StringSliceFlagParseError` on the first malformed value, + * failing the command before it runs. * - * **Throws `StringSliceFlagParseError`** on the first malformed value, matching - * Go's pflag parse-time behaviour where a bad value fails the command before it - * runs (Go: `invalid argument "..." for "--<flag>" flag: parse error ...`). - * - * Valid behaviour: - * - `"tenant,one"` → `["tenant,one"]` (quoted comma stays one field) - * - `public,private` → `["public", "private"]` - * - no trimming, `""` escapes a literal quote inside a quoted field - * - empty string → no field; a value with an unquoted newline keeps only - * the first line's record (pflag reads a single CSV record) + * Quoted commas stay in one field (`"tenant,one"` → `["tenant,one"]`); + * whitespace is not trimmed; an unquoted newline keeps only the first line. */ export function parseStringSliceFlag(rawValues: ReadonlyArray<string>): ReadonlyArray<string> { const values: string[] = []; @@ -282,26 +242,12 @@ export function parseStringSliceFlag(rawValues: ReadonlyArray<string>): Readonly } /** - * Builds a flag that ports a pflag `StringSliceVar`/`StringSliceVarP`: - * repeatable, CSV-split per occurrence, accumulated across repeats. - * - * On malformed CSV it fails at parse time — matching pflag's - * `readAsCSV` error aborting flag parsing before the `--experimental` gate, - * telemetry, and the handler — with - * pflag's exact diagnostic: `invalid argument %q for %q flag: %v`. - * The full established message is emitted as the failure's `expected` text so the - * renderer's pflag passthrough (`formatInvalidValueMessage`) prints it - * verbatim, byte-matching the established stderr line. `JSON.stringify` mirrors - * `%q` for the ASCII/printable-Unicode values these flags carry — - * including `\n`/`\r` escapes in multiline values (same precedent as - * `sso.format.ts`). + * Builds a repeatable CSV-split flag matching pflag's `StringSliceVar` + * behavior, including its `invalid argument %q for %q flag: %v` diagnostic. * - * `options.alias` ports the `StringSliceVarP` shorthand (e.g. `start`'s - * `-x`). pflag then frames the diagnostic with BOTH spellings — `invalid - * argument %q for "-x, --exclude" flag: ...` (branches on - * `flag.Shorthand`) — regardless of which one the user typed, so the alias - * must be registered here (not piped on afterwards) for the framing to - * stay byte-identical. + * `options.alias` must be registered here, not piped on afterwards: pflag's + * diagnostic frames both spellings (`-x, --exclude`), so the alias must be + * present when the error message is built. */ export function stringSliceFlag( name: string, diff --git a/apps/cli/src/command-internal/string-slice-flag.unit.test.ts b/apps/cli/src/command-internal/string-slice-flag.unit.test.ts index 9a3cbfedec..7d70f88533 100644 --- a/apps/cli/src/command-internal/string-slice-flag.unit.test.ts +++ b/apps/cli/src/command-internal/string-slice-flag.unit.test.ts @@ -7,7 +7,6 @@ describe("parseStringSliceFlag (pflag StringSlice CSV parity)", () => { }); it("keeps a quoted value with embedded comma as a single element", () => { - // pflag TestSSWithComma: `"tenant,one"` → one element "tenant,one" expect(parseStringSliceFlag(['"tenant,one"'])).toEqual(["tenant,one"]); }); @@ -28,7 +27,6 @@ describe("parseStringSliceFlag (pflag StringSlice CSV parity)", () => { }); it("unescapes doubled double-quote inside quoted field", () => { - // Go csv: `"a""b"` → field is `a"b` expect(parseStringSliceFlag(['"a""b"'])).toEqual(['a"b']); }); @@ -37,63 +35,51 @@ describe("parseStringSliceFlag (pflag StringSlice CSV parity)", () => { }); it("preserves whitespace (Go does not trim)", () => { - // Go csv passes raw field values; pflag does not trim expect(parseStringSliceFlag([" public , private "])).toEqual([" public ", " private "]); }); - // --- malformed inputs: must THROW with Go's exact message --- - // - // Columns are 1-based BYTE offsets, matching Go `encoding/csv`'s - // `ParseError.Column`. Every vector below was verified against the real Go - // CLI (pflag v1.0.10 → encoding/csv, Go 1.26). + // Columns are 1-based byte offsets. it("throws on an unterminated quoted field (column = byte length + 1, Go hits EOF)", () => { - // `"tenant` — opening quote but no closing quote; 7 bytes → column 8 expect(() => parseStringSliceFlag(['"tenant'])).toThrow(StringSliceFlagParseError); expect(() => parseStringSliceFlag(['"tenant'])).toThrow( 'parse error on line 1, column 8: extraneous or missing " in quoted-field', ); - // `"1.2.3.4` — 8 bytes → column 9 expect(() => parseStringSliceFlag(['"1.2.3.4'])).toThrow( 'parse error on line 1, column 9: extraneous or missing " in quoted-field', ); - // `a,"b` — the unterminated quote opens the SECOND field, but the column - // still counts from the start of the whole value; 4 bytes → column 5 + // Column counts from the start of the whole value, not per-field. expect(() => parseStringSliceFlag(['a,"b'])).toThrow( 'parse error on line 1, column 5: extraneous or missing " in quoted-field', ); }); it("throws on extra bytes after a closing quote (column = byte position of the closing quote)", () => { - // `"a"b` — closing quote at byte 3 expect(() => parseStringSliceFlag(['"a"b'])).toThrow(StringSliceFlagParseError); expect(() => parseStringSliceFlag(['"a"b'])).toThrow( 'parse error on line 1, column 3: extraneous or missing " in quoted-field', ); - // `aa,"b"x` — closing quote of the second field at byte 6 expect(() => parseStringSliceFlag(['aa,"b"x'])).toThrow( 'parse error on line 1, column 6: extraneous or missing " in quoted-field', ); }); it("throws on a bare quote inside an unquoted field (column = byte position of the quote)", () => { - // `a"b` — bare " at byte 2 expect(() => parseStringSliceFlag(['a"b'])).toThrow(StringSliceFlagParseError); expect(() => parseStringSliceFlag(['a"b'])).toThrow( 'parse error on line 1, column 2: bare " in non-quoted-field', ); - // `1.2.3.4,5"6` — bare " in the second field, at byte 10 of the value expect(() => parseStringSliceFlag(['1.2.3.4,5"6'])).toThrow( 'parse error on line 1, column 10: bare " in non-quoted-field', ); }); it("counts columns in bytes, not code points (Go csv tracks byte offsets)", () => { - // `é"x` — é is 2 UTF-8 bytes, so the bare quote sits at byte 3 + // é is 2 UTF-8 bytes, so the bare quote is at byte 3. expect(() => parseStringSliceFlag(['é"x'])).toThrow( 'parse error on line 1, column 3: bare " in non-quoted-field', ); - // `"é` — 3 bytes total, EOF in a quoted field → column 4 + // "é is 3 bytes total (quote + 2-byte é). expect(() => parseStringSliceFlag(['"é'])).toThrow( 'parse error on line 1, column 4: extraneous or missing " in quoted-field', ); @@ -105,8 +91,7 @@ describe("parseStringSliceFlag (pflag StringSlice CSV parity)", () => { expect.unreachable("expected parseStringSliceFlag to throw"); } catch (err) { if (!(err instanceof StringSliceFlagParseError)) throw err; - // pflag wraps the csv error PER OCCURRENCE (`flag.go` `Set`), quoting - // only the malformed value — not the accumulated list. + // The error quotes only the malformed value, not the accumulated list. expect(err.value).toBe('"broken'); expect(err.message).toBe( 'parse error on line 1, column 8: extraneous or missing " in quoted-field', @@ -115,23 +100,15 @@ describe("parseStringSliceFlag (pflag StringSlice CSV parity)", () => { }); it("throws on the first malformed value in a multi-value list", () => { - // The valid "public" comes before the malformed one; the error is still thrown expect(() => parseStringSliceFlag(["public", '"broken'])).toThrow(StringSliceFlagParseError); }); - // --- multiline values: Go tracks PHYSICAL lines (encoding/csv readLine) --- - // - // pflag calls `csv.Reader.Read()` exactly once, so only the FIRST CSV - // record survives, blank lines before it are skipped, `\r\n` is normalized - // to `\n`, and parse errors report per-line byte columns with a - // `record on line N; ` prefix when the record starts before the error line - // (`csv.ParseError.Error()`). Every vector below was verified against the - // real Go CLI (pflag v1.0.10 → encoding/csv, Go 1.26). + // Only the first CSV record survives; blank lines before it are skipped, + // `\r\n` normalizes to `\n`, and parse errors report per-line byte columns. it("keeps only the first record when an unquoted newline ends it (pflag reads ONE record)", () => { expect(parseStringSliceFlag(["1.2.3.4\n5.6.7.8"])).toEqual(["1.2.3.4"]); - // …even when the dropped remainder would itself be malformed CSV — Go - // never parses past the first record, so no error is raised. + // The dropped remainder is itself malformed CSV, but only the first record is parsed. expect(parseStringSliceFlag(['1.2.3.4\na"b'])).toEqual(["1.2.3.4"]); expect(parseStringSliceFlag(["a,b\nc"])).toEqual(["a", "b"]); expect(parseStringSliceFlag(['"a"\njunk'])).toEqual(["a"]); @@ -142,8 +119,7 @@ describe("parseStringSliceFlag (pflag StringSlice CSV parity)", () => { it("skips blank lines before the record and errors with pflag's EOF for blank-only values", () => { expect(parseStringSliceFlag(["\n\na"])).toEqual(["a"]); expect(parseStringSliceFlag(["\r\na"])).toEqual(["a"]); - // A value of only blank lines: csv.Read returns io.EOF, which pflag - // surfaces verbatim (`invalid argument "\n" for "--x" flag: EOF`). + // A value of only blank lines throws the bare `EOF` message. expect(() => parseStringSliceFlag(["\n"])).toThrow(StringSliceFlagParseError); expect(() => parseStringSliceFlag(["\n"])).toThrow(/^EOF$/); }); @@ -159,12 +135,11 @@ describe("parseStringSliceFlag (pflag StringSlice CSV parity)", () => { }); it("reports the physical error line, with the record-start prefix when they differ", () => { - // Unterminated quote spanning lines: EOF on line 2, column 5 (one past - // `junk`), record started on line 1. + // Unterminated quote spanning lines; record started on line 1. expect(() => parseStringSliceFlag(['"1.2.3.4\njunk'])).toThrow( 'record on line 1; parse error on line 2, column 5: extraneous or missing " in quoted-field', ); - // Blank lines INSIDE the quoted field still count as physical lines. + // Blank lines inside the quoted field still count as physical lines. expect(() => parseStringSliceFlag(['"1.2.3.4\n\njunk'])).toThrow( 'record on line 1; parse error on line 3, column 5: extraneous or missing " in quoted-field', ); @@ -176,11 +151,11 @@ describe("parseStringSliceFlag (pflag StringSlice CSV parity)", () => { expect(() => parseStringSliceFlag(['a,"b\nc'])).toThrow( 'record on line 1; parse error on line 2, column 2: extraneous or missing " in quoted-field', ); - // Error on line 1 keeps the plain format (StartLine == Line)… + // Error on line 1 keeps the plain format (no record-start prefix). expect(() => parseStringSliceFlag(['a"b\nc'])).toThrow( 'parse error on line 1, column 2: bare " in non-quoted-field', ); - // …as does a record that STARTS on line 2 (leading blank line skipped). + // So does a record starting on line 2 (leading blank line skipped). expect(() => parseStringSliceFlag(['\n"bad'])).toThrow( 'parse error on line 2, column 5: extraneous or missing " in quoted-field', ); @@ -201,7 +176,7 @@ describe("parseStringSliceFlag (pflag StringSlice CSV parity)", () => { expect(() => parseStringSliceFlag(['"a\r'])).toThrow( 'parse error on line 1, column 3: extraneous or missing " in quoted-field', ); - // `"a\n` — the newline is quoted-field DATA (1 byte after normalization), + // `"a\n` — the newline is quoted-field data (1 byte after normalization), // and the error line stays 1 because no further line was read. expect(() => parseStringSliceFlag(['"a\n'])).toThrow( 'parse error on line 1, column 4: extraneous or missing " in quoted-field', diff --git a/apps/cli/src/command-internal/temp-paths.ts b/apps/cli/src/command-internal/temp-paths.ts index add8878de9..e3a67a8ead 100644 --- a/apps/cli/src/command-internal/temp-paths.ts +++ b/apps/cli/src/command-internal/temp-paths.ts @@ -6,11 +6,9 @@ import { } from "../shared/telemetry/error-actionability.ts"; /** - * A real failure reading `<workdir>/supabase/.temp/project-ref` (e.g. the path is a - * directory or permissions deny access). Mirrors `flags.LoadProjectRef`, which - * returns `failed to load project ref: <err>` for any non-not-exist read error - * rather than treating it - * as an unlinked project. + * A real failure reading `<workdir>/supabase/.temp/project-ref` (e.g. the + * path is a directory, or permissions deny access) — distinct from the file + * simply not existing, which means "not linked" rather than an error. */ export class ProjectRefReadError extends Data.TaggedError("ProjectRefReadError")<{ readonly message: string; @@ -21,13 +19,9 @@ export class ProjectRefReadError extends Data.TaggedError("ProjectRefReadError") } /** - * Absolute paths to the established files written under `<workdir>/supabase/.temp/`. - * - * `supabase link` / `supabase unlink` are the authoritative writers and remover - * of this directory, but several layers (`project-ref.layer.ts`, - * `linked-project-cache.layer.ts`) also read from it. Centralising the - * joins here keeps the path layout in one place instead of re-inlining - * `path.join(workdir, "supabase", ".temp", "...")` at every call site. + * Absolute paths to the established files under `<workdir>/supabase/.temp/`. + * `supabase link`/`unlink` own writing and removing this directory; other + * layers only read from it. */ export interface TempPaths { readonly tempDir: string; @@ -60,13 +54,9 @@ export function tempPaths(path: Path.Path, workdir: string): TempPaths { /** * Reads the linked project ref from `<workdir>/supabase/.temp/project-ref`, - * returning `None` when the file is absent or blank. Mirrors the non-prompting - * file read in `flags.LoadProjectRef`: a single read - * where a not-exist file is "not linked" (→ `None`), but any other read error (the - * path is a directory, permission denied, …) surfaces `failed to load project ref` - * rather than being swallowed into an unlinked result. Shared by the project-ref - * resolver and the declarative smart-generate prompt so both detect a linked workdir — - * and a broken one — the same way. + * returning `None` when the file is absent or blank. A missing file means + * "not linked"; any other read error (a directory, permission denied, …) + * fails instead of being swallowed into an unlinked result. */ export const readProjectRefFile = ( fs: FileSystem.FileSystem, @@ -75,9 +65,8 @@ export const readProjectRefFile = ( ): Effect.Effect<Option.Option<string>, ProjectRefReadError> => Effect.gen(function* () { const refPath = tempPaths(path, workdir).projectRef; - // One read, mirroring Go's single `afero.ReadFile`. Effect surfaces not-exist as - // a `PlatformError` with a `SystemError` reason tagged `"NotFound"` → treat as the - // unlinked/fall-through case; every other read error fails (`errors.Errorf`). + // A `NotFound` PlatformError means unlinked (fall through); any other + // read error fails. const content = yield* fs.readFileString(refPath).pipe( Effect.catchTag("PlatformError", (error) => error.reason._tag === "NotFound" diff --git a/apps/cli/src/command-internal/tenant-keys.ts b/apps/cli/src/command-internal/tenant-keys.ts index bd93843e4d..7d43420c07 100644 --- a/apps/cli/src/command-internal/tenant-keys.ts +++ b/apps/cli/src/command-internal/tenant-keys.ts @@ -6,12 +6,10 @@ export interface ApiKeyEntry { } /** - * Mirrors `tenant.NewApiKey` (`apps/cli-go/internal/utils/tenant/client.go:28-57`): - * `publishable` -> anon, `secret` with `role=service_role` -> service_role, else the - * legacy name-based fallback (`anon` / `service_role`). - * - * Shared by `link` (which writes the service-role key into `.temp` version probes) - * and `bootstrap` (which derives the anon key for the generated `.env`). + * Extracts the anon and service-role keys from an api-keys response: + * `publishable` type → anon; `secret` type with `role=service_role` → + * service_role; otherwise falls back to matching by legacy key name + * (`anon`/`service_role`). */ export function extractServiceKeys(keys: ReadonlyArray<ApiKeyEntry>): { readonly anon: string; diff --git a/apps/cli/src/command-internal/tenant-versions.ts b/apps/cli/src/command-internal/tenant-versions.ts index 1542febfb9..0fd531fe84 100644 --- a/apps/cli/src/command-internal/tenant-versions.ts +++ b/apps/cli/src/command-internal/tenant-versions.ts @@ -3,23 +3,12 @@ import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; /** - * Best-effort probes for the deployed versions of a project's REST (PostgREST), - * Auth (GoTrue) and Storage services. Mirrors `apps/cli-go/internal/utils/tenant/ - * {postgrest,gotrue,storage}.go`, which `supabase link`'s `LinkServices` calls to - * write `rest-version` / `gotrue-version` / `storage-version` under - * `supabase/.temp/`. - * - * Requests go directly to the project's service gateway - * (`https://<ref>.<projectHost>`) using the service-role key, replicating Go's - * `fetcher.NewServiceGateway` auth headers (`apps/cli-go/pkg/fetcher/gateway.go:25-31`): - * - always send `apikey: <serviceKey>`; - * - additionally send `Authorization: Bearer <serviceKey>` unless the key is a - * new-style `sb_…` key (which carries auth in the `apikey` header alone). - * - * Every probe is best-effort: any transport error, non-200 status, parse failure, - * or empty/sentinel version resolves to `Option.none()` so the caller skips the - * corresponding file write without failing the link. This matches Go, where each - * job's error is only logged to the debug logger. + * Best-effort probes for the deployed versions of a project's REST, Auth, + * and Storage services, used by `supabase link`. Requests use the + * service-role key: always `apikey: <serviceKey>`, plus `Authorization: + * Bearer <serviceKey>` unless the key is a new-style `sb_…` key. Any + * transport error, non-200 status, parse failure, or missing version + * resolves to `Option.none()` instead of failing the link. */ interface TenantVersionOptions { @@ -29,12 +18,10 @@ interface TenantVersionOptions { readonly userAgent: string; } -// Pure parsers — exported for focused unit coverage. - /** - * PostgREST advertises its version in the OpenAPI/Swagger `info.version` field at - * `GET /rest/v1/`. Go takes the first whitespace-delimited token and prefixes it - * with `v` (`postgrest.go:37-40`). + * PostgREST advertises its version in the OpenAPI/Swagger `info.version` + * field at `GET /rest/v1/`. Returns the first whitespace-delimited token, + * prefixed with `v`. */ export function parsePostgrestVersion(body: unknown): Option.Option<string> { if (typeof body !== "object" || body === null) return Option.none(); @@ -48,8 +35,8 @@ export function parsePostgrestVersion(body: unknown): Option.Option<string> { } /** - * GoTrue reports its version in the `version` field of `GET /auth/v1/health` - * (`gotrue.go:28-31`). Returned verbatim (no `v` prefix). + * GoTrue reports its version in the `version` field of `GET /auth/v1/health`. + * Returned verbatim (no `v` prefix). */ export function parseGotrueVersion(body: unknown): Option.Option<string> { if (typeof body !== "object" || body === null) return Option.none(); @@ -59,17 +46,15 @@ export function parseGotrueVersion(body: unknown): Option.Option<string> { } /** - * Storage returns its bare version string at `GET /storage/v1/version`. Go treats - * an empty body or the `0.0.0` sentinel as "not found" and otherwise prefixes the - * body with `v` (`storage.go:25-28`). + * Storage returns its bare version string at `GET /storage/v1/version`. An + * empty body or the `0.0.0` sentinel means "not found"; otherwise the body is + * prefixed with `v`. */ export function parseStorageVersion(body: string): Option.Option<string> { if (body.length === 0 || body === "0.0.0") return Option.none(); return Option.some(`v${body}`); } -// Effectful probes. - function tenantRequest(opts: TenantVersionOptions, pathName: string) { let request = HttpClientRequest.get(`https://${opts.ref}.${opts.projectHost}${pathName}`).pipe( HttpClientRequest.setHeader("apikey", opts.serviceKey), 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 61d547784e..72546c7ee0 100644 --- a/apps/cli/src/command-internal/test-db.command-handler.ts +++ b/apps/cli/src/command-internal/test-db.command-handler.ts @@ -9,24 +9,16 @@ import { withCommandTelemetry } from "../telemetry/command-telemetry.ts"; import type { TestDbNoTestsError, TestDbRunError } from "./test-db.errors.ts"; import { testDb } from "./test-db.handler.ts"; -/** - * Short/description text shared verbatim by both Go-parity entry points - * (`commands/test/db/db.command.ts`, `commands/db/test/test.command.ts`), - * mirroring Go's own single-source (`cmd/test.go:19`: `Short: dbTestCmd.Short`). - * Byte-matches Go's Short text (`cmd/db.go:425`). - */ +/** Shared verbatim by both `test db` and its `db test` alias. */ export const TEST_DB_DESCRIPTION = "Tests local database with pgTAP."; export const TEST_DB_SHORT = "Tests local database with pgTAP"; /** - * `test db` has no machine-format envelope: its entire output is the streamed - * pg_prove TAP on stdout (Go has no `--output-format` for it). On a *run* failure - * (failing tests, or a run that executed none), the default `withJsonErrorHandling` - * would append a JSON error object to stdout — after the TAP already streamed — - * corrupting machine consumers. - * So in json/stream-json mode, send the diagnostic to stderr and exit 1 instead, - * matching Go's `recoverAndExit` (stderr, exit 1). Text mode keeps the normal error - * rendering; pre-stream errors still flow through `withJsonErrorHandling`. + * `test db`'s entire output is the streamed pg_prove TAP on stdout. A run + * failure (failing tests, or a run that executed none) sends its diagnostic + * to stderr and exits 1 in json/stream-json mode instead of the default JSON + * error handling, which would corrupt the already-streamed TAP on stdout. + * Text mode and pre-stream errors are unaffected. */ const onRunFailure = (error: TestDbRunError | TestDbNoTestsError) => Effect.gen(function* () { @@ -38,22 +30,12 @@ const onRunFailure = (error: TestDbRunError | TestDbNoTestsError) => }); /** - * Flag config shared verbatim by `supabase test db` - * (`commands/test/db/db.command.ts`) and its hidden Go-parity alias - * `supabase db test` (`commands/db/test/test.command.ts`). Go registers - * an independent-but-identical flag set on each `cobra.Command` (`cmd/db.go: - * 736-740`, `cmd/test.go:40-44`: same names, defaults, descriptions, and - * `MarkFlagsMutuallyExclusive` group); the TS port reuses a single object - * instead of declaring it twice. + * Flag config shared verbatim by `supabase test db` and its hidden alias + * `supabase db test`. * - * Lives in `command-internal/` (not either command's own directory) because it — - * along with `runTestDbCommand` below and `testDbRuntimeLayer` - * (`./test-db.layers.ts`) — is consumed by TWO different top-level - * command families (`db` and `test`); `code-structure.unit.test.ts` mechanically - * forbids one `commands/<family>/` file from importing another family's - * internals, so anything genuinely shared across families must live outside - * `commands/` entirely (CLAUDE.md's "Hoist Before You Duplicate" rule, - * CLI-1962). + * Lives in `command-internal/` because it (with `runTestDbCommand` and + * `testDbRuntimeLayer`) is shared across the `db` and `test` command + * families — see "Hoist Before You Duplicate" in `apps/cli/CLAUDE.md`. */ export const testDbConfig = { paths: Argument.string("path").pipe( @@ -91,10 +73,8 @@ export interface TestDbFlags { /** * Assembled `test db` / `db test` handler: telemetry, run-failure routing - * (`onRunFailure`), and JSON-error-handling wiring shared verbatim by both - * Go-parity entry points — mirroring `cmd/test.go:19-20`'s - * `RunE: dbTestCmd.RunE`, which literally reuses the other command's handler - * rather than re-implementing it. + * (`onRunFailure`), and JSON-error-handling wiring shared by both entry + * points. */ export function runTestDbCommand(flags: CliCommand.Command.Config.Infer<typeof testDbConfig>) { return testDb({ @@ -111,13 +91,9 @@ export function runTestDbCommand(flags: CliCommand.Command.Config.Infer<typeof t local: flags.local, "project-ref": flags.projectRef, }, - // TS-only flag with no Go telemetry-safety baseline; Go's nearest - // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most - // others) are unmarked, so it stays redacted. + // No safe-flag whitelist entry for --project-ref here, so it stays redacted. }), - // Run failures (failing tests, or a run that executed none) must not corrupt the - // TAP stream on stdout in machine modes; other errors (pre-stream) still get the - // JSON envelope. + // Run failures must not corrupt the TAP stream in machine modes; see `onRunFailure`. Effect.catchTag(["TestDbRunError", "TestDbNoTestsError"], onRunFailure), withJsonErrorHandling, ); diff --git a/apps/cli/src/command-internal/test-db.errors.ts b/apps/cli/src/command-internal/test-db.errors.ts index 67c7073b2b..4ba24afb41 100644 --- a/apps/cli/src/command-internal/test-db.errors.ts +++ b/apps/cli/src/command-internal/test-db.errors.ts @@ -6,11 +6,7 @@ import { ErrorActionabilityId, } from "../shared/telemetry/error-actionability.ts"; -/** - * `create extension if not exists pgtap` failed. Byte-matches Go's - * `"failed to enable pgTAP: " + err` (`apps/cli-go/internal/db/test/test.go:70`, - * deleted in CLI-1970; last present at commit 7b469f5b3). - */ +/** `create extension if not exists pgtap` failed. */ export class TestDbEnablePgtapError extends Data.TaggedError("TestDbEnablePgtapError")<{ readonly message: string; }> { @@ -20,9 +16,8 @@ export class TestDbEnablePgtapError extends Data.TaggedError("TestDbEnablePgtapE } /** - * `pg_prove` exited non-zero (test failures or a container error). Byte-matches - * Go's `"error running container: exit " + code` (`apps/cli-go/internal/utils/docker.go` - * `DockerStreamLogs`). The TAP failure detail is already on stdout. + * `pg_prove` exited non-zero (test failures or a container error). The TAP + * failure detail is already on stdout. */ export class TestDbRunError extends Data.TaggedError("TestDbRunError")<{ readonly message: string; @@ -45,11 +40,7 @@ export class TestDbNoTestsError extends Data.TaggedError("TestDbNoTestsError")<{ } } -/** - * More than one of `--db-url` / `--linked` / `--local` was set. Reproduces - * cobra's `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` error from - * `apps/cli-go/cmd/db.go:485`, byte-for-byte. - */ +/** More than one of `--db-url` / `--linked` / `--local` was set. */ export class TestDbMutuallyExclusiveFlagsError extends Data.TaggedError( "TestDbMutuallyExclusiveFlagsError", )<{ diff --git a/apps/cli/src/command-internal/test-db.handler.ts b/apps/cli/src/command-internal/test-db.handler.ts index 28ebcb79ce..e1c2ac8e22 100644 --- a/apps/cli/src/command-internal/test-db.handler.ts +++ b/apps/cli/src/command-internal/test-db.handler.ts @@ -22,40 +22,23 @@ import { } from "./test-db.errors.ts"; import { buildPgProveArgs } from "./test-db.pg-prove-args.ts"; -// Go: `apps/cli-go/internal/db/test/test.go:24-25` (deleted in CLI-1970; last -// present at commit 7b469f5b3). const ENABLE_PGTAP = "create extension if not exists pgtap with schema extensions"; const DISABLE_PGTAP = "drop extension if exists pgtap"; -// Go bakes this default into the Dockerfile (`pkg/config/templates/Dockerfile:20`). -// The TS config schema does not model an `[images]` override, so it is fixed here. -// Go resolves it through `GetRegistryImageUrl` (`DockerStart`), honoring -// `SUPABASE_INTERNAL_IMAGE_REGISTRY` / the default ECR mirror, so do the same -// before passing it to `docker run`. Re-verify `NO_TESTS_VERDICT` still matches -// when bumping this tag. +// Fixed here: the config schema has no `[images]` override for this. Re-verify +// `NO_TESTS_VERDICT` still matches pg_prove's summary format when bumping this tag. const PG_PROVE_IMAGE = "supabase/pg_prove:3.36"; const MAX_PROJECT_ID_LENGTH = 40; /** - * `TAP::Harness` closes every run with exactly one `Result: <verdict>` line. - * `pg_prove` still exits 0 for the empty-run verdict, so "found nothing to run" is - * otherwise indistinguishable from "everything passed" — a typo'd path, an empty - * directory, or a bind the daemon resolved against a different filesystem than the - * CLI's (a sibling-container Docker socket) all report a green build that ran zero - * tests (CLI-2194). - * - * Only the harness's FINAL verdict decides: under `--debug` (`--verbose`) the - * harness replays each test's raw TAP, and a passing test may legally print its own - * `Result: …` line, which must not be mistaken for the run's outcome. Matching the - * harness's human summary is a heuristic pinned to the image tag above. - * - * The verdict alone is not enough: a suite that deliberately skips itself - * (`1..0 # SKIP …`) also ends `NOTESTS`, but reports `Files=1`. Only a run that - * aggregated ZERO files found nothing to run, so both signals must agree. + * `pg_prove` exits 0 even when it finds nothing to run, so a typo'd path or a + * misresolved bind can silently report success on zero tests. Detecting + * "nothing ran" needs both the harness's final `Result: NOTESTS` line (not an + * earlier per-test line under `--debug`) and a `Files=0` summary — a + * self-skipping suite also prints `NOTESTS` but reports `Files=1`. */ const VERDICT_PREFIX = "Result: "; const NO_TESTS_VERDICT = "Result: NOTESTS"; const FILES_SUMMARY = /^Files=(\d+),/; -/** Port of Go's `sanitizeProjectId` (`pkg/config/config.go:1037`). */ function sanitizeProjectId(src: string): string { return src .replace(/[^a-zA-Z0-9_.-]+/g, "_") @@ -79,10 +62,8 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { const cliArgs = yield* CliArgs; yield* Effect.gen(function* () { - // Reproduce cobra's MarkFlagsMutuallyExclusive("db-url","linked","local") - // (`apps/cli-go/cmd/db.go:740`). Selection is keyed off flag PRESENCE (cobra's - // `Changed`), not boolean value — `--linked=false` and `--no-linked` both count - // as explicitly setting the `linked` flag (`db_url.go:46-63`). + // Selection is keyed off flag presence, not its boolean value: `--linked=false` + // and `--no-linked` both count as explicitly setting `linked`. const target = resolveDbTargetFlags(cliArgs.args); const { setFlags } = target; if (setFlags.length > 1) { @@ -95,9 +76,8 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { const connType = target.connType ?? "local"; - // `--project-ref` never implies `--linked` and must not be silently - // discarded on a non-linked target — see push.handler.ts's identical guard - // (db push) for the full TS-only rationale. + // `--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({ @@ -123,7 +103,7 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { // For a local database the pg_prove container joins the supabase docker // network and reaches postgres via the internal `db:5432` alias; otherwise - // it uses host networking (Go: test.go:79-87). + // it uses host networking. const runEnv = { PGHOST: isLocal ? "db" : conn.host, PGPORT: isLocal ? "5432" : String(conn.port), @@ -132,10 +112,9 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { PGDATABASE: conn.database, }; - // Network selection mirrors Go's DockerRunOnceWithConfig: a non-empty - // `--network-id` overrides everything (even host mode); otherwise local uses - // the generated `supabase_network_<project_id>` network and remote uses host - // networking (`apps/cli-go/internal/utils/docker.go:379-384`, `test.go:79-87`). + // A non-empty `--network-id` overrides everything (even host mode); + // otherwise local uses the generated `supabase_network_<project_id>` + // network and remote uses host networking. const networkId = Option.getOrUndefined(networkIdFlag); const network = networkId !== undefined && networkId.length > 0 @@ -143,12 +122,9 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { : isLocal ? yield* Effect.gen(function* () { const toml = yield* readDbToml(fs, path, cliSettings.workdir); - // Go sanitizes `c.ProjectId` unconditionally (`config.go:471`) — - // whether it came from `config.toml` or the cwd-basename fallback — - // before deriving the network name `supabase_network_<id>` - // (`config.go:57-58`, `GetId`). A configured `project_id` like - // "my project" must join the same sanitized network the local stack - // created, not the literal raw value. + // The project id is sanitized unconditionally before deriving the + // 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)), ); @@ -165,21 +141,16 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { const { exitCode } = yield* Effect.scoped( Effect.gen(function* () { // stdout is reserved for the pg_prove TAP stream (forwarded byte-exact - // below), so connection diagnostics must go to stderr — - // exactly as Go does (`ConnectByConfigStream` writes "Connecting to … - // database…" to `os.Stderr`, `connect.go:205-228`). A `Output.task` - // spinner would corrupt the TAP stream: clack writes spinner ANSI to - // stdout in text mode, and the stream-json layer emits task JSON log - // events to stdout. Go has no "Running pgTAP tests…" line at all. + // below), so connection diagnostics go to stderr. An `Output.task` + // spinner would also corrupt the stream: it writes ANSI to stdout in + // text mode, and JSON log events to stdout in stream-json mode. yield* output.raw(`Connecting to ${isLocal ? "local" : "remote"} database...\n`, "stderr"); const session = yield* dbConn.connect(conn, { isLocal, dnsResolver }); // Detect pre-existence before enabling so the drop is skipped when pgTAP - // was already installed (Go keys this off an OnNotice 42710 callback, - // which @effect/sql-pg does not expose — equivalent observable result). - // Checked by extension name only, regardless of schema: Go's duplicate-object - // notice fires for any pre-existing pgTAP, so a pgTAP the user installed in - // e.g. `public` must also be detected and left untouched. + // was already installed. Checked by extension name only, regardless of + // schema, so a pgTAP the user installed elsewhere (e.g. `public`) is + // also detected and left untouched. const alreadyExists = yield* session.extensionExists("pgtap"); yield* session.exec(ENABLE_PGTAP).pipe( Effect.mapError( @@ -201,20 +172,16 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { ); } - // Bitbucket Pipelines rejects `--security-opt`, so Go clears - // `hostConfig.SecurityOpt` when `BITBUCKET_CLONE_DIR` is set - // (`apps/cli-go/internal/utils/docker.go:401-405`). Match that exactly: - // omit the option in Bitbucket CI, where it would abort container creation. + // Bitbucket Pipelines rejects `--security-opt`, so it's omitted when + // `BITBUCKET_CLONE_DIR` is set, where it would abort container creation. const inBitbucket = (process.env["BITBUCKET_CLONE_DIR"] ?? "") !== ""; - // Go adds `host.docker.internal:host-gateway` to every container's - // ExtraHosts on Linux (`apps/cli-go/internal/utils/docker_linux.go`); macOS/ - // Windows Docker Desktop provide the mapping natively (empty there). + // `host.docker.internal:host-gateway` is added on Linux; macOS/Windows + // Docker Desktop provide the mapping natively. const extraHosts = runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; - // Stream (rather than inherit) stdout so the verdict can be read on the way - // past; every chunk is forwarded byte-exact and unframed, leaving the TAP - // stream identical to what the container wrote. stderr is teed live, as - // inheriting it did. + // Stream (rather than inherit) stdout so the verdict can be read on the + // way past; every chunk is forwarded byte-exact and unframed. stderr is + // teed live, as inheriting it did. return yield* docker.runStream( { image: getRegistryImageUrl(PG_PROVE_IMAGE), @@ -248,13 +215,12 @@ export const testDb = Effect.fn("test.db")(function* (flags: TestDbFlags) { }), ); - // No machine-format envelope: Go has no `--output-format` for `test db`; its - // entire output is the streaming pg_prove TAP, which is emitted to stdout in - // every mode (the docker subprocess inherits stdout). Appending a JSON object - // here would corrupt that stream for `--output-format json` consumers. + // No machine-format envelope: the entire output is the streaming pg_prove + // TAP on stdout in every mode. Appending a JSON object here would corrupt + // that stream for `--output-format json` consumers. - // Non-zero pg_prove exit → fail (exit 1), matching Go's cobra error return. - // The TAP failure detail has already streamed to stdout. + // 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 container: exit ${exitCode}` }), 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 31230b8bc8..ffe99c5c91 100644 --- a/apps/cli/src/command-internal/test-db.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.integration.test.ts @@ -252,15 +252,12 @@ describe("test db integration", () => { expect(run?.env["PGPORT"]).toBe("5432"); expect(run?.securityOpt).toEqual(["label:disable"]); expect(run?.cmd.slice(0, 5)).toEqual(["pg_prove", "--ext", ".pg", "--ext", ".sql"]); - // The setup connection must be told it is local so the driver disables TLS - // (Go's `ConnectLocalPostgres` sets `cc.TLSConfig = nil`). expect(connection.connectCalls[0]?.isLocal).toBe(true); }).pipe(Effect.provide(layer)); }); it.live("adds the host.docker.internal host-gateway mapping on Linux", () => { - // Go populates HostConfig.ExtraHosts with this on Linux (docker_linux.go); the - // test RuntimeInfo mock reports platform "linux". + // The default RuntimeInfo mock reports platform "linux". const { layer, docker } = setup(); return Effect.gen(function* () { yield* testDb(flags()); @@ -269,9 +266,6 @@ describe("test db integration", () => { }); it.live("omits the host-gateway mapping on macOS/Windows", () => { - // Docker Desktop provides the host.docker.internal mapping natively there - // (docker_darwin.go / docker_windows.go both declare an empty extraHosts); - // only Linux needs the explicit ExtraHosts entry. const { layer, docker } = setup({ platform: "darwin" }); return Effect.gen(function* () { yield* testDb(flags()); @@ -280,8 +274,6 @@ describe("test db integration", () => { }); it.live("omits --security-opt inside Bitbucket Pipelines (BITBUCKET_CLONE_DIR set)", () => { - // Go clears hostConfig.SecurityOpt when BITBUCKET_CLONE_DIR is set, because - // Bitbucket rejects --security-opt (apps/cli-go/internal/utils/docker.go:288-293). const { layer, docker } = setup(); const prev = process.env["BITBUCKET_CLONE_DIR"]; process.env["BITBUCKET_CLONE_DIR"] = "/opt/atlassian/pipelines/agent/build"; @@ -330,9 +322,6 @@ describe("test db integration", () => { }); it.live("mounts a single file's containing directory so `\\ir` includes resolve", () => { - // CLI-1139: a lone-file bind leaves sibling files absent in the container, so - // `\ir ./sibling.sql` fails. The containing directory is mounted instead; the - // file path is still what pg_prove runs. const { layer, docker } = setup(); return Effect.gen(function* () { yield* testDb(flags({ paths: ["/abs/a_test.sql"] })); @@ -362,10 +351,7 @@ describe("test db integration", () => { expect(run?.network).toEqual({ _tag: "host" }); expect(run?.env["PGHOST"]).toBe(REMOTE_CONN.host); expect(run?.env["PGPORT"]).toBe("5432"); - // Remote connection → driver must enable TLS (Go strips non-TLS fallbacks - // in `ConnectByUrl`); the handler signals this via `isLocal: false`. expect(connection.connectCalls[0]?.isLocal).toBe(false); - // Default DNS resolver flows through to the driver unchanged. expect(connection.connectCalls[0]?.dnsResolver).toBe("native"); }).pipe(Effect.provide(layer)); }); @@ -379,9 +365,6 @@ describe("test db integration", () => { }); return Effect.gen(function* () { yield* testDb(flags({ dbUrl: Option.some("postgres://x") })); - // Go installs the DoH fallback resolver for remote connects when - // `--dns-resolver https` is set (`connect.go:211-213`); the handler must - // hand the same value to the driver rather than silently using OS DNS. expect(connection.connectCalls[0]?.dnsResolver).toBe("https"); }).pipe(Effect.provide(layer)); }); @@ -463,8 +446,8 @@ describe("test db integration", () => { }); it.live("passes a suite that deliberately skips itself, which also ends NOTESTS", () => { - // `1..0 # SKIP …` reports `Files=1, Tests=0` + `Result: NOTESTS` and exits 0. A - // file WAS found, so this is a successful run, not an empty one (PR #6210 review). + // `1..0 # SKIP …` reports `Files=1, Tests=0` + `Result: NOTESTS` and exits 0 — + // a file was found, so this is a successful run, not an empty one. const { layer } = setup({ exitCode: 0, stdout: [ @@ -490,9 +473,8 @@ describe("test db integration", () => { }); it.live("takes the harness's final verdict, not a passing test's own Result: line", () => { - // `--debug` replays each test's raw TAP, and a passing test may legally print a - // line of its own starting `Result: NOTESTS…`. Only the harness's last verdict - // decides the run (PR #6210 review). + // A passing test's own `--debug` TAP replay may legally print a line + // starting `Result: NOTESTS…`; only the harness's last verdict decides. const { layer } = setup({ exitCode: 0, stdout: [ @@ -509,8 +491,8 @@ describe("test db integration", () => { }); it.live("tees container stderr without retaining it, as inheriting stdio did", () => { - // A pgTAP suite's psql notices are unbounded; buffering them for a string nothing - // reads would grow with the whole run (PR #6210 review). + // A pgTAP suite's psql notices are unbounded; buffering them for a string + // nothing reads would grow with the whole run. const { layer, docker } = setup(); return Effect.gen(function* () { yield* testDb(flags()); @@ -543,8 +525,6 @@ describe("test db integration", () => { const { layer, out } = setup({ format: "json", exitCode: 0 }); return Effect.gen(function* () { yield* testDb(flags()); - // Go has no machine output for `test db`; the TS port must not append a - // JSON object that would corrupt the pg_prove TAP stream on stdout. expect(out.messages.find((m) => m.type === "success")).toBeUndefined(); }).pipe(Effect.provide(layer)); }); @@ -571,7 +551,6 @@ describe("test db integration", () => { }); it.live("--local=false --linked fails with mutual-exclusion (sorted set [linked local])", () => { - // Both flags Changed → mutual exclusion fires with cobra's sorted alphabetical set. const { layer } = setup({ args: ["--local=false", "--linked"] }); return Effect.gen(function* () { const exit = yield* Effect.exit(testDb(flags())); @@ -585,20 +564,17 @@ describe("test db integration", () => { }); it.live("--linked=false routes to the linked branch (Changed, not value)", () => { - // cobra's Changed fires when the flag appears regardless of value: - // `--linked=false` is still "explicitly set" → linked branch. - // The resolver mock will be called with connType="linked". const { layer } = setup({ args: ["--linked=false"] }); return Effect.gen(function* () { - // The resolver mock doesn't validate — success means routing reached resolver.resolve - // with connType "linked" (no mutual-exclusion error, no local fallback error). + // 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)); }); it.live("tests the project given via --project-ref --linked", () => { - // test db defaults to local; only with --linked does the flag reach the - // resolver as `linkedProjectRef`. + // Only reaches the resolver as `linkedProjectRef` when --linked is set. const FLAG_REF = "flagflagflagflagflag"; const { layer, resolver } = setup({ conn: REMOTE_CONN, isLocal: false, args: ["--linked"] }); return Effect.gen(function* () { @@ -609,8 +585,7 @@ describe("test db integration", () => { }); it.live("rejects --project-ref on the default local target", () => { - // test db defaults to local when no target flag is set — the guard must - // fire from the flag alone, with no explicit --local/--db-url needed. + // No explicit --local/--db-url flag; the guard must fire from the default alone. const FLAG_REF = "flagflagflagflagflag"; const { layer, connection, docker, resolver } = setup(); return Effect.gen(function* () { @@ -647,13 +622,8 @@ describe("test db integration", () => { const { layer, out } = setup(); return Effect.gen(function* () { yield* testDb(flags()); - // Go writes "Connecting to local database..." to os.Stderr and reserves - // stdout for the pg_prove TAP stream. A spinner/task on stdout would corrupt - // that stream (and stream-json task events would too), so the port must emit - // this on stderr and produce no stdout bytes of its own. expect(out.stderrText).toContain("Connecting to local database..."); expect(out.stdoutText).toBe(""); - // Go has no "Running pgTAP tests..." line and no spinner task messages. expect(out.messages).toEqual([]); }).pipe(Effect.provide(layer)); }); @@ -674,9 +644,6 @@ describe("test db integration", () => { it.live("sanitizes a configured project_id when naming the local network (Go parity)", () => { const workdir = tempWorkdir.current; mkdirSync(join(workdir, "supabase"), { recursive: true }); - // Go auto-fixes an invalid project_id via sanitizeProjectId (config.go:471, - // 803-805); the local stack network is created from the sanitized id, so - // `test db --local` must join `supabase_network_My_Project`, not the raw value. writeFileSync(join(workdir, "supabase", "config.toml"), 'project_id = "My Project"\n'); const { layer, docker } = setup({ workdir }); return Effect.gen(function* () { diff --git a/apps/cli/src/command-internal/test-db.layers.ts b/apps/cli/src/command-internal/test-db.layers.ts index 6763181857..52d9b90447 100644 --- a/apps/cli/src/command-internal/test-db.layers.ts +++ b/apps/cli/src/command-internal/test-db.layers.ts @@ -10,28 +10,19 @@ import { telemetryStateLayer } from "../telemetry/telemetry-state.layer.ts"; import { commandRuntimeLayer } from "../shared/runtime/command-runtime.layer.ts"; /** - * Runtime layer shared by `supabase test db` and its hidden Go-parity alias - * `supabase db test` (`apps/cli/src/commands/db/test/test.command.ts`). - * Go registers these as two distinct `cobra.Command`s that share the literal - * same `RunE` (`apps/cli-go/cmd/test.go:19-20`, deleted in CLI-1970; last - * present at commit 7b469f5b3: `RunE: dbTestCmd.RunE`); the TS - * port mirrors that by having both `.command.ts` files call this same factory - * and `runTestDbCommand`. + * Runtime layer shared by `supabase test db` and its hidden alias `supabase + * db test`, both calling this same factory and `runTestDbCommand`. * - * The Management API stack is intentionally NOT merged here: it resolves an - * access token eagerly at build, which would break the auth-free `--local` / - * `--db-url` paths. The `--linked` path provides it lazily inside the resolver - * (`db-config.layer.ts`), so this layer only exposes the always-needed, - * auth-free services. `commandSettingsLayer` is provided to the resolver AND - * exposed at the top level (the handler yields it; `Layer.provide` does not - * share to merge siblings — CLAUDE.md invariant 5). + * The Management API stack is intentionally not merged here: it resolves an + * access token eagerly at build, breaking the auth-free `--local`/`--db-url` + * paths. The `--linked` path provides it lazily inside the resolver + * (`db-config.layer.ts`). `commandSettingsLayer` is provided to the resolver + * and exposed at the top level, since `Layer.provide` does not share to merge + * siblings. * - * `commandPath` must reflect the ACTUAL invoked command path (`["test", "db"]` - * or `["db", "test"]`): Go's `cli_command_executed` telemetry records - * `strings.TrimSpace(cmd.CommandPath())` (`cmd/root_analytics.go:33`), which - * differs between the two entry points even though the underlying `RunE` is - * identical — the TS `command` telemetry property and trace span name must - * differ the same way (see `commandRuntimeLayer`). + * `commandPath` must reflect the actual invoked path (`["test", "db"]` or + * `["db", "test"]`) so the telemetry `command` property and trace span name + * differ between the two entry points despite sharing one handler. */ const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); @@ -39,8 +30,7 @@ const dbConfig = dbConfigLayer.pipe( Layer.provide(cliSettings), Layer.provide(dbConnectionLayer), Layer.provide(debugLoggerLayer), - // The resolver's lazy `--linked` stack snapshots the one per-command - // `IdentityStitch` (Go's single root-context `sync.Once`). + // The resolver's lazy `--linked` stack snapshots this one per-command IdentityStitch. Layer.provide(identityStitchLayer), ); @@ -50,12 +40,9 @@ export const testDbRuntimeLayer = (commandPath: ReadonlyArray<string>) => dbConnectionLayer, dockerRunLayer, cliSettings, - // The one per-command identity stitcher (Go's single root-context `sync.Once`), - // exposed at top level so `withCommandTelemetry` can read - // `stitchedDistinctId()` and attribute the cli_command_executed event to the - // gotrue id. The SAME reference is provided to dbConfig above, so memoisation - // gives the lazy linked stack a single `stitchAttempted` guard — aliasing/ - // persisting at most once. Mirrors lint.layers.ts / advisors.layers.ts. + // Exposed at top level so `withCommandTelemetry` can read + // `stitchedDistinctId()`. The same reference is provided to `dbConfig` + // above, so the lazy linked stack shares a single stitch attempt. identityStitchLayer, telemetryStateLayer, commandRuntimeLayer(commandPath), 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 56b0b30957..c9ed4b7832 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 @@ -1,19 +1,10 @@ /** - * Layer-exposure test for `testDbRuntimeLayer`. - * - * Verifies that `IdentityStitch` is exposed at the top level of the - * runtime layer so that `withCommandTelemetry` can read - * `stitchedDistinctId()` via `Effect.serviceOption(IdentityStitch)` and - * attribute the `cli_command_executed` event to the gotrue id. - * - * The bug this guards against: `Layer.provide(A, B)` satisfies A's dep on B - * but does NOT expose B to sibling layers inside a `Layer.mergeAll`. If - * `identityStitchLayer` is only provided to the child `dbConfig` layer - * and NOT added to the top-level `Layer.mergeAll`, then - * `serviceOption(IdentityStitch)` returns `None` and `test db --linked`'s - * `cli_command_executed` is mis-attributed to the device id. - * - * Mirrors `db/lint/lint.layers.unit.test.ts`. + * Verifies `IdentityStitch` is exposed at the top level of + * `testDbRuntimeLayer`, not just provided to the child `dbConfig` layer — + * see `CLAUDE.md`'s "Layer.provide does not share to siblings" invariant. + * Otherwise `withCommandTelemetry` can't read `stitchedDistinctId()` and + * `test db --linked`'s `cli_command_executed` is mis-attributed to the + * device id. */ import { describe, expect, it } from "@effect/vitest"; @@ -85,7 +76,7 @@ function ambientStubs() { return Layer.mergeAll( BunServices.layer, - // The runtime layer under test builds the REAL commandSettingsLayer against + // The runtime layer under test builds the real commandSettingsLayer against // the real filesystem — see isolatedHomeLayer's docs. isolatedHomeLayer(tempRoot.current), mockTty(), 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 09698c1ddd..68b464d79e 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 @@ -9,9 +9,9 @@ export interface PgProveArgs { /** Docker volume binds, each `hostpath:dockerpath:ro`. */ readonly binds: ReadonlyArray<string>; /** - * The searched paths as they exist on the *host*, for diagnostics. Deliberately - * not the `toDockerMountPath` form used in `cmd`: on Windows that has the volume - * name stripped, so an error naming it would point at a path the user does not have. + * The searched paths as they exist on the host, for diagnostics — not the + * `toDockerMountPath` form used in `cmd`, which strips the volume name on + * Windows and would point an error at a path the user doesn't have. */ readonly hostPaths: ReadonlyArray<string>; /** Container working directory (dir of the first test path). */ @@ -19,20 +19,16 @@ export interface PgProveArgs { } /** - * Build the `pg_prove` command, volume binds, and working directory for a - * `test db` run. Pure port of the loop in `apps/cli-go/internal/db/test/test.go:29-56` - * (deleted in CLI-1970; last present at commit 7b469f5b3). + * Builds the `pg_prove` command, volume binds, and working directory for a + * `test db` run. * - * - No paths → default to `<workdir>/supabase/tests` (Go's `filepath.Abs(DbTestsDir)` - * after chdir to the project root). - * - Relative paths resolve against `cwd` (Go's `utils.CurrentDirAbs`, the original - * invocation directory). - * - `--verbose` is appended when debug logging is enabled (Go's `viper.GetBool("DEBUG")`). + * - No paths defaults to `<workdir>/supabase/tests`. + * - Relative paths resolve against `cwd`, the original invocation directory. + * - `--verbose` is appended when debug logging is enabled. * - * Intentional divergence from Go (CLI-1139): for a file path we mount its parent - * *directory* rather than the lone file, so psql `\ir`/`\i` includes resolve. Go - * mounts the file alone, which breaks single-file runs that include a sibling. - * Output is unchanged — the full file path is still passed to `pg_prove`. + * For a file path, the bind mounts its parent directory (not the lone file), + * so psql `\ir`/`\i` includes to a sibling file resolve; the full path is + * still passed to `pg_prove`. */ export function buildPgProveArgs(opts: { readonly paths: ReadonlyArray<string>; @@ -48,7 +44,7 @@ export function buildPgProveArgs(opts: { const hostPaths: string[] = []; const seenTargets = new Set<string>(); // `testFiles` is never empty (it defaults to supabase/tests), so the first - // iteration always sets this; Go derives workingDir from the first path only. + // iteration always sets this. let workingDir = ""; for (const candidate of testFiles) { @@ -57,11 +53,10 @@ export function buildPgProveArgs(opts: { cmd.push(dockerPath); hostPaths.push(fp); - // Mount the *directory* containing a test file (not the lone file) so psql - // `\ir ./sibling.sql` includes resolve: they look relative to the test file's - // own directory, and a single-file bind leaves siblings absent in the - // container (CLI-1139). Directories are mounted as-is. The file-vs-directory - // heuristic (presence of an extension) matches Go's workingDir logic. + // Mount the directory containing a test file (not the lone file) so psql + // `\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 hostMount = isFile ? nodePath.dirname(fp) : fp; const dockerMount = toDockerMountPath(hostMount); diff --git a/apps/cli/src/command-internal/test-db.pg-prove-args.unit.test.ts b/apps/cli/src/command-internal/test-db.pg-prove-args.unit.test.ts index 262d94fe22..24431dfa4b 100644 --- a/apps/cli/src/command-internal/test-db.pg-prove-args.unit.test.ts +++ b/apps/cli/src/command-internal/test-db.pg-prove-args.unit.test.ts @@ -36,9 +36,6 @@ describe("buildPgProveArgs", () => { }); test("mounts the containing directory (not the lone file) for a single file path", () => { - // CLI-1139: mounting only the file leaves sibling `\ir` includes absent in - // the container. Mount the parent directory so they resolve; the file path is - // still what pg_prove runs. const result = buildPgProveArgs({ paths: ["/abs/dir/a_test.sql"], cwd: "/cwd", @@ -57,7 +54,6 @@ describe("buildPgProveArgs", () => { workdir: "/work", debug: false, }); - // A single bind for the shared directory; both files still run. expect(result.binds).toEqual(["/abs/dir:/abs/dir:ro"]); expect(result.cmd).toContain("/abs/dir/a_test.sql"); expect(result.cmd).toContain("/abs/dir/b_test.sql"); @@ -71,7 +67,6 @@ describe("buildPgProveArgs", () => { debug: false, }); expect(result.binds).toEqual(["/abs/dir:/abs/dir:ro"]); - // workingDir is derived from the first path (a directory → itself). expect(Option.getOrNull(result.workingDir)).toBe("/abs/dir"); }); @@ -82,15 +77,8 @@ describe("buildPgProveArgs", () => { workdir: "/work", debug: false, }); - expect(result.binds).toEqual([ - // First path is a file → its containing directory is mounted. - "/abs:/abs:ro", - // Second path is a directory → mounted as-is. - "/abs/second/dir:/abs/second/dir:ro", - ]); - // workingDir is derived from the first path only (a file → its parent). + expect(result.binds).toEqual(["/abs:/abs:ro", "/abs/second/dir:/abs/second/dir:ro"]); expect(Option.getOrNull(result.workingDir)).toBe("/abs"); - // `hostPaths` reports what pg_prove searches — the files/dirs, not their mounts. expect(result.hostPaths).toEqual(["/abs/first_test.sql", "/abs/second/dir"]); }); diff --git a/apps/cli/src/command-internal/timestamp.format.ts b/apps/cli/src/command-internal/timestamp.format.ts index 8ca9c526f8..00d7d4bd42 100644 --- a/apps/cli/src/command-internal/timestamp.format.ts +++ b/apps/cli/src/command-internal/timestamp.format.ts @@ -3,16 +3,14 @@ function pad2(value: number): string { } /** - * Reproduces `utils.FormatTimestamp` from `apps/cli-go/internal/utils/render.go:17`: - * parse RFC3339; on success format as UTC "YYYY-MM-DD HH:MM:SS"; on failure - * return the input verbatim. + * Parses an RFC3339 timestamp and formats it as UTC "YYYY-MM-DD HH:MM:SS"; + * returns the input verbatim on parse failure. */ export function formatTimestamp(value: string): string { if (value.length === 0) return value; - // Go uses time.Parse(time.RFC3339, value). Date.parse accepts a broader format - // surface, so we additionally require the year-month-day prefix to weed out - // values like "2026-02-08 16:44:07" (already-formatted) that Date.parse would - // happily accept but Go's strict RFC3339 parser would reject. + // `Date.parse` accepts a broader format surface than strict RFC3339, so this + // requires the year-month-day-T prefix to reject already-formatted values + // like "2026-02-08 16:44:07" that `Date.parse` would otherwise accept. if (!/^\d{4}-\d{2}-\d{2}T/.test(value)) { return value; } diff --git a/apps/cli/src/command-internal/upgrade-notice.ts b/apps/cli/src/command-internal/upgrade-notice.ts index 8094ddd948..b0e8825c4e 100644 --- a/apps/cli/src/command-internal/upgrade-notice.ts +++ b/apps/cli/src/command-internal/upgrade-notice.ts @@ -1,8 +1,8 @@ /** - * Port of Go's post-command upgrade notice (`cmd/root.go`'s `checkUpgrade`/ - * `shouldFetchRelease`/`suggestUpgrade`), with the `SUPABASE_NO_UPDATE_NOTIFIER` - * opt-out from supabase/cli#5853. An empty cache written on a failed fetch is - * Go's own offline backoff. + * Post-command upgrade notice: checks GitHub's latest release against the + * running version and prints a notice to stderr, honoring + * `SUPABASE_NO_UPDATE_NOTIFIER`. A failed fetch writes an empty cache as an + * offline backoff. */ import { lstat, mkdir, open, readFile } from "node:fs/promises"; @@ -25,10 +25,10 @@ import { candidateDotenvFilenames } from "./project-environment.ts"; const LATEST_RELEASE_URL = "https://api.github.com/repos/supabase/cli/releases/latest"; const CACHE_TTL_MS = 10 * 60 * 60 * 1000; -/** No Go equivalent (its client sets no timeout); bounds this pre-exit hook's latency. */ +/** Bounds this pre-exit hook's latency. */ const FETCH_TIMEOUT_MS = 3000; -/** Go `strconv.ParseBool`'s true spellings — anything else, including garbage, leaves the notifier on. */ +/** Recognized "true" spellings; anything else, including garbage, leaves the notifier on. */ const PARSE_BOOL_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); export function updateNotifierDisabled(value: string | undefined): boolean { @@ -36,14 +36,10 @@ export function updateNotifierDisabled(value: string | undefined): boolean { } /** - * Go's `viper.GetBool("DEBUG")`: the `--debug`/`--debug=<bool>` flag when set - * (last wins; `viper.BindPFlags` runs at package init, so the flag reads even - * for the built-ins), else `SUPABASE_DEBUG` — but the env only when a real - * command ran: `viper.AutomaticEnv` binds inside `cobra.OnInitialize`, which - * `--help`/`--version`/a bare group's help never reach (Go says so itself at - * `updateNotifierEnabled`, `cmd/root.go:246-247`). The token walk skips - * operands after `--` and values consumed by value-taking global flags, which - * pflag never reads as flags. + * `--debug`/`--debug=<bool>` when set (last occurrence wins, even for + * built-ins like `--help`), else `SUPABASE_DEBUG` — but the env only applies + * to a real command, not `--help`/`--version`/a bare group's help. The token + * walk skips operands after `--` and values consumed by other flags. */ function debugEnabled( deps: UpgradeNoticeDeps, @@ -99,9 +95,8 @@ function compareNumericIdentifier(left: string, right: string): number { } /** - * Go's `semver.Compare(latest, "v"+utils.Version) > 0` gate: an invalid latest - * tag (including the empty offline-cache sentinel) never suggests, an invalid - * current version (Go dev builds carry an empty `utils.Version`) always does. + * An invalid latest tag (including the empty offline-cache sentinel) never + * suggests an upgrade; an invalid current version always does. */ export function isNewerCliVersion(latestTag: string, currentVersion: string): boolean { const latest = parseSemver(latestTag); @@ -118,7 +113,7 @@ export function isNewerCliVersion(latestTag: string, currentVersion: string): bo return comparePrerelease(latest.prerelease, current.prerelease) > 0; } -/** Semver-spec prerelease precedence, matching Go's `comparePrerelease` (numeric identifiers compare numerically, so `beta.9 < beta.10`). */ +/** Semver-spec prerelease precedence: numeric identifiers compare numerically, so `beta.9 < beta.10`. */ function comparePrerelease(left: string, right: string): number { const leftParts = left.split("."); const rightParts = right.split("."); @@ -136,9 +131,8 @@ function comparePrerelease(left: string, right: string): number { } /** - * Byte-for-byte Go `suggestUpgrade`. Styling goes through `colors.ts`: - * raw `styleText` paints escapes unconditionally on Bun, even under `NO_COLOR` - * and on piped stderr. + * Styling goes through `colors.ts`, not raw `styleText`, which paints escapes + * unconditionally on Bun even under `NO_COLOR` or on piped stderr. */ export function formatUpgradeNotice(latestTag: string, currentVersion: string): string { return ( @@ -148,31 +142,11 @@ export function formatUpgradeNotice(latestTag: string, currentVersion: string): } /** - * Writes the cache file refusing to follow a symlink at the FINAL path - * component. The `lstat` guard at the call site runs before a network fetch - * bounded only by `FETCH_TIMEOUT_MS`, so by write time it only proves the path - * was safe seconds ago; a concurrent process that swaps `cli-latest` for a - * symlink inside that window makes a plain `writeFile` truncate an arbitrary - * user-writable target (CWE-59/TOCTOU). `O_NOFOLLOW` moves that one decision - * into the kernel's `open`, which fails with `ELOOP` instead. - * - * This does NOT close the window for the `supabase/` and `.temp/` DIRECTORY - * components: `O_NOFOLLOW` only applies to the last component, and resolving - * the rest against a verified directory handle needs `openat`, which Node does - * not expose. A directory swapped for a symlink inside the same window is still - * followed by the preceding `mkdir -p` and by this `open`. Those components - * keep only the advisory `lstat`/`isRealDirOrAbsent` checks — narrower than the - * final-component guarantee, and still stricter than Go, which writes through - * symlinks at every level. - * - * Same path, mode, and truncate semantics as the `writeFile` it replaces, so - * Go's filesystem side effects are unchanged — including the empty-string - * offline backoff write. - * - * `O_NOFOLLOW` is POSIX-only; Node leaves it undefined on Windows, where this - * falls back to the plain flags and the final component drops back to the same - * advisory-only footing as the directories. Creating a symlink there needs - * Developer Mode or `SeCreateSymbolicLinkPrivilege`. + * Writes the cache file with `O_NOFOLLOW`, so the kernel refuses to follow a + * symlink swapped in for the final path component between the earlier + * `lstat` check and this write (CWE-59/TOCTOU) — a plain `writeFile` would + * otherwise truncate an arbitrary user-writable target. The parent + * directories only get a weaker advisory `lstat` check; Node has no `openat`. */ async function writeCacheFileNoFollow(cacheFile: string, contents: string): Promise<void> { const handle = await open( @@ -191,11 +165,10 @@ async function writeCacheFileNoFollow(cacheFile: string, contents: string): Prom } /** - * The directory Go's relative `supabase/.temp` paths resolve against after - * `ChangeWorkDir`, matching `command-settings.layer.ts`'s `resolveWorkdir` - * precedence. Not reused from there: that resolution lives inside a command's - * own layer stack, and this hook also runs for `--help`/`--version`, which - * never build one. + * The directory `supabase/.temp` resolves against, matching + * `command-settings.layer.ts`'s `resolveWorkdir` precedence. Not reused from + * there: that resolution lives inside a command's own layer stack, and this + * hook also runs for `--help`/`--version`, which never build one. */ function resolveNoticeBaseDir( cwd: string, @@ -203,8 +176,8 @@ function resolveNoticeBaseDir( env: Readonly<Record<string, string | undefined>>, isValueTakingFlagToken?: (token: string) => boolean, ): string { - // Viper: a set flag beats the env even when empty, and an empty effective - // value falls through to the ancestor walk (`ChangeWorkDir`'s own rule). + // A set flag beats the env even when empty; an empty effective value falls + // through to the ancestor walk. const flagValue = lastGlobalFlagValue(args, "--workdir", isValueTakingFlagToken); const explicit = flagValue !== undefined ? flagValue : env["SUPABASE_WORKDIR"]; if (explicit !== undefined && explicit !== "") { @@ -220,25 +193,19 @@ function resolveNoticeBaseDir( } /** - * The project dotenv chain as a merged map, mirroring what Go's - * `godotenv.Load` writes into the real environment before the `Execute()` - * tail reads `SUPABASE_NO_UPDATE_NOTIFIER` and `viper` reads `SUPABASE_DEBUG` - * (`pkg/config/config.go:1220-1241`) — but only when the command loaded its - * config. This hook cannot see whether the run's command did, so it reads the - * chain for every real command; the only divergences from Go are a suppressed - * notice, or an extra debug diagnostic, for a project that configured exactly - * that. Same chain and precedence as `resolveProjectEnvironmentValues`: - * `<base>/supabase` then `<base>`, first file to define a key wins, and the - * shell env always beats a chain value (godotenv never overrides). + * The project dotenv chain as a merged map: `<base>/supabase` then `<base>`, + * first file to define a key wins, shell env always beats a chain value — + * same precedence as `resolveProjectEnvironmentValues`. Read for every real + * command since this hook can't tell whether the command loads config; the + * only effect is a suppressed notice or extra debug diagnostic either way. */ async function projectDotenvValues( base: string, env: Readonly<Record<string, string | undefined>>, ): Promise<Record<string, string>> { const merged: Record<string, string> = {}; - // Go's walk loads `<base>/supabase` then `<base>` — except at the filesystem - // root, where `loadNestedEnv`'s `cwd != filepath.Dir(repoDir)` bound - // degenerates (`Dir("/") == "/"`) and only `/supabase` is read. + // Loads `<base>/supabase` then `<base>`, except at the filesystem root, + // where only `<base>/supabase` is read. const dirs = dirname(base) === base ? [join(base, "supabase")] : [join(base, "supabase"), base]; for (const dir of dirs) { for (const filename of candidateDotenvFilenames(env["SUPABASE_ENV"] || "development")) { @@ -250,8 +217,7 @@ async function projectDotenvValues( } } catch { // A malformed file is only reachable here when the command never - // loaded config (a load would have failed the run before this hook), - // and then Go never read any of the chain either. + // loaded config — a load would have failed the run before this hook. return {}; } } @@ -268,9 +234,9 @@ async function isRealDirOrAbsent(path: string): Promise<boolean> { export interface UpgradeNoticeDeps { readonly env: Readonly<Record<string, string | undefined>>; readonly args: ReadonlyArray<string>; - /** The exit-0 ShowHelp branch (bare group command) — served without Go's `ChangeWorkDir`. */ + /** The exit-0 ShowHelp branch (bare group command), served without a resolved working directory. */ readonly cleanShowHelp?: boolean; - /** Value-taking-token predicate for this argv (global + resolved leaf flags), so a token a local flag consumed (`login --name --debug`) is never read as a flag. */ + /** Value-taking-token predicate for this argv, so a token a local flag consumed (e.g. `login --name --debug`) is never read as a flag. */ readonly isValueTakingFlagToken?: (token: string) => boolean; readonly cwd: string; readonly resolvedCwd?: string; @@ -283,11 +249,10 @@ export interface UpgradeNoticeDeps { export async function runUpgradeNotice(deps: UpgradeNoticeDeps): Promise<void> { if (updateNotifierDisabled(deps.env["SUPABASE_NO_UPDATE_NOTIFIER"])) return; - // `--help`/`--version` and a bare group's clean ShowHelp all skip cobra's - // `PersistentPreRunE`, so Go never runs `ChangeWorkDir` for them — the cache - // resolves against the bare cwd, with `--workdir`/`SUPABASE_WORKDIR` and the - // ancestor walk all ignored — and never reaches a config load that could - // pull the opt-out from a project dotenv. + // `--help`/`--version` and a bare group's clean ShowHelp resolve the cache + // against the bare cwd, ignoring `--workdir`/`SUPABASE_WORKDIR` and the + // ancestor walk, and never reach a config load that could pull the opt-out + // from a project dotenv. const builtin = deps.cleanShowHelp === true || hasRootHelpOrVersionFlag(deps.args, deps.isValueTakingFlagToken); const base = builtin @@ -295,8 +260,8 @@ export async function runUpgradeNotice(deps: UpgradeNoticeDeps): Promise<void> { : (deps.resolvedCwd ?? resolveNoticeBaseDir(deps.cwd, deps.args, deps.env, deps.isValueTakingFlagToken)); const projectEnv = builtin ? {} : await projectDotenvValues(base, deps.env); - // godotenv never overrides: a shell env that defines a key at all beats the - // project dotenv chain, even when set to an empty or unparseable value. + // A shell env that defines a key at all beats the project dotenv chain, + // even when set to an empty or unparseable value. const effectiveEnv = (key: string): string | undefined => deps.env[key] !== undefined ? deps.env[key] : projectEnv[key]; if (updateNotifierDisabled(effectiveEnv("SUPABASE_NO_UPDATE_NOTIFIER"))) return; @@ -306,20 +271,17 @@ export async function runUpgradeNotice(deps: UpgradeNoticeDeps): Promise<void> { const cacheFile = join(tempDir, "cli-latest"); // A hostile checkout can commit a symlink at any level of this well-known - // path to clobber an arbitrary user-writable file (CWE-59): a symlink - // anywhere disables the cache. Do not relax to `stat`/`existsSync`, which - // follow links. These checks are advisory only — they run before a fetch that - // can take FETCH_TIMEOUT_MS, so they cannot be trusted at write time. The - // write re-establishes the guarantee in the kernel for the cache file itself - // via `O_NOFOLLOW`; the two directory components stay advisory-only for want - // of `openat` (see `writeCacheFileNoFollow`). + // path to clobber an arbitrary user-writable file (CWE-59), so a symlink + // anywhere disables the cache. These `lstat` checks are advisory only, + // since they run before a fetch that can take `FETCH_TIMEOUT_MS`; see + // `writeCacheFileNoFollow` for the write-time guarantee. const cacheLstat = await lstat(cacheFile).catch(() => undefined); const cachePathIsSafe = cacheLstat?.isSymbolicLink() !== true && (await isRealDirOrAbsent(supabaseDir)) && (await isRealDirOrAbsent(tempDir)); - // Go's `rootCmd.Flag("version").Changed` — a subcommand's own `--version` must not bypass the cache. + // A subcommand's own `--version` must not bypass the cache. const forceFetch = hasRootVersionFlag(deps.args, deps.isValueTakingFlagToken); const cacheFresh = cachePathIsSafe && @@ -330,16 +292,12 @@ export async function runUpgradeNotice(deps: UpgradeNoticeDeps): Promise<void> { if (forceFetch || !cacheFresh) { let notifyError: Error | undefined; latestTag = await deps.fetchLatestTag().catch((error: unknown) => { - // Go's `GetLatestRelease` wrap (`internal/utils/release.go:42`) — - // capital F and all. notifyError = new Error(`Failed to fetch latest release: ${errorMessage(error)}`); return ""; }); - // Go's `checkUpgrade` (`cmd/root.go:254-258`) overwrites the fetch error - // with the offline-backoff write's result when inside a project, so a - // successful write silences the diagnostic — only a missing project (no - // backoff) or a failing write leaves an error to log, carrying the write - // path's own wraps (`failed to mkdir`/`failed to write file`, misc.go). + // The offline-backoff write's result overwrites the fetch error when + // inside a project, so a successful write silences the diagnostic; only a + // missing project (no backoff) or a failing write leaves an error to log. if (cachePathIsSafe && existsSync(supabaseDir)) { notifyError = await mkdir(tempDir, { recursive: true, mode: 0o755 }).then( () => @@ -371,8 +329,8 @@ export async function runUpgradeNotice(deps: UpgradeNoticeDeps): Promise<void> { } async function fetchLatestReleaseTag(): Promise<string> { - // Go's `GetGitHubClient` authenticates when GITHUB_TOKEN is set, for the - // higher rate limit on shared-egress CI runners. + // Authenticates when GITHUB_TOKEN is set, for the higher rate limit on + // shared-egress CI runners. const token = process.env["GITHUB_TOKEN"]; const response = await fetch(LATEST_RELEASE_URL, { signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), diff --git a/apps/cli/src/command-internal/upgrade-notice.unit.test.ts b/apps/cli/src/command-internal/upgrade-notice.unit.test.ts index 087cb56a2d..62c3243b89 100644 --- a/apps/cli/src/command-internal/upgrade-notice.unit.test.ts +++ b/apps/cli/src/command-internal/upgrade-notice.unit.test.ts @@ -51,7 +51,7 @@ describe("isNewerCliVersion", () => { ["v2.114.0-beta.1", "2.114.0", false], ["", "2.113.0", false], ["not-a-version", "2.113.0", false], - // x/mod/semver requires the leading v: a bare tag is invalid to Go. + // A bare tag (no leading "v") is invalid. ["2.114.0", "2.113.0", false], ["v2.114.0", "0.0.0-dev", true], ["v2.114.0", "", true], @@ -211,7 +211,7 @@ describe("runUpgradeNotice", () => { expect(on.stderr).toContain("Failed to fetch latest release"); on.cleanup(); - // A set flag (`--debug=false`) beats SUPABASE_DEBUG, like viper. + // A set flag (`--debug=false`) beats SUPABASE_DEBUG. const off = setup({ fetchFails: true, project: false, @@ -224,8 +224,6 @@ describe("runUpgradeNotice", () => { }); it("a built-in ignores SUPABASE_DEBUG but still honors the --debug flag, like cobra's init order", async () => { - // AutomaticEnv binds inside cobra.OnInitialize, which --help/--version - // never reach; BindPFlags runs at package init, so the flag still reads. const viaEnv = setup({ fetchFails: true, project: false, @@ -329,8 +327,8 @@ describe("runUpgradeNotice", () => { ); it("a --debug consumed by the leaf command's own value flag is not the debug flag, like pflag", async () => { - // `login --name --debug`: pflag hands `--debug` to `--name`. The real CLI - // passes the resolved leaf's value-flag predicate into the hook. + // `login --name --debug`: `--debug` is consumed as `--name`'s value; the + // real CLI passes the resolved leaf's value-flag predicate into the hook. const ctx = setup({ fetchFails: true, project: false, args: ["login", "--name", "--debug"] }); await runUpgradeNotice({ ...ctx.deps, @@ -341,9 +339,8 @@ describe("runUpgradeNotice", () => { }); it("a false root version flag before a leaf runs the normal path, workdir included", async () => { - // `--version=false <leaf>`: cobra parses false, runs the leaf with - // `ChangeWorkDir` — but pflag still marks the flag changed, forcing the - // fetch. Only the built-in classification must not trigger. + // `--version=false <leaf>` still marks the flag as set, forcing the fetch; + // only the built-in classification must not trigger. const ctx = setup({ project: false }); const flagged = join(workdir, "flagged"); mkdirSync(join(flagged, "supabase"), { recursive: true }); @@ -367,15 +364,14 @@ describe("runUpgradeNotice", () => { await runUpgradeNotice(ctx.deps); chmodSync(ctx.cachePath, 0o644); expect(ctx.stderr).toContain("failed to write file"); - // The stale cache survives, exactly like Go's failed open. expect(readFileSync(ctx.cachePath, "utf8")).toBe("v2.115.0"); ctx.cleanup(); }, ); it("project dotenv SUPABASE_DEBUG surfaces diagnostics, like godotenv before the Execute tail", async () => { - // A symlinked .temp disables the backoff write, so the fetch error is what - // remains to log — and the debug gate resolves through the project chain. + // The symlinked .temp disables the backoff write, so the fetch error is + // what remains to log. const ctx = setup({ fetchFails: true }); writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_DEBUG=1\n"); mkdirSync(join(workdir, "elsewhere"), { recursive: true }); @@ -384,7 +380,7 @@ describe("runUpgradeNotice", () => { expect(ctx.stderr).toContain("Failed to fetch latest release"); ctx.cleanup(); - // A shell env that defines the key blocks the chain, like os.Environ. + // A shell env that defines the key blocks the chain. const blocked = setup({ fetchFails: true, env: { SUPABASE_DEBUG: "" } }); writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_DEBUG=1\n"); mkdirSync(join(workdir, "elsewhere"), { recursive: true }); @@ -397,7 +393,7 @@ describe("runUpgradeNotice", () => { it("a failed fetch inside a project stays silent under --debug, matching Go's backoff", async () => { const ctx = setup({ fetchFails: true, args: ["db", "start", "--debug"] }); await runUpgradeNotice(ctx.deps); - // The empty-cache backoff write succeeds, so Go emits no debug line. + // The empty-cache backoff write succeeds, so no debug line is emitted. expect(ctx.stderr).toBe(""); expect(readFileSync(ctx.cachePath, "utf8")).toBe(""); ctx.cleanup(); @@ -474,8 +470,8 @@ describe("runUpgradeNotice", () => { }); it("a shell env that defines the key beats the project dotenv, like godotenv's no-override", async () => { - // Defined-but-unparseable in the shell env: Go's os.Environ presence stops - // godotenv from overriding, and ParseBool("") keeps the notifier on. + // Defined-but-unparseable in the shell env still blocks the project + // dotenv from overriding, and an empty value keeps the notifier on. const ctx = setup({ env: { SUPABASE_NO_UPDATE_NOTIFIER: "" } }); writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_NO_UPDATE_NOTIFIER=1\n"); await runUpgradeNotice(ctx.deps); @@ -492,16 +488,14 @@ describe("runUpgradeNotice", () => { }); it("resolves --help and --version against the bare cwd, ignoring --workdir, like Go", async () => { - // Go serves the built-ins without `ChangeWorkDir`, so the flagged project - // must not gain a cache entry; the caller's cwd (no supabase/) writes none. const ctx = setup({ project: false }); const flagged = join(workdir, "flagged"); mkdirSync(join(flagged, "supabase"), { recursive: true }); for (const args of [ ["--workdir", flagged, "--help"], ["--workdir", flagged, "--version"], - // Valued spellings request the same built-ins, and a false value still - // does: the non-runnable root/group serves help before `preRun`. + // Valued spellings request the same built-ins; even a false value does, + // since the non-runnable root/group serves help before running anything. ["--workdir", flagged, "--help=true"], ["branches", "--workdir", flagged, "-h=1"], ["--workdir", flagged, "--version=true"], @@ -547,8 +541,6 @@ describe("runUpgradeNotice", () => { args: ["branches", "--workdir", flagged], cleanShowHelp: true, }); - // Go serves the bare group's help without ChangeWorkDir: nothing lands in - // the flagged project, and the caller's cwd (no supabase/) writes nothing. expect(() => readFileSync(join(flagged, "supabase", ".temp", "cli-latest"), "utf8")).toThrow(); expect(ctx.stderr).toContain("v2.114.0"); ctx.cleanup(); @@ -560,7 +552,6 @@ describe("runUpgradeNotice", () => { mkdirSync(join(elsewhere, "supabase"), { recursive: true }); const opCtx = { ...ctx.deps, args: ["db", "start", "--", "--workdir", elsewhere] }; await runUpgradeNotice(opCtx); - // The operand is not a flag: the cache lands in the real project, not `elsewhere`. expect(readFileSync(ctx.cachePath, "utf8")).toBe("v2.114.0"); expect(() => readFileSync(join(elsewhere, "supabase", ".temp", "cli-latest"), "utf8"), @@ -589,11 +580,9 @@ describe("runUpgradeNotice", () => { writeFileSync(victim, "v9.9.9"); mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); - // The `lstat` guard runs BEFORE the fetch, so it only proves the path was - // safe up to FETCH_TIMEOUT_MS ago. Planting the symlink from inside - // `fetchLatestTag` lands it in exactly that check-then-write window — a - // concurrent process doing this made the old plain `writeFile` follow the - // link and truncate `victim`. The `O_NOFOLLOW` open fails with ELOOP. + // Planting the symlink inside `fetchLatestTag` simulates a concurrent + // swap landing in the check-then-write window; `O_NOFOLLOW` makes the + // open fail with ELOOP instead of following it. await runUpgradeNotice({ ...ctx.deps, fetchLatestTag: () => { @@ -636,13 +625,10 @@ describe("runUpgradeNotice", () => { }); /** - * The `delegatedToGo` guard is why a proxied command doesn't print the notice - * twice: the Go child ran its own `checkUpgrade` and already printed one. - * - * Asserted here rather than by spawning a real Phase 0 command, so the coverage - * doesn't depend on which commands are still Go wrappers — that set shrinks - * every time one is ported (`docs/go-cli-porting-status.md`), and an e2e test - * pinned to one breaks the moment it does. + * `delegatedToGo` is asserted directly rather than via a real proxied + * command, so this doesn't depend on which commands still delegate to Go — + * that set only shrinks (`docs/go-cli-porting-status.md`), and a test pinned + * to one command would break the moment it's ported. */ describe("upgradeNoticeHook", () => { async function stderrFromHook(delegatedToGo: boolean): Promise<string> { diff --git a/apps/cli/src/command-internal/upgrade-suggest.ts b/apps/cli/src/command-internal/upgrade-suggest.ts index c72c5d1e7c..8878e42a6e 100644 --- a/apps/cli/src/command-internal/upgrade-suggest.ts +++ b/apps/cli/src/command-internal/upgrade-suggest.ts @@ -72,13 +72,13 @@ export const gateMapError = }); /** - * Ports `plan_gate.go:SuggestUpgradeOnError`. Never fails the caller. + * Suggests an upgrade when a request fails with a plan-gate error. Never + * fails the caller. * - * The fallback bypasses the typed API client: its strict response schemas + * The fallback bypasses the typed API client, whose strict response schemas * reject the cli-e2e replay fixtures' placeholder refs (same workaround as - * `linked-project-cache.layer.ts`). - * Returns whether the feature was confirmed plan-gated so callers can carry - * that typed result into their error classification. + * `linked-project-cache.layer.ts`). Returns whether the feature was + * confirmed plan-gated. */ export const suggestUpgrade = Effect.fnUntraced(function* (opts: { readonly projectRef: string; @@ -91,29 +91,21 @@ export const suggestUpgrade = Effect.fnUntraced(function* (opts: { readonly statusCode: number; readonly response?: HttpClientResponse.HttpClientResponse; /** - * Overrides the API base URL of the fallback project + entitlement GETs. - * `SuggestUpgradeOnError` calls `GetSupabase()`, which targets the - * process-wide `CurrentProfile` — commands that reconcile a pflag-effective - * profile differing from the config layer's (sso add/update, PR #5974 - * round 7) pass that profile's URL so the gate requests hit the same host - * as their main calls. Defaults to `CommandSettings.apiUrl`. + * Overrides the API base URL of the fallback project + entitlement GETs, + * for commands that reconcile a profile differing from the config layer's + * (sso add/update), so the gate requests hit the same host as their main + * calls. Defaults to `CommandSettings.apiUrl`. */ readonly apiUrl?: string; /** * Overrides the bearer token of the fallback GETs, complementing `apiUrl`: - * Go resolves credentials for the process-wide reconciled `CurrentProfile`, - * so callers that pass a reconciled `apiUrl` must - * pass the reconciled profile's token too — otherwise the stale profile's - * bearer token would be sent to the reconciled host (review r3684524241). - * `Some` uses that token, `None` sends unauthenticated (the reconciled - * profile has no token — matching Go, which fails its token lookup and - * never attaches the stale one), `undefined` resolves from the service. + * a caller passing a reconciled `apiUrl` must also pass that profile's + * token, or the stale profile's token would reach the reconciled host. + * `Some` uses that token, `None` sends unauthenticated, `undefined` + * resolves from the service. */ readonly accessToken?: Option.Option<Redacted.Redacted<string>>; - /** - * Set false where the Go twin fires no `TrackUpgradeSuggested` (vanity - * check-availability), keeping telemetry 1:1. - */ + /** Set false for call sites that must not fire `EventUpgradeSuggested` (vanity check-availability). */ readonly trackAnalytics?: boolean; }) { if (opts.statusCode < 400 || opts.statusCode >= 500) { diff --git a/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts b/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts index 19c2d3a1ee..fe56288f28 100644 --- a/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts +++ b/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts @@ -289,10 +289,6 @@ describe("suggestUpgrade", () => { }); it.live("a caller-provided reconciled token authenticates the fallback GETs", () => { - // Go resolves credentials for the process-wide reconciled CurrentProfile — - // a reconciled caller passes its token with the - // URL so the stale profile's bearer token never follows the reconciled - // host (review r3684524241). const { layer, api } = setup(); return Effect.gen(function* () { yield* suggestUpgrade({ @@ -309,8 +305,6 @@ describe("suggestUpgrade", () => { }); it.live("a reconciled profile with no token sends the fallback GETs unauthenticated", () => { - // `None` means the reconciled profile's own lookup found nothing — Go - // never falls back to the stale profile's token in that case. const { layer, api } = setup(); return Effect.gen(function* () { yield* suggestUpgrade({ diff --git a/apps/cli/src/command-internal/vault-decrypt.ts b/apps/cli/src/command-internal/vault-decrypt.ts index f6e9a3fd71..358f36f66f 100644 --- a/apps/cli/src/command-internal/vault-decrypt.ts +++ b/apps/cli/src/command-internal/vault-decrypt.ts @@ -2,33 +2,29 @@ import { Buffer } from "node:buffer"; import { decrypt, PrivateKey } from "eciesjs"; /** - * dotenvx vault-secret decryption: ECIES over secp256k1 (uncompressed ephemeral key, - * HKDF-SHA256 with no salt/info, AES-256-GCM with a 16-byte nonce). dotenvx - * itself encrypts with the JS `eciesjs` library, whose defaults match that wire - * format byte-for-byte, so we decrypt with `eciesjs` here. Validated against - * a known-good test vector. + * dotenvx vault-secret decryption: ECIES over secp256k1 (uncompressed + * ephemeral key, HKDF-SHA256 with no salt/info, AES-256-GCM with a 16-byte + * nonce) — the same wire format the JS `eciesjs` library produces, so this + * decrypts with `eciesjs` directly. * * An `encrypted:` value that cannot be decrypted aborts the whole command - * with `failed to parse config: <error>` — it is never silently skipped. The - * caller maps a non-`ok` result into that error. + * with `failed to parse config: <error>`; the caller maps a non-`ok` result + * into that error. */ -/** `ENCRYPTED_PREFIX`. */ const ENCRYPTED_PREFIX = "encrypted:"; -/** `PRIVATE_KEY_PREFIX`. */ const PRIVATE_KEY_ENV_PREFIX = "DOTENV_PRIVATE_KEY"; -/** Standard base64 alphabet (`base64.StdEncoding`), optional `=` padding. */ +/** Standard base64 alphabet, with optional `=` padding. */ const STD_BASE64_PATTERN = /^[A-Za-z0-9+/]*={0,2}$/u; -/** Whether a `[db.vault]` value is a dotenvx ciphertext (`ENCRYPTED_PREFIX`). */ +/** Whether a `[db.vault]` value is a dotenvx ciphertext. */ export const isEncryptedSecret = (value: string): boolean => value.startsWith(ENCRYPTED_PREFIX); /** - * Collects dotenvx private keys from the environment, mirroring Go's env scan: - * every `DOTENV_PRIVATE_KEY` or `DOTENV_PRIVATE_KEY_*` - * variable, comma-split (`strToArr`, empties dropped). The decrypt loop - * tries each key until one succeeds, so enumeration order only matters when more - * than one distinct key could decrypt the same ciphertext (not a real scenario). + * Collects dotenvx private keys from the environment: every + * `DOTENV_PRIVATE_KEY` or `DOTENV_PRIVATE_KEY_*` variable, comma-split with + * empties dropped. Enumeration order only matters when more than one + * distinct key could decrypt the same ciphertext (not a real scenario). */ export function collectDotenvPrivateKeys( env: Record<string, string | undefined>, @@ -45,7 +41,7 @@ export function collectDotenvPrivateKeys( return keys; } -/** Decrypt outcome — plaintext on success, else Go's error message. */ +/** Decrypt outcome — plaintext on success, else an error message. */ export type DecryptedSecret = | { readonly ok: true; readonly value: string } | { readonly ok: false; readonly error: string }; @@ -53,22 +49,21 @@ export type DecryptedSecret = const errorMessage = (cause: unknown): string => cause instanceof Error ? cause.message : String(cause); -/** Go's per-key `decrypt`: hex key → base64 payload → ECIES. */ +/** Per-key decrypt: hex key → base64 payload → ECIES. */ function decryptWithKey(keyHex: string, encryptedValue: string): DecryptedSecret { if (keyHex.length === 0) return { ok: false, error: "missing private key" }; let privateKeyHex: string; try { privateKeyHex = PrivateKey.fromHex(keyHex).toHex(); } catch { - // Fixed message rather than the underlying error (`ecies.NewPrivateKeyFromHex` - // returns the generic "cannot decode hex string" too): the fallback hex decoder some - // runtimes take for a malformed key can otherwise echo a fragment of the bad input - // (offending character + index) into this error, which reaches the user's stderr. + // Fixed message, not the underlying error: some runtimes' fallback hex + // decoder echoes a fragment of the bad input (offending char + index) + // into the error, which would otherwise reach the user's stderr. return { ok: false, error: "failed to hex decode private key: cannot decode hex string" }; } const encoded = encryptedValue.slice(ENCRYPTED_PREFIX.length); - // Node's `Buffer.from(s, "base64")` silently drops invalid characters, unlike - // `base64.StdEncoding.DecodeString`, so reject malformed input explicitly. + // Node's `Buffer.from(s, "base64")` silently drops invalid characters, so + // reject malformed input explicitly. if (!STD_BASE64_PATTERN.test(encoded) || encoded.length % 4 !== 0) { return { ok: false, error: "failed to base64 decode secret: invalid base64 data" }; } @@ -82,9 +77,9 @@ function decryptWithKey(keyHex: string, encryptedValue: string): DecryptedSecret } /** - * Decrypts a dotenvx `encrypted:` secret — a port of `Secret.Decrypt`: - * with no keys, `missing private key`; otherwise try each - * key, the first success wins, and on total failure return the last key's error. + * Decrypts a dotenvx `encrypted:` secret: with no keys, `missing private + * key`; otherwise tries each key, the first success wins, and on total + * failure returns the last key's error. */ export function decryptSecret( encryptedValue: string, diff --git a/apps/cli/src/command-internal/vault-decrypt.unit.test.ts b/apps/cli/src/command-internal/vault-decrypt.unit.test.ts index 42f06962b8..4e21dd66aa 100644 --- a/apps/cli/src/command-internal/vault-decrypt.unit.test.ts +++ b/apps/cli/src/command-internal/vault-decrypt.unit.test.ts @@ -2,9 +2,8 @@ import { describe, expect, it } from "vitest"; import { collectDotenvPrivateKeys, decryptSecret, isEncryptedSecret } from "./vault-decrypt.ts"; -// A known-good test vector. The same -// keypair/ciphertext must decrypt identically here, proving `eciesjs` -// cross-decrypts `github.com/ecies/go/v2`'s output. +// This keypair/ciphertext must decrypt identically here, proving `eciesjs` +// cross-decrypts another ECIES implementation's output. const PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; const ENCRYPTED_VALUE = "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; @@ -14,7 +13,7 @@ describe("isEncryptedSecret", () => { it("matches only the leading encrypted: prefix", () => { expect(isEncryptedSecret(ENCRYPTED_VALUE)).toBe(true); expect(isEncryptedSecret("encrypted:anything")).toBe(true); - // Must START with the prefix — a value that merely contains it is not encrypted. + // A value that merely contains the prefix is not encrypted. expect(isEncryptedSecret("not-encrypted:value")).toBe(false); expect(isEncryptedSecret("plain")).toBe(false); }); @@ -78,7 +77,7 @@ describe("collectDotenvPrivateKeys", () => { it("requires the underscore for the prefixed form and drops empty entries", () => { const keys = collectDotenvPrivateKeys({ - // No underscore after the prefix → not a private-key var (Go requires `_`). + // No underscore after the prefix → not a private-key var. DOTENV_PRIVATE_KEYX: "nope", DOTENV_PRIVATE_KEY: ",,", }); diff --git a/apps/cli/src/command-internal/vault.ts b/apps/cli/src/command-internal/vault.ts index 4596624f8b..60500c78a8 100644 --- a/apps/cli/src/command-internal/vault.ts +++ b/apps/cli/src/command-internal/vault.ts @@ -8,7 +8,7 @@ import { } from "../shared/telemetry/error-actionability.ts"; import type { DbSession } from "./db-connection.service.ts"; -/** Reading or updating `vault.secrets` failed (`UpsertVaultSecrets` errors). */ +/** Reading or updating `vault.secrets` failed. */ export class MigrationVaultError extends Data.TaggedError("MigrationVaultError")<{ readonly message: string; }> { @@ -17,24 +17,23 @@ export class MigrationVaultError extends Data.TaggedError("MigrationVaultError") } } -/** A resolved `[db.vault]` secret. `resolved` mirrors `len(SHA256) > 0` gate. */ +/** A resolved `[db.vault]` secret; `resolved` is true when a SHA256 digest was computed. */ export interface VaultSecret { readonly name: string; readonly value: string; readonly resolved: boolean; } -// Exported for the shadow baseline cache's embedded-SQL digest (`shadow-cache.ts`), which must -// re-key whenever the SQL this module bakes into a baseline changes across CLI releases. +// Exported so the shadow baseline cache's embedded-SQL digest (`shadow-cache.ts`) +// re-keys whenever this SQL changes across CLI releases. export const READ_VAULT_KV = "SELECT id, name FROM vault.secrets WHERE name = ANY($1)"; export const UPDATE_VAULT_KV = "SELECT vault.update_secret($1, $2)"; export const CREATE_VAULT_KV = "SELECT vault.create_secret($1, $2)"; /** - * Upserts `[db.vault]` secrets into `vault.secrets`. Port of Go's - * `vault.UpsertVaultSecrets`: only resolved secrets - * (Go gates on a non-empty SHA256) are processed; existing names are updated by - * id, the rest are created. No resolved secrets → no-op (no DB round-trip). + * Upserts `[db.vault]` secrets into `vault.secrets`: only resolved secrets + * are processed, existing names are updated by id, and the rest are created. + * No resolved secrets is a no-op (no DB round-trip). */ export const upsertVaultSecrets = (session: DbSession, secrets: ReadonlyArray<VaultSecret>) => Effect.gen(function* () { @@ -55,7 +54,7 @@ export const upsertVaultSecrets = (session: DbSession, secrets: ReadonlyArray<Va existing.map((row) => [String(row["name"]), String(row["id"])] as const), ); - // One transaction, mirroring Go's implicitly-transactional `SendBatch`. + // One transaction: all secrets update/create together, or none do. const batch = Effect.gen(function* () { yield* session.exec("BEGIN"); for (const secret of resolved) { diff --git a/apps/cli/src/command-internal/viper-env.ts b/apps/cli/src/command-internal/viper-env.ts index d3bbd91ef0..01bc4257d7 100644 --- a/apps/cli/src/command-internal/viper-env.ts +++ b/apps/cli/src/command-internal/viper-env.ts @@ -1,59 +1,30 @@ /** - * Go's root command binds every persistent flag to viper and enables - * `AutomaticEnv` with the `SUPABASE` prefix and a `-`→`_` key replacer - * (`apps/cli-go/cmd/root.go:318-320,334`): - * - * ```go - * viper.SetEnvPrefix("SUPABASE") - * viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) - * viper.AutomaticEnv() - * viper.BindPFlags(flags) - * ``` - * - * The net effect is that any global flag `--foo-bar` falls back to the - * `SUPABASE_FOO_BAR` env var when the flag is absent. `viper.GetBool` casts the - * env string via `strconv.ParseBool` (through `cast.ToBool`), which recognizes - * exactly `1/t/T/TRUE/true/True` as true and `0/f/F/FALSE/false/False` as false; - * any other value (including `yes`/`on`/empty/garbage) parses to an error that - * `cast.ToBool` swallows to `false`. - * - * This helper reproduces `viper.GetBool` for a single bound boolean key so the - * CLI honors `SUPABASE_YES`, `SUPABASE_EXPERIMENTAL`, etc. exactly like - * the Go CLI. Effect CLI's flag parser carries no env binding, so callers OR the - * parsed flag value with this read (flag-set wins, matching viper precedence). + * Any global flag `--foo-bar` falls back to the `SUPABASE_FOO_BAR` env var + * when the flag is absent: `1/t/T/TRUE/true/True` parses as true, + * `0/f/F/FALSE/false/False` as false, and any other value (`yes`, `on`, + * empty, garbage) as false. Effect CLI's flag parser carries no env binding, + * so callers OR the parsed flag value with this read (flag-set wins). */ const VIPER_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); -/** `viper.GetBool` truthiness for an already-resolved env value (see module doc). */ +/** Truthiness for an already-resolved env value (see module doc). */ function viperBool(raw: string | undefined): boolean { return raw !== undefined && VIPER_TRUE.has(raw); } -/** `viper.GetBool` for a single `SUPABASE_*` env var read from `process.env` (see module doc). */ +/** Reads a single `SUPABASE_*` boolean env var from `process.env` (see module doc). */ export function viperEnvBool(name: string): boolean { return viperBool(process.env[name]); } /** - * `viper.GetBool` for a `SUPABASE_*` key where a project `supabase/.env` value may also - * apply. Go loads the project env via `godotenv.Load`, which builds its presence map from - * `os.Environ()` and never overwrites a key that already exists in the shell env — even one - * set to the empty string (`godotenv@v1.5.1/godotenv.go:184-200`, called by `loadNestedEnv` - * at `apps/cli-go/pkg/config/config.go:1220-1261`). `viper.GetBool` then reads the merged - * env, and since the CLI never enables `AllowEmptyEnv`, an empty shell value resolves to the - * `false` default (`viper@v1.21.0/viper.go:442-450`). - * - * Net effect: shell *presence* — any value, including `false`, `""`, or garbage (all of - * which cast to `false`) — suppresses the project value entirely; the file value is - * consulted only when the variable is absent from the shell env. `??` (not `||`) encodes - * exactly that presence check. - * - * `opts.whenUnset` is a CLI-chosen extension over viper (whose zero value is always `false`): - * it resolves a key that is absent from BOTH the shell and the project env, letting an - * opt-out gate (e.g. `SUPABASE_SHADOW_CACHE`) default ON while a *present* value keeps the - * exact `ParseBool` semantics above — so `=0`, `=false`, empty, and garbage all still - * disable. + * Resolves a `SUPABASE_*` boolean where a project `supabase/.env` value may + * also apply: shell presence (any value, including `false`, `""`, or + * garbage) suppresses the project value entirely; `??` encodes exactly that + * presence check. `opts.whenUnset` resolves a key absent from both shell and + * project env, letting an opt-out gate default on while any present value + * still disables. */ export function viperEnvBoolWithProjectFallback( name: string, @@ -66,13 +37,10 @@ export function viperEnvBoolWithProjectFallback( } /** - * `viper.GetString` for a `SUPABASE_*` key where a project `supabase/.env` value may also - * apply — same shell-*presence*-wins semantics as {@link viperEnvBoolWithProjectFallback} - * (godotenv.Load's "don't override a key that already exists in `os.Environ()`" check is - * presence-based, not value-based, so an empty shell value still blocks the project file's - * value), but for a plain string-typed viper-bound flag — no `ParseBool`/`cast.ToBool` coercion, - * just the raw merged string (or `""` when the key is absent from both, matching `viper.GetString` - * always returning a string rather than `undefined`). `??` (not `||`) encodes the presence check. + * Resolves a `SUPABASE_*` string with the same shell-presence-wins semantics + * as {@link viperEnvBoolWithProjectFallback}, but with no boolean coercion — + * the raw merged string, or `""` when absent from both. `??` (not `||`) + * encodes the presence check. */ export function viperEnvStringWithProjectFallback( name: string, From 69a01187265d6974cab463bb39dbb61b287b3b6e Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 12:40:07 +0100 Subject: [PATCH 15/56] chore(repo): trim comments, shard 14 (1764 -> 669 comment lines) --- .../local-config-values.unit.test.ts | 562 +++--------------- .../command-internal/local-project-context.ts | 161 +---- .../local-project-context.unit.test.ts | 22 +- .../src/command-internal/login-api.layer.ts | 13 +- apps/cli/src/command-internal/make-dir.ts | 13 +- .../management-api-runtime.layer.ts | 106 +--- .../src/command-internal/migrate-and-seed.ts | 29 +- .../migrate-and-seed.unit.test.ts | 25 +- .../src/command-internal/migration-apply.ts | 526 +++++----------- .../migration-apply.unit.test.ts | 168 +----- .../command-internal/migration-fetch-run.ts | 30 +- .../src/command-internal/migration-file.ts | 12 +- .../src/command-internal/migration-history.ts | 195 +++--- .../migration-history.unit.test.ts | 27 +- .../src/command-internal/migration-list.ts | 16 +- .../migration-list.unit.test.ts | 22 +- .../src/command-internal/migration-pending.ts | 42 +- .../migration-pending.unit.test.ts | 15 +- 18 files changed, 444 insertions(+), 1540 deletions(-) diff --git a/apps/cli/src/command-internal/local-config-values.unit.test.ts b/apps/cli/src/command-internal/local-config-values.unit.test.ts index 27f20c14df..a874e2e2d7 100644 --- a/apps/cli/src/command-internal/local-config-values.unit.test.ts +++ b/apps/cli/src/command-internal/local-config-values.unit.test.ts @@ -122,8 +122,6 @@ describe("resolveLocalConfigValues", () => { it("signs the default anon/service_role JWTs from the resolved secret", () => { const config = baseConfig(); const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - // Byte-exact Go-parity shape is covered by go-jwt.unit.test.ts; here we - // only assert the resolver wires the default secret through to both roles. const [, anonPayload] = values.anonKey.split("."); const [, serviceRolePayload] = values.serviceRoleKey.split("."); expect(JSON.parse(Buffer.from(anonPayload ?? "", "base64url").toString())).toMatchObject({ @@ -151,9 +149,6 @@ describe("resolveLocalConfigValues", () => { }); it("rejects a configured jwt_secret shorter than 16 characters", () => { - // Go's Config.Validate fails this at config-load time, before any command - // can render output — reproduced as a thrown - // error here rather than silently signing with the too-short secret. const config = baseConfig({ auth: { jwt_secret: "a".repeat(15) } }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( InvalidJwtSecretError, @@ -161,8 +156,7 @@ describe("resolveLocalConfigValues", () => { }); describe("encrypted auth secrets", () => { - // A known-good test vector: this ciphertext - // decrypts to "value" under the keypair below. + // This ciphertext decrypts to "value" under the keypair below. const VAULT_PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; const VAULT_ENCRYPTED = "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; @@ -172,8 +166,6 @@ describe("resolveLocalConfigValues", () => { }); it("decrypts an encrypted: jwt_secret when DOTENV_PRIVATE_KEY is set", () => { - // "value" is only 5 characters, shorter than Go's minimum JWT secret length, - // so pad it out the way a real deployment's decrypted secret would be sized. process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; const config = baseConfig({ auth: { jwt_secret: VAULT_ENCRYPTED } }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( @@ -189,8 +181,6 @@ describe("resolveLocalConfigValues", () => { }); it("fails config loading for an encrypted: secret with no private key, matching Go", () => { - // Go aborts the whole command with `failed to parse config: <error>` rather - // than silently using the ciphertext as literal key material. const config = baseConfig({ auth: { publishable_key: VAULT_ENCRYPTED } }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( "failed to parse config: missing private key", @@ -212,8 +202,6 @@ describe("resolveLocalConfigValues", () => { }); it("decrypts an encrypted: SUPABASE_AUTH_* env override, not just the config.toml value", () => { - // Go's decrypt hook runs on whatever value reaches the config.Secret field, - // whether it was sourced from config.toml or a Viper env override. process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; const config = baseConfig(); const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, { @@ -225,11 +213,8 @@ describe("resolveLocalConfigValues", () => { }); it("rejects an explicit empty project_id, matching Go's Config.Validate", () => { - // Go's Config.Validate checks ProjectId first, before any other field. - // The workdir-basename default is merged - // in as a viper default BEFORE config.toml is merged, so an explicit - // `project_id = ""` in the file overwrites that default with the literal - // empty string rather than being treated as absent — Go fails outright. + // An explicit `project_id = ""` overwrites the workdir-basename default with the literal + // empty string, unlike an absent key. const config = baseConfig({ project_id: "" }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( "Missing required field in config: project_id", @@ -242,11 +227,8 @@ describe("resolveLocalConfigValues", () => { }); it("rejects an absent project_id when the workdir basename sanitizes to empty, matching Go", () => { - // `mergeDefaultValues` merges `sanitizeProjectId(filepath.Base(cwd))` in as a viper - // DEFAULT before config.toml is merged — - // so `c.ProjectId` is never Go's zero value by the time `Validate` runs. A workdir whose - // basename sanitizes to `""` (every character invalid, e.g. `!!!`) therefore still fails - // config loading in Go even with no `project_id` key in the file at all. + // The workdir-basename default still applies with no `project_id` key present, so a workdir + // whose basename sanitizes to empty (e.g. `!!!`) still fails validation. const config = Schema.decodeUnknownSync(CliConfigSchema)({}); expect(() => resolveLocalConfigValues(config, "127.0.0.1", "/tmp/!!!")).toThrow( "Missing required field in config: project_id", @@ -263,9 +245,6 @@ describe("resolveLocalConfigValues", () => { }); it("lets SUPABASE_PROJECT_ID override an explicit empty project_id", () => { - // Viper's AutomaticEnv binds SUPABASE_PROJECT_ID with higher precedence - // than config.toml, so a non-empty env override must - // win even when the file's project_id is explicitly empty. const config = baseConfig({ project_id: "" }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, { @@ -287,8 +266,6 @@ describe("resolveLocalConfigValues", () => { describe("SUPABASE_AUTH_* env overrides", () => { const tempRoot = useTempWorkdir("supabase-signing-keys-env-override-test-"); - // Go's Config.Load binds Viper with SetEnvPrefix("SUPABASE") + AutomaticEnv() — - // env vars take precedence over config.toml. const ENV_KEYS = [ "SUPABASE_AUTH_JWT_SECRET", "SUPABASE_AUTH_PUBLISHABLE_KEY", @@ -376,13 +353,8 @@ describe("resolveLocalConfigValues", () => { }); describe("SUPABASE_* env(VAR) indirection (Go's LoadEnvHook)", () => { - // `LoadEnvHook` is - // the first mapstructure decode hook composed into `v.UnmarshalExact`, - // so it resolves a nested `env(VAR)` - // reference on ANY string mapstructure decodes into the struct — including - // a `SUPABASE_*` env-override value itself, not just a `config.toml` - // literal. `envOverride`'s callers (string/port/bool fields) must all see - // that same resolution. + // `env(VAR)` indirection resolves inside any string field, including a `SUPABASE_*` override + // value itself, not just a config.toml literal. const ENV_KEYS = ["SUPABASE_AUTH_JWT_SECRET", "SUPABASE_DB_PORT", "SUPABASE_API_ENABLED"]; afterEach(() => { @@ -414,11 +386,6 @@ describe("resolveLocalConfigValues", () => { const config = baseConfig({ api: { enabled: true, tls: { enabled: true, cert_path: "missing-cert.pem" } }, }); - // If the bool override weren't resolved through the indirection, the - // literal "env(INDIRECT_API_ENABLED)" string would fail Go's - // strconv.ParseBool acceptance set and throw InvalidBoolEnvOverrideError; - // resolving it to "false" disables api.enabled and skips the TLS check - // that would otherwise throw on the missing cert file. expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); @@ -426,20 +393,11 @@ describe("resolveLocalConfigValues", () => { process.env["SUPABASE_AUTH_JWT_SECRET"] = "env(INDIRECT_JWT_SECRET)"; const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); - // Go's LoadEnvHook only substitutes when the target var is non-empty — - // an unset indirection leaves the literal - // `env(VAR)` string, same as an unresolved config.toml-level reference. expect(values.jwtSecret).toBe("env(INDIRECT_JWT_SECRET)"); }); }); describe("non-auth SUPABASE_* env overrides", () => { - // Go's Config.Load binds Viper with SetEnvPrefix("SUPABASE") + AutomaticEnv() - // generically across the whole config struct, - // not just auth fields — this is also exercised against - // auth.site_url, and status.go's toValues() reads the already-overridden - // utils.Config.* directly, so every port/URL status derives must honor the - // same override. const ENV_KEYS = [ "SUPABASE_DB_PORT", "SUPABASE_STUDIO_PORT", @@ -497,10 +455,6 @@ describe("resolveLocalConfigValues", () => { expect(values.dbUrl).toBe("postgresql://postgres:postgres@127.0.0.1:54322/postgres"); }); - // Go's Config.Load decodes `SUPABASE_*_PORT` overrides as `uint16` via - // Viper's UnmarshalExact (WeaklyTypedInput - // decodes the override string with strconv.ParseUint and hard-fails on a - // malformed value) rather than silently producing a `NaN`-laced URL. it.each([ "SUPABASE_DB_PORT", "SUPABASE_STUDIO_PORT", @@ -522,11 +476,6 @@ describe("resolveLocalConfigValues", () => { ); }); - // Go's `strconv.ParseUint(str, 0, 16)` (base 0) auto-detects octal/hex/binary literals for - // `SUPABASE_*_PORT` overrides too — same base-0 grammar as `envOverrideUint` - // (`parseGoBaseZeroUint`), just capped at `uint16` instead of `uint64`. The old - // `/^\d+$/`-plus-`Number()` parsing silently misread a bare-leading-zero override as decimal - // instead of octal, and rejected a `0x`-prefixed override outright even though Go accepts it. it("resolves an octal leading-zero SUPABASE_DB_PORT override to its octal value, not decimal", () => { process.env["SUPABASE_DB_PORT"] = "010"; const config = baseConfig({ db: { port: 54322 } }); @@ -557,10 +506,8 @@ describe("resolveLocalConfigValues", () => { ); }); - // Unlike the malformed/out-of-range cases above (a decode-time hard-fail, - // uniform across all four SUPABASE_*_PORT fields), db.port=0 is a - // Config.Validate-time hard-fail specific to db.port: it has no `enabled` - // gate in Go, unlike api.port/studio.port/local_smtp.port. + // Unlike the malformed/out-of-range cases above, db.port=0 is a required-field failure with + // no `enabled` gate, unlike api.port/studio.port/local_smtp.port. it("rejects a zero SUPABASE_DB_PORT override, matching Go's required-field check", () => { process.env["SUPABASE_DB_PORT"] = "0"; const config = baseConfig(); @@ -569,9 +516,8 @@ describe("resolveLocalConfigValues", () => { ); }); - // Unlike db.port, Go gates the api.port===0 rejection on api.enabled — - // api.enabled defaults to true, so a - // configured or env-overridden zero port is rejected by default. + // api.enabled defaults to true, so this rejection applies without an explicit + // `api.enabled = true`. it("rejects a configured api.port of 0 when api is enabled", () => { const config = baseConfig({ api: { port: 0 } }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( @@ -592,10 +538,8 @@ describe("resolveLocalConfigValues", () => { expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); - // Go gates the studio.port===0 rejection on studio.enabled, - // same pattern as api.port above. - // studio.enabled defaults to true, so a configured or env-overridden zero - // port is rejected by default. + // studio.enabled defaults to true, so this rejection applies without an explicit + // `studio.enabled = true`. it("rejects a configured studio.port of 0 when studio is enabled", () => { const config = baseConfig({ studio: { port: 0 } }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( @@ -616,8 +560,6 @@ describe("resolveLocalConfigValues", () => { expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); - // Go's Config.Validate parses studio.api_url with net/url.Parse right - // after the port check, still inside `if c.Studio.Enabled`. it("rejects a malformed studio.api_url (unterminated IPv6 literal) when studio is enabled", () => { const config = baseConfig({ studio: { api_url: "http://[::1" } }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( @@ -643,11 +585,8 @@ describe("resolveLocalConfigValues", () => { ); }); - // Go gates the local_smtp.port===0 rejection on local_smtp.enabled (Go's - // struct field is still named `Inbucket` for the `[local_smtp]` TOML - // section), same pattern as api.port/ - // studio.port above. local_smtp.enabled defaults to true, so a configured - // or env-overridden zero port is rejected by default. + // local_smtp.enabled defaults to true, so this rejection applies without an explicit + // `local_smtp.enabled = true`. it("rejects a configured local_smtp.port of 0 when local_smtp is enabled", () => { const config = baseConfig({ local_smtp: { port: 0 } }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( @@ -670,9 +609,8 @@ describe("resolveLocalConfigValues", () => { }); describe("db.major_version (required field in config)", () => { - // The pure 0/12/13-17/generic-invalid assertions moved to - // `config-validate.unit.test.ts` (direct `validateResolvedConfig` calls) — - // only the SUPABASE_DB_MAJOR_VERSION env-override mechanics stay here. + // Required-field/range assertions live in config-validate.unit.test.ts; only env-override + // mechanics are tested here. afterEach(() => { delete process.env["SUPABASE_DB_MAJOR_VERSION"]; }); @@ -707,10 +645,8 @@ describe("resolveLocalConfigValues", () => { }); describe("SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED env override", () => { - // `[db.network_restrictions]` ships uncommented in Go's default template and - // `NetworkRestrictions` is a plain, non-pointer `db` struct field, so Viper always registers a - // default and decodes this override unconditionally during `Config.Load` — same bucket as - // `db.port`/`db.major_version` above, validated eagerly rather than skipped. + // Always validated eagerly, with no presence/enabled gate — same bucket as + // db.port/db.major_version above. afterEach(() => { delete process.env["SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED"]; }); @@ -748,10 +684,6 @@ describe("resolveLocalConfigValues", () => { }); it("rejects a non-string root_key (e.g. a bare TOML integer), matching Go's Secret decode failure", () => { - // `db.root_key` isn't modeled in `@supabase/config`'s schema, so Go's own - // decode failure (mapstructure rejecting a scalar into the `Secret` struct) - // must be reproduced here rather than letting the raw - // number flow unguarded into `envOverride`/`decryptAuthSecret`. const config = baseConfig(); const document = { db: { root_key: 12345 } }; expect(() => @@ -762,19 +694,13 @@ describe("resolveLocalConfigValues", () => { }); }); - // Go's Config.Validate runs ValidateBucketName over every [storage.buckets.*] - // key right after db.major_version, unconditionally — there is no - // storage.enabled-style gate. - // - // Moved to `config-validate.unit.test.ts` (direct `validateResolvedConfig` - // calls) — this section has no L-specific derivation or env-override mechanics of its own. + // storage.buckets validation lives entirely in config-validate.unit.test.ts; this file has no + // bucket-related mechanics of its own. - // Go's Config.Validate rejects an invalid edge_runtime.deno_version - // unconditionally — NOT gated on edge_runtime.enabled. + // Not gated on edge_runtime.enabled — an invalid value is rejected unconditionally. describe("edge_runtime.deno_version (required field in config)", () => { - // The pure 0/1/2/generic-invalid/disabled assertions moved to - // `config-validate.unit.test.ts` (direct `validateResolvedConfig` calls) — - // only the SUPABASE_EDGE_RUNTIME_DENO_VERSION env-override mechanics stay here. + // Required-field/range assertions live in config-validate.unit.test.ts; only env-override + // mechanics are tested here. afterEach(() => { delete process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; }); @@ -811,14 +737,8 @@ describe("resolveLocalConfigValues", () => { }); describe("analytics (BigQuery backend required fields)", () => { - // `Config.Validate` validates `[analytics]` right after - // `edge_runtime.deno_version`: when - // `analytics.enabled` and `analytics.backend == "bigquery"`, all three GCP - // fields are required, checked in that order. - // - // The pure required-field/complete/disabled assertions moved to - // `config-validate.unit.test.ts` (direct `validateResolvedConfig` calls) — - // only the SUPABASE_ANALYTICS_* env-override mechanics stay here. + // Required-field/range assertions live in config-validate.unit.test.ts; only env-override + // mechanics are tested here. afterEach(() => { delete process.env["SUPABASE_ANALYTICS_ENABLED"]; delete process.env["SUPABASE_ANALYTICS_BACKEND"]; @@ -851,11 +771,6 @@ describe("resolveLocalConfigValues", () => { expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); - // `LogflareBackend.UnmarshalText` hard-rejects any - // `analytics.backend` value outside `postgres`/`bigquery` during the same - // `UnmarshalExact` decode every `SUPABASE_*` override goes through — - // a malformed `SUPABASE_ANALYTICS_BACKEND` fails - // config loading outright, same mechanism as the port/bool overrides below. it("rejects an invalid SUPABASE_ANALYTICS_BACKEND override", () => { process.env["SUPABASE_ANALYTICS_BACKEND"] = "mysql"; const config = baseConfig({ analytics: { enabled: true, backend: "postgres" } }); @@ -869,31 +784,15 @@ describe("resolveLocalConfigValues", () => { }); describe("experimental.* (experimental.validate())", () => { - // `(e *experimental) validate()`, - // called right after the analytics/bigquery block and right before - // `Config.Validate` returns — unconditionally, no `enabled` gate of its own. - // - // Every webhooks-presence/enabled combination and the pgdelta format_options JSON checks - // moved to `config-validate.unit.test.ts` (direct `validateResolvedConfig` - // calls, setting `experimental.webhooksPresent`/`webhooksEnabled` directly instead of - // deriving them from a raw `document`) — only this document-THREADING-specific case stays - // here, since it exercises this function's own "no document provided" fallback rather than - // a check `validateResolvedConfig` itself owns. + // Exercises this function's own fallback when no `document` (5th param) is supplied; the + // required-field/enabled checks themselves live in config-validate.unit.test.ts. it("does not throw a present [experimental.webhooks] section without enabled when no document is provided", () => { - // No `document` (5th param) at all — e.g. a caller that hasn't threaded - // `LoadedCliConfig.document` through yet. The presence-only check - // can't run without it, so it's skipped rather than guessed at; this - // also covers every pre-existing call site/test in this file that - // doesn't pass a 5th argument. const config = baseConfig({ experimental: { webhooks: {} } }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); - // `experimental.webhooks.enabled`/`experimental.pgdelta.format_options` are Viper-bound like - // any other leaf field once `[experimental]` is present (`ExperimentalBindStruct`/ - // `AutomaticEnv`) — same SUPABASE_*-env-override MECHANICS split as - // `auth.captcha`/`auth.passkey` above: the required-field/JSON-shape checks themselves live in - // `config-validate.unit.test.ts`, only the env-override wiring is exercised here. + // Required-field/range assertions live in config-validate.unit.test.ts; only env-override + // mechanics are tested here. afterEach(() => { delete process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"]; delete process.env["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"]; @@ -937,11 +836,6 @@ describe("resolveLocalConfigValues", () => { }); it("suppresses a malformed SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS when a remote block already set experimental.pgdelta.format_options (review: PRRT_kwDOErm0O86XLe6o)", () => { - // Same `experimental.webhooks.enabled` bug class, just for the OTHER Viper-bound - // `[experimental]` leaf this resolver derives: `experimental.pgdelta.format_options` is - // ALSO in `ENV_OVERRIDABLE_KEYS`, so a matched `[remotes.<ref>]` block's own valid - // value must win over a malformed ambient env override, matching `mergeRemoteConfig` - // (`v.Set` above `AutomaticEnv`). process.env["SUPABASE_EXPERIMENTAL_PGDELTA_FORMAT_OPTIONS"] = "{not valid json"; const config = baseConfig({ experimental: { pgdelta: { format_options: '{"keywordCase":"upper"}' } }, @@ -960,10 +854,8 @@ describe("resolveLocalConfigValues", () => { }); describe("SUPABASE_API_TLS_ENABLED env override", () => { - // Go applies the Viper-bound `api.tls.enabled` override - // BEFORE deriving the default `api.external_url` scheme, - // so an ambient/dotenv override flips http/https even when config.toml says - // otherwise. + // Applied before the default `api.external_url` scheme is derived, so it can flip http/https + // even when config.toml disagrees. afterEach(() => { delete process.env["SUPABASE_API_TLS_ENABLED"]; }); @@ -1019,9 +911,6 @@ describe("resolveLocalConfigValues", () => { expect(envOverrideRealtimeIpVersion("IPv4", undefined)).toBe("IPv6"); }); - // `AddressFamily.UnmarshalText` hard-rejects - // any value outside `{IPv4, IPv6}` during the same `UnmarshalExact` decode every - // `SUPABASE_*` override goes through, same mechanism as the analytics backend override. it("rejects an invalid override instead of falling back to the configured value", () => { process.env["SUPABASE_REALTIME_IP_VERSION"] = "IPv5"; expect(() => envOverrideRealtimeIpVersion("IPv4", undefined)).toThrow( @@ -1055,10 +944,6 @@ describe("resolveLocalConfigValues", () => { ).toBe(16384); }); - // `uint` is 64 bits wide on every platform this CLI ships for, decoded via - // mapstructure's `decodeUint` (`strconv.ParseUint(str, 0, 64)`) under Viper's - // `WeaklyTypedInput: true`. A value one past `2^64-1` genuinely fails that parse in Go, so - // it must be rejected here too instead of silently losing precision through `Number(value)`. it("rejects an override exceeding the uint64 max (2^64), matching Go's ParseUint failure", () => { process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "18446744073709551616"; expect(() => envOverrideRealtimeMaxHeaderLength(4096, undefined)).toThrow( @@ -1066,18 +951,11 @@ describe("resolveLocalConfigValues", () => { ); }); - // Guards against an overcorrected fix that used an imprecise `Number`-based bound and - // rejected values Go itself still accepts. it("accepts an override of exactly the uint64 max (2^64-1)", () => { process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "18446744073709551615"; expect(() => envOverrideRealtimeMaxHeaderLength(4096, undefined)).not.toThrow(); }); - // Guards against the base-0 grammar rewrite (`parseGoBaseZeroUint`) silently - // reintroducing precision loss or an unbounded parse for a non-decimal literal — - // `0x10000000000000000` is exactly 2^64, one past `UINT_MAX`, same as the - // decimal literal above, just routed through the hex branch instead of the plain - // decimal branch. it("rejects a hex override exceeding the uint64 max (2^64), matching Go's ParseUint failure", () => { process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "0x10000000000000000"; expect(() => envOverrideRealtimeMaxHeaderLength(4096, undefined)).toThrow( @@ -1101,13 +979,9 @@ describe("resolveLocalConfigValues", () => { }); }); - // `envOverrideMajorVersion` is exercised directly (rather than through the - // full `resolveLocalConfigValues` pipeline, as the "db.major_version (required - // field in config)" describe block above does) so these assertions cover only - // `envOverrideUint`'s base-0 grammar parsing, not `validateResolvedConfig`'s - // separate "is this a supported Postgres major version" switch — most of the octal/hex/ - // binary literals below don't correspond to a supported major version and would fail - // that unrelated downstream check even once correctly parsed. + // Exercises `envOverrideMajorVersion`'s base-0 parsing directly; most literals below don't + // correspond to a supported Postgres major version, which the full pipeline would separately + // reject. describe("envOverrideMajorVersion", () => { afterEach(() => { delete process.env["SUPABASE_DB_MAJOR_VERSION"]; @@ -1117,9 +991,6 @@ describe("resolveLocalConfigValues", () => { expect(envOverrideMajorVersion(17, undefined)).toBe(17); }); - // Go's `strconv.ParseUint(str, 0, 64)` treats a bare leading zero followed by more - // digits as octal, not decimal — `"010"` is `8`, not `10` — a silent value - // divergence the old `/^\d+$/`-plus-`Number()` parsing didn't reproduce. it("resolves an octal leading-zero override to its octal value, not decimal", () => { process.env["SUPABASE_DB_MAJOR_VERSION"] = "010"; expect(envOverrideMajorVersion(17, undefined)).toBe(8); @@ -1140,14 +1011,11 @@ describe("resolveLocalConfigValues", () => { expect(envOverrideMajorVersion(17, undefined)).toBe(15); }); - // Underscore digit separators are only legal in Go's base-0 mode (Go 1.13+). it("permits an underscore digit separator between decimal digits", () => { process.env["SUPABASE_DB_MAJOR_VERSION"] = "1_000"; expect(envOverrideMajorVersion(17, undefined)).toBe(1000); }); - // Go does NOT fall back to decimal when a bare-leading-zero literal contains an - // invalid octal digit — `"08"`/`"09"` are rejected outright, never read as 8/9. it("rejects an invalid octal digit instead of silently falling back to decimal", () => { process.env["SUPABASE_DB_MAJOR_VERSION"] = "08"; expect(() => envOverrideMajorVersion(17, undefined)).toThrow( @@ -1155,7 +1023,6 @@ describe("resolveLocalConfigValues", () => { ); }); - // `strconv.ParseUint` never accepts a leading sign, unlike `ParseInt`. it("rejects a signed override", () => { process.env["SUPABASE_DB_MAJOR_VERSION"] = "+5"; expect(() => envOverrideMajorVersion(17, undefined)).toThrow( @@ -1178,8 +1045,6 @@ describe("resolveLocalConfigValues", () => { expect(envOverridePoolMode("transaction", undefined)).toBe("session"); }); - // `PoolMode.UnmarshalText` hard-rejects any - // value outside `{transaction, session}`. it("rejects an invalid override instead of falling back to the configured value", () => { process.env["SUPABASE_DB_POOLER_POOL_MODE"] = "invalid"; expect(() => envOverridePoolMode("transaction", undefined)).toThrow( @@ -1205,8 +1070,6 @@ describe("resolveLocalConfigValues", () => { expect(envOverrideEdgeRuntimePolicy("oneshot", undefined)).toBe("per_worker"); }); - // `RequestPolicy.UnmarshalText` hard-rejects - // any value outside `{per_worker, oneshot}`. it("rejects an invalid override instead of falling back to the configured value", () => { process.env["SUPABASE_EDGE_RUNTIME_POLICY"] = "invalid"; expect(() => envOverrideEdgeRuntimePolicy("oneshot", undefined)).toThrow( @@ -1301,11 +1164,6 @@ describe("resolveLocalConfigValues", () => { }); it("suppresses a malformed SUPABASE_AUTH_CAPTCHA_ENABLED when a remote block already set auth.captcha.enabled", () => { - // Regression (review: PRRT_kwDOErm0O86W6R-G): same "throws before a value the caller - // needs is resolved" bug class as `studio.enabled`/`auth.enabled` above — this function's - // own ungated `envOverrideBool` call would abort the whole - // `resolveLocalConfigValues` caller (and the shadow it feeds) on a malformed - // override the remote block should have made irrelevant. process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"] = "not-a-bool"; const authDocument = { captcha: { enabled: false } }; expect(() => @@ -1331,12 +1189,6 @@ describe("resolveLocalConfigValues", () => { }); it("suppresses a malformed SUPABASE_AUTH_CAPTCHA_SECRET when a remote block already set auth.captcha.secret", () => { - // Regression (review: PRRT_kwDOErm0O86XJ4HR) — same bug class as `auth.email.smtp.pass` - // (review: PRRT_kwDOErm0O86XJYol): this function's own ungated `envOverride` call fed - // a malformed ambient override straight into `decryptAuthSecret`, which throws on an - // undecryptable `encrypted:...` value — aborting the whole `resolveLocalConfigValues` - // caller (and the shadow it feeds) on an env value `v.Set` (override tier, above - // `AutomaticEnv`) never lets reach decryption once a remote block already set the secret. process.env["SUPABASE_AUTH_CAPTCHA_SECRET"] = "encrypted:not-a-real-ciphertext"; const authDocument = { captcha: { enabled: true } }; const resolved = resolveAuthCaptcha( @@ -1361,12 +1213,6 @@ describe("resolveLocalConfigValues", () => { }); it("preserves a remote block's valid auth.captcha.provider over an unsupported ambient override", () => { - // Regression (review: PRRT_kwDOErm0O86XLAYn): `provider` can't throw on its own - // (`envOverride` is a plain string read), but an ungated override here still let a - // stale/unsupported ambient `SUPABASE_AUTH_CAPTCHA_PROVIDER` outrank a matched remote's own - // valid provider — `validateResolvedConfig`'s enum check downstream then aborts the - // whole `resolveLocalConfigValues` caller (and the shadow it feeds) on a value Go's - // `v.Set` (override tier, above `AutomaticEnv`) never lets win. process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"] = "recaptcha"; const authDocument = { captcha: { enabled: true, provider: "hcaptcha" } }; const resolved = resolveAuthCaptcha( @@ -1395,9 +1241,6 @@ describe("resolveLocalConfigValues", () => { delete process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_CONFIRMATION_SUBJECT"]; }); - // `emailTemplate.Subject` is `*string` — an explicit - // `subject = ""` in config.toml is a real, non-nil state, distinct from an absent key, that - // Go's mailer-env block honors by still emitting `GOTRUE_MAILER_SUBJECTS_*=` (empty). it("keeps an explicit empty subject present in the raw document, not omitted", () => { const config = baseConfig({ auth: { email: { template: { confirmation: { subject: "", content_path: "x" } } } }, @@ -1482,7 +1325,6 @@ describe("resolveLocalConfigValues", () => { const resolved = resolveAuthHooks(authDocument, allHooks, undefined); expect(resolved.customAccessToken.enabled).toBe(true); expect(resolved.customAccessToken.uri).toBe("https://example.com/hook"); - // Unrelated hooks stay untouched. expect(resolved.mfaVerificationAttempt.enabled).toBe(false); }); @@ -1493,7 +1335,6 @@ describe("resolveLocalConfigValues", () => { }); it("suppresses a malformed SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED when a remote block already set that hook's enabled", () => { - // Regression (review: PRRT_kwDOErm0O86W6R-G) — same bug class as `studio.enabled` above. process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "not-a-bool"; const authDocument = { hook: { custom_access_token: { enabled: false } } }; expect(() => @@ -1515,10 +1356,6 @@ describe("resolveLocalConfigValues", () => { }); it("prefers a remote-set auth.hook.custom_access_token.uri over a conflicting SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI", () => { - // Regression (review: PRRT_kwDOErm0O86XGTq5) — `mergeRemoteConfig` flattens the whole - // matched block via `u.AllKeys()` and applies EVERY leaf with `v.Set`, - // not just `enabled`. Leaving `uri` ungated - // let a stale/malformed env var beat a remote's already-merged, valid `uri`. process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "ftp://example.com"; const hooksWithRemoteUri = { ...allHooks, @@ -1568,11 +1405,6 @@ describe("resolveLocalConfigValues", () => { }); it("suppresses a malformed SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED when a remote block already set auth.mfa.totp.enroll_enabled", () => { - // Regression (review: PRRT_kwDOErm0O86W6R-G) — same bug class as `studio.enabled` above: - // every `auth.mfa.*` leaf here is unconditionally resolved by - // `resolveLocalConfigValues` (inside its `authEnabled` block), so an ungated call - // would abort that whole caller on a malformed override the remote block should have made - // irrelevant. process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "not-a-bool"; const mfa = baseConfig().auth.mfa; expect(() => @@ -1638,7 +1470,6 @@ describe("resolveLocalConfigValues", () => { }); it("suppresses a malformed SUPABASE_AUTH_EMAIL_SMTP_ENABLED when a remote block already set auth.email.smtp.enabled", () => { - // Regression (review: PRRT_kwDOErm0O86W6R-G) — same bug class as `studio.enabled` above. process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "not-a-bool"; const authDocument = { email: { smtp: { enabled: true } } }; expect(() => @@ -1655,9 +1486,6 @@ describe("resolveLocalConfigValues", () => { }); it("suppresses a malformed SUPABASE_AUTH_EMAIL_SMTP_PASS when a remote block already set auth.email.smtp.pass", () => { - // Regression (review: PRRT_kwDOErm0O86XJYol) — same bug class as `.enabled`/`.port` - // above, just for this Secret-typed leaf: an ungated env override reached - // `decryptAuthSecret` and threw before the remote's own valid `pass` was used. process.env["SUPABASE_AUTH_EMAIL_SMTP_PASS"] = "encrypted:not-a-real-ciphertext"; const authDocument = { email: { smtp: { enabled: true, pass: "remote-pass" } } }; const resolved = resolveAuthEmailSmtp( @@ -1859,19 +1687,11 @@ describe("resolveLocalConfigValues", () => { projectEnvValues, ); expect(resolved["google"]).toBeUndefined(); - // apple is still unconditionally present (Go's default template), but unaffected by - // the unrelated google env vars above. expect(resolved["apple"]?.enabled).toBe(false); }); }); describe("resolveAuthExternalProviders — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - // Regression (review: PRRT_kwDOErm0O86XKYiF): this resolver had no `remoteOverrideKeys` - // parameter at all, so a matched `[remotes.<ref>]` block's own valid `auth.external.<name>.*` - // value could always lose to a conflicting/malformed ambient `SUPABASE_AUTH_EXTERNAL_<NAME>_*` - // override — `secret`/`enabled`/`skip_nonce_check`/`email_optional` can additionally THROW on - // a malformed override, aborting the whole `resolveLocalConfigValues` caller (and the - // shadow it feeds). it("prefers a remote-set auth.external.<name>.secret over a malformed SUPABASE_AUTH_EXTERNAL_<NAME>_SECRET", () => { const authDocument = { external: { my_custom: { enabled: true, secret: "remote-secret" } }, @@ -2038,8 +1858,6 @@ describe("resolveLocalConfigValues", () => { ); }); - // `db.settings.*` uint fields decode through the same `strconv.ParseUint(str, 0, 64)` - // base-0 grammar as `envOverrideUint`'s callers, not a plain-decimal parse. it("resolves a 0x-prefixed uint override as hex", () => { process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "0x10"; expect( @@ -2081,8 +1899,6 @@ describe("resolveLocalConfigValues", () => { expect(resolveDbSettingsEnvOverrides({}, undefined).session_replication_role).toBeUndefined(); }); - // `SessionReplicationRole.UnmarshalText` - // hard-rejects anything outside `{origin, replica, local}`. it("rejects an invalid session_replication_role override", () => { process.env["SUPABASE_DB_SETTINGS_SESSION_REPLICATION_ROLE"] = "invalid"; expect(() => resolveDbSettingsEnvOverrides({}, undefined)).toThrow( @@ -2186,9 +2002,6 @@ describe("resolveLocalConfigValues", () => { ); }); - // `Validate` only opens/parses `signing_keys_path` inside - // `if c.Auth.Enabled` — a disabled - // auth section never touches the file, however stale or missing it is. it("skips reading a missing signing_keys_path when auth is disabled", () => { const config = baseConfig({ auth: { enabled: false, signing_keys_path: "missing.json" }, @@ -2204,10 +2017,8 @@ describe("resolveLocalConfigValues", () => { auth: { enabled: false, signing_keys_path: "signing_keys.json" }, }); const values = resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current); - // Go's `generateJWT` checks `len(a.SigningKeysPath) > 0 && - // len(a.SigningKeys) > 0`, NOT `auth.enabled` — `a.SigningKeys` is never empty (it keeps - // its `NewConfig()`-seeded default when the file read is skipped), so a disabled-auth - // config with a configured path still signs with the default ES256 key, not HMAC. + // Disabled auth with a configured signing-keys path still signs with the default ES256 key, + // not HMAC — signing depends on whether keys were loaded, not on auth being enabled. const publicKey = await importJWK( { ...DEFAULT_SIGNING_KEY, d: undefined, key_ops: undefined }, "ES256", @@ -2218,12 +2029,8 @@ describe("resolveLocalConfigValues", () => { }); describe("SUPABASE_AUTH_ENABLED env override", () => { - // `c.Auth.Enabled` is Viper-bound like any other field, - // so `Validate`'s `if c.Auth.Enabled` gate - // reads the POST-override value, not raw - // TOML — a stale/missing signing_keys_path must be skipped when auth is - // disabled only via env/dotenv, and read when auth is enabled only via - // env/dotenv despite TOML saying otherwise. + // Reads the post-override `auth.enabled` value, not raw TOML, so an env-only disable/enable + // still gates whether `signing_keys_path` is read. afterEach(() => { delete process.env["SUPABASE_AUTH_ENABLED"]; }); @@ -2260,9 +2067,8 @@ describe("resolveLocalConfigValues", () => { }); describe("auth.site_url (required field in config)", () => { - // The pure empty/set/disabled assertions moved to `config-validate.unit.test.ts` - // (direct `validateResolvedConfig` calls) — only the SUPABASE_AUTH_ENABLED / - // SUPABASE_AUTH_SITE_URL env-override mechanics stay here. + // Required-field/range assertions live in config-validate.unit.test.ts; only env-override + // mechanics are tested here. describe("SUPABASE_AUTH_ENABLED / SUPABASE_AUTH_SITE_URL env overrides", () => { afterEach(() => { delete process.env["SUPABASE_AUTH_ENABLED"]; @@ -2367,16 +2173,10 @@ describe("resolveLocalConfigValues", () => { }); }); - // auth.captcha/passkey/webauthn/hook/smtp REQUIRED-FIELD checks (the actual `enabled` ⇒ - // provider/secret/uri/host/etc. logic) live entirely in `config-validate.unit.test.ts` - // (direct `validateResolvedConfig` calls). Only the SUPABASE_*-env-override MECHANICS - // this resolver owns — layering an env/dotenv value on top of the TOML-decoded or - // raw-document-derived value before that validation ever runs — are tested here, same split as - // `auth.site_url` above. + // Required-field/range assertions live in config-validate.unit.test.ts; only env-override + // mechanics are tested here. describe("auth.captcha env overrides", () => { - // `auth.captcha.*` is Viper-bound like any other nested field once `[auth.captcha]` is - // present in config.toml (`ExperimentalBindStruct`/`AutomaticEnv`). afterEach(() => { delete process.env["SUPABASE_AUTH_CAPTCHA_ENABLED"]; delete process.env["SUPABASE_AUTH_CAPTCHA_PROVIDER"]; @@ -2419,11 +2219,8 @@ describe("resolveLocalConfigValues", () => { }); describe("auth.passkey / auth.webauthn env overrides", () => { - // `auth.passkey.enabled`/`auth.webauthn.*` are Viper-bound like any other nested field once - // `[auth.passkey]`/`[auth.webauthn]` are present in config.toml. Both are read from the raw - // `document` (5th param), same as the presence-based defaulting above, so these tests thread - // a `document` object through explicitly instead of relying on `baseConfig`'s decoded schema - // (which has no `passkey`/`webauthn` fields at all). + // `auth.passkey`/`auth.webauthn` have no decoded-schema presence signal, so these tests thread + // a raw `document` object through explicitly instead of relying on `baseConfig`. afterEach(() => { delete process.env["SUPABASE_AUTH_PASSKEY_ENABLED"]; delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"]; @@ -2460,9 +2257,6 @@ describe("resolveLocalConfigValues", () => { }); it("throws on an unparsable raw auth.passkey.enabled string instead of silently disabling it", () => { - // e.g. a still-literal `env(VAR)` placeholder when the referenced var was never set, or a - // typo — Go's `strconv.ParseBool` hard-rejects this during `Config.Load`, it never silently - // treats it as `false`. const config = baseConfig(); const document = { auth: { passkey: { enabled: "not-a-bool" } } }; expect(() => @@ -2472,10 +2266,8 @@ describe("resolveLocalConfigValues", () => { }); describe("auth.hook.* env overrides", () => { - // `auth.hook.<type>.*` is Viper-bound like any other nested field once `[auth.hook.<type>]` - // is present in config.toml. `@supabase/config`'s hook schema always decodes a default - // `{ enabled: false }` regardless of file presence, so — like passkey/webauthn above — the - // presence gate is read from the raw `document`, not the decoded `config`. + // The hook schema always decodes a default `{ enabled: false }` regardless of file presence, + // so presence here is read from the raw `document`, not the decoded `config`. afterEach(() => { delete process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED"]; delete process.env["SUPABASE_AUTH_HOOK_SEND_EMAIL_URI"]; @@ -2508,9 +2300,6 @@ describe("resolveLocalConfigValues", () => { }); describe("auth.email.smtp env overrides", () => { - // `auth.email.smtp.*` is Viper-bound like any other nested field once `[auth.email.smtp]` - // is present in config.toml — layered on top of the presence-aware raw-document read that - // already exists here for Go's presence-based `enabled` default. afterEach(() => { delete process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"]; delete process.env["SUPABASE_AUTH_EMAIL_SMTP_HOST"]; @@ -2563,9 +2352,8 @@ describe("resolveLocalConfigValues", () => { }); describe("auth.mfa env overrides", () => { - // `auth.mfa.<factor>.*` is Viper-bound unconditionally (value-typed struct fields, never - // `nil`) — unlike hooks/smtp above, no raw-document presence gate is needed; see the block - // comment above the `mfa` array in local-config-values.ts. + // These are plain value-typed fields with no presence gate, unlike hooks/smtp above — they're + // overridable unconditionally. afterEach(() => { delete process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"]; delete process.env["SUPABASE_AUTH_MFA_TOTP_VERIFY_ENABLED"]; @@ -2596,8 +2384,8 @@ describe("resolveLocalConfigValues", () => { }); describe("auth.third_party env overrides", () => { - // Same value-typed-struct reasoning as auth.mfa above — including `workos`, whose default - // template omits `[auth.third_party.workos]` entirely yet is still unconditionally overridable. + // Same as auth.mfa above — including workos, whose default template omits the whole section + // yet leaves it still unconditionally overridable. afterEach(() => { delete process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"]; delete process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_PROJECT_ID"]; @@ -2625,8 +2413,6 @@ describe("resolveLocalConfigValues", () => { }); describe("auth.email.template/notification (content_path validation)", () => { - // `(e *email) validate(fsys)`, - // called right after `Auth.MFA.validate()`, still inside `if c.Auth.Enabled`. const tempRoot = useTempWorkdir("supabase-email-templates-test-"); it("rejects a template content_path pointing at a missing file", () => { @@ -2643,10 +2429,6 @@ describe("resolveLocalConfigValues", () => { }); it("rejects an absolute template content_path outside the project root", () => { - // Proves the shared validator (`resolveEmailTemplateContentPath` in - // `config-validate.ts`) now enforces project-root containment on the - // `db`/`migration`/`status`/`stop`/... shared-validation path too, not only inside - // `config push`'s own content loader — CLI-2339's centralization. const config = baseConfig({ auth: { enabled: true, @@ -2740,10 +2522,6 @@ describe("resolveLocalConfigValues", () => { expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); }); - // Divergence #2 (see `config-validate.ts`'s port-plan notes): Go's asymmetric - // content-vs-content_path exclusivity — a raw `content` key present - // with no `content_path` is an error, not a silent no-op. `@supabase/config`'s schema has no - // `content` field to see, so this only fires when the raw `document` (5th param) carries it. it("rejects a template content key present without content_path", () => { const config = baseConfig({ auth: { @@ -2763,10 +2541,8 @@ describe("resolveLocalConfigValues", () => { }); describe("auth.email.template/notification env overrides", () => { - // `auth.email.template.<name>.*`/`auth.email.notification.<name>.*` are Viper-bound like any - // other nested field once the section is present in config.toml. Unlike hook/passkey, no - // extra raw-document presence gate is needed: `email.template`/`email.notification` are - // `Schema.Record`s, so `Object.entries` on the decoded config already reflects presence. + // No raw-document presence gate needed here: `email.template`/`email.notification` are + // `Schema.Record`s, so the decoded config already reflects presence. const tempRoot = useTempWorkdir("supabase-email-template-env-test-"); afterEach(() => { @@ -2791,9 +2567,6 @@ describe("resolveLocalConfigValues", () => { }); it("rejects a notification enabled only via env with a missing content_path file", () => { - // Go applies SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED before - // Auth.Email.validate() decides whether to read content_path — a notification disabled - // in TOML but enabled by env must still be checked. process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"] = "true"; const config = baseConfig({ auth: { @@ -2837,11 +2610,6 @@ describe("resolveLocalConfigValues", () => { expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); }); - // Go's Viper `AutomaticEnv` folds a `SUPABASE_AUTH_EMAIL_TEMPLATE_<NAME>_CONTENT`/ - // `_NOTIFICATION_<NAME>_CONTENT` override into `Content *string` before `Config.Validate` - // runs, so it's "present" for the content-vs-content_path exclusivity - // check exactly like a raw TOML `content` key — a bare env override with no content_path - // configured anywhere must be rejected, not silently accepted. it("rejects a template _CONTENT env override with no content_path configured", () => { process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT"] = "<html>Hi</html>"; const config = baseConfig({ @@ -2897,12 +2665,6 @@ describe("resolveLocalConfigValues", () => { }); it("preserves a remote block's valid template content_path over a missing-file ambient override", () => { - // Regression (review: PRRT_kwDOErm0O86XLAYn): `content_path` is the field that can - // actually abort resolution here — an ungated override let a stale/missing ambient - // `_CONTENT_PATH` outrank a matched remote's own valid path, and the caller-side file read - // (`readAuthEmailTemplateContent`) then threw, aborting the whole - // `resolveLocalConfigValues` call (and the shadow it feeds) on a value `v.Set` - // (override tier, above `AutomaticEnv`) never lets win. writeFileSync(join(tempRoot.current, "invite.html"), "<html></html>"); process.env["SUPABASE_AUTH_EMAIL_TEMPLATE_INVITE_CONTENT_PATH"] = "missing.html"; const config = baseConfig({ @@ -2966,9 +2728,6 @@ describe("resolveLocalConfigValues", () => { }); it("suppresses a malformed ambient notification _ENABLED when a remote block already set enabled", () => { - // `enabled` is a direct `envOverrideBool` call, so a malformed ambient override - // throws on its own regardless of the exclusivity/file-read checks above — same bug class - // as `auth.email.enable_signup`/`.enable_confirmations` (review: PRRT_kwDOErm0O86XLAYo). process.env["SUPABASE_AUTH_EMAIL_NOTIFICATION_PASSWORD_CHANGED_ENABLED"] = "not-a-bool"; const config = baseConfig({ auth: { @@ -2990,15 +2749,12 @@ describe("resolveLocalConfigValues", () => { }); }); - // auth.third_party.* (thirdParty.validate()) and functions.* (function-slug validation) - // moved entirely to `config-validate.unit.test.ts` (direct `validateResolvedConfig` - // calls) — L pre-filters to enabled-only third_party providers and derives function slugs - // directly off `config.functions` with no env-override mechanics of its own for these checks. + // Required-field/range assertions live in config-validate.unit.test.ts; only env-override + // mechanics are tested here. describe("auth.external (external.validate(), D-only, ported to L)", () => { - // `auth.external` is a genuine Go `map[string]provider`, so an unmodeled/arbitrary provider - // name is a legitimate config shape `@supabase/config`'s schema silently drops at decode — - // this check reads the raw `document` (5th param) instead, same as passkey/hook above. + // Unmodeled external providers are silently dropped by the decoded config, so this reads the + // raw `document` (5th param) instead. it("rejects an enabled unmodeled external provider missing client_id", () => { const config = baseConfig(); const document = { auth: { external: { custom: { enabled: true } } } }; @@ -3050,9 +2806,6 @@ describe("resolveLocalConfigValues", () => { }); describe("auth.external env overrides", () => { - // `auth.external.<name>.*` is Viper-bound like any other nested field once - // `[auth.external.<name>]` is present in config.toml — same gap the schema's own - // `requiredWhenEnabled` check has for KNOWN providers too. afterEach(() => { delete process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_ENABLED"]; delete process.env["SUPABASE_AUTH_EXTERNAL_CUSTOM_CLIENT_ID"]; @@ -3086,11 +2839,9 @@ describe("resolveLocalConfigValues", () => { }); describe("auth.sms env overrides (provider switch)", () => { - // `(s *sms) validate()` is a `switch` that validates - // ONLY the first enabled provider in a fixed priority order (twilio, twilio_verify, - // messagebird, textlocal, vonage). `@supabase/config`'s schema already implements this switch - // for the schema-decoded (pre-env-override) TOML value; this re-runs it against the raw - // document with `SUPABASE_AUTH_SMS_*` overrides applied, since the schema never sees them. + // Validates only the first enabled provider, in priority order (twilio, twilio_verify, + // messagebird, textlocal, vonage), re-run here against the raw document with env overrides + // applied. afterEach(() => { delete process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"]; delete process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"]; @@ -3120,8 +2871,6 @@ describe("resolveLocalConfigValues", () => { }); it("only validates the first enabled provider in Go's fixed priority order", () => { - // twilio is disabled via env; messagebird becomes the switch winner and is missing its - // required fields — twilio's own (still-missing) fields must never be inspected. process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "false"; process.env["SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED"] = "true"; const config = baseConfig(); @@ -3134,12 +2883,9 @@ describe("resolveLocalConfigValues", () => { }); it("throws for a provider enabled only via env with missing required fields even when the document has no auth.sms section at all", () => { - // Unlike the other 4 providers, twilio's presence is NOT gated on the document: Go's - // ejected default config.toml (`pkg/config/templates/config.toml:288-293`) always emits an - // uncommented `[auth.sms.twilio]` table, so `mergeDefaultValues` registers - // `auth.sms.twilio.*` with Viper even when the user's own config.toml has no `[auth.sms]` - // section at all — `SUPABASE_AUTH_SMS_TWILIO_ENABLED` applies with nothing left to supply - // the required credentials, so this now fails validation instead of silently doing nothing. + // Unlike the other 4 providers, twilio's presence isn't gated on the document — the default + // config always registers `auth.sms.twilio.*`, so enabling only via env still fails + // validation instead of doing nothing. process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "true"; const config = baseConfig(); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( @@ -3160,9 +2906,8 @@ describe("resolveLocalConfigValues", () => { }); it("still does not synthesize messagebird purely from an env override when the section is absent from the document", () => { - // messagebird (like twilio_verify/textlocal/vonage) has no entry at all in Go's default - // template, so an absent `[auth.sms.messagebird]` table genuinely means Viper never - // registers it — the presence gate is still correct parity for these 4 providers. + // Unlike twilio, messagebird has no entry in the default config template, so an absent + // section genuinely means it was never registered. process.env["SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED"] = "true"; const config = baseConfig(); const resolved = resolveAuthSms(undefined, config.auth.sms, undefined); @@ -3184,8 +2929,8 @@ describe("resolveLocalConfigValues", () => { process.env["SUPABASE_AUTH_SMS_ENABLE_CONFIRMATIONS"] = "true"; process.env["SUPABASE_AUTH_SMS_MAX_FREQUENCY"] = "10s"; process.env["SUPABASE_AUTH_SMS_TEMPLATE"] = "Your OTP is {{ .Code }}"; - // A provider must be enabled, or `enable_signup` gets downgraded to `false` regardless of - // the override — see the "disables phone login" tests below for that behavior itself. + // A provider must be enabled, or `enable_signup` gets downgraded to false regardless of the + // override. const configured = { ...baseConfig().auth.sms, twilio: { ...baseConfig().auth.sms.twilio, enabled: true }, @@ -3244,16 +2989,6 @@ describe("resolveLocalConfigValues", () => { }); describe("resolveAuthSms — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - // Regression (review: PRRT_kwDOErm0O86XFmjZ) — a prior review rejected this exact gap as - // "unreachable from the db diff --linked/db pull shadow path," having only grepped direct - // `resolveAuthSms(` call sites in `start.handler.ts`/`db/start/start.handler.ts` and - // missed that `resolveLocalConfigValues` (this function's own shadow-consuming caller, - // via `buildLocalDbContainerInputs`) calls it too, through its own - // `validateAuthSmsProviders` wrapper, whenever `authEnabled`. `enable_signup`/ - // `enable_confirmations`/each provider's `enabled` THROW via `envOverrideBool`, and each - // provider's Secret-typed field THROWS via `decryptAuthSecret` — either can abort the - // whole `resolveLocalConfigValues` call (and the shadow it feeds) on a malformed ambient - // override even when a matched remote block already set that field. afterEach(() => { delete process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; delete process.env["SUPABASE_AUTH_SMS_VONAGE_ENABLED"]; @@ -3311,9 +3046,6 @@ describe("resolveLocalConfigValues", () => { }); it("still rejects a malformed SUPABASE_AUTH_SMS_VONAGE_API_SECRET when no remote block matched", () => { - // `vonage` isn't `twilio` (the one provider Go's default template always registers), so the - // env override is only consulted at all when the raw `[auth.sms.vonage]` table is present — - // same presence gate `providerPresent` already applies for the remote-set case above. process.env["SUPABASE_AUTH_SMS_VONAGE_API_SECRET"] = "encrypted:garbage"; const authDocument = { sms: { vonage: {} } }; const configured = { @@ -3325,9 +3057,6 @@ describe("resolveLocalConfigValues", () => { ); }); - // Regression: `resolveField`'s non-secret provider leaves (`account_sid`/`message_service_sid`/ - // `originator`/`sender`/`from`/`api_key`) had no `remoteWins` branch at all — `vonage.api_key` - // sitting right next to the already-gated `vonage.api_secret` was the clearest tell. it("prefers a remote-set auth.sms.twilio.account_sid over a conflicting SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID", () => { process.env["SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID"] = "env-sid"; const configured = { @@ -3456,12 +3185,6 @@ describe("resolveLocalConfigValues", () => { }); it("still aborts resolveLocalConfigValues on a malformed SUPABASE_AUTH_SMS_ENABLE_SIGNUP reached via validateAuthSmsProviders, unless remoteOverrideKeys suppresses it", () => { - // End-to-end proof that the gap is reachable from the exact function this PR's shadow - // provisioning calls (`buildLocalDbContainerInputs` -> `resolveLocalConfigValues` - // -> `validateAuthSmsProviders` -> `resolveAuthSms`), not just the standalone resolver. - // Built by spreading an already-decoded `baseConfig()` (not re-decoding through - // `CliConfigSchema` via `baseConfig({...})`'s shallow-merge overrides) so `vonage`'s - // other schema-required fields (`from`, etc.) keep their valid decoded defaults. process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "not-a-bool"; const base = baseConfig(); const config: CliConfig = { @@ -3508,15 +3231,12 @@ describe("resolveLocalConfigValues", () => { } it("does not throw when tls.enabled with neither cert_path nor key_path set", () => { - // Go's Validate only rejects the "exactly one set" case; - // tls.enabled with nothing configured still loads. const config = baseConfig({ api: { tls: { enabled: true } } }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); }); - // The "exactly one of cert/key set" presence-only assertions moved to - // `config-validate.unit.test.ts` (direct `validateResolvedConfig` calls) — - // the actual file reads below stay here, since I/O is per-caller. + // The "exactly one of cert/key set" checks live in config-validate.unit.test.ts; the file-read + // behavior below is tested here. it("throws a Go-worded error when the configured cert file does not exist", () => { writeTlsFile(tempRoot.current, "key.pem"); @@ -3548,8 +3268,6 @@ describe("resolveLocalConfigValues", () => { }); it("resolves cert_path/key_path against <workdir>/supabase unconditionally, no isAbsolute guard", () => { - // `path.Join` absorbs a leading "/" — unlike - // signing_keys_path, which Go DOES guard with filepath.IsAbs. writeTlsFile(tempRoot.current, "cert.pem"); writeTlsFile(tempRoot.current, "key.pem"); const config = baseConfig({ @@ -3564,9 +3282,6 @@ describe("resolveLocalConfigValues", () => { expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).not.toThrow(); }); - // `Validate` nests the whole TLS branch inside `if c.Api.Enabled` — - // a disabled api section never validates cert/key, - // however invalid the pairing. it("skips TLS validation entirely when api is disabled", () => { const config = baseConfig({ api: { enabled: false, tls: { enabled: true, cert_path: "missing-cert.pem" } }, @@ -3602,12 +3317,8 @@ describe("resolveLocalConfigValues", () => { }); describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - // `mergeRemoteConfig` installs every matched `[remotes.<ref>]` leaf at viper's OVERRIDE - // tier, above `AutomaticEnv` — so once a remote - // block sets a field, a conflicting `SUPABASE_*` env var must never be consulted for it. - // `resolveDbBootstrapConfig`/`resolveDbSettingsEnvOverrides` already gated this - // (review: PRRT_kwDOErm0O86W2LL4); this covers the remaining leaves this resolver derives - // that the shadow's own container/setup spec also consumes (review: PRRT_kwDOErm0O86W2tRi). + // Once a remote block sets a field, a conflicting `SUPABASE_*` env var must not be consulted + // for it — verified per field below. afterEach(() => { for (const name of [ "SUPABASE_DB_MAJOR_VERSION", @@ -3656,11 +3367,6 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi const tempRoot = useTempWorkdir("supabase-remote-signing-keys-test-"); it("prefers a remote-set auth.signing_keys_path over a conflicting SUPABASE_AUTH_SIGNING_KEYS_PATH", () => { - // Regression (review: PRRT_kwDOErm0O86W3Ox_): `resolveConfiguredSigningKeys` — shared - // by this function's own `anonKey`/`serviceRoleKey` asymmetric signing and by - // `resolveLocalJwks` — used to reapply a conflicting env override even when a remote - // block already set `auth.signing_keys_path`, which would have pointed the shadow's - // asymmetric signing at the wrong (env-supplied) file. writeSigningKeys(tempRoot.current, [generateRsaJwk()]); process.env["SUPABASE_AUTH_SIGNING_KEYS_PATH"] = "missing-file.json"; const config = baseConfig({ auth: { signing_keys_path: "signing_keys.json" } }); @@ -3686,12 +3392,6 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi }); it("suppresses a malformed SUPABASE_DB_MAJOR_VERSION when a remote block already set db.major_version", () => { - // Regression (review: PRRT_kwDOErm0O86W2tRi): this function validates `db.major_version` - // early but has no `majorVersion` field on its own return type (the shadow's actually- - // consumed value comes from the already-gated `resolveDbBootstrapConfig`) — before - // this fix, the validate-only read here still decoded a conflicting env var unconditionally, - // so a malformed value the remote block should have made irrelevant failed config loading - // outright instead of the command proceeding on the remote's value, matching Go. process.env["SUPABASE_DB_MAJOR_VERSION"] = "abc"; const config = baseConfig({ db: { major_version: 14 } }); expect(() => @@ -3744,15 +3444,6 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi }); it("prefers a remote-set auth.third_party.clerk.domain over a conflicting env override during validation", () => { - // Regression (review: PRRT_kwDOErm0O86W93Ex): this function's OWN validation-only - // `thirdParty` array used to gate `enabled` on `remoteWins` but leave the sibling - // `requiredField` (domain/tenant/user_pool_id/issuer_url) ungated — even though - // `auth.third_party.clerk.domain` is already tracked in `ENV_OVERRIDABLE_KEYS`. A - // matched remote's valid domain lost to a conflicting, invalid `SUPABASE_AUTH_THIRD_PARTY_ - // CLERK_DOMAIN`, so `validateResolvedConfig`'s Clerk domain-regex check rejected an - // otherwise-valid, remote-backed configuration before the shadow was ever created — Go's - // `mergeRemoteConfig` sets the whole matched block at viper's OVERRIDE tier, above - // `AutomaticEnv`, so the env var is never even consulted once a remote sets this key. process.env["SUPABASE_AUTH_THIRD_PARTY_CLERK_ENABLED"] = "false"; process.env["SUPABASE_AUTH_THIRD_PARTY_CLERK_DOMAIN"] = "not-a-clerk-domain"; const config = baseConfig({ @@ -3801,10 +3492,8 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi }); it("prefers a remote-set api.tls.cert_path/key_path over a conflicting (missing-file) env override", () => { - // The ambient env vars point at files that don't exist — if they won, `readApiTlsFiles` - // would throw. `mergeRemoteConfig` installs the matched remote block's cert/key - // paths at viper's OVERRIDE tier (above `AutomaticEnv`), so they must win instead and the - // load must succeed using the real, remote-supplied paths. + // The env vars point at files that don't exist; the load only succeeds if the remote-set + // paths win instead. writeTlsFile(tempRoot.current, "cert.pem"); writeTlsFile(tempRoot.current, "key.pem"); process.env["SUPABASE_API_TLS_CERT_PATH"] = "missing-cert.pem"; @@ -3902,10 +3591,6 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi }); it("suppresses a malformed SUPABASE_STUDIO_API_URL when a remote block already set studio.api_url", () => { - // Regression (review: PRRT_kwDOErm0O86XKYiF's sibling gap): `studio.api_url` feeds - // `validateResolvedConfig`'s `goUrlParse` check, which throws on a malformed URL - // even though the read itself (`envOverride`) never does — same "non-throwing read, - // throwing downstream consumer" bug class as `resolveAuthHooks`'s `uri`/`secrets`. process.env["SUPABASE_STUDIO_API_URL"] = "http://[::1"; const config = baseConfig({ studio: { api_url: "http://remote.test" } }); expect(() => @@ -3921,10 +3606,6 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi }); it("prefers a remote-set studio.openai_api_key over a conflicting SUPABASE_STUDIO_OPENAI_API_KEY", () => { - // Regression: `studio.openai_api_key` is a `config.Secret`, - // decrypted the same way `anon_key`/`service_role_key` above are — an ungated - // `envOverride` here could let a malformed ambient override outrank a matched remote's - // own valid value and throw during decryption. process.env["SUPABASE_STUDIO_OPENAI_API_KEY"] = "encrypted:not-a-real-ciphertext"; const config = baseConfig({ studio: { openai_api_key: "remote-openai-key" } }); const values = resolveLocalConfigValues( @@ -3947,9 +3628,6 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi }); it("prefers remote-set auth.publishable_key/auth.secret_key over conflicting env overrides", () => { - // Regression: `auth.publishable_key`/`auth.secret_key` are - // `config.Secret`-typed exactly like `anon_key`/`service_role_key` above, but were missed - // when that sibling pair was gated. process.env["SUPABASE_AUTH_PUBLISHABLE_KEY"] = "encrypted:not-a-real-ciphertext"; process.env["SUPABASE_AUTH_SECRET_KEY"] = "encrypted:not-a-real-ciphertext"; const config = baseConfig({ @@ -3984,9 +3662,6 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi }); it("suppresses a malformed SUPABASE_DB_SETTINGS_MAX_CONNECTIONS when the remote block set db.settings.max_connections", () => { - // Same validate-only shape as `db.major_version` above — `resolveDbSettingsEnvOverrides` - // is threaded `remoteOverrideKeys` here too, not just at its OWN (already-gated) call site - // in `resolveDbBootstrapConfig`. process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "not-a-number"; const config = baseConfig({ db: { settings: { max_connections: 100 } } }); expect(() => @@ -4002,12 +3677,6 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi }); it("suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", () => { - // Regression (review: PRRT_kwDOErm0O86W30n6): `auth.enabled` gates the signing-keys file - // read/validate-only auth block below but has no `authEnabled` field on its own return type — - // before this fix, the ungated `envOverrideBool` call still decoded a conflicting env - // var unconditionally, so a malformed value the remote block should have made irrelevant - // failed this WHOLE function (and therefore the shadow's `dbPort`/`jwtSecret`/etc. it also - // resolves) instead of the command proceeding on the remote's value, matching Go. process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; const config = baseConfig({ auth: { enabled: false } }); expect(() => @@ -4031,10 +3700,6 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi }); it("suppresses a malformed SUPABASE_ANALYTICS_ENABLED when a remote block already set analytics.enabled", () => { - // Same class of gap as `auth.enabled` above — `analytics.enabled` is also in - // `ENV_OVERRIDABLE_KEYS` and `analyticsEnabled` is never read by the shadow's own - // container inputs, but an ungated `envOverrideBool` call still aborts this whole - // function on a malformed override the remote block should have made irrelevant. process.env["SUPABASE_ANALYTICS_ENABLED"] = "not-a-bool"; const config = baseConfig({ analytics: { enabled: false } }); expect(() => @@ -4177,13 +3842,9 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi }); describe("auth.webauthn.rp_id / auth.webauthn.rp_origins — remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - // `rpId`/`rpOrigins` aren't part of this function's return value (only - // `validateResolvedConfig`'s passkey step consumes them), so precedence is proven - // through that step's emptiness check: the document deliberately leaves the field EMPTY (a - // real, present-but-empty state, not "absent") while the env var supplies a non-empty value — - // ungated, the non-throwing env value wins and validation passes; gated, the remote's own - // (empty) value wins and validation throws exactly like `Validate` would for a - // `[remotes.*]`-supplied empty field. + // rpId/rpOrigins aren't part of the return value, so precedence is proven through + // validateResolvedConfig's own emptiness check on a document that leaves the field present + // but empty. afterEach(() => { delete process.env["SUPABASE_AUTH_PASSKEY_ENABLED"]; delete process.env["SUPABASE_AUTH_WEBAUTHN_RP_ID"]; @@ -4250,10 +3911,6 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi }); it("suppresses a malformed SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED when a remote block already set auth.third_party.firebase.enabled", () => { - // Same class of gap as `auth.enabled`/`analytics.enabled` above, for this function's OWN - // validation-only `thirdParty` block (distinct from `resolveLocalJwks`'s own, already- - // gated `thirdParty` — see that param's doc comment). Auth must be enabled for this block to - // run at all. process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "not-a-bool"; const config = baseConfig({ auth: { enabled: true, third_party: { firebase: { enabled: false } } }, @@ -4281,11 +3938,6 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi }); it("suppresses a malformed SUPABASE_EDGE_RUNTIME_DENO_VERSION when a remote block already set edge_runtime.deno_version", () => { - // Regression (review: PRRT_kwDOErm0O86W4gCk): same class of gap as `auth.enabled`/ - // `analytics.enabled` above — `edge_runtime.deno_version` is also in - // `ENV_OVERRIDABLE_KEYS` and `denoVersion` is never read by the shadow's own - // container inputs, but an ungated `envOverrideDenoVersion` call still aborts this - // whole function on a malformed override the remote block should have made irrelevant. process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "abc"; const config = baseConfig({ edge_runtime: { deno_version: 2 } }); expect(() => @@ -4309,13 +3961,6 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi }); it("suppresses a malformed SUPABASE_API_ENABLED when a remote block already set api.enabled", () => { - // Regression (review: PRRT_kwDOErm0O86W5UlV): same class of gap as `auth.enabled`/ - // `analytics.enabled`/`edge_runtime.deno_version` above — `api.enabled` is also in - // `ENV_OVERRIDABLE_KEYS` and `apiEnabled` is never read by the shadow's own - // container inputs (unlike its siblings `apiTlsEnabled`/`apiPort`, which feed `apiUrl`), - // but an ungated `envOverrideBool` call still aborts this whole function — denying - // it `apiPort`/`apiUrl`/`dbPort`/`rootKey`/etc. too — on a malformed override the remote - // block should have made irrelevant. process.env["SUPABASE_API_ENABLED"] = "not-a-bool"; const config = baseConfig({ api: { enabled: false } }); expect(() => @@ -4339,14 +3984,6 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi }); it("suppresses a malformed SUPABASE_STUDIO_ENABLED when a remote block already set studio.enabled", () => { - // Regression (review: PRRT_kwDOErm0O86W6R-G): the doc comment on this function's - // `remoteOverrideKeys` parameter used to claim `studio`/`local_smtp`/the auth - // enable_signup/-anonymous_sign_ins/refresh-token/manual-linking/password-length/ - // -requirements group/passkey/hooks/mfa/captcha/email.smtp/experimental.webhooks fields could - // stay ungated because their own `envOverride*` calls "cannot throw before a value the - // caller needs has already been resolved" — that's false: this function either returns its - // whole object or throws, so ANY unconditional throw anywhere in its body aborts the entire - // call, denying the shadow `dbPort`/`jwtSecret`/etc. too, regardless of textual position. process.env["SUPABASE_STUDIO_ENABLED"] = "not-a-bool"; const config = baseConfig({ studio: { enabled: false } }); expect(() => @@ -4461,12 +4098,6 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi }); it("suppresses a scheme-invalid SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI when a remote block already set that hook's uri", () => { - // Regression (review: PRRT_kwDOErm0O86XGTq5): the remote can supply a valid `uri` while a - // stale/malformed `SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI` sits in the ambient - // environment. `mergeRemoteConfig` sets EVERY matched-block leaf - // above `AutomaticEnv`, so the remote's valid uri must win and validation must pass — before - // this fix, the ungated env read won instead and `validateResolvedConfig`'s scheme - // check rejected a linked diff/pull that Go would have accepted. process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI"] = "ftp://example.com"; const config = baseConfig({ auth: { @@ -4512,9 +4143,6 @@ describe("resolveLocalJwks", () => { const tempRoot = useTempWorkdir("supabase-local-jwks-test-"); it("includes the default ES256 signing key and the oct JWT-secret fallback when no signing_keys_path is configured", async () => { - // `a.SigningKeys` defaults to this single ES256 key at `NewConfig()` time, - // unconditionally — `ResolveJWKS` always publishes it - // (in public form) unless a configured `signing_keys_path` file overrides it. const config = baseConfig(); const jwks = await resolveLocalJwks(config, tempRoot.current, "a".repeat(32)); expect(JSON.parse(jwks)).toEqual({ @@ -4554,10 +4182,6 @@ describe("resolveLocalJwks", () => { expect(parsed.keys.some((key) => key["kty"] === "oct")).toBe(false); }); - // Go decodes `auth.signing_keys_path` directly into `[]JWK`, - // so a configured key's `use`/`key_ops`/`ext` metadata must round-trip into the published JWKS - // via `ToPublicJWK`, which keeps `use`/`ext` verbatim and - // filters `key_ops` down to `"verify"` entries only (never dropping the other two fields). it("preserves a configured signing key's use/ext and filters key_ops to verify-only", async () => { const jwk = { ...generateRsaJwk(), use: "sig", ext: true, key_ops: ["sign", "verify"] }; writeSigningKeys(tempRoot.current, [jwk]); @@ -4568,14 +4192,6 @@ describe("resolveLocalJwks", () => { expect(parsed.keys[0]).toMatchObject({ use: "sig", ext: true, key_ops: ["verify"] }); }); - // Go quirk this reproduces: `a.SigningKeysPath` is resolved to an absolute path - // unconditionally, but the FILE is only read into - // `a.SigningKeys` when auth is enabled (`Config.Validate`'s file read is nested inside - // `if c.Auth.Enabled`) — so a disabled-auth config with a - // configured `signing_keys_path` never reads the file, and `a.SigningKeys` stays at its - // unconditional `NewConfig()` default (the single ES256 key) rather than becoming empty. - // The oct fallback is still skipped (`len(a.SigningKeysPath) == 0` is false), so the - // default ES256 key is the ONLY entry — neither the file's keys nor the oct key appear. it("falls back to the default ES256 signing key (not the configured file, not the oct fallback) when auth is disabled but signing_keys_path is set", async () => { writeSigningKeys(tempRoot.current, [generateRsaJwk()]); const config = baseConfig({ @@ -4643,10 +4259,6 @@ describe("resolveLocalJwks", () => { }); it("does not validate third-party providers when auth is disabled, matching Go's ResolveJWKS/IssuerURL", async () => { - // `Auth.ThirdParty.validate()` (the "at most one enabled" check above) only runs - // inside `Config.Validate`'s `if Auth.Enabled` block — `ResolveJWKS`/`IssuerURL()` is called - // unconditionally and never validates, it just picks the first enabled provider by fixed - // priority (firebase, auth0, aws_cognito, clerk, workos) and resolves its remote JWKS. const remoteKeys = [{ kty: "RSA", kid: "firebase-key", n: "abc", e: "AQAB" }]; const issuerUrl = "https://securetoken.google.com/my-project"; const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => { @@ -4681,11 +4293,6 @@ describe("resolveLocalJwks", () => { fetchMock.mockRestore(); }); - // `ResolveJWKS` only attempts the remote fetch when `issuerURL != ""`; - // workos's own `issuerURL()` is a raw field read - // with no validation, so an enabled-but-unconfigured workos provider - // with `auth.enabled = false` resolves an empty issuer URL that Go tolerates by skipping the - // fetch entirely, rather than attempting (and failing) a fetch against an empty URL. it('does not attempt a remote JWKS fetch for an enabled third-party provider with an empty issuer_url, matching Go\'s issuerURL != "" check', async () => { const fetchMock = vi.spyOn(globalThis, "fetch"); const config = baseConfig({ @@ -4735,10 +4342,6 @@ describe("resolveLocalJwks", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); - // The key divergence from `shared/functions/serve.ts`'s own (unrelated) - // `finalizeAuthArtifacts`: `start` treats a remote-JWKS fetch failure as a hard, - // command-failing error — `resolveLocalJwks` - // must propagate it too, not swallow it and continue with zero remote keys. it("fails the whole resolution when the remote JWKS fetch fails, unlike functions serve's leniency", async () => { vi.spyOn(globalThis, "fetch").mockImplementation(async () => { throw new Error("oidc discovery failed"); @@ -4754,11 +4357,6 @@ describe("resolveLocalJwks", () => { }); describe("remoteOverrideKeys (linked shadow provisioning, CLI-1956)", () => { - // `mergeRemoteConfig` installs every matched `[remotes.<ref>]` leaf at viper's OVERRIDE - // tier, above `AutomaticEnv` — regression - // coverage for review PRRT_kwDOErm0O86W3Ox_, which found `auth.signing_keys_path`/ - // `auth.third_party.*` reapplying a conflicting `SUPABASE_AUTH_*` env value even after a - // matched remote block set them. afterEach(() => { for (const name of [ "SUPABASE_AUTH_SIGNING_KEYS_PATH", @@ -4835,11 +4433,6 @@ describe("resolveLocalJwks", () => { }); it("suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", async () => { - // Regression (review: PRRT_kwDOErm0O86W30n6): this function recomputes `authEnabled` - // itself (see its own doc comment) to gate `resolveThirdPartyIssuerUrl`'s throwing validate - // path — before this fix, the ungated `envOverrideBool` call still decoded a - // conflicting env var unconditionally, so a malformed value the remote block should have - // made irrelevant failed the shadow's PG15+ one-shot auth-migration job outright. process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; const config = baseConfig({ auth: { enabled: false } }); await expect( @@ -4911,9 +4504,6 @@ describe("resolveConfiguredSigningKeys — remoteOverrideKeys (linked shadow pro }); it("suppresses a malformed SUPABASE_AUTH_ENABLED when a remote block already set auth.enabled", () => { - // Regression (review: PRRT_kwDOErm0O86W30n6): this function's own `authEnabled` recompute - // (see its doc comment) used to be ungated, so a malformed override the remote block should - // have made irrelevant aborted the anon/service_role asymmetric-signing path outright. process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; const config = baseConfig({ auth: { enabled: false } }); expect(() => diff --git a/apps/cli/src/command-internal/local-project-context.ts b/apps/cli/src/command-internal/local-project-context.ts index 371dcc51b8..1d8b690f03 100644 --- a/apps/cli/src/command-internal/local-project-context.ts +++ b/apps/cli/src/command-internal/local-project-context.ts @@ -12,166 +12,69 @@ import { resolveLocalProjectId, sanitizeProjectId } from "./docker-ids.ts"; import { getHostname } from "./hostname.ts"; import { resolveProjectEnvironmentValues } from "./project-environment.ts"; -/** - * The config-load/env/project-id resolution `stop` (its non-`--all`/non-`--project-id` branch) - * and `status` (unconditionally) both duplicated verbatim before this hoist. - * - * Deliberately excludes workdir validation (`validateWorkdirIsDirectory`, - * `workdir-validation.ts`): both callers already invoke that themselves, at a point in - * their own control flow that differs (`stop` validates unconditionally before its `--all`/ - * `--project-id` mutual-exclusivity check and bypass branching; `status` validates before its own - * `--override-name` parsing, which itself must win over a config-load error). Folding workdir - * validation into this function would force one of those two orderings to move, which would - * silently change which error wins when multiple things are wrong at once — see `status.handler.ts`'s - * own numbered comments for exactly why that ordering is load-bearing. Callers keep calling - * {@link validateWorkdirIsDirectory} themselves, unchanged, before this. - * - * The sanitized project id (a singleton, rewritten once by validation - * at config-load time) IS included here, even though `status` - * previously computed it AFTER its own `resolveStatusLocalState` call, and `stop` computed it - * after its own `resolveLocalConfigValues` call — both of those are pure, non-throwing string - * derivations (`resolveLocalProjectId`/`sanitizeProjectId`, see their own doc comments) - * with no observable failure mode, so resolving it earlier here, ahead of each caller's own - * subsequent (throwing) config-value resolution, cannot change which error a caller surfaces first. - * - * `mapConfigLoadError` lets each caller tag a config-load failure with its own command-specific - * error type (`StopConfigLoadError`/`StatusConfigLoadError` today), matching the - * `mapError: (message: string) => E` idiom already used by `migration-apply.ts`'s exports. - */ +/** Config, resolved project env values, hostname, and sanitized project id for a command. */ export interface LocalProjectContext { readonly config: CliConfig; readonly projectEnvValues: Record<string, string>; - /** `null` when no `supabase/config.toml` was found — see `loadCliConfig`'s own contract. */ + /** `null` when no `supabase/config.toml` was found. */ readonly loaded: LoadedCliConfig | null; readonly hostname: string; - /** Config/env-derived, sanitized project id — see {@link sanitizeProjectId}'s doc comment. */ + /** Sanitized project id; see {@link sanitizeProjectId}. */ readonly projectId: string; } export const loadLocalProjectContext = <E>( workdir: string, mapConfigLoadError: (message: string) => E, - // The resolved `--linked`/`--project-ref` ref, when the caller already has one in scope - // (`db diff`/`db pull`'s shadow-provisioning prelude — CLI-1956 — and the `functions` - // Docker paths' Go-config pipeline — CLI-1963) — threaded straight into - // `loadCliConfig`'s own `projectRef` option so the matching `[remotes.<ref>]` block - // merges over the base config, exactly like `readDbToml(..., ref)` already does for - // those same commands' OTHER config read. It also supplies `Eject` default: - // `flags.LoadConfig` pre-sets `Config.ProjectId = - // ProjectRef` before merging the file, so `Eject`'s own basename fallback only triggers - // when that default is itself empty. `db start`/`db reset`/`start`/`stop`/`status` never - // pass this, so it defaults to `undefined` — no remote merge, unchanged from before. + // An already-resolved `--linked`/`--project-ref` value, when the caller has one; merges the + // matching `[remotes.<ref>]` block over the base config. Defaults to `undefined` (no remote + // merge) for callers that don't have one yet. projectRef?: string, ): Effect.Effect<LocalProjectContext, E, FileSystem.FileSystem | Path.Path> => Effect.gen(function* () { - // `search: false`: `workdir` already IS the fully-resolved chdir target (`command-settings. - // layer.ts`'s `resolveWorkdir` mirrors `ChangeWorkDir`'s explicit-exact-vs-default-searched - // resolution), so letting `@supabase/config`'s - // `findCliProjectPaths` climb ancestors again on top of that would let an unrelated ancestor - // project's config.toml win when `--workdir`/`SUPABASE_WORKDIR` points at a subdirectory with - // no `supabase/config.toml` of its own — this never searches past the exact (explicit or - // defaulted) workdir (`NewPathBuilder`). + // `workdir` is already the fully-resolved chdir target, so `search: false` stops + // `@supabase/config` from climbing ancestors and picking up an unrelated project's + // config.toml when `workdir` has none of its own. const projectEnv = yield* loadCliProjectEnvironment({ cwd: workdir, baseEnv: process.env, search: false, - // `loadDefaultEnv` omits `.env.local` - // from its candidate list whenever `SUPABASE_ENV=test` — a malformed or intentionally - // non-test `supabase/.env.local` is then invisible to Go and must not fail config loading - // here either. `resolveProjectEnvironmentValues` below already applies this same gate - // for the project-root pass; this mirrors it for the `supabase/`-dir pass - // `loadCliProjectEnvironment` itself performs. + // Omits `.env.local` when `SUPABASE_ENV=test`, matching + // `resolveProjectEnvironmentValues`'s gating for the project-root pass. skipEnvLocal: (process.env["SUPABASE_ENV"] || "development") === "test", }).pipe( Effect.mapError((cause) => mapConfigLoadError(`failed to read config: ${String(cause)}`)), ); - // Resolved BEFORE `loadCliConfig` decodes config.toml (not after): `Config.Load` runs - // `loadNestedEnv` before `LoadEnvHook` decodes `env(...)` references, so - // an `env(...)`-valued `project_id` sourced only from a project-root/`SUPABASE_ENV`-selected - // file must already be visible to the decoder, not just to the `SUPABASE_PROJECT_ID` override - // read below. A malformed extra dotenv file throws here (see `readDotEnvFile`), matching Go's - // `loadNestedEnv` propagating `godotenv`'s parse error instead of silently skipping the bad - // line. `workdir` is passed through so dotenv files under `<workdir>/supabase`/`workdir` are - // still discovered even when `projectEnv` is `null` (no config.toml there) — Go's own - // `loadNestedEnv` runs unconditionally, before `config.toml` is ever opened. + // Must resolve before `loadCliConfig` decodes config.toml: an `env(...)`-valued `project_id` + // needs these values available to the decoder already. `workdir` is passed through so dotenv + // files under `<workdir>/supabase` are still discovered even when `projectEnv` is `null`. const projectEnvValues = yield* Effect.try({ try: () => resolveProjectEnvironmentValues(projectEnv, workdir), catch: (cause) => mapConfigLoadError(`failed to read config: ${String(cause)}`), }); - // `godotenv.Load` (`loadEnvIfExists`, called by `loadNestedEnv` above this same - // config-load pass) installs every parsed dotenv key into - // the process's OWN environment via `os.Setenv` — never overriding an already-set key — - // so it's visible to every subsequent call in THIS process that reads `process.env` at - // CALL time, not just to config decoding. `BITBUCKET_CLONE_DIR` is the - // one key this applies to today: `os.Getenv("BITBUCKET_CLONE_DIR")` read - // lives inside `DockerStart`, a regular - // function invoked during the command's own `Run()`, well after config load has already - // installed dotenv keys into the process env — not in a - // package-level `var` initializer evaluated before that ever runs (see - // {@link BITBUCKET_CLONE_DIR_ENV_KEY}'s own doc comment; review: - // PRRT_kwDOErm0O86VmHkm) — so a value set ONLY in a project `.env` file genuinely reaches - // it too. Deliberately permanent (unlike `applyProjectEnv`'s own narrower, - // explicitly-scoped opt-in around a single command's container work) — matching the - // established non-reverting `os.Setenv`, which persists for that single-command process's entire - // lifetime. + // Installs `BITBUCKET_CLONE_DIR_ENV_KEY` into `process.env` for the rest of this process's + // lifetime, without overriding an already-set value, so a value set only in a project `.env` + // file is visible to the later code that reads it directly from `process.env`. for (const [key, value] of Object.entries(projectEnvValues)) { if (key === BITBUCKET_CLONE_DIR_ENV_KEY && process.env[key] === undefined) { process.env[key] = value; } } - // Deliberately NOT extended to Docker-client keys (`DOCKER_HOST`/`DOCKER_CONTEXT`/ - // `DOCKER_CONFIG`/etc, `isDockerClientEnvKey`), unlike an earlier version of this - // function — same reasoning as `SUPABASE_SERVICES_HOSTNAME` right below, with even more - // direct evidence: the reference implementation's ENTIRE Docker connectivity is the package-level - // `var Docker = NewDocker()`, whose - // `cli.Initialize(&dockerFlags.ClientOptions{})` reads these exact env vars once, at - // BINARY STARTUP — before `main()` runs, before cobra parses argv, before any command's - // `Run()` calls `flags.LoadConfig` -> `Config.Load` -> `loadNestedEnv` -> `godotenv.Load`. - // The reference implementation never shells out to a `docker`/`podman` binary for its own - // container work — - // every container operation - // goes through that single already-frozen SDK client, so a project-dotenv-only Docker-client - // override can NEVER retarget that daemon, any more than it can retarget - // `utils.Config.Hostname` below. Verified empirically (a scratch probe reproducing the exact - // package-var-init-before-dotenv-load ordering): a value installed via `os.Setenv` after a - // package var has already captured the environment never reaches that var. Installing these - // keys here would make native `db start`/`start`/`stop`/`status` — which DO read - // `process.env` at each `docker`/`podman` subprocess spawn (`hostname.ts`, - // `extendEnv: true` at every spawn site) — inspect and mutate a DIFFERENT daemon than the Go - // command targets: a NEW divergence from Go, not a fix for one (review: - // PRRT_kwDOErm0O86WXFqw). + // Docker-client env vars (`DOCKER_HOST`, `DOCKER_CONTEXT`, `DOCKER_CONFIG`, etc.) and + // `SUPABASE_SERVICES_HOSTNAME` are resolved once, earlier in process startup, so installing + // them here from a project dotenv file would have no effect and must not be added to the + // loop above. - // Deliberately NOT extended to `SUPABASE_SERVICES_HOSTNAME` (review: PRRT_kwDOErm0O86VlqIJ): - // `GetHostname()` has exactly one call - // site — `var Config = config.NewConfig(config.WithHostname(GetHostname()))`, - // a package-level `var` initializer. Go's runtime evaluates - // every package-level `var` before `main()` runs, which is before cobra parses argv, which is - // before ANY command's `RunE`/`PersistentPreRunE` calls `flags.LoadConfig` -> `Config.Load` -> - // `loadNestedEnv` -> `godotenv.Load`. So `utils.Config.Hostname` is permanently fixed to - // whatever `os.Getenv("SUPABASE_SERVICES_HOSTNAME")` returns at Go BINARY STARTUP — before a - // project dotenv file is ever parsed by that process — and nothing re-reads `GetHostname()` - // afterward to pick up a dotenv-installed value. Verified empirically (scratch probe - // reproducing the exact package-var-init-before-dotenv-load ordering): a project-dotenv-only - // `SUPABASE_SERVICES_HOSTNAME` never reaches Go's hostname resolution; only a value already - // present in the shell env before the binary starts does. `getHostname()` right below - // must therefore NOT see a project-dotenv-only override either — installing it into - // `process.env` here would make native `db start`/`start`/`stop`/`status` honor a case Go's - // own `utils.Config.Hostname` can never observe, which is a NEW divergence from Go, not a fix - // for one. - - // An absent config.toml is not a failure — `flags.LoadConfig` still resolves a project id - // via the workdir basename default. Only a malformed file (`loadCliConfig` failing rather - // than returning `null`) is a hard error. + // An absent config.toml is not a failure — a project id still resolves from the workdir + // basename default. Only a malformed file is a hard error. const loaded = yield* loadCliConfig(workdir, { cliProjectEnv: projectEnv !== null ? { ...projectEnv, values: projectEnvValues } : undefined, search: false, - // `NewPathBuilder`/`Config.Load` only ever resolves - // `supabase/config.toml` — it has no concept of a JSON project config file. Without this, a - // workdir with a stray `config.json` would make `loadCliConfig` prefer it over - // `config.toml`. + // Restricts resolution to `supabase/config.toml`; without this, a workdir with a stray + // `config.json` would be preferred over it. tomlOnly: true, goViperCompat: true, projectRef, @@ -180,17 +83,9 @@ export const loadLocalProjectContext = <E>( ); const config = loaded?.config ?? Schema.decodeUnknownSync(CliConfigSchema)({}); const hostname = getHostname(); - // `loaded?.appliedRemote !== undefined` means a `[remotes.<ref>]` block matched - // `projectRef` above and `loadCliConfig` merged it over the base document - // (`packages/config/src/io.ts`'s `applyRemoteOverride`) — including that block's OWN - // `project_id` field, which is what selected it (`config.project_id` already equals - // `projectRef`). `mergeRemoteConfig` installs that value at viper's override tier, - // above `AutomaticEnv`, so a stale/ - // differently-scoped `SUPABASE_PROJECT_ID` must not win over it here either — otherwise - // this context's `projectId` (network id, container labels — same field - // `db-config.toml-read.ts`'s own `project_id` gating protects for the pg-delta - // context) resolves the WRONG id for a linked `db diff --linked`/`db pull` shadow - // (review: PRRT_kwDOErm0O86XHGDL). + // When a `[remotes.<ref>]` block matched `projectRef` above, its own `project_id` field is + // what selected it, so a stale or differently-scoped `SUPABASE_PROJECT_ID` must not win over + // it here. const projectId = sanitizeProjectId( resolveLocalProjectId( loaded?.appliedRemote !== undefined diff --git a/apps/cli/src/command-internal/local-project-context.unit.test.ts b/apps/cli/src/command-internal/local-project-context.unit.test.ts index 96f3271e74..8b582ceb96 100644 --- a/apps/cli/src/command-internal/local-project-context.unit.test.ts +++ b/apps/cli/src/command-internal/local-project-context.unit.test.ts @@ -7,23 +7,10 @@ import { Effect } from "effect"; import { useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; import { loadLocalProjectContext } from "./local-project-context.ts"; -/** - * `DOCKER_HOST` stands in for the whole Docker-client key set (`isDockerClientEnvKey`, - * `db-bootstrap/docker-create-args.ts`) here — `loadLocalProjectContext` deliberately does - * NOT install any of these from a project `.env` (see its own doc comment; review: - * PRRT_kwDOErm0O86WXFqw): Go's Docker connectivity is the package-level - * `var Docker = NewDocker()`, frozen at binary - * startup, well before `godotenv.Load` ever runs — so a project-dotenv-only override can never - * reach it, and installing it here would retarget native commands' Docker daemon relative to Go. - */ +/** Stands in for the whole Docker-client env-key set, which a project dotenv file never reaches. */ const DOCKER_HOST_KEY = "DOCKER_HOST"; -/** - * `BITBUCKET_CLONE_DIR` is installed alongside the Docker-client keys even though it isn't one - * itself — see `BITBUCKET_CLONE_DIR_ENV_KEY`'s doc comment (review: - * PRRT_kwDOErm0O86VmHkm) for why this key, unlike `SUPABASE_SERVICES_HOSTNAME`, must reach - * `process.env` from a project-only dotenv file. - */ +/** Unlike Docker-client keys, this one is read at container-spawn time, so a project dotenv file can still set it. */ const BITBUCKET_CLONE_DIR_KEY = "BITBUCKET_CLONE_DIR"; function writeDotEnv(workdir: string, contents: string): void { @@ -56,11 +43,6 @@ describe("loadLocalProjectContext", () => { it.effect( "prefers a matched [remotes.<ref>]'s project_id over a conflicting SUPABASE_PROJECT_ID", () => { - // Regression (review: PRRT_kwDOErm0O86XHGDL) — `loadCliConfig`'s own remote merge - // (`packages/config/src/io.ts`) already installs the matched block's `project_id` at - // Go's viper override tier before this reads it; letting an unrelated - // `SUPABASE_PROJECT_ID` win here would resolve the WRONG project id for the shadow's - // own network id/container labels on a linked `db diff`/`db pull`. process.env["SUPABASE_PROJECT_ID"] = "local"; const ref = "abcdefghijklmnopqrst"; const workdir = tempRoot.current; diff --git a/apps/cli/src/command-internal/login-api.layer.ts b/apps/cli/src/command-internal/login-api.layer.ts index 9dfe7b4386..259b32398e 100644 --- a/apps/cli/src/command-internal/login-api.layer.ts +++ b/apps/cli/src/command-internal/login-api.layer.ts @@ -8,10 +8,8 @@ import { LoginVerificationError } from "../commands/login/login.errors.ts"; const POLL_TIMEOUT = "10 seconds"; -// HttpClientError reasons that mean the response arrived but its body could not -// be decoded (including a 2xx whose body isn't valid JSON). These are API -// response problems, not transport ones, so they classify by `decode` rather -// than as a network failure. Mirrors `next/auth/api.layer.ts`. +// HttpClientError reasons meaning the response arrived but its body couldn't be decoded +// (including a 2xx with invalid JSON) — classified as `decode`, not a transport `network` failure. const BODY_DECODE_REASONS = new Set<string>(["DecodeError", "EmptyBodyError"]); function readString(obj: unknown, key: string): string { @@ -53,11 +51,8 @@ export const loginApiLayer = Layer.effect( }; return session; }).pipe( - // Map transport / JSON-decode failures to the retry-driving error. - // The explicit non-200 `LoginVerificationError` above passes - // through untouched (it is not an `HttpClientError`). A body-decode - // reason means the response arrived but its body was unparseable — an - // API response problem (`decode`), not a transport (`network`) one. + // The explicit non-200 `LoginVerificationError` above passes through untouched here, + // since it isn't an `HttpClientError`. Effect.catchTag("HttpClientError", (cause) => Effect.fail( BODY_DECODE_REASONS.has(cause.reason._tag) diff --git a/apps/cli/src/command-internal/make-dir.ts b/apps/cli/src/command-internal/make-dir.ts index dea0264aa8..eaa7668638 100644 --- a/apps/cli/src/command-internal/make-dir.ts +++ b/apps/cli/src/command-internal/make-dir.ts @@ -2,16 +2,11 @@ import { Effect, FileSystem } from "effect"; import type { PlatformError } from "effect/PlatformError"; /** - * `os.MkdirAll`-equivalent: create `dir` and any missing parents, treating an - * already-existing directory as success. + * Creates `dir` and any missing parents, treating an already-existing directory as success. * - * Go's `os.MkdirAll` returns nil when the target is already a directory, so the - * Go CLI's migration writers never failed on a pre-existing `supabase/migrations`. - * Effect's Bun `FileSystem.makeDirectory` does not always match that: even with - * `recursive: true` it can surface an `AlreadyExists` `SystemError` for an - * existing directory on some platforms (notably Windows / OneDrive reparse - * points — see CLI-1849). Recover from that single reason so re-creating an - * existing directory is a no-op, and let every other failure propagate. + * Effect's Bun `FileSystem.makeDirectory` can surface an `AlreadyExists` `SystemError` for an + * existing directory even with `recursive: true`, on some platforms (notably Windows/OneDrive + * reparse points) — that single reason is treated as a no-op, and every other failure propagates. */ export const makeDir = ( fs: FileSystem.FileSystem, diff --git a/apps/cli/src/command-internal/management-api-runtime.layer.ts b/apps/cli/src/command-internal/management-api-runtime.layer.ts index 24c9001374..7e925a2d7a 100644 --- a/apps/cli/src/command-internal/management-api-runtime.layer.ts +++ b/apps/cli/src/command-internal/management-api-runtime.layer.ts @@ -30,49 +30,27 @@ import { commandRuntimeLayer } from "../shared/runtime/command-runtime.layer.ts" * Composes the runtime layer for a Management-API-style `supabase <command> <subcommand>` * invocation. * - * `commandSettingsLayer` must be piped to both the platform API stack and - * `projectRefLayer`. `Layer.provide` satisfies a requirement on the target layer; - * it does not expose the provided service to siblings of a `Layer.mergeAll(...)`. The - * project-ref layer reads `CommandSettings` directly for workdir/projectId resolution, - * so without an explicit provide here the bundled runtime panics with - * `Service not found: supabase/cli/CommandSettings`. Handlers that yield `CommandSettings` - * directly (e.g. `branches get`, `suggestUpgrade`) also need the service exposed - * at the top level of the merged layer, hence the top-level `cliSettings` entry below. + * `commandSettingsLayer`, `httpClientLayer`, and `CommandCredentials` are exposed at the top + * level, not just piped into the platform API stack, because `Layer.provide` doesn't share a + * service to siblings inside `Layer.mergeAll` (see the CLI Invariants in apps/cli/CLAUDE.md) — + * several handlers yield these services directly or bypass the typed Management API client. * - * `httpClientLayer` and `CommandCredentials` are exposed at the top level so - * handlers / helpers that bypass the typed Management API client can read them - * directly: - * - `sso add` / `sso update` POST/PUT raw JSON to preserve arbitrary - * `attribute_mapping.keys.<x>.default` fields the typed input schema omits. - * - `suggestUpgrade` GETs `/v1/projects/{ref}` and `/v1/organizations/{slug}/entitlements` - * directly because the typed `V1GetProjectOutput` decode rejects the - * `__PROJECT_REF__` placeholder cli-e2e replay fixtures embed in response bodies - * (`ref: isMinLength(20)` fails on the 15-char placeholder). - * - * Layers are memoised by reference, so the merge + provide combos reuse the same - * instance instead of building two debug-logging wrappers / two keyring readers. + * Layers are memoised by reference, so shared instances aren't rebuilt per merge/provide site. * * @param subcommand - command path segments after `supabase`, e.g. `["backups", "list"]`. */ export function managementApiRuntimeLayer(subcommand: ReadonlyArray<string>) { - // Memoise the shared layers so the platform API, top-level service surface, - // project resolver, and linked-project cache all reuse the same config / - // credentials / HTTP instances. const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); const httpClient = httpClientLayer.pipe(Layer.provide(debugLoggerLayer)); const credentials = commandCredentialsLayer.pipe( Layer.provide(cliSettings), Layer.provide(debugLoggerLayer), ); - // `commandPlatformApiLayer` applies typed API debug logging after generated - // requests have been prefixed with the active profile's API URL. - // `identityStitchLayer` is the one per-command identity stitcher; the - // SAME reference is provided to the cache below so (by layer memoisation) the - // typed client and the cache GET share a single `stitchAttempted` guard — Go's - // one root-context `sync.Once`, not one per transport. - // `dohFetchLayer` overrides `FetchHttpClient.Fetch` with a - // DNS-over-HTTPS-aware fetch when `--dns-resolver https` is set — mirrors - // `withFallbackDNS` hook. + // `identityStitchLayer` is the one per-command identity stitcher; the same reference is + // provided to the cache below so, by layer memoisation, the typed client and the cache share a + // single `stitchAttempted` guard instead of one per transport. + // `dohFetchLayer` overrides `FetchHttpClient.Fetch` with a DNS-over-HTTPS-aware fetch when + // `--dns-resolver https` is set. const platformApiStack = commandPlatformApiLayer.pipe( Layer.provide(credentials), Layer.provide(cliSettings), @@ -99,33 +77,18 @@ export function managementApiRuntimeLayer(subcommand: ReadonlyArray<string>) { ), telemetryStateLayer, commandRuntimeLayer([...subcommand]), - // Expose the single per-command identity stitcher at the top level so the - // post-run instrumentation can read stitchedDistinctId() via serviceOption. - // The same reference is provided into platformApiStack and the cache, so by - // layer memoisation all three share one stitchAttempted guard — Go's one - // root-context sync.Once. + // Exposed at the top level so post-run instrumentation can read `stitchedDistinctId()` via + // `serviceOption`. identityStitchLayer, - // Expose the same memoised instance already provided into cliSettings/httpClient/etc. - // at the top level so handlers can log a swallowed, non-fatal error directly - // (`fmt.Fprintln(utils.GetDebugLogger(), err)` pattern, e.g. `secrets set`). + // Exposed at the top level so handlers can log a swallowed, non-fatal error directly + // (e.g. `secrets set`). debugLoggerLayer, ); - // Compile-time guarantee that the merged layer exposes every service a - // Management-API handler is allowed to yield from its top-level - // `Effect.fn` body. If a future handler yields a service NOT in this union, - // either: - // (a) the new service belongs in the runtime layer — add it to the merge - // above AND to `ManagementApiServices` below, or - // (b) the service comes from the surrounding root layer (`Output`, - // `OutputFlag`, `Analytics`, `Stdio`, `Tty`, …) and is therefore - // already provided via `runCli` / `cliProgramFor` — no change here. - // - // The assertion uses `unknown` for E and R so that the assertion ONLY fires - // for missing exposed services; changes to the layer's internal error / - // requirement channels do not perturb this check. cli-e2e parity tests - // surface missing-service runtime panics, but the same class of bug is now - // caught at compile time. + // Compile-time guarantee that the merged layer exposes every service a Management-API handler + // may yield from its top-level `Effect.fn` body — a service missing from `ManagementApiServices` + // below becomes a type error here instead of a `Service not found` runtime panic. `unknown` for + // E and R keeps this check scoped to exposed services only. const _serviceCoverageCheck: Layer.Layer<ManagementApiServices, unknown, unknown> = built; void _serviceCoverageCheck; @@ -133,16 +96,13 @@ export function managementApiRuntimeLayer(subcommand: ReadonlyArray<string>) { } /** - * Services that every Management-API handler is allowed to yield - * directly from its top-level `Effect.fn` body. Adding a new `yield* X` in a - * handler without adding `X` here is a **compile error**, surfacing what was - * previously a runtime `Service not found: …` panic that only the cli-e2e - * parity suite caught. + * Services every Management-API handler may yield directly from its top-level `Effect.fn` body. + * Adding a new `yield* X` without adding `X` here is a compile error instead of a runtime + * `Service not found: …` panic. * - * `Output`, `OutputFlag`, `Analytics`, `Stdio`, `Tty`, `ProcessControl`, - * and `RuntimeInfo` are intentionally NOT listed — they're root-level services - * provided by `runCli` / the shared `cliProgramFor`, not by this command-level - * runtime layer. + * `Output`, `OutputFlag`, `Analytics`, `Stdio`, `Tty`, `ProcessControl`, and `RuntimeInfo` are + * intentionally not listed — they're root-level services provided by `runCli`/`cliProgramFor`, + * not by this command-level runtime layer. */ type ManagementApiServices = | CommandPlatformApi @@ -158,18 +118,9 @@ type ManagementApiServices = /** * Runtime layer for the `--linked` db-config resolver path (`db dump`, `db query`, - * `db schema declarative generate/sync`). Identical to `managementApiRuntimeLayer` - * except it exposes the access token **lazily** via `CommandPlatformApiFactory` - * (`commandPlatformApiFactoryLayer`) instead of the eager `CommandPlatformApi` stack. - * - * Building this layer resolves NO access token — `commandPlatformApiFactoryLayer` - * captures context and wraps `makeCommandPlatformApi` in `Effect.cached`, deferring - * token resolution to the first `factory.make` (i.e. when `initLoginRole` / - * `listAndUnban` actually call the Management API). This never loads a token when a - * DB password - * is supplied — so `db dump --linked --password …` / `… generate --linked --password` - * succeed without a login. Management API commands that legitimately require a token - * keep using `managementApiRuntimeLayer`, where the eager stack fails up front. + * `db schema declarative generate/sync`). Identical to `managementApiRuntimeLayer`, except the + * access token resolves lazily via `CommandPlatformApiFactory` on first use instead of eagerly, so + * `--linked --password …` invocations succeed without requiring a login. */ export function linkedDbResolverRuntimeLayer(subcommand: ReadonlyArray<string>) { const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); @@ -178,9 +129,6 @@ export function linkedDbResolverRuntimeLayer(subcommand: ReadonlyArray<string>) Layer.provide(cliSettings), Layer.provide(debugLoggerLayer), ); - // Lazy factory: its build does NOT resolve a token (see doc above). The factory - // shares the same underlying deps as the eager platform API stack, so the - // ambient requirements match `managementApiRuntimeLayer` exactly. const platformApiFactory = commandPlatformApiFactoryLayer.pipe( Layer.provide(credentials), Layer.provide(cliSettings), diff --git a/apps/cli/src/command-internal/migrate-and-seed.ts b/apps/cli/src/command-internal/migrate-and-seed.ts index 5d906edf9c..bae901144d 100644 --- a/apps/cli/src/command-internal/migrate-and-seed.ts +++ b/apps/cli/src/command-internal/migrate-and-seed.ts @@ -13,17 +13,14 @@ export interface MigrateAndSeedConfig { readonly migrationsEnabled: boolean; readonly seed: SeedConfig; /** - * `--experimental`/`SUPABASE_EXPERIMENTAL` — together with an empty `version` and - * `pgDeltaEnabled === false`, switches the branch below from applying migration files to - * applying `schemaPaths`'s declarative schema files instead. `migration down` (the other - * caller of this function) always passes a concrete `version`, so `len(version) == 0` - * half of the same condition is already false there regardless of this field — see that - * call site's own comment for why a static value is safe. + * `--experimental`/`SUPABASE_EXPERIMENTAL` — combined with an empty `version` and + * `pgDeltaEnabled === false`, switches from applying migration files to applying + * `schemaPaths`'s declarative schema files instead. */ readonly experimental: boolean; - /** `[experimental.pgdelta] enabled` — `utils.IsPgDeltaEnabled()`. See `experimental` above. */ + /** `[experimental.pgdelta] enabled`. See `experimental` above. */ readonly pgDeltaEnabled: boolean; - /** `db.migrations.schema_paths` — `Config.Db.Migrations.SchemaPaths`. Only read by the declarative branch above. */ + /** `db.migrations.schema_paths`; only read by the declarative branch above. */ readonly schemaPaths: ReadonlyArray<string>; /** * Effective local `[experimental.webhooks].enabled` value. `undefined` means @@ -49,15 +46,13 @@ const migrationApplyError = ( }; /** - * Reapplies local migrations up to `version`, then runs seed files. Port of Go's - * `apply.MigrateAndSeed`: when `experimental` is - * set, `version` is empty, and `pgDeltaEnabled` is false, the declarative `schemaPaths` - * files are applied INSTEAD of migration files via the shared {@link applySchemaFiles} - * (`migration-apply.ts` — also used by `db reset`'s own `--experimental` remote path, - * so both callers share one Go-quirk-preserving implementation instead of two), bypassing - * `migrationsEnabled` entirely — `applySchemaFiles` has no such gate, only - * `applyMigrationFiles` does; otherwise migration apply is gated on `db.migrations.enabled` as - * before. Seeding (`db.seed.enabled`, inside the seed helper) always runs, on either branch. + * Reapplies local migrations up to `version`, then runs seed files. + * + * When `experimental` is set, `version` is empty, and `pgDeltaEnabled` is false, the declarative + * `schemaPaths` files are applied instead of migration files, via the shared + * {@link applySchemaFiles} — bypassing `migrationsEnabled` entirely, since only + * `applyMigrationFiles` is gated on it. Otherwise migration apply is gated on + * `db.migrations.enabled` as usual. Seeding always runs, on either branch. */ export const migrateAndSeed = ( session: DbSession, diff --git a/apps/cli/src/command-internal/migrate-and-seed.unit.test.ts b/apps/cli/src/command-internal/migrate-and-seed.unit.test.ts index 9e9b57122c..9676f442d1 100644 --- a/apps/cli/src/command-internal/migrate-and-seed.unit.test.ts +++ b/apps/cli/src/command-internal/migrate-and-seed.unit.test.ts @@ -141,8 +141,6 @@ describe("migrateAndSeed experimental declarative-schema branch", () => { Effect.sync(() => { expect(execs).toContain("create table schema_marker ()"); expect(execs).not.toContain("create table migration_marker ()"); - // `applyMigrationFiles` prints "Applying migration ...", which - // `applySchemaFiles` never does — confirms the migration branch didn't run too. expect(out.rawChunks.map((c) => c.text).join("")).not.toContain("Applying migration"); rmSync(workdir, { recursive: true, force: true }); }), @@ -267,9 +265,7 @@ describe("migrateAndSeed experimental declarative-schema branch", () => { experimental: true, pgDeltaEnabled: false, schemaPaths: ["supabase/schemas/a.sql"], - // Both `schemaPaths` and `SeedConfig.sqlPaths` arrive already - // `supabase/`-prefixed by their real caller (`db-config.toml-read.ts`) — see - // its own doc comment. Neither field does its own path-shape work anymore. + // schemaPaths/sqlPaths arrive already `supabase/`-prefixed from their real caller. seed: { enabled: true, sqlPaths: ["supabase/seed.sql"] }, }, session, @@ -285,7 +281,6 @@ describe("migrateAndSeed experimental declarative-schema branch", () => { ); }); - // Same two scenarios as the original test suite this was ported from. it.effect( "expands a directory schema_paths entry to its .sql files, recursively, in declared order", () => { @@ -349,9 +344,8 @@ describe("migrateAndSeed experimental declarative-schema branch", () => { it.effect.skipIf(isRoot)( "fails a matched schema_paths directory that cannot be traversed, instead of treating it as empty", () => { - // `walkMatchedDir` returns `failed to walk matched directory: %w` on a read - // error; `applySchemaFiles` propagates it when nothing else matched either. Mode - // 000 makes `stat` (parent-directory lookup) succeed but `readdir` fail with EACCES. + // Mode 000 makes `stat` (parent-directory lookup) succeed but `readdir` fail with EACCES, + // which `applySchemaFiles` propagates as "failed to walk matched directory". const workdir = makeWorkdir(); const lockedDir = join(workdir, "supabase", "schemas", "locked"); mkdirSync(lockedDir, { recursive: true }); @@ -388,10 +382,9 @@ describe("migrateAndSeed experimental declarative-schema branch", () => { it.effect( "skips a symlinked .sql file and an entire symlinked subdirectory inside a matched schema_paths directory", () => { - // `walkMatchedDir` (`fs.WalkDir` + `entry.Type().IsRegular()`) never follows a - // symlinked `DirEntry` — a symlinked `.sql` file is excluded regardless of target, and a - // symlinked subdirectory is never even descended into. Both live OUTSIDE the matched - // directory here, so applying either would mean executing SQL Go would never touch. + // Symlinked files and directories are never followed, regardless of target; both live + // outside the matched directory here, so applying either would mean executing SQL that + // should never run. const workdir = makeWorkdir(); const outsideDir = mkdtempSync(join(tmpdir(), "migrate-and-seed-outside-")); writeFileSync(join(outsideDir, "escaped.sql"), "select 999;"); @@ -542,10 +535,8 @@ describe("migrateAndSeed local pg_net remediation", () => { describe("migrateAndSeed apply order", () => { it.effect("applies mixed-width versions in version order, like db push (#6036)", () => { const workdir = makeWorkdir(); - // `20260420010000_b.sql` precedes `20260420_a.sql` in file-name order - // ('0' < '_'), the reverse of the version order `db push` applies in since - // #6038. Unsorted, `db reset`/`db start` replay `b` before `a` locally while - // `db push` sends `a` before `b` remotely. + // File-name order would sort `20260420010000_b.sql` before `20260420_a.sql` ('0' < '_'), + // the reverse of version order — this asserts the migrations replay in version order. writeFile(workdir, "supabase/migrations/20260420_a.sql", "create table t (id int);"); writeFile(workdir, "supabase/migrations/20260420010000_b.sql", "alter table t add c int;"); const { session, execs } = fakeSession(); diff --git a/apps/cli/src/command-internal/migration-apply.ts b/apps/cli/src/command-internal/migration-apply.ts index 8cfb3dabf5..0b1cd71ace 100644 --- a/apps/cli/src/command-internal/migration-apply.ts +++ b/apps/cli/src/command-internal/migration-apply.ts @@ -21,13 +21,11 @@ import { sqlFilesGlob } from "./sql-files-glob.ts"; import { splitSqlTokens } from "./sql-split.ts"; /** - * Applying a migration file failed (`ApplyMigrations` / `ExecBatch` error). - * Used by `migration up` and `migration down`'s migrate-and-seed step. The + * A migration file failed to apply. Used by `migration up`/`down`'s migrate-and-seed step; the * declarative sync handler maps its own error type instead. * - * `suggestion` carries caller remediation. This includes schema-file guidance - * from `applySchemaFiles` and the local-only pg_net/webhooks remediation added - * when start/reset replay has enough structured context to identify that failure. + * `suggestion` carries caller remediation: schema-file guidance from `applySchemaFiles`, or + * local-only pg_net/webhooks remediation when start/reset replay can identify that failure. */ export class MigrationApplyError extends Data.TaggedError("MigrationApplyError")<{ readonly message: string; @@ -41,23 +39,15 @@ export class MigrationApplyError extends Data.TaggedError("MigrationApplyError") } } -// Byte order mark (U+FEFF) — stripped from the head of a statement like Go does. +// Byte order mark (U+FEFF), stripped from the head of a statement. const BOM_CODE_POINT = 0xfeff; // Statements that PostgreSQL refuses to run inside a transaction block / extended-query // pipeline (SQLSTATE 25001). Matched against the upper-cased, comment-stripped statement. // -// Provenance (CLI-1989): the design for this behaviour is the fix proposed for -// supabase/cli#5139 in PR supabase/cli#5156 (`isPipelineIncompatible` / -// `trimLeadingSQLComments`). That PR was closed WITHOUT merging — its design was adopted -// directly into this TS apply instead in PR supabase/cli#5671 (squash-merged to develop -// as b48fad60; the #5156 closing comment cites the PR-branch commit 29d3fb0e). -// -// Known residual delta: JS `\s` matches `\v` (vertical tab), but Go RE2 `\s` is -// `[\t\n\f\r ]` and does not. PostgreSQL >= 14 treats `\v` as SQL whitespace, so a -// statement separated only by `\v` (e.g. `VACUUM\v(FULL)`) classifies as -// pipeline-incompatible here but wouldn't under that narrower definition. Not worth -// changing behaviour over — flagging so a future review doesn't rediscover it. +// JS's `\s` also matches `\v` (vertical tab), which PostgreSQL >= 14 treats as SQL whitespace — +// so a statement separated only by `\v` (e.g. `VACUUM\v(FULL)`) classifies as +// pipeline-incompatible here, a known, deliberately unfixed edge case. const CREATE_INDEX_CONCURRENTLY_PATTERN = /^CREATE\s+(?:UNIQUE\s+)?INDEX\s+CONCURRENTLY(?:\s|$)/u; const DROP_INDEX_CONCURRENTLY_PATTERN = /^DROP\s+INDEX\s+CONCURRENTLY(?:\s|$)/u; const REINDEX_CONCURRENTLY_PATTERN = /^REINDEX(?:\s|\().*\sCONCURRENTLY(?:\s|$)/u; @@ -70,11 +60,9 @@ const TRANSACTION_CONTROL_PATTERN = /** * Strips a leading BOM, whitespace, and SQL line (`--`) and block comments from the * front of a statement so the keyword check below sees the real first token. - * Port of `trimLeadingSQLComments` (`pkg/migration/file.go`, supabase/cli#5156). */ const trimLeadingSqlComments = (sql: string): string => { - // `TrimLeftFunc` drops a leading BOM together with whitespace; strip the BOM - // via its code point so no irregular whitespace lands in the source. + // Stripped via code point comparison so it isn't relied on to match a whitespace regex class. let trimmed = sql.replace(/^[ \t\n\r]+/u, ""); while (trimmed.charCodeAt(0) === BOM_CODE_POINT) { trimmed = trimmed.slice(1).replace(/^[ \t\n\r]+/u, ""); @@ -95,12 +83,10 @@ const trimLeadingSqlComments = (sql: string): string => { }; /** - * Whether a migration statement cannot run inside a transaction block — `CREATE - * [UNIQUE] INDEX CONCURRENTLY`, `DROP INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, - * `VACUUM`, `ALTER SYSTEM`, `CLUSTER`. Such statements fail with SQLSTATE 25001 - * inside the implicit transaction - * created by a migration batch, so `execMigrationBatch` runs them standalone. - * Port of `isPipelineIncompatible` (`pkg/migration/file.go`, supabase/cli#5156). + * Whether a migration statement cannot run inside a transaction block — `CREATE [UNIQUE] INDEX + * CONCURRENTLY`, `DROP INDEX CONCURRENTLY`, `REINDEX … CONCURRENTLY`, `VACUUM`, `ALTER SYSTEM`, + * `CLUSTER`. These fail with SQLSTATE 25001 inside the implicit transaction a migration batch + * creates, so `execMigrationBatch` runs them standalone. */ export const isPipelineIncompatible = (sql: string): boolean => { const upper = trimLeadingSqlComments(sql).toUpperCase(); @@ -137,15 +123,12 @@ const QUOTED_ROLE_VALUE_PATTERN = /^SET\s+(?:SESSION\s+)?ROLE(?:\s+TO\s+|\s*=\s*|\s+)(['"])(.*?)\1(?:\s|;|$)/iu; /** - * Whether a top-level statement reverts a stepped-down session to its login role - * (`RESET ROLE`, the generic-`SET` spellings of `role`'s reset, `RESET SESSION - * AUTHORIZATION` and friends, `DISCARD ALL`). File runners re-assert `postgres` - * right after each match, so the rest of the file keeps `current_user = postgres` - * as on a password session (supabase/cli#6236); reverts a lexical check cannot - * see (dynamic SQL, `SET LOCAL ROLE NONE` — deliberately unmatched, since a - * session-scoped restore would override its transaction scope) are backstopped - * by the trailing restore before any CLI-owned write. `RESET ALL` is - * deliberately absent — `role` carries `GUC_NO_RESET_ALL`. + * Whether a top-level statement reverts a stepped-down session to its login role (`RESET ROLE`, + * `SET`-based role resets, `RESET SESSION AUTHORIZATION`, `DISCARD ALL`). File runners re-assert + * `postgres` right after each match; reverts a lexical check can't see (dynamic SQL, `SET LOCAL + * ROLE NONE`, since a session-scoped restore would override its transaction scope) are + * backstopped by the trailing restore before any CLI-owned write. `RESET ALL` is absent because + * `role` carries `GUC_NO_RESET_ALL`. */ export const revertsToLoginRole = (sql: string): boolean => { const trimmed = trimLeadingSqlComments(sql); @@ -157,109 +140,24 @@ export const revertsToLoginRole = (sql: string): boolean => { const utf8ByteLength = (value: string): number => new TextEncoder().encode(value).length; -// `startBufSize` — the fixed initial scanner -// buffer size pre-allocated before applying the configured/default max. The -// scanner's buffer therefore starts at exactly this size regardless of how small -// `SUPABASE_SCANNER_BUFFER_SIZE` is set, and the too-long check only fires once -// the buffer is full — so a statement must reach at least this many raw bytes -// before an oversized-token error can ever be raised, no matter how small the override. -// Verified empirically: a -// single-statement probe of exactly 4096 raw bytes always succeeds — even with -// `SUPABASE_SCANNER_BUFFER_SIZE` set to 10 bytes — while 4097 bytes always fails; -// with the override set above this floor (e.g. 5000 bytes), the exact same -// pattern repeats at the override's own value (5000 succeeds, 5001 fails). +// The scanner buffer starts at this size before applying the configured/default max, so a +// statement must reach at least this many bytes before an oversized-token error can fire, +// regardless of how small an override is set. const GO_SCANNER_START_BUF_SIZE = 4096; -// The hardcoded default scanner capacity -// fallen back to when `viper.GetSizeInBytes("SCANNER_BUFFER_SIZE")` -// returns `0`. Reached whenever the env var is SET but resolves to a non-positive size — -// including a value `parseScannerBufferSize` can't parse at all. Verified -// empirically: -// `SUPABASE_SCANNER_BUFFER_SIZE=5M` (a bare multiplier suffix with NO trailing `b`/`B`) -// behaves byte-for-byte identically to the var being completely unset from the default -// cap's own first-failure point on — because `parseSizeInBytes` only ever recognizes a -// `k`/`m`/`g` multiplier when it immediately precedes a trailing `b`/`B`; -// "5M" never strips a suffix, so it falls through to -// `cast.ToInt("5M")`, which fails whole (not a leading-digits prefix parse — unlike -// JS's lenient `Number.parseInt`) and returns `0`. This is NOT the same as truly unset, -// though: `viper.IsSet("SCANNER_BUFFER_SIZE")` is still `true` (the var IS present, just -// unparseable), so `parseFile`'s file-size auto-growth (see `checkScannerBufferSize`'s -// doc comment) never runs — the cap stays pinned at this hardcoded default regardless of -// the real file's size, unlike the genuinely-unset case where it grows to match. +// Fallback cap when `SUPABASE_SCANNER_BUFFER_SIZE` is set but parses to a non-positive size, +// including a value that can't be parsed at all (e.g. a bare "5M" with no trailing "B"). const GO_DEFAULT_MAX_SCANNER_CAPACITY = 256 * 1024; -/** - * `viper.GetSizeInBytes("SCANNER_BUFFER_SIZE")` (env-prefixed - * `SUPABASE_SCANNER_BUFFER_SIZE`): an integer byte count, - * optionally suffixed `k`/`K`/`m`/`M`/`g`/`G` (× 1024/1024²/1024³) plus a trailing - * `b`/`B` (e.g. `"5MB"`, `"256KB"`, or a bare byte count). Ported 1:1 from viper's - * own parser (`parseSizeInBytes`): - * an unparseable or non-positive result is treated as unset (`0`). - * - * The multiplier is recognized ONLY when the string's LAST character is literally - * `b`/`B` — a bare `"5M"` (no trailing `B`) is NOT 5 MiB in real Go: `sizeStr[lastChar]` - * isn't `b`/`B`, so the multiplier branch never runs and the whole (unstripped) string - * is handed to `cast.ToInt`, which fails on the trailing letter and yields `0`. Do NOT - * special-case a bare `k`/`m`/`g` suffix here; that would make this port accept a value - * real Go rejects. - * - * Go's inner `lastChar > 1` gate means the trailing-`B`-strip ALSO never - * runs for a 2-character value like `"5B"`/`"5b"` — only 3+ characters (an actual - * multiplier letter, or at least one digit, before the `B`) reach the switch at all — so - * `"5B"` is unstripped, `cast.ToInt("5B")` fails, and the whole thing is `0` too, same as - * `"5M"`. `cast.ToInt` (`strconv.ParseInt`) also requires the ENTIRE remaining string to - * be a clean integer — trailing garbage fails the WHOLE parse, unlike JS's lenient - * `Number.parseInt`, which stops at the first non-digit and returns whatever numeric - * prefix it found (`Number.parseInt("5M", 10) === 5`, silently discarding the "M", where - * Go's parse rejects the string outright). `cast.ToInt` does tolerate one decimal point - * via its own `trimDecimal` (keeps only the integer part, e.g. `"5.5"` → `"5"`), so allow - * exactly that one exception. All of the above verified empirically - * (`"5"→5, "5B"→0, "50B"→50, "5KB"→5120, "5M"→0, "0"→0, "5.5"→5, "5.5MB"→5242880`). - * - * `cast.ToInt`'s underlying `strconv.ParseInt(s, 0, 0)` (review CLI-1958) uses base - * `0`, so the remaining (post multiplier-strip) string is ALSO accepted as a - * `0x`/`0X`-prefixed hex literal, an `0o`/`0O`-prefixed OR bare-leading-zero octal - * literal, or a `0b`/`0B`-prefixed binary literal — handled below by - * {@link parseGoBaseZeroInt}. Verified empirically - * (via `viper.GetSizeInBytes`, since `cast.ToInt` is itself unexported call - * plumbing): `"0x100000"→1048576`, `"0o40000"→16384`, - * `"0b100000000000000000000"→2097152`, `"0755"` (legacy octal, no `"o"`)`→493`, - * `"0x"/"garbage"/"0x1g"→unparseable (0)`. A hex/octal/binary value ending in a - * literal `b`/`B` digit (e.g. `"0x1B"`) still hits the multiplier-strip switch - * ABOVE first, same as any other value — that consumes the trailing `B` before - * base-0 parsing ever sees it (`"0x1B"→1`, not `27`), which is a genuine Go quirk - * this port reproduces automatically by keeping the same two-step order, not a bug. - * Go's base-0 grammar also permits `_` digit separators (e.g. `"1_048_576"`) — see - * {@link parseGoBaseZeroInt}'s own doc comment for the exact placement grammar. - */ -/** - * Go's underscore digit-separator grammar (`go.dev/ref/spec#Integer_literals`, - * reproduced by `strconv.ParseInt`'s base-0 mode, review CLI-1958): a SINGLE `_` - * may sit immediately after a base prefix (explicit `0x`/`0o`/`0b`, or the bare - * leading `"0"` of legacy octal) or between two digits of the same base — never - * doubled, never leading a plain (no-prefix) decimal literal, and never trailing. - * Verified empirically against the real `strconv.ParseInt(s, 0, 64)`: - * `"1_048_576"→1048576`, `"0x_100000"/"0x10_0000"→1048576`, - * `"0o_40000"/"0o4_0000"→16384`, `"0b_100000000000000000000"→1048576`, - * `"0_755"/"07_55"→493` (legacy octal, underscore right after the leading `"0"` - * or between later octal digits); while `"_1048576"`, `"1048576_"`, - * `"1__048576"`, `"0x100000_"`, and `"0_x100000"` (underscore splitting the - * leading `"0"` from the `"x"` — not a real prefix, so it's parsed as legacy - * octal digits `"x100000"`) all fail. - */ -// `strconv.ParseInt(s, 0, 0)`'s bitSize-0 mode requires the result to fit in `int` — -// 64 bits on every platform this CLI ships for (amd64/arm64). A magnitude outside -// this range is a range error, and `cast.ToInt` discards ANY parse error — range or -// syntax — and returns exactly `0`, not the (possibly huge, saturated-to-max-magnitude) -// value `strconv.ParseInt` itself returns alongside that error. Verified empirically: -// `cast.ToInt("9223372036854775808")` (one over -// `math.MaxInt64`) → `0`. `Number.parseInt` has no such range check (it silently rounds -// via IEEE-754 double precision instead), so this must reject the same magnitudes, -// or it would treat an out-of-range override as an enormous-but-finite limit -// instead of falling back to the 256KiB default (review CLI-1958 round 18). const GO_MAX_INT64 = 9223372036854775807n; const GO_MIN_INT64 = -9223372036854775808n; +/** + * Parses a base-0 integer literal: decimal, or `0x`/`0o`/`0b`-prefixed hex/octal/binary, or a + * bare leading-zero octal (e.g. `"0755"`), with `_` digit separators allowed between digits of + * the same base (never leading, trailing, or doubled). Returns `undefined` for anything invalid + * or outside the 64-bit signed integer range. + */ const parseGoBaseZeroInt = (value: string): number | undefined => { const negative = value.startsWith("-"); const unsigned = negative || value.startsWith("+") ? value.slice(1) : value; @@ -286,7 +184,7 @@ const parseGoBaseZeroInt = (value: string): number | undefined => { // Only a real base prefix (or the legacy-octal leading "0") may be followed // immediately by an underscore; a plain decimal literal has no prefix to - // follow, so a leading underscore there is always invalid (matches Go). + // follow, so a leading underscore there is always invalid. const hadPrefix = base !== 10; const digitClass = base === 16 ? "0-9a-fA-F" : base === 8 ? "0-7" : base === 2 ? "01" : "0-9"; const validPattern = new RegExp( @@ -306,13 +204,9 @@ const parseGoBaseZeroInt = (value: string): number | undefined => { return negative ? -n : n; }; -// `cast.ToInt`'s `trimDecimal` runs BEFORE -// `strconv.ParseInt`: when the whole string is a sign + plain decimal digits + an -// optional ".digits" tail (`stringNumberRe`, `^([-+]?\d*)(\.\d*)?$` — never matches -// a `0x`/`0o`/`0b` literal, which contains letters), it drops the fractional part -// outright rather than rounding (`"5.5"` → `"5"`). Anything else (including a -// non-decimal-looking string that merely contains a ".") passes through unchanged -// and is left for {@link parseGoBaseZeroInt} to accept or reject. +// Drops a decimal literal's fractional part rather than rounding (`"5.5"` → `"5"`); a +// `0x`/`0o`/`0b` literal (which contains letters) never matches and passes through unchanged for +// {@link parseGoBaseZeroInt} to accept or reject. const trimGoDecimal = (value: string): string => { if (!value.includes(".")) return value; const match = /^([+-]?\d*)(?:\.\d*)?$/.exec(value); @@ -322,6 +216,13 @@ const trimGoDecimal = (value: string): string => { return intPart === "" ? "0" : intPart; }; +/** + * `SUPABASE_SCANNER_BUFFER_SIZE` accepts an integer byte count, optionally suffixed + * `k`/`m`/`g` (× 1024/1024²/1024³) immediately followed by a trailing `b`/`B` (e.g. `"5MB"`). A + * bare `"5M"` (no trailing `B`) is NOT 5 MiB — it fails to parse as an integer and is treated as + * unset (`0`), same as any other unparseable or non-positive value. See {@link parseGoBaseZeroInt} + * for the accepted integer-literal grammar (hex/octal/binary prefixes, `_` separators). + */ const parseScannerBufferSize = (raw: string): number => { let value = raw.trim(); let multiplier = 1; @@ -350,36 +251,13 @@ const parseScannerBufferSize = (raw: string): number => { }; /** - * `parser.Split`/`SplitAndTrim` enforces - * `SUPABASE_SCANNER_BUFFER_SIZE` as the `bufio.Scanner`'s max token size — but only - * when the env var is actually SET: `parseFile` - * otherwise grows the package-level `parser.MaxScannerCapacity` to the real file's - * byte length before the scan even starts (`viper.IsSet("SCANNER_BUFFER_SIZE")` - * gates the auto-growth), so the DEFAULT (unset) path can never hit - * `bufio.ErrTooLong` for a file read this way — no single statement can be bigger - * than the whole file. Every caller of `execMigrationBatch` mirrors exactly this - * Go call site (`ApplyMigrations`, `SeedGlobals`, `applySchemaFiles` all read their - * file via `NewMigrationFromFile`/`parseFile`), so this is the correct single home - * for the check (CLI-1958 review) rather than duplicating it per caller. - * - * Fails on the FIRST raw (pre-trim) statement - * whose byte length exceeds the effective limit (`Math.max(configured, - * GO_SCANNER_START_BUF_SIZE)` — see that constant's comment). `"After statement - * <n>: …"` reports the count and RAW text of the last statement successfully - * scanned BEFORE the oversized one: the scan loop body - * never runs for the failing scan, so the last token still holds whatever the - * previous iteration left it as (`""` if the very first statement is already - * oversized) — verified empirically. This is a "read"-phase failure (returns - * before a suggestion is ever set), so it carries no - * suggestion, same as the file-open failure above. + * Enforces `SUPABASE_SCANNER_BUFFER_SIZE` as the per-statement scan limit — a no-op unless the + * env var is set — as the single home for this check shared by every `execMigrationBatch` caller. * - * `projectEnv`, when given, is the caller's already-loaded `loadProjectEnv` map: - * `loadNestedEnv` `os.Setenv`s every project-`.env` - * key that isn't already in the shell env BEFORE `ParseDatabaseConfig` returns — i.e. - * before ANY command body (including this scan) runs — so `viper.AutomaticEnv()` sees a - * `supabase/.env`-only `SUPABASE_SCANNER_BUFFER_SIZE` exactly like a real shell-exported - * one. Defaults to `{}` for callers that haven't threaded a project-env map through - * (shell-only, same as before this parameter existed). + * Fails on the first raw (pre-trim) statement exceeding the effective limit, reporting the count + * and raw text of the last statement scanned before it; this is a "read"-phase failure, so it + * carries no suggestion. `projectEnv`, when given, is the caller's already-loaded project-env map, + * so a `supabase/.env`-only override is honored here too. */ export const checkScannerBufferSize = <E>( content: string, @@ -390,31 +268,23 @@ export const checkScannerBufferSize = <E>( process.env["SUPABASE_SCANNER_BUFFER_SIZE"] ?? projectEnv["SUPABASE_SCANNER_BUFFER_SIZE"]; if (raw === undefined) return Effect.void; const configuredLimit = parseScannerBufferSize(raw); - // `configuredLimit <= 0` covers both an explicit non-positive size and an unparseable - // value (e.g. a bare "5M", see `GO_DEFAULT_MAX_SCANNER_CAPACITY`'s comment) — Go's - // `viper.GetSizeInBytes` collapses all of these to `0` too, and `parser.Split` then - // falls back to its OWN hardcoded default cap, not to "no limit". + // Covers both an explicit non-positive size and an unparseable value (see + // `GO_DEFAULT_MAX_SCANNER_CAPACITY` above) — both fall back to the hardcoded default cap, not + // to "no limit". const limit = configuredLimit > 0 ? Math.max(configuredLimit, GO_SCANNER_START_BUF_SIZE) : GO_DEFAULT_MAX_SCANNER_CAPACITY; - // Go's suggestion reports `maxbuf>>10` — the EFFECTIVE cap actually passed to - // `scanner.Buffer`, which is the raw configured value - // (even below the `GO_SCANNER_START_BUF_SIZE` floor — the floor only affects when - // `bufio.ErrTooLong` can fire, never the number Go prints) when positive, or the - // hardcoded default once Go has fallen back to it. + // The reported limit is the raw configured value, even below the `GO_SCANNER_START_BUF_SIZE` + // floor (which only affects when the too-long error can fire, not the number reported), or the + // hardcoded default once that's been fallen back to. const reportedLimit = configuredLimit > 0 ? configuredLimit : GO_DEFAULT_MAX_SCANNER_CAPACITY; let emitted = 0; let lastRaw = ""; for (const token of splitSqlTokens(content)) { - // A delimiter-terminated token is found (and emitted) by `parser.Split`'s scan in - // the SAME `Scan()` call that fills the buffer to capacity — before Go's too-long - // check is ever reached — so a token exactly AT `limit` still succeeds; only - // strictly-over fails (`>`). The trailing, unterminated token (only ever the LAST - // one `splitSqlTokens` returns, if any — see `SplitSqlToken.terminated`) - // has no delimiter to find: once the buffer fills to `limit` bytes without one, the - // too-long check fires immediately, without Go ever attempting the extra `Read()` - // that would reveal real EOF — so a trailing token AT `limit` already fails (`>=`). + // A terminated token exactly at `limit` still succeeds (only strictly-over fails, `>`); an + // unterminated trailing token at `limit` already fails (`>=`), since there's no delimiter left + // to find once the buffer fills without one. const tooLong = token.terminated ? utf8ByteLength(token.raw) > limit : utf8ByteLength(token.raw) >= limit; @@ -427,14 +297,9 @@ export const checkScannerBufferSize = <E>( ), ); } - // `token = scanner.Text()` runs on EVERY successful - // `Scan()` — unconditionally, before the `len(trim) > 0` gate that decides whether to - // `append` to `stats` — so `token` (and therefore the eventual `bufio.ErrTooLong` - // message) reflects the last RAW text scanned even when that statement trimmed to - // empty and was never appended (e.g. a lone `;` immediately before an oversized - // statement reports "After statement N: ;", not a blank token). `emitted` mirrors - // `len(stats)` (append-gated); `lastRaw` must NOT share that gate — verified - // against the Go source directly (review CLI-1958 round 18). + // `lastRaw` updates on every scanned token, even ones that trim to empty and don't advance + // `emitted` — so a lone `;` right before an oversized statement reports it accurately instead + // of a blank token. lastRaw = token.raw; if (token.trimmed.length > 0) { emitted += 1; @@ -444,15 +309,11 @@ export const checkScannerBufferSize = <E>( }; /** - * Port of `markError`: renders a `^` caret - * line under the error position of the failing statement. `pos` is the server's - * 1-based error cursor (`pgErr.Position`); Go consumes it against **byte** - * lengths (`len(line)` on a Go string counts UTF-8 bytes) and pads the caret with - * `pos-1` space bytes, so multibyte statements shift the caret exactly as Go does - * (verified empirically against the Go implementation). The caret line REPLACES - * every line after the error line (`append(lines[:j+1], caret)` truncates - * the tail). Position 0 (absent), a position past the end of the statement, or - * one landing exactly on a line break leave the statement untouched. + * Renders a `^` caret line under the error position of a failing statement. `pos` is the + * server's 1-based error cursor, measured in UTF-8 bytes, so multibyte statements shift the caret + * correctly. The caret line replaces every line after the error line. Position 0 (absent), a + * position past the end of the statement, or one landing exactly on a line break leave the + * statement untouched. */ export const markError = (stat: string, pos: number): string => { const lines = stat.split("\n"); @@ -471,23 +332,19 @@ export const markError = (stat: string, pos: number): string => { return stat; }; -// `typeNamePattern`: extracts the type name from -// PostgreSQL error messages like `type "ltree" does not exist`. Unanchored, so it -// matches identically inside the rendered `ERROR: … (SQLSTATE …)` head line. +// Extracts the type name from PostgreSQL error messages like `type "ltree" does not exist`. +// Unanchored, so it also matches inside the rendered `ERROR: … (SQLSTATE …)` head line. const TYPE_NAME_PATTERN = /type "([^"]+)" does not exist/; /** - * Mirrors `MigrationFile.ExecBatch` error context: - * on a failed statement, render the `^` caret under the server-reported error - * position, the `Detail` line when present, the SQLSTATE-42704 extension hint, - * then `At statement: <index>` and the (caret-marked) statement text. The - * structured `detail`/`position` fields are only set by the driver for server - * ErrorResponses, mirroring `errors.As(err, &pgErr)` gate. + * Renders a failed statement's error context: the `^` caret under the server-reported error + * position, the `Detail` line when present, the SQLSTATE-42704 extension hint, then + * `At statement: <index>` and the caret-marked statement text. The structured `detail`/`position` + * fields are only set by the driver for real server error responses. * - * Exported so any caller that runs a raw batch-equivalent statement set outside a real - * migration file (e.g. `resetRecreateDatabases`'s PG14 `DROP`/`CREATE DATABASE` - * statements, which are also routed through this exact formatter) gets - * the same rich error context instead of the bare driver error. + * Exported so any caller that runs a raw batch-equivalent statement set outside a migration file + * (e.g. `resetRecreateDatabases`'s PG14 `DROP`/`CREATE DATABASE` statements) gets the same rich + * error context instead of the bare driver error. */ export const formatExecBatchError = (e: DbExecError, index: number, stat: string): Error => { const marked = markError(stat, e.position ?? 0); @@ -508,7 +365,7 @@ export const formatExecBatchError = (e: DbExecError, index: number, stat: string return formattedExecBatchFailure(`${errorMessage(e)}\n${msg.join("\n")}`, e); }; -/** Retains the server ErrorResponse after adding Go-compatible statement context. */ +/** Retains the server error after adding statement context to the message. */ const FormattedExecBatchDbErrorId: unique symbol = Symbol("FormattedExecBatchDbError"); type FormattedExecBatchFailure = Error & { readonly [FormattedExecBatchDbErrorId]: DbExecError; @@ -526,34 +383,21 @@ const formattedExecBatchDbError = (error: unknown): DbExecError | undefined => { }; /** - * Runs a single migration/seed file's statements (plus the optional history insert). - * Statements run inside an implicitly transactional extended-protocol batch, - * except pipeline-incompatible ones - * (`isPipelineIncompatible` — `CREATE INDEX CONCURRENTLY`, `VACUUM`, …) which - * cannot run in a transaction block: the open batch is flushed (committed), the - * statement runs standalone, then batching resumes (supabase/cli#5156). The history - * insert goes in the final batch, so the migration is recorded only after every - * statement succeeds. On a stepped-down session ({@link DbSession.restoreRoleSql}) - * the `postgres` role is re-asserted immediately after each top-level role-reverting - * statement ({@link revertsToLoginRole}) and again at the end of the file before - * the history insert (supabase/cli#6236), so the whole file behaves as on a password - * session and leaves the session role-clean for whatever runs next. Injected restores - * never shift `At statement: N` and are never recorded in the history row. - * A file with no such statements uses one batch and one Sync. - * Pg-delta files whose first line is `-- pg-delta: transaction=false` instead run - * every statement sequentially without a CLI-owned transaction. This keeps their - * session preamble, nontransactional action, and cleanup on the same connection. + * Runs a single migration/seed file's statements, plus the optional history insert. + * + * Statements batch inside an implicit transaction, except pipeline-incompatible ones + * ({@link isPipelineIncompatible}), which flush the batch, run standalone, then resume it. The + * history insert is part of the final batch, so it's recorded only once every statement + * succeeds. On a stepped-down session, the `postgres` role is re-asserted after each top-level + * role-reverting statement ({@link revertsToLoginRole}) and again before the history insert — + * applies to every file runner, including `seedGlobals`, and never shifts `At statement: N`. * - * Does NOT create the history table and does not unconditionally `RESET ALL` — - * those are the migration-apply path's responsibility, so ordinary role/globals - * files (`seedGlobals`) stay reset-free. (The role re-assert above is not - * session hygiene but a connection-layer invariant, so it applies to every file - * runner, globals included.) The one exception is best-effort - * cleanup after a failed pg-delta no-transaction file. When `forceNoVersion` is set - * the history insert is skipped regardless of filename. + * A file starting `-- pg-delta: transaction=false` instead runs every statement sequentially on + * one connection, with a best-effort session reset on failure. * - * `projectEnv` is forwarded to {@link checkScannerBufferSize} — see its own doc comment - * for why a project-`.env`-only `SUPABASE_SCANNER_BUFFER_SIZE` must be visible here too. + * Does not create the history table or unconditionally `RESET ALL` (caller responsibility). + * `forceNoVersion` skips the history insert; `projectEnv` is forwarded to + * {@link checkScannerBufferSize}. */ const execMigrationBatch = <E>( session: DbSession, @@ -566,38 +410,18 @@ const execMigrationBatch = <E>( projectEnv: Readonly<Record<string, string>> = {}, ): Effect.Effect<void, E | DbConnectError> => Effect.gen(function* () { - // Receives an already-read/parsed file (the read - // happens earlier, which wraps the open - // failure as `"failed to open migration file: %w"`) — - // so a read failure here is a DIFFERENT error class than a statement-execution - // failure below, and needs the same prefix so stderr/JSON errors don't surface - // the bare platform error text. Tagged "read" so callers that attach a suggestion - // only around execution failures can tell the two apart. + // A read failure here is a different error class than a statement-execution failure below, + // tagged "read" so callers that attach a suggestion only around execution failures can tell + // the two apart. // - // The workdir-RELATIVE form `[db.migrations].schema_paths`/ - // `[db.seed].sql_paths` already resolved to at config-load time is what should be - // reported. This - // module deliberately never `process.chdir`s (only `bootstrap` does, as its own - // documented one-off), so callers must pass an ABSOLUTE `migrationPath` for the - // real read to work — but that means the platform error's embedded path is - // absolute too. When it differs from `displayPath` (the caller's relative - // path), substitute it in so the wrapped message still reports the - // relative form, not a leaked local temp/absolute path. + // This module never `process.chdir`s, so callers must pass an absolute `migrationPath` — but + // that means the platform error's embedded path is absolute too. When it differs from + // `displayPath` (the caller's relative path), substitute it in so the wrapped message reports + // the relative form instead. // - // Known residual delta (CLI-1958 review): `readFileString` decodes via `TextDecoder` - // with `fatal: false` (the Effect `FileSystem` default), so an invalid-UTF-8 byte - // sequence in the file is lossily replaced with U+FFFD before it ever reaches - // `splitAndTrim`/`session.exec`. `parseFile` instead scans the raw byte - // stream and preserves those bytes verbatim into the statement strings it sends to - // PostgreSQL. Reading raw bytes here (`fs.readFile`) and mapping them 1:1 into a - // "binary string" would fix the split/parse stage, but the fix dies at the wire: the - // shared `pg`/`pg-protocol` layer this session is built on unconditionally UTF-8- - // encodes query text before writing it (`pg-protocol/dist/serializer.js` — - // `buff.write(string, offset, 'utf-8')`, no raw-byte send API), so ANY string - // representation still gets re-mangled at that boundary, just differently. Faithful - // byte parity would require patching that shared wire-serializer — infrastructure - // every DB command's `session.exec` funnels through, not something scoped to - // this file's read path — so it's flagged here rather than "fixed" underneath it. + // Known limitation: invalid UTF-8 bytes in the file are lossily replaced with U+FFFD before + // reaching `session.exec`, since the underlying `pg` wire protocol always re-encodes query + // text as UTF-8 regardless of how the bytes are read here. const content = yield* fs.readFileString(migrationPath).pipe( Effect.mapError((error) => { const message = relativizeErrorMessage(errorMessage(error), migrationPath, displayPath); @@ -605,19 +429,12 @@ const execMigrationBatch = <E>( }), ); - // Still `NewMigrationFromFile`/`parseFile`'s territory — - // `parser.SplitAndTrim` runs INSIDE `parseFile`, before `ExecBatch` ever sees the - // statements, so a `SUPABASE_SCANNER_BUFFER_SIZE` violation is a "read"-phase - // failure like the open failure above, not an "exec"-phase one. See - // `checkScannerBufferSize`'s comment for why this is a no-op unless the env var - // is explicitly set. + // A scanner-buffer violation is a "read"-phase failure like the open failure above, not + // "exec" — this call is a no-op unless the env var is explicitly set. yield* checkScannerBufferSize(content, mapError, projectEnv); - // Everything below mirrors `(*MigrationFile).ExecBatch` (`pkg/migration/file.go`), - // which runs against an already-read file — so every failure from here on is an - // execution failure, tagged "exec" (as opposed to the "read" failure above, which - // mirrors `NewMigrationFromFile`). Only execution failures get `CmdSuggestion`; - // callers rely on this tag to replicate that split. + // Every failure from here on is an execution failure, tagged "exec" (vs. the "read" failures + // above) — only execution failures get a suggestion attached; callers rely on this tag. yield* Effect.gen(function* () { const { statements, transactionMode } = parseMigrationContent(content); const filename = path.basename(migrationPath); @@ -673,25 +490,20 @@ const execMigrationBatch = <E>( ), ); - // The pg-delta directive is file-level execution metadata. Run the complete - // sequence on this session without adding transaction boundaries so session - // settings remain active for the nontransactional action. History is recorded - // only after every statement succeeds. A failed sequence gets a best-effort - // session reset because the generated trailing RESET ALL may not have run yet. + // Session settings must remain active for the nontransactional action, so no transaction + // boundary is added around this branch. if (transactionMode === "none") { return yield* executeSequentially("RESET ALL"); } - // A headerless file with authored transaction boundaries owns those semantics. - // Execute the statements exactly as written, clean up a failed authored - // transaction, and only send the history insert after every statement succeeds. + // A file with authored transaction boundaries owns those semantics; execute statements + // exactly as written and only record history after they all succeed. if (statements.some(hasTransactionControl)) { return yield* executeSequentially("ROLLBACK"); } - // `executed` is the global statement index of the next statement to run, so the - // error context stays accurate across flushed batches and standalone statements - // (Go threads the same counter through `ExecBatch`). + // The global statement index of the next statement to run, so error context stays accurate + // across flushed batches and standalone statements. let pending: Array<string> = []; let executed = 0; @@ -783,11 +595,10 @@ const execMigrationBatch = <E>( }); /** - * Go's per-migration connection reset: `RESET ALL` clears any - * connection settings a prior statement on the same session may have changed - * (e.g. `set_config('search_path', …)`), run before each migration's `ExecBatch`. - * Only the migration-apply path does this — `SeedGlobals` (role/globals files) - * must NOT, so this is a caller responsibility, never inside `execMigrationBatch`. + * Clears any connection settings a prior statement on the same session may have changed (e.g. + * `set_config('search_path', …)`), run before each migration's batch. Only the migration-apply + * path does this — `seedGlobals` (role/globals files) must not, so this is a caller + * responsibility, never inside `execMigrationBatch`. */ const resetConnectionState = <E>( session: DbSession, @@ -797,15 +608,13 @@ const resetConnectionState = <E>( /** * Applies a single migration file to the connected database and records it in - * `supabase_migrations.schema_migrations`. Mirrors `migration.ApplyMigrations` - * for one file (`pkg/migration/apply.go` + `(*MigrationFile).ExecBatch`): `RESET ALL` - * first to clear any session state leaked by a prior file (e.g. - * `SET default_transaction_read_only = on`) before the history-table DDL, then create - * the history table, then run the file's statements + the history insert. + * `supabase_migrations.schema_migrations`: `RESET ALL` first to clear any session state leaked by + * a prior file, then create the history table, then run the file's statements plus the history + * insert. * - * `mapError` lets the caller tag the failure (e.g. `PgDeltaDeclarativeApplyError`). - * Statement failures also expose their structured PostgreSQL error so local replay - * can classify precise SQLSTATE/object combinations without parsing formatted context. + * `mapError` lets the caller tag the failure (e.g. `PgDeltaDeclarativeApplyError`). Statement + * failures also expose their structured PostgreSQL error so local replay can classify precise + * SQLSTATE/object combinations without parsing formatted context. */ export const applyMigrationFile = <E>( session: DbSession, @@ -828,10 +637,9 @@ export const applyMigrationFile = <E>( }); /** - * Applies a list of pending migration files, mirroring Go's - * `migration.ApplyMigrations`: create the - * history table once when there is anything to apply, then for each file emit - * `Applying migration <name>...` to stderr, `RESET ALL`, and run it transactionally. + * Applies a list of pending migration files: creates the history table once when there is + * anything to apply, then for each file emits `Applying migration <name>...` to stderr, resets + * connection state, and runs it transactionally. */ export const applyMigrations = <E>( session: DbSession, @@ -844,25 +652,20 @@ export const applyMigrations = <E>( const output = yield* Output; if (pending.length === 0) return; yield* createMigrationTable(session).pipe(Effect.mapError((e) => mapError(errorMessage(e)))); - // Sorted by version, not by file name: `db push` has applied in version - // order since supabase/cli#6038, so callers that hand over a name-ordered - // listing (`db reset`, the shadow-database replay) would otherwise apply the - // same files in the opposite order (#6036). Idempotent for callers that - // already sorted. + // Sorted by version, not file name, so callers passing a name-ordered listing (`db reset`, + // the shadow-database replay) apply files in the same order `db push` does. Idempotent for + // callers that already sorted. for (const migrationPath of sortMigrationPathsByVersion(pending)) { yield* output.raw(`Applying migration ${path.basename(migrationPath)}...\n`, "stderr"); - // Reset connection state per migration before running the batch. yield* resetConnectionState(session, mapError); yield* execMigrationBatch(session, fs, path, migrationPath, mapError, false); } }); /** - * Applies custom-role / globals files: - * for each file emit `Seeding globals from - * <name>...` to stderr and run it transactionally WITHOUT inserting a migration - * history row, WITHOUT creating the history table, and WITHOUT - * `RESET ALL`. + * Applies custom-role/globals files: for each file, emits `Seeding globals from <name>...` to + * stderr and runs it transactionally, without inserting a migration history row, creating the + * history table, or running `RESET ALL`. */ export const seedGlobals = <E>( session: DbSession, @@ -880,23 +683,15 @@ export const seedGlobals = <E>( }); /** - * Runs one SQL file's statements transactionally, WITHOUT `seedGlobals`'s - * per-file `Seeding globals from <name>...` stderr message, WITHOUT inserting a - * migration history row, WITHOUT creating the history table, and WITHOUT `RESET - * ALL` (same batching semantics as `seedGlobals`, `forceNoVersion: true`, - * just silent). `initSchema`/`InitSchema14`/`ApplyApiPrivileges`-equivalent callers - * run a batch DIRECTLY on an in-memory SQL constant — bypassing - * `seedGlobals`'s message — so reusing `seedGlobals` for those - * would print an extra, unwanted line. Callers write the in-memory SQL - * constant to a temp file first (this module only reads files, like - * `execMigrationBatch`'s other callers). + * Runs one SQL file's statements transactionally, without `seedGlobals`'s per-file stderr + * message, history row, history table, or `RESET ALL` — same batching semantics as `seedGlobals` + * with `forceNoVersion: true`, just silent. Used by callers that run a batch directly on an + * in-memory SQL constant (written to a temp file first, since this module only reads files) and + * would otherwise print an unwanted extra line. * - * `displayPath`, when given, is the path a read-failure's wrapped message should - * report instead of `filePath` — see `execMigrationBatch`'s comment on why the two - * can differ (an absolute path is required for the real read, but Go's equivalent - * error names the workdir-relative form). `projectEnv`, when given, is forwarded to - * {@link checkScannerBufferSize} via `execMigrationBatch` — see that helper's doc - * comment. + * `displayPath`, when given, is the path a read-failure's wrapped message should report instead + * of `filePath` — see `execMigrationBatch`'s comment on why the two can differ. `projectEnv`, + * when given, is forwarded to {@link checkScannerBufferSize} via `execMigrationBatch`. */ export const execSqlFile = <E>( session: DbSession, @@ -910,42 +705,23 @@ export const execSqlFile = <E>( execMigrationBatch(session, fs, path, filePath, mapError, true, displayPath, projectEnv); /** - * Applies the EXPERIMENTAL declarative schema-files branch. - * Reads `[db.migrations] - * schema_paths` (already resolved to its config-load form — supabase-joined when - * relative, verbatim when absolute) via the shared glob - * ({@link sqlFilesGlob}), then runs each matched file's statements with - * {@link execSqlFile} in glob order — no history table, no history row, and no - * `RESET ALL` between files: connection state is never reset here (a stepped-down - * session's role re-assert at each file's end is the one exception — see - * {@link DbSession.restoreRoleSql}). + * Applies the experimental declarative schema-files branch. Reads `schema_paths` via the shared + * glob ({@link sqlFilesGlob}), then runs each matched file's statements with {@link execSqlFile} + * in glob order — no history table, no history row, and no `RESET ALL` between files (a + * stepped-down session's role re-assert at each file's end is the one exception). * - * Callers gate the call on the three-conjunct condition (`--experimental` + no resolved - * version + pg-delta NOT enabled) themselves — this function only performs - * the branch's body, mirroring `applySchemaFiles`'s own signature (it never re-checks the - * gate). It is the caller's responsibility to skip `applyMigrations` entirely when - * this is called (`if`/`else if` is mutually exclusive). + * Callers gate the call on `--experimental` + no resolved version + pg-delta disabled themselves; + * this function only performs the branch's body and never re-checks the gate. * - * Faithfully reproduces two undocumented, unfixed-upstream quirks that are load-bearing - * for the strict 1:1 contract (CLI-1958): - * - **Empty `schema_paths` (the `supabase init` default) silently applies nothing** and - * returns success — globbing zero patterns returns no error, so - * `applySchemaFiles` returns success too. - * - **A PARTIAL glob failure is silently dropped**: per-pattern warnings are only - * surfaced (as the returned failure) when NO pattern matched anything at all; - * once at least one file is found, every other - * pattern's warning is discarded — unlike the seed path's `WARN:` line. + * Two behaviors that would otherwise look like bugs are intentional: + * - An empty `schema_paths` (the `supabase init` default) silently applies nothing and succeeds. + * - A partial glob failure is silently dropped: per-pattern warnings only surface when no pattern + * matched anything at all. * - * On a per-file EXECUTION failure only, attaches a `CmdSuggestion = "See schema file: - * <Bold(fp)>"` via the optional second argument of `mapError`. A file-READ - * failure returns before the suggestion is - * ever set, so it must NOT carry the suggestion — {@link execSqlFile}'s `mapError` - * receives the `"read"`/`"exec"` phase precisely so this call site can tell them apart. + * On a per-file execution failure, attaches `"See schema file: <file>"` as a suggestion via the + * optional second argument of `mapError`; a file-read failure carries no suggestion. * - * `projectEnv` is the caller's already-loaded `loadProjectEnv` map, forwarded to - * {@link checkScannerBufferSize} (via `execSqlFile`/`execMigrationBatch`) so a - * `SUPABASE_SCANNER_BUFFER_SIZE` set only in `supabase/.env` is honored here too — - * see that helper's doc comment. + * `projectEnv` is forwarded to {@link checkScannerBufferSize} via `execSqlFile`. */ export const applySchemaFiles = <E>( session: DbSession, @@ -959,8 +735,8 @@ export const applySchemaFiles = <E>( Effect.gen(function* () { const { files, warnings } = yield* sqlFilesGlob(fs, path, schemaPaths, workdir); if (files.length === 0) { - // Go: `if len(declared) == 0 { return err }` — `err` is `nil` when there were no - // patterns to glob at all, and the joined per-pattern warnings otherwise. + // Succeeds when there were no patterns to glob at all; fails with the joined per-pattern + // warnings otherwise. if (warnings.length > 0) { return yield* Effect.fail(mapError(warnings.join("\n"))); } @@ -968,9 +744,9 @@ export const applySchemaFiles = <E>( } for (const file of files) { const absolutePath = path.isAbsolute(file) ? file : path.join(workdir, file); - // `file` is already `fp` form (workdir-relative when the declared pattern - // was relative, verbatim when absolute) — pass it through as the display path so - // a read failure reports it instead of the `absolutePath` the real read needs. + // `file` is workdir-relative when the declared pattern was relative, verbatim when + // absolute — passed through as the display path so a read failure reports it instead of + // the `absolutePath` the real read needs. yield* execSqlFile( session, fs, diff --git a/apps/cli/src/command-internal/migration-apply.unit.test.ts b/apps/cli/src/command-internal/migration-apply.unit.test.ts index 9e183196b3..e1b33615b0 100644 --- a/apps/cli/src/command-internal/migration-apply.unit.test.ts +++ b/apps/cli/src/command-internal/migration-apply.unit.test.ts @@ -141,11 +141,6 @@ describe("applyMigrationFile", () => { const execs = executedSql(calls); expect(execs).toContain("CREATE SCHEMA IF NOT EXISTS supabase_migrations"); expect(execs).toContain("RESET ALL"); - // The history-table setup scopes lock_timeout to its own transaction - // (SET LOCAL), so it reverts on COMMIT and never leaks into the migration's - // statements — matching Go's implicit ExecBatch transaction. - // RESET ALL runs FIRST — before the history-table setup transaction — so a - // session default leaked by a prior migration is cleared before this DDL. expect(execs[0]).toBe("RESET ALL"); const firstBegin = execs.indexOf("BEGIN"); const setupCommit = execs.indexOf("COMMIT"); @@ -153,15 +148,12 @@ describe("applyMigrationFile", () => { expect(firstBegin).toBe(1); expect(setLocal).toBeGreaterThan(firstBegin); expect(setLocal).toBeLessThan(setupCommit); - // The migration's statements and history insert share one implicitly - // transactional extended-protocol batch. const migrationBatch = calls.find((call) => call.kind === "batch"); expect(migrationBatch?.statements?.map(({ sql }) => sql)).toEqual([ "ALTER TABLE a ADD COLUMN b int", "CREATE INDEX i ON a(b)", expect.stringContaining("supabase_migrations.schema_migrations"), ]); - // History insert carries version, name, and the statements array. const insert = migrationBatch?.statements?.at(-1); expect(insert?.sql).toContain("supabase_migrations.schema_migrations"); expect(insert?.params).toEqual([ @@ -209,7 +201,6 @@ describe("applyMigrationFile", () => { Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); expect(calls.filter((call) => call.kind === "batch")).toHaveLength(1); - // Go's ExecBatch appends the failing statement number + text for context. if (Exit.isFailure(exit)) { const msg = JSON.stringify(exit.cause); expect(msg).toContain("At statement: 0"); @@ -304,11 +295,6 @@ describe("applyMigrationFile", () => { it.effect( "wraps a read failure with Go's parse-file error text (Go NewMigrationFromFile parity)", () => { - // `NewMigrationFromFile`/`parseFile` wraps the open failure as - // `"failed to open migration file: %w"` before - // `ApplyMigrations`/`applySchemaFiles` ever get a chance to attach a - // `CmdSuggestion` — a read failure here must carry the same prefix, not the bare - // platform error text. const dir = mkdtempSync(join(tmpdir(), "apply-read-fail-")); const missingFile = join(dir, "20240101120000_missing.sql"); const { session } = fakeSession(); @@ -342,8 +328,6 @@ describe("applyMigrationFile", () => { const execs = executedSql(calls); const concurrently = "CREATE INDEX CONCURRENTLY a_idx ON a(id)"; expect(execs).toContain(concurrently); - // The CONCURRENTLY statement must not run inside an implicit batch - // transaction, so the compatible statements are flushed on each side. const batches = calls.filter((call) => call.kind === "batch"); expect(batches).toHaveLength(2); expect(batches[0]?.statements?.map(({ sql }) => sql)).toEqual([ @@ -353,7 +337,6 @@ describe("applyMigrationFile", () => { "ALTER TABLE a ENABLE ROW LEVEL SECURITY", expect.stringContaining("supabase_migrations.schema_migrations"), ]); - // The migration is still recorded once every statement succeeds. const insert = batches[1]?.statements?.at(-1); expect(insert?.params?.[0]).toBe("20240101120000"); rmSync(dir, { recursive: true, force: true }); @@ -383,8 +366,6 @@ describe("applyMigrationFile", () => { const action = execs.indexOf("DROP SUBSCRIPTION app_events"); const cleanup = execs.lastIndexOf("RESET ALL"); - // The history-table setup owns the only CLI transaction. Pg-delta's - // preamble, action, and cleanup then run sequentially on this session. expect(execs.filter((sql) => sql === "BEGIN")).toHaveLength(1); expect(execs.filter((sql) => sql === "COMMIT")).toHaveLength(1); expect(set).toBeGreaterThan(setupCommit); @@ -448,11 +429,9 @@ describe("applyMigrationFile", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const msg = JSON.stringify(exit.cause); - // Index 1: the leading `create table a` (index 0) committed in its own batch first. expect(msg).toContain("At statement: 1"); expect(msg).toContain("CREATE INDEX CONCURRENTLY a_idx ON a(id)"); } - // The migration version is not recorded when a statement fails. expect( executedSql(calls).some((sql) => sql.includes("INSERT INTO supabase_migrations.schema_migrations"), @@ -473,8 +452,6 @@ describe("applyMigrationFile", () => { Effect.tap(() => Effect.sync(() => { const execs = calls.filter((call) => call.kind === "exec").map((call) => call.sql); - // One BEGIN/COMMIT belongs to history-table setup; the other pair is - // exactly the authored boundary, with no nested migration wrapper. expect(execs.filter((sql) => sql === "BEGIN")).toHaveLength(2); expect(execs.filter((sql) => sql === "COMMIT")).toHaveLength(2); expect(execs).toContain("SET LOCAL check_function_bodies = off"); @@ -503,9 +480,6 @@ describe("applyMigrationFile", () => { return run(session, file).pipe( Effect.tap(() => Effect.sync(() => { - // `ROLLBACK TO SAVEPOINT` is not an authored boundary, so the file keeps the - // CLI-managed transaction — which is the single implicitly transactional - // batch (one Sync), carrying the history insert as its last statement. const batches = calls.filter((call) => call.kind === "batch"); expect(batches).toHaveLength(1); expect(batches[0]?.statements?.map(({ sql }) => sql)).toEqual([ @@ -515,7 +489,6 @@ describe("applyMigrationFile", () => { "SELECT 1", expect.stringContaining("supabase_migrations.schema_migrations"), ]); - // No standalone history insert: it rides the same batch as the statements. expect( calls.filter((call) => call.kind === "query" && call.params !== undefined), ).toHaveLength(0); @@ -545,9 +518,6 @@ describe("applyMigrationFile", () => { ); }); - // Passwordless remote sessions step down from the temp login role via - // `SET SESSION ROLE postgres`; a migration's own `RESET ROLE` reverts to the - // login role, which used to fail the history insert with 42501 (supabase/cli#6236). it.effect( "re-asserts the stepped-down role between the statements and the history insert", () => { @@ -558,9 +528,6 @@ describe("applyMigrationFile", () => { return run(session, file).pipe( Effect.tap(() => Effect.sync(() => { - // The restore is injected right after the trailing RESET ROLE (the - // end-of-file restore dedupes away), so the insert runs as postgres — - // and the committed session ends role-clean for the next file. const batch = calls.find((call) => call.kind === "batch"); expect(batch?.statements?.map(({ sql }) => sql)).toEqual([ "set role repro_writer", @@ -569,7 +536,6 @@ describe("applyMigrationFile", () => { "SET SESSION ROLE postgres", expect.stringContaining("supabase_migrations.schema_migrations"), ]); - // The ledger row records only the file's own statements. expect(batch?.statements?.at(-1)?.params?.[2]).toEqual([ "set role repro_writer", "create table t (id int)", @@ -639,7 +605,6 @@ describe("applyMigrationFile", () => { Effect.tap((error) => Effect.sync(() => { expect(error.message).toContain("permission denied for schema supabase_migrations"); - // The CLI-internal restore op must not shift Go's `At statement: N`. expect(error.message).toContain("At statement: 1"); expect(error.message).toContain("INSERT INTO supabase_migrations.schema_migrations"); rmSync(dir, { recursive: true, force: true }); @@ -669,9 +634,6 @@ describe("applyMigrationFile", () => { }); it.effect("keeps the deferred-failure index when a restore op is appended", () => { - // Mirrors "defaults a deferred batch failure to the migration history - // statement": the restore op between the statements and the insert must not - // shift the deferred (post-Sync) index either. const dir = mkdtempSync(join(tmpdir(), "apply-")); const file = join(dir, "20240101120000_deferred.sql"); writeFileSync(file, "SELECT 1;"); @@ -716,8 +678,6 @@ describe("applyMigrationFile", () => { }); it.effect("keeps the insert index when the final batch holds only the trailing ops", () => { - // A trailing CONCURRENTLY statement empties `pending`, so the final batch is - // just [restore, insert] — the index math must still report the file's count. const dir = mkdtempSync(join(tmpdir(), "apply-")); const file = join(dir, "20240101120000_fail.sql"); writeFileSync(file, "SELECT 1;\nCREATE INDEX CONCURRENTLY i ON a(id);"); @@ -738,8 +698,6 @@ describe("applyMigrationFile", () => { }); it.effect("restores postgres immediately after a mid-file RESET ROLE, silently", () => { - // Statements after the reset now run as postgres again (avallete's #6246 - // review), so the old drift WARN is gone — there is no drift left to surface. const dir = mkdtempSync(join(tmpdir(), "apply-")); const file = join(dir, "20240101120000_reset_role.sql"); writeFileSync(file, "set role r;\nreset role;\nselect 1;"); @@ -801,8 +759,6 @@ describe("applyMigrationFile", () => { }); it.effect("emits exactly one restore when a no-transaction file ends in a revert", () => { - // Sequential path: the injected restore after the trailing `reset role` - // makes the end-of-file restore redundant, so it must dedupe away. const dir = mkdtempSync(join(tmpdir(), "apply-")); const file = join(dir, "20240101120000_seq_reset.sql"); writeFileSync(file, "-- pg-delta: transaction=false\nset role r;\nreset role;"); @@ -823,8 +779,6 @@ describe("applyMigrationFile", () => { }); it.effect("keeps the deferred-failure index when mid-file restores were injected", () => { - // The `injectedBefore[raw] ?? injected` fallback only matters when the - // deferred (post-Sync) index lands past the ops array AND injections exist. const dir = mkdtempSync(join(tmpdir(), "apply-")); const file = join(dir, "20240101120000_deferred.sql"); writeFileSync(file, "set role r;\nreset role;\nselect 1;"); @@ -880,8 +834,6 @@ describe("applyMigrationFile", () => { Effect.flip, Effect.tap((error) => Effect.sync(() => { - // The injected op inherits `reset role`'s index and shows the SQL that - // actually failed, so debugging lands on the right line of the file. expect(error.message).toContain("At statement: 1"); expect(error.message).toContain("SET SESSION ROLE postgres"); rmSync(dir, { recursive: true, force: true }); @@ -936,8 +888,7 @@ describe("hasTransactionControl", () => { }); describe("migration failure rendering (Go ExecBatch parity)", () => { - // Byte-exact ports of `(*MigrationFile).ExecBatch` error layout: - // `<pg error>\n[Detail]\n[42704 hint]\n` + // Error message layout: `<pg error>\n[Detail]\n[42704 hint]\n` then // `At statement: <i>\n<caret-marked statement>`. const failing = ( sql: string, @@ -1001,8 +952,6 @@ describe("migration failure rendering (Go ExecBatch parity)", () => { }); it.effect("skips the hint when the SQLSTATE is not 42704", () => { - // Go gates the hint on `pgErr.Code == "42704"` in addition to the message - // pattern — a matching message under a different code must stay hint-free. const stat = "CREATE TABLE test (path ltree NOT NULL)"; return failing(stat, { message: 'ERROR: type "ltree" does not exist (SQLSTATE 42P01)', @@ -1051,9 +1000,6 @@ describe("migration failure rendering (Go ExecBatch parity)", () => { }); describe("markError", () => { - // Every expectation below is pinned against `markError` output, - // captured by running the Go function - // directly on the same inputs. it("places the caret under a mid-statement position", () => { expect(markError("create table t (col ltree)", 21)).toBe( "create table t (col ltree)\n ^", @@ -1084,16 +1030,12 @@ describe("markError", () => { }); it("consumes the position in UTF-8 bytes like Go, not characters", () => { - // "héllo" is 5 characters but 6 bytes; Go decrements the position by the - // BYTE length + 1 per line, so position 8 lands on column 1 of "world" - // (character counting would land on column 2). Verified against Go. expect(markError("héllo\nworld", 8)).toBe("héllo\nworld\n^"); expect(markError("sélect 1", 3)).toBe("sélect 1\n ^"); }); }); describe("isPipelineIncompatible", () => { - // Mirrors `TestIsPipelineIncompatible` (`pkg/migration/file_test.go`, supabase/cli#5156). const cases: ReadonlyArray<readonly [string, string, boolean]> = [ [ "create index concurrently", @@ -1172,8 +1114,6 @@ describe("seedGlobals", () => { const path = yield* Path.Path; yield* seedGlobals(session, fs, path, [file], (message) => new TestError({ message })); const execs = executedSql(calls); - // Go's SeedGlobals calls ExecBatch directly — no RESET ALL (that's only the - // migration-apply path) and no schema-migrations history insert. expect(execs).not.toContain("RESET ALL"); expect(execs).toContain("CREATE ROLE my_role"); expect( @@ -1187,8 +1127,8 @@ describe("seedGlobals", () => { }); it.effect("leaves a stepped-down session role-clean after a globals file", () => { - // Globals run before the vault upsert and the history-table DDL on the same - // session, so a `reset role` here must not leak the login role into them. + // Globals run on the same session as the later vault upsert and history-table DDL, so a role + // must not leak into them. const dir = mkdtempSync(join(tmpdir(), "globals-")); const file = join(dir, "roles.sql"); writeFileSync(file, "CREATE ROLE my_role;\nset role my_role;\nreset role;"); @@ -1214,16 +1154,9 @@ describe("applySchemaFiles", () => { it.effect( "reports a read failure with the workdir-relative path, not the absolute path used to read it (Go open supabase/... parity)", () => { - // Go opens the workdir-relative `fp` from `schema_paths` directly (its process - // cwd is always the workdir, `ChangeWorkDir`), so a read failure reports - // `open supabase/unreadable.sql: ...`. This module never `process.chdir`s, so - // the real read needs an absolute path — but the wrapped read-failure message - // must still show the relative `supabase/...` form, not that absolute path. An - // unreadable file (a real permission failure, not a missing-path one) reproduces - // a genuine read failure while still passing the glob's own stat/type check — - // `stat` only needs directory execute permission, not read permission on the - // file itself, so this still resolves as a `"File"` match, unlike a directory - // (which the glob would instead expand via `walkSqlFiles`). + // An unreadable file (permission denied, not missing) still passes the glob's own + // stat/type check, since that only needs directory execute permission, not read access to + // the file itself. const dir = mkdtempSync(join(tmpdir(), "schema-files-read-fail-")); const file = join(dir, "supabase", "unreadable.sql"); mkdirSync(join(dir, "supabase"), { recursive: true }); @@ -1258,18 +1191,11 @@ describe("applySchemaFiles", () => { it.effect( "rejects an oversized statement when SUPABASE_SCANNER_BUFFER_SIZE is configured (Go bufio.Scanner: token too long parity)", () => { - // `parser.Split` only enforces - // `SUPABASE_SCANNER_BUFFER_SIZE` when it's explicitly set — `parseFile` - // otherwise auto-grows the scanner to the real file's byte length, so the - // DEFAULT path can never hit `bufio.ErrTooLong`. With it set below a single - // statement's raw byte length, this fails with `bufio.Scanner: token too long` - // instead of silently applying the oversized statement — verified empirically - // (a `parser.SplitAndTrim` scratch probe). const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-")); mkdirSync(join(dir, "supabase"), { recursive: true }); const file = join(dir, "supabase", "big.sql"); - // A single, un-splittable statement whose raw text exceeds the 4096-byte floor - // (`bufio.Scanner` starts at that size regardless of the configured limit). + // Exceeds the 4096-byte floor the scanner always starts at, regardless of the configured + // limit. writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(5000)}';\n`); const { session } = fakeSession(); const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; @@ -1309,13 +1235,6 @@ describe("applySchemaFiles", () => { it.effect( "reports the last scanned RAW token in the too-long error even when it trimmed to empty (Go scanner.Text() parity, review CLI-1958)", () => { - // `token = scanner.Text()` runs on EVERY - // successful `Scan()`, unconditionally — BEFORE the `len(trim) > 0` gate that - // decides whether to append to `stats`. So when a statement trims to empty - // (a lone ";") immediately before an oversized one, `bufio.ErrTooLong` - // message still reports that lone ";" as the last-scanned text, not a blank - // token — `len(stats)` (this port's `emitted`) stays gated on non-empty trim, - // but the reported RAW text must not share that gate. const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-empty-token-")); mkdirSync(join(dir, "supabase"), { recursive: true }); const file = join(dir, "supabase", "big.sql"); @@ -1339,9 +1258,6 @@ describe("applySchemaFiles", () => { if (Exit.isFailure(exit)) { const msg = JSON.stringify(exit.cause); expect(msg).toContain("bufio.Scanner: token too long"); - // 0 statements were EMITTED (the lone ";" trimmed to empty and was never - // appended), but the last scanned RAW token (";") must still show — not a - // blank token, which a trim-gated tracker would wrongly report instead. expect(msg).toContain("After statement 0: ;"); } }).pipe( @@ -1395,14 +1311,6 @@ describe("applySchemaFiles", () => { it.effect( "falls back to Go's hardcoded default cap when SUPABASE_SCANNER_BUFFER_SIZE is set but unparseable (viper parity, not '5M' == 5MiB)", () => { - // Verified empirically: a bare multiplier suffix with NO trailing "b"/"B" (e.g. "5M") - // is NOT 5 MiB in real Go — `parseSizeInBytes` only recognizes a multiplier when - // the string's LAST character is literally "b"/"B", so "5M" never strips a - // suffix and `cast.ToInt("5M")` fails whole, yielding 0. `viper.IsSet` is still - // true (the var IS present), so `parseFile`'s file-size auto-growth never runs — - // `parser.Split` falls back to its OWN hardcoded default cap - // (`MaxScannerCapacity`, 256KiB), not to "no limit" and not to a tiny 5-byte - // limit either. A statement past that hardcoded default must still fail. const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-garbage-")); mkdirSync(join(dir, "supabase"), { recursive: true }); const file = join(dir, "supabase", "big.sql"); @@ -1426,7 +1334,6 @@ describe("applySchemaFiles", () => { if (Exit.isFailure(exit)) { const msg = JSON.stringify(exit.cause); expect(msg).toContain("bufio.Scanner: token too long"); - // 256KiB (`parser.MaxScannerCapacity` default), not "5MB" and not ~0KB. expect(msg).toContain( "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 256KB)", ); @@ -1447,14 +1354,6 @@ describe("applySchemaFiles", () => { it.effect( "accepts a hex-literal SUPABASE_SCANNER_BUFFER_SIZE (Go strconv.ParseInt base-0 parity, review CLI-1958)", () => { - // `viper.GetSizeInBytes` → `cast.ToInt` → `strconv.ParseInt(s, 0, 0)` parses - // with base 0, so a `0x`-prefixed literal is a valid byte count in real Go: - // "0x1400" is 5120 (5KiB) — verified empirically against vendored - // `viper@v1.21.0` (`viper.GetSizeInBytes("SCANNER_BUFFER_SIZE")` with the env - // var set to "0x1400" returns 5120). A decimal-only parser would reject this - // string outright and silently fall back to the 256KiB default instead, so a - // statement between 5120 and 262144 bytes would apply in TS but Go would - // already have failed with "bufio.Scanner: token too long" at 5121 bytes. const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-hex-")); mkdirSync(join(dir, "supabase"), { recursive: true }); const file = join(dir, "supabase", "big.sql"); @@ -1478,8 +1377,6 @@ describe("applySchemaFiles", () => { if (Exit.isFailure(exit)) { const msg = JSON.stringify(exit.cause); expect(msg).toContain("bufio.Scanner: token too long"); - // 5KiB (0x1400 bytes), not the 256KiB hardcoded fallback a decimal-only - // parser would have silently used instead. expect(msg).toContain( "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 5KB)", ); @@ -1500,14 +1397,6 @@ describe("applySchemaFiles", () => { it.effect( "accepts underscore digit separators in a decimal SUPABASE_SCANNER_BUFFER_SIZE (Go strconv.ParseInt base-0 underscore-literal parity, review CLI-1958)", () => { - // Go's base-0 integer grammar (`go.dev/ref/spec#Integer_literals`, reproduced - // by `strconv.ParseInt`) permits a single `_` between digits: "5_120" is the - // same 5120 (5KiB) byte count as the hex-literal test above's "0x1400" — - // verified empirically against the real `strconv.ParseInt("5_120", 0, 64)`. - // A parser that rejects underscores outright would silently fall back to the - // 256KiB default instead, so a statement between 5120 and 262144 bytes would - // apply in TS but Go would already have failed with "bufio.Scanner: token too - // long" at 5121 bytes. const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-underscore-")); mkdirSync(join(dir, "supabase"), { recursive: true }); const file = join(dir, "supabase", "big.sql"); @@ -1531,8 +1420,6 @@ describe("applySchemaFiles", () => { if (Exit.isFailure(exit)) { const msg = JSON.stringify(exit.cause); expect(msg).toContain("bufio.Scanner: token too long"); - // 5KiB (5_120 bytes), not the 256KiB hardcoded fallback an - // underscore-rejecting parser would have silently used instead. expect(msg).toContain( "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 5KB)", ); @@ -1553,12 +1440,6 @@ describe("applySchemaFiles", () => { it.effect( "rejects an invalid underscore placement in SUPABASE_SCANNER_BUFFER_SIZE, unlike a valid digit separator (Go strconv.ParseInt underscore-grammar parity, review CLI-1958)", () => { - // Go only permits a SINGLE underscore immediately after a base prefix or - // between two digits — never leading a plain (no-prefix) decimal literal, - // never doubled, never trailing. "_5120" (leading underscore, no prefix) is - // invalid in real Go (`strconv.ParseInt("_5120", 0, 64)` errors), so it falls - // back to the same 256KiB default as a genuinely unset/unparseable value — - // verified empirically against the real Go `strconv.ParseInt`. const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-bad-underscore-")); mkdirSync(join(dir, "supabase"), { recursive: true }); const file = join(dir, "supabase", "big.sql"); @@ -1578,8 +1459,6 @@ describe("applySchemaFiles", () => { (message, suggestion) => new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), ).pipe(Effect.exit); - // The 5116-byte statement fits comfortably under the 256KiB default - // fallback, so an invalid underscore placement must NOT fail the apply. expect(Exit.isSuccess(exit)).toBe(true); }).pipe( Effect.ensuring( @@ -1597,15 +1476,6 @@ describe("applySchemaFiles", () => { it.effect( "falls back to Go's hardcoded default cap when SUPABASE_SCANNER_BUFFER_SIZE overflows Go's signed int range (strconv.ParseInt/cast.ToInt range-error parity, review CLI-1958)", () => { - // "9223372036854775808" is one more than `math.MaxInt64`. Go's - // `strconv.ParseInt(s, 0, 0)` rejects it with a range error, and `cast.ToInt` - // discards ANY `parseFn` error — - // range or syntax — returning exactly `0`, never the huge (if imprecise) - // magnitude `Number.parseInt` would otherwise accept. `viper.IsSet` is still - // true, so this falls back to the 256KiB hardcoded default, same as a - // genuinely unparseable value ("5M" above) — NOT to "no limit". Verified - // empirically against the pinned `spf13/cast@v1.10.0` - // (`cast.ToInt("9223372036854775808")` → `0`). const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-int64-overflow-")); mkdirSync(join(dir, "supabase"), { recursive: true }); const file = join(dir, "supabase", "big.sql"); @@ -1629,8 +1499,6 @@ describe("applySchemaFiles", () => { if (Exit.isFailure(exit)) { const msg = JSON.stringify(exit.cause); expect(msg).toContain("bufio.Scanner: token too long"); - // 256KiB (Go's hardcoded default), not "no limit" — a treat-as-unbounded - // bug would let this 300_000-byte statement apply successfully instead. expect(msg).toContain( "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 256KB)", ); @@ -1651,11 +1519,6 @@ describe("applySchemaFiles", () => { it.effect( "still accepts the exact int64 boundary magnitudes for SUPABASE_SCANNER_BUFFER_SIZE (Go strconv.ParseInt range-boundary parity, review CLI-1958)", () => { - // `math.MaxInt64` itself ("9223372036854775807", one less than the overflow - // test above) is NOT a range error in Go — only magnitudes strictly beyond it - // are. A range check that's off-by-one in the strict direction would wrongly - // reject this legitimate (if enormous) configured size and fall back to the - // 256KiB default instead of the requested cap. const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-int64-boundary-")); mkdirSync(join(dir, "supabase"), { recursive: true }); const file = join(dir, "supabase", "big.sql"); @@ -1675,8 +1538,6 @@ describe("applySchemaFiles", () => { (message, suggestion) => new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), ).pipe(Effect.exit); - // The 5116-byte statement fits comfortably under the (enormous) configured - // limit, so this must succeed, not fall back to the 256KiB default. expect(Exit.isSuccess(exit)).toBe(true); }).pipe( Effect.ensuring( @@ -1694,12 +1555,6 @@ describe("applySchemaFiles", () => { it.effect( "rejects an oversized statement when SUPABASE_SCANNER_BUFFER_SIZE is set only in the project env (Go loadNestedEnv parity)", () => { - // `loadNestedEnv` `os.Setenv`s every - // project-`.env` key that isn't already in the shell env BEFORE the command body - // runs, so `viper.AutomaticEnv()` sees a `supabase/.env`-only - // `SUPABASE_SCANNER_BUFFER_SIZE` exactly like a real shell-exported one. - // `applySchemaFiles`'s `projectEnv` parameter threads the caller's already - // -loaded `loadProjectEnv` map through to the same check. const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-projectenv-")); mkdirSync(join(dir, "supabase"), { recursive: true }); const file = join(dir, "supabase", "big.sql"); @@ -1740,17 +1595,14 @@ describe("applySchemaFiles", () => { it.effect( "shell env still wins over the project env for SUPABASE_SCANNER_BUFFER_SIZE (Go godotenv 'never overrides' parity)", () => { - // `godotenv.Load`'s `overload=false` never sets a key already present in - // `os.Environ()` — the shell value must - // win even when a (different) project-env value is also threaded through. const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-shellwins-")); mkdirSync(join(dir, "supabase"), { recursive: true }); const file = join(dir, "supabase", "big.sql"); writeFileSync(file, `SELECT '${"a".repeat(5000)}';\n`); const { session, calls } = fakeSession(); const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - // Shell explicitly unsets enforcement (0 → treated as unset, no check) while the - // project env sets a tiny limit — the shell value must win. + // "0" is treated as unset (no check); the shell value must still win over the project + // env's tiny limit. process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "0"; return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/cli/src/command-internal/migration-fetch-run.ts b/apps/cli/src/command-internal/migration-fetch-run.ts index 471a7f30c6..b4ea180eb0 100644 --- a/apps/cli/src/command-internal/migration-fetch-run.ts +++ b/apps/cli/src/command-internal/migration-fetch-run.ts @@ -20,10 +20,8 @@ import { migrationConfirm } from "../commands/migration/migration.prompt.ts"; import type { MigrationFetchFlags } from "../commands/migration/fetch/fetch.command.ts"; import { MigrationFetchWriteError } from "../commands/migration/fetch/fetch.errors.ts"; -// `MigrationFetchFlags` is `runMigrationFetch`'s own parameter type, defined alongside the -// standalone `migration fetch` command that also constructs it — re-exported here so an -// in-process caller (`pull`) can build one without reaching into `commands/migration/**` -// directly. +// Re-exported so an in-process caller (`pull`) can build a `MigrationFetchInput` without +// reaching into `commands/migration/**` directly. export type { MigrationFetchFlags }; export interface MigrationFetchInput { @@ -48,9 +46,7 @@ export const runMigrationFetch = Effect.fnUntraced(function* (input: MigrationFe const path = yield* Path.Path; const dnsResolver = yield* DnsResolverFlag; - // Flag-group mutual-exclusion first: cobra's `MarkFlagsMutuallyExclusive` validates at - // parse time, ahead of the root `PersistentPreRunE` (same ordering as `migration down`/ - // `repair`). + // Validated first, before any other work — same ordering as `migration down`/`repair`. if (target.setFlags.length > 1) { return yield* Effect.fail( new MigrationTargetFlagsError({ @@ -107,16 +103,11 @@ export const runMigrationFetch = Effect.fnUntraced(function* (input: MigrationFe const fetchBody = Effect.gen(function* () { const migrationsDir = path.join(cliSettings.workdir, "supabase", "migrations"); - // Create the migrations dir if missing, then prompt before overwriting a - // non-empty migrations dir (default YES). Cancel → cancellation. yield* fs .makeDirectory(migrationsDir, { recursive: true }) .pipe(Effect.mapError((cause) => new MigrationFetchWriteError({ message: cause.message }))); - // The overwrite prompt is gated on directory emptiness, which aborts on - // ANY read failure before fetching/writing. - // Only a missing directory counts as "empty"; a read error (e.g. an unreadable dir) - // must propagate — collapsing it to empty would skip the confirmation and clobber - // existing migrations. + // Only a missing directory counts as "empty"; any other read error must propagate — + // collapsing it to empty would skip the confirmation and risk clobbering existing migrations. const existing = yield* fs.readDirectory(migrationsDir).pipe( Effect.catchTag("PlatformError", (cause) => cause.reason._tag === "NotFound" @@ -159,12 +150,10 @@ export const runMigrationFetch = Effect.fnUntraced(function* (input: MigrationFe const written: Array<string> = []; for (const file of migrations) { - // The version/name come from the remote `schema_migrations` table. A - // tampered/hostile remote could supply path separators or `..` in EITHER field to - // escape the migrations dir on write (CWE-22). The raw column values write - // verbatim, with no digit check, so reject only the - // actual traversal vectors — separators and `..` segments — in both fields. This - // keeps a signed version like `-1` writable while closing the vector. + // The version/name come from the remote `schema_migrations` table. A hostile remote could + // supply path separators or `..` in either field to escape the migrations dir on write + // (CWE-22); reject only those traversal vectors so a signed version like `-1` stays + // writable. const escapes = (segment: string) => /[/\\]/u.test(segment) || segment.split(/[/\\]/u).includes(".."); if (escapes(file.version) || escapes(file.name)) { @@ -177,7 +166,6 @@ export const runMigrationFetch = Effect.fnUntraced(function* (input: MigrationFe } const name = `${file.version}_${file.name}.sql`; const filePath = path.join(migrationsDir, name); - // The written form joins statements with `;\n`, plus a trailing `;\n`. const contents = `${file.statements.join(";\n")};\n`; yield* fs.writeFileString(filePath, contents, { mode: 0o644 }).pipe( Effect.mapError( diff --git a/apps/cli/src/command-internal/migration-file.ts b/apps/cli/src/command-internal/migration-file.ts index 2a0499462a..2a4669916f 100644 --- a/apps/cli/src/command-internal/migration-file.ts +++ b/apps/cli/src/command-internal/migration-file.ts @@ -39,20 +39,16 @@ export function parseMigrationContent(content: string): ParsedMigrationContent { } /** - * Go's `GetCurrentTimestamp` (`apps/cli-go/internal/utils/misc.go:130`): the - * current time formatted UTC as `YYYYMMDDHHMMSS` (Go's `layoutVersion` - * `20060102150405`). Takes the epoch millis (from `Clock.currentTimeMillis`) so - * it stays deterministic under test. + * Formats the current time as `YYYYMMDDHHMMSS` in UTC. Takes epoch millis (from + * `Clock.currentTimeMillis`) so it stays deterministic under test. */ export function formatMigrationTimestamp(millis: number): string { return new Date(millis).toISOString().replace(/\D/gu, "").slice(0, 14); } /** - * Go's `new.GetMigrationPath` (`apps/cli-go/internal/migration/new/new.go:31`): - * `<workdir>/supabase/migrations/<timestamp>_<name>.sql`. Returned absolute so - * callers can write it regardless of the process CWD (Go chdir's into the workdir - * in its persistent pre-run; the native shell resolves against it explicitly). + * Builds `<workdir>/supabase/migrations/<timestamp>_<name>.sql`. Returned absolute so callers + * can write it regardless of the process's current working directory. */ export function getMigrationPath( path: Path.Path, diff --git a/apps/cli/src/command-internal/migration-history.ts b/apps/cli/src/command-internal/migration-history.ts index 7ed0a4992e..9cf6e62243 100644 --- a/apps/cli/src/command-internal/migration-history.ts +++ b/apps/cli/src/command-internal/migration-history.ts @@ -15,20 +15,14 @@ import { MigrationsReadError } from "./migration.errors.ts"; import { parseMigrationContent } from "./migration-file.ts"; /** - * Consolidated `supabase_migrations.schema_migrations` history module — the - * single home for the migration-history DDL/DML and the read/reconcile helpers - * shared by `db diff/pull`, `migration *`, and the declarative generate/sync - * handlers. SQL is verbatim from `pkg/migration/history.go`; the helpers - * port the established list/reconcile logic. + * Consolidated `supabase_migrations.schema_migrations` history module — the single home for + * the migration-history DDL/DML and the read/reconcile helpers shared by `db diff/pull`, + * `migration *`, and the declarative generate/sync handlers. */ -// Migration-history DDL/DML, verbatim from `pkg/migration/history.go`. -// `SET LOCAL` (not bare `SET`) scopes the timeout to the wrapping transaction so it -// reverts on `COMMIT` — reproducing `CreateMigrationTable`/`CreateSeedTable` -// running through `pgconn.ExecBatch` (an implicit transaction whose `SET` reverts when the -// batch ends). A bare session-level `SET` would leak -// the 4s timeout into a caller's real work (e.g. `migration repair`'s TRUNCATE/UPSERT -// or seed SQL), which this must never do. +// `SET LOCAL` (not bare `SET`) scopes the timeout to the wrapping transaction, so it reverts on +// `COMMIT` instead of leaking the 4s timeout into a caller's real work (e.g. `migration repair`'s +// TRUNCATE/UPSERT or seed SQL). const SET_LOCAL_LOCK_TIMEOUT = "SET LOCAL lock_timeout = '4s'"; const CREATE_VERSION_SCHEMA = "CREATE SCHEMA IF NOT EXISTS supabase_migrations"; const CREATE_VERSION_TABLE = @@ -38,53 +32,48 @@ const ADD_STATEMENTS_COLUMN = const ADD_NAME_COLUMN = "ALTER TABLE supabase_migrations.schema_migrations ADD COLUMN IF NOT EXISTS name text"; -/** `INSERT(version, name, statements)` — `INSERT_MIGRATION_VERSION`. */ export const INSERT_MIGRATION_VERSION = "INSERT INTO supabase_migrations.schema_migrations(version, name, statements) VALUES($1, $2, $3)"; -/** Upsert variant used to record an already-applied migration — Go's repair UPSERT. */ +/** Used by `migration repair` to record an already-applied migration. */ export const UPSERT_MIGRATION_VERSION = "INSERT INTO supabase_migrations.schema_migrations(version, name, statements) VALUES($1, $2, $3) ON CONFLICT (version) DO UPDATE SET name = EXCLUDED.name, statements = EXCLUDED.statements"; -/** `DELETE ... WHERE version = ANY($1)` — `DELETE_MIGRATION_VERSION` (repair reverted). */ +/** Used by `migration repair --status reverted`. */ export const DELETE_MIGRATION_VERSION = "DELETE FROM supabase_migrations.schema_migrations WHERE version = ANY($1)"; -/** `DELETE ... WHERE version <= $1` — `DELETE_MIGRATION_BEFORE` (squash baseline). */ +/** Drops history at or before a `migration squash` baseline version. */ export const DELETE_MIGRATION_BEFORE = "DELETE FROM supabase_migrations.schema_migrations WHERE version <= $1"; -/** `TRUNCATE supabase_migrations.schema_migrations` — Go's repair-all reset. */ +/** Used by `migration repair` to reset the whole history table. */ export const TRUNCATE_VERSION_TABLE = "TRUNCATE supabase_migrations.schema_migrations"; -/** `SELECT version FROM supabase_migrations.schema_migrations ORDER BY version`. */ const LIST_MIGRATION_VERSION = "SELECT version FROM supabase_migrations.schema_migrations ORDER BY version"; -/** `SELECT_VERSION_TABLE` — full history rows for `migration fetch`. */ +/** Full history rows for `migration fetch`. */ const SELECT_VERSION_TABLE = "SELECT version, coalesce(name, '') as name, statements FROM supabase_migrations.schema_migrations"; -/** `supabase_migrations.seed_files` DDL/DML — Go's seed-tracking SQL (`history.go`). */ const CREATE_SEED_TABLE = "CREATE TABLE IF NOT EXISTS supabase_migrations.seed_files (path text NOT NULL PRIMARY KEY, hash text NOT NULL)"; export const UPSERT_SEED_FILE = "INSERT INTO supabase_migrations.seed_files(path, hash) VALUES($1, $2) ON CONFLICT (path) DO UPDATE SET hash = EXCLUDED.hash"; const SELECT_SEED_TABLE = "SELECT path, hash FROM supabase_migrations.seed_files"; -/** `pkg/migration/file.go` — `<digits>_<name>.sql`. */ +/** Matches `<digits>_<name>.sql`. */ export const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/u; /** - * Read-only probe: `true` when the relation is an ordinary or partitioned table - * carrying every live column its DDL creates, i.e. when each setup statement - * below is a guaranteed no-op. Sent with no bind parameters, matching the wire - * shape of the `migration list` SELECT that demonstrably survives the poolers - * this setup DDL dies on (supabase/cli#6393). Any unexpected answer falls - * through to the DDL path, but a probe FAILURE deliberately aborts instead: a - * connection that cannot serve this SELECT will not serve the setup transaction - * either, and failing loudly surfaces the real error rather than masking it - * behind a DDL failure. Interpolates its arguments verbatim: callers pass + * Read-only probe: `true` when the relation is an ordinary or partitioned table carrying every + * live column its DDL creates, i.e. when each setup statement below is a guaranteed no-op. Sent + * with no bind parameters, matching the wire shape of the `migration list` SELECT that + * demonstrably survives poolers this setup DDL dies on. Any unexpected answer falls through to + * the DDL path, but a probe failure aborts instead: a connection that cannot serve this SELECT + * will not serve the setup transaction either, and failing loudly surfaces the real error rather + * than masking it behind a DDL failure. Interpolates its arguments verbatim: callers pass * compile-time literals only. */ const provisionedProbe = (relation: string, columns: ReadonlyArray<string>) => @@ -104,13 +93,10 @@ const isTableProvisioned = (session: DbSession, probe: string) => session.query(probe).pipe(Effect.map((rows) => rows[0]?.["provisioned"] === true)); /** - * Creates the migration-history schema/table (idempotent). `CreateMigrationTable`. - * Skipped entirely when the provisioning probe finds the ledger already current, so - * an already-provisioned remote runs no DDL (supabase/cli#6393); an older or partial - * ledger still gets the full setup. The setup runs in one transaction so - * `SET LOCAL lock_timeout` is scoped to it and reverts on `COMMIT`, matching Go's - * implicit `pgconn.ExecBatch` transaction; the GUC never leaks into the caller's - * subsequent work. A failed statement rolls back. + * Creates the migration-history schema/table (idempotent). Skipped entirely when the + * provisioning probe finds the ledger already current; an older or partial ledger still gets the + * full setup, in one transaction so `SET LOCAL lock_timeout` reverts on `COMMIT` and never leaks + * into the caller's subsequent work. A failed statement rolls back. */ export const createMigrationTable = (session: DbSession) => Effect.flatMap(isTableProvisioned(session, SELECT_VERSION_TABLE_PROVISIONED), (provisioned) => @@ -128,10 +114,10 @@ export const createMigrationTable = (session: DbSession) => ); /** - * Creates the `seed_files` schema/table (idempotent). `CreateSeedTable`. Probed and - * skipped when already provisioned; otherwise the same transaction-scoped - * `SET LOCAL lock_timeout` as `createMigrationTable` so the timeout reverts on - * `COMMIT` and never leaks into the seed SQL the caller runs next. + * Creates the `seed_files` schema/table (idempotent). Probed and skipped when already + * provisioned; otherwise the same transaction-scoped `SET LOCAL lock_timeout` as + * `createMigrationTable`, so the timeout reverts on `COMMIT` and never leaks into the seed SQL + * the caller runs next. */ export const createSeedTable = (session: DbSession) => Effect.flatMap(isTableProvisioned(session, SELECT_SEED_TABLE_PROVISIONED), (provisioned) => @@ -146,16 +132,15 @@ export const createSeedTable = (session: DbSession) => }).pipe(Effect.tapError(() => session.exec("ROLLBACK").pipe(Effect.ignore))), ); -/** A recorded seed file's path + content hash. `migration.SeedFile`. */ +/** A recorded seed file's path + content hash. */ export interface SeedRow { readonly path: string; readonly hash: string; } /** - * Reads `supabase_migrations.seed_files` (path → hash). Mirrors Go's - * `getRemoteSeeds`: a missing table (42P01) means no - * seeds applied yet → empty. + * Reads `supabase_migrations.seed_files` (path → hash). A missing table (42P01) means no seeds + * applied yet, so this returns empty rather than failing. */ export const readSeedTable = (session: DbSession) => session.query(SELECT_SEED_TABLE).pipe( @@ -179,24 +164,19 @@ export type MigrationSync = | { readonly kind: "conflict"; readonly suggestion: string }; /** - * Reconciles the remote and local migration version lists. Pure port of Go's - * `assertRemoteInSync` two-pointer comparison: - * versions that fail to parse as integers are skipped (`Atoi` error → - * `continue`); any extra remote/local version is a conflict; an empty local set - * is `missing`; otherwise in-sync. + * Reconciles the remote and local migration version lists via a two-pointer comparison: + * versions that fail to parse as integers are skipped; any extra remote/local version is a + * conflict; an empty local set is `missing`; otherwise in-sync. */ export function reconcileMigrations( remote: ReadonlyArray<string>, local: ReadonlyArray<string>, isLocal = false, ): MigrationSync { - // `MIGRATION_VERSION_MAX` is `math.MaxInt` (int64 max) and pins the - // exhausted side; `parseMigrationVersion` mirrors Go's `strconv.Atoi` - // (digits only, within int64, BigInt for exact ordering) and is shared with - // `migration list` so both surfaces skip the same edge-case versions. - // `loadLocalVersions` yields versions in file-name order, which reverses - // `ORDER BY version` whenever one version is a prefix of another - // (supabase/cli#6036) — the same desynchronisation + // `MIGRATION_VERSION_MAX` pins the exhausted side of the walk; `parseMigrationVersion` is + // shared with `migration list` so both surfaces skip the same edge-case versions. + // `loadLocalVersions` yields versions in file-name order, which reverses `ORDER BY version` + // whenever one version is a prefix of another — the same desynchronisation // `findPendingMigrations` sorts away below. const sortedLocal = sortMigrationVersions(local); const extraRemote: Array<string> = []; @@ -245,7 +225,6 @@ export function reconcileMigrations( return { kind: "in-sync" }; } -/** `suggestMigrationRepair`. */ export function suggestMigrationRepair( extraRemote: ReadonlyArray<string>, extraLocal: ReadonlyArray<string>, @@ -263,10 +242,7 @@ export function suggestMigrationRepair( return result; } -/** - * `suggestRevertHistory`. `fmt.Sprintln` - * appends a trailing newline to each line, so the suggestion ends with `\n`. - */ +/** Each generated line ends with a trailing newline, including the last one. */ export function suggestRevertHistory(versions: ReadonlyArray<string>, isLocal = false): string { const localFlag = isLocal ? " --local" : ""; return ( @@ -278,14 +254,11 @@ export function suggestRevertHistory(versions: ReadonlyArray<string>, isLocal = } /** - * Lists the remote project's applied migration versions. Mirrors Go's - * `migration.ListRemoteMigrations`: ONLY a missing - * history table (`pgerrcode.UndefinedTable` = `42P01`) means the remote has no - * migrations and returns `[]`; any other error (e.g. a malformed table missing the - * `version` column, `42703`) propagates rather than being silently treated as an - * initial pull. We match the SQLSTATE like Go; if the driver didn't surface a code, - * fall back to a message check that matches a missing relation but NOT a missing - * column. + * Lists the remote project's applied migration versions. Only a missing history table (SQLSTATE + * 42P01) means the remote has no migrations and returns `[]`; any other error (e.g. a malformed + * table missing the `version` column, 42703) propagates rather than being silently treated as an + * initial pull. If the driver doesn't surface a SQLSTATE, falls back to a message check that + * matches a missing relation but not a missing column. */ export const listRemoteMigrations = (session: DbSession) => session.query(LIST_MIGRATION_VERSION).pipe( @@ -297,7 +270,7 @@ export const listRemoteMigrations = (session: DbSession) => ), ); -/** Whether a query error is Postgres `undefined_table` (42P01), matching `pgerrcode.UndefinedTable`. */ +/** Whether a query error is Postgres's `undefined_table` (42P01). */ const isUndefinedTableError = (error: DbExecError): boolean => { if (error.code !== undefined) return error.code === "42P01"; // No SQLSTATE surfaced: a relation-not-exist message counts, a column-not-exist @@ -309,11 +282,10 @@ const isUndefinedTableError = (error: DbExecError): boolean => { }; /** - * Lists local migration file paths (sorted, init-schema skipped). Thin re-export - * of `listLocalMigrations` so `migration` handlers reach it through this - * shared module rather than importing the `db`-command-scoped cache directly - * (keeps the command-family boundary; the `commands/db/shared` cache stays the - * single implementation). Mirrors `migration.ListLocalMigrations`. + * Lists local migration file paths (sorted, init-schema skipped). Thin re-export of + * `listLocalMigrations` so `migration` handlers reach it through this shared module rather than + * importing the `db`-command-scoped cache directly; `commands/db/shared` stays the single + * implementation. */ export const listLocalMigrationPaths = ( fs: FileSystem.FileSystem, @@ -321,11 +293,7 @@ export const listLocalMigrationPaths = ( migrationsDir: string, ) => listLocalMigrations(fs, path, migrationsDir); -/** - * Loads the local migration versions (the `<timestamp>` prefixes). Mirrors Go's - * `LoadLocalVersions` → `ListLocalMigrations` - * with a version-collecting filter. - */ +/** Loads the local migration versions (the `<timestamp>` prefixes). */ export const loadLocalVersions = ( fs: FileSystem.FileSystem, path: Path.Path, @@ -344,13 +312,11 @@ export const loadLocalVersions = ( const baseName = (filePath: string): string => filePath.split(/[\\/]/u).pop() ?? filePath; /** - * Orders local migration paths by version so they line up with - * `schema_migrations` (`ORDER BY version`) before a two-pointer walk compares - * the two lists. Go sorts these by file name and compares by version, which - * only agrees while versions are the same width: `20260420010000_b.sql` sorts - * before `20260420_a.sql` by name (`'0'` < `'_'`) but after it by version, - * desynchronising the walk (supabase/cli#6036). Stable and keyed on the version - * alone, so same-width sets keep the exact name order Go produced. + * Orders local migration paths by version so they line up with `schema_migrations` + * (`ORDER BY version`) before a two-pointer walk compares the two lists. File-name order and + * version order only agree while versions are the same width: `20260420010000_b.sql` sorts + * before `20260420_a.sql` by name (`'0'` < `'_'`) but after it by version. Stable and keyed on + * the version alone, so same-width sets keep their original name order. */ export function sortMigrationPathsByVersion( localPaths: ReadonlyArray<string>, @@ -362,21 +328,20 @@ export function sortMigrationPathsByVersion( }); } -/** Outcome of `findPendingMigrations` — `(slice, error)` as a tagged union. */ +/** Outcome of `findPendingMigrations`. */ export type PendingMigrations = | { readonly kind: "pending"; readonly paths: ReadonlyArray<string> } - // `ErrMissingLocal`: remote versions absent from the local directory. + // Remote versions absent from the local directory. | { readonly kind: "missing-local"; readonly versions: ReadonlyArray<string> } - // `ErrMissingRemote`: out-of-order local migrations before the last remote. + // Out-of-order local migrations before the last remote. | { readonly kind: "missing-remote"; readonly paths: ReadonlyArray<string> }; /** - * Pure port of `FindPendingMigrations`: a - * two-pointer walk over local paths + remote versions. Returns the pending local - * paths, or flags a remote version missing from local (`missing-local`) or an - * out-of-order local migration (`missing-remote`). `localPaths` are full paths - * whose basenames match `<version>_<name>.sql`; `remoteVersions` are sorted. - * Both sides must agree on ordering, so `localPaths` is re-sorted by version. + * A two-pointer walk over local paths + remote versions. Returns the pending local paths, or + * flags a remote version missing from local (`missing-local`) or an out-of-order local migration + * (`missing-remote`). `localPaths` are full paths whose basenames match `<version>_<name>.sql`; + * `remoteVersions` are sorted. Both sides must agree on ordering, so `localPaths` is re-sorted by + * version. */ export function findPendingMigrations( localPaths: ReadonlyArray<string>, @@ -413,10 +378,8 @@ export function findPendingMigrations( } /** - * Loads local migration paths whose version is `<= version` (or all when - * `version` is empty). Mirrors `list.LoadPartialMigrations`; - * version comparison is lexical, matching - * `v <= version` on zero-padded timestamps. + * Loads local migration paths whose version is `<= version` (or all when `version` is empty). + * Version comparison is lexical, matching zero-padded timestamp ordering. */ export const loadPartialMigrations = ( fs: FileSystem.FileSystem, @@ -426,9 +389,8 @@ export const loadPartialMigrations = ( ) => listLocalMigrations(fs, path, migrationsDir).pipe( Effect.map((paths) => - // Sorted by version, not by file name: `db push` has applied in version - // order since supabase/cli#6038, so replaying in name order here would - // apply the same files in the opposite order locally (#6036). + // Sorted by version, not file name, so replaying here applies files in the same order + // `db push` does remotely. sortMigrationPathsByVersion( paths.filter((p) => { if (version.length === 0) return true; @@ -439,7 +401,7 @@ export const loadPartialMigrations = ( ), ); -/** A migration's version, name, and SQL statements — `migration.MigrationFile`. */ +/** A migration's version, name, and SQL statements. */ export interface MigrationFile { readonly version: string; readonly name: string; @@ -450,10 +412,7 @@ export interface MigrationFile { const toStatements = (value: unknown): ReadonlyArray<string> => Array.isArray(value) ? value.map((entry) => String(entry)) : []; -/** - * Reads the full migration-history rows (version, name, statements). Mirrors Go's - * `ReadMigrationTable` — used by `migration fetch`. - */ +/** Reads the full migration-history rows (version, name, statements); used by `migration fetch`. */ export const readMigrationTable = (session: DbSession) => session.query(SELECT_VERSION_TABLE).pipe( Effect.map((rows) => @@ -472,14 +431,11 @@ export const readMigrationTable = (session: DbSession) => ); /** - * Resolves the local migration file for a version by globbing `<version>_*.sql` - * against the migrations dir. Mirrors `repair.GetMigrationFile`: - * `afero.Glob` reads the - * directory then byte-sorts entries (`sort.Strings`) before - * matching, so ties resolve to the byte-ordered first match, - * not JS's default UTF-16-code-unit order — or `None` when nothing matches (the - * caller raises the not-found error so the exact message can be assembled). A - * missing directory is treated as no match. + * Resolves the local migration file for a version by globbing `<version>_*.sql` against the + * migrations dir. Reads the directory then byte-sorts entries before matching, so ties resolve + * to the byte-ordered first match, not JS's default UTF-16-code-unit order. Returns `None` when + * nothing matches — the caller raises the not-found error so the exact message can be assembled. + * A missing directory is treated as no match. */ export const resolveMigrationFile = ( fs: FileSystem.FileSystem, @@ -509,9 +465,8 @@ export const resolveMigrationFile = ( ); /** - * Reads a migration file into its version/name/statements. Mirrors Go's - * `NewMigrationFromFile`: split the SQL with the - * shared splitter and parse the version + name from the basename. + * Reads a migration file into its version/name/statements: splits the SQL with the shared + * splitter and parses the version + name from the basename. */ export const readMigrationFile = ( fs: FileSystem.FileSystem, diff --git a/apps/cli/src/command-internal/migration-history.unit.test.ts b/apps/cli/src/command-internal/migration-history.unit.test.ts index 3cee0f085c..944292147d 100644 --- a/apps/cli/src/command-internal/migration-history.unit.test.ts +++ b/apps/cli/src/command-internal/migration-history.unit.test.ts @@ -35,8 +35,6 @@ describe("reconcileMigrations", () => { }); it("reports missing only when both histories are empty", () => { - // Go checks for conflicts (extra remote/local) before the empty-local guard, - // so a remote-only migration is a conflict, not missing. expect(reconcileMigrations([], [])).toEqual({ kind: "missing" }); expect(reconcileMigrations(["20240101000000"], []).kind).toBe("conflict"); }); @@ -60,42 +58,28 @@ describe("reconcileMigrations", () => { }); it("is in sync when an 8-digit and a 14-digit version share a prefix (#6036)", () => { - // Local versions arrive in file-name order, where `20260420010000_b.sql` - // precedes `20260420_a.sql` ('0' < '_') — the reverse of the `ORDER BY - // version` order `schema_migrations` is read back in. Unsorted, the walk - // desynchronises into a conflict whose repair suggestion asks for the same - // version to be marked both reverted and applied. expect( reconcileMigrations(["20260420", "20260420010000"], ["20260420010000", "20260420"]), ).toEqual({ kind: "in-sync" }); }); it("skips versions that do not parse as integers", () => { - // A non-numeric remote version is skipped (Go's Atoi-error continue), leaving - // the numeric ones in sync. expect(reconcileMigrations(["bogus", "20240101000000"], ["20240101000000"])).toEqual({ kind: "in-sync", }); }); it("skips empty / whitespace versions (matches strconv.Atoi, not Number())", () => { - // `Number("")`/`Number(" ")` are 0; Go's Atoi errors on both → skip. The - // numeric entries still reconcile in-sync rather than spuriously conflicting. expect(reconcileMigrations(["", "20240101000000"], [" ", "20240101000000"])).toEqual({ kind: "in-sync", }); }); it("treats a version within Go's int64 range as a real conflict (BigInt parity)", () => { - // 9999999999999999 (~1e16) is above Number.MAX_SAFE_INTEGER but within int64, - // so Go's strconv.Atoi accepts it and surfaces it as an extra-remote conflict. - // A Number-based parser would skip it (initial pull); BigInt compares exactly. expect(reconcileMigrations(["9999999999999999"], []).kind).toBe("conflict"); }); it("skips a version beyond Go's int64 range instead of hanging the scan", () => { - // A 19-digit value exceeds int64 max (9223372036854775807); Go's Atoi returns a - // range error and skips it, so the scan can't stall on the exhausted-side pin. expect( reconcileMigrations(["20240101000000", "9999999999999999999"], ["20240101000000"]), ).toEqual({ kind: "in-sync" }); @@ -146,9 +130,6 @@ describe("findPendingMigrations (Go TestPendingMigrations / TestIgnoreVersionMis }); it("is up to date when an 8-digit and a 14-digit version share a prefix (#6036)", () => { - // Local files arrive in name order, where `20260420010000_…` precedes - // `20260420_…` ('0' < '_') — the reverse of the version order - // `schema_migrations` is read back in. const local = ["20260420010000", "20260420"].map(mig); const result = findPendingMigrations(local, ["20260420", "20260420010000"]); expect(result).toEqual({ kind: "pending", paths: [] }); @@ -207,10 +188,10 @@ describe("suggestRevertHistory", () => { describe("resolveMigrationFile (byte-ordered match, Go's sort.Strings via afero match.go:91)", () => { it("picks the UTF-8-byte-first match, not JS's default UTF-16 code-unit order", async () => { // A supplementary-plane character (U+1F600, a UTF-16 surrogate pair) alongside a BMP - // private-use character (U+E000): JS's default `.sort()` (no comparator) ranks the - // surrogate pair FIRST — its leading high-surrogate code unit (0xD83D) is less than - // the private-use code unit (0xE000). `sort.Strings` (UTF-8 byte order) ranks the - // private-use character first instead (0xEE... < 0xF0... in its UTF-8 encoding). + // private-use character (U+E000): JS's default `.sort()` ranks the surrogate pair first — + // its leading high-surrogate code unit (0xD83D) is less than the private-use code unit + // (0xE000) — while byte-wise UTF-8 order ranks the private-use character first instead + // (0xEE... < 0xF0...). const surrogatePair = "20240101000000_a\u{1f600}.sql"; const privateUse = "20240101000000_a\u{e000}.sql"; expect([surrogatePair, privateUse].sort()[0]).toBe(surrogatePair); diff --git a/apps/cli/src/command-internal/migration-list.ts b/apps/cli/src/command-internal/migration-list.ts index 8ea19e078a..556b9d5d76 100644 --- a/apps/cli/src/command-internal/migration-list.ts +++ b/apps/cli/src/command-internal/migration-list.ts @@ -47,23 +47,15 @@ export const listLocalMigrations = Effect.fnUntraced(function* ( ), ); if (names.length === 0) return NO_MIGRATIONS; - // Entries must sort byte-wise over each name's UTF-8 encoding — NOT JS's default - // `Array.prototype.sort()`, which compares UTF-16 code units and disagrees with byte/codepoint - // order for a supplementary-plane filename character alongside a BMP private-use one (see - // {@link compareUtf8Bytes}'s own doc comment). Left uncorrected, such a migrations - // directory would replay in a different order than previous releases, and a dependent - // migration could fail or produce a different shadow schema. const sorted = [...names].sort(compareUtf8Bytes); const result: Array<string> = []; for (let index = 0; index < sorted.length; index++) { const name = sorted[index]!; const entryPath = path.join(migrationsDir, name); - // Directory entries are classified from their own type without following symlinks: a - // `.sql` symlink whose target is a directory is never skipped as a directory here — it - // only fails later, when the migration is read as a regular file. `fs.stat` below follows - // symlinks, so it would misclassify a symlink-to-directory as a plain directory and - // silently skip it. Check `readLink` (which only succeeds for a symlink) first and skip - // the directory check entirely for symlinks. + // Classified from the entry's own type without following symlinks: `fs.stat` below follows + // symlinks and would misclassify a symlink-to-directory as a plain directory, silently + // skipping it. Check `readLink` first (only succeeds for a symlink) and skip the directory + // check entirely for symlinks. const isSymlink = Option.isSome(yield* fs.readLink(entryPath).pipe(Effect.option)); if (!isSymlink) { const stat = yield* fs.stat(entryPath).pipe(Effect.option); diff --git a/apps/cli/src/command-internal/migration-list.unit.test.ts b/apps/cli/src/command-internal/migration-list.unit.test.ts index 56d36fe193..640139adc5 100644 --- a/apps/cli/src/command-internal/migration-list.unit.test.ts +++ b/apps/cli/src/command-internal/migration-list.unit.test.ts @@ -48,8 +48,6 @@ describe("listLocalMigrations", () => { it.effect( "warns (byte-exact, on stderr) when skipping a deprecated init and a misnamed file", () => { - // One stderr line for the deprecated `_init.sql` first file and one for any - // name that does not match `<timestamp>_name.sql`. const dir = withTemp(); const migrationsDir = join(dir, "supabase", "migrations"); mkdirSync(migrationsDir, { recursive: true }); @@ -81,11 +79,6 @@ describe("listLocalMigrations", () => { ); it.effect("includes a validly-named .sql symlink to a directory (no symlink follow)", () => { - // A directory entry is classified from its own type without following symlinks, - // so a `.sql` symlink whose target is a directory is NOT skipped as a directory — - // it is only ever dropped later, if something actually tries to read it as a - // file. A naive stat-based directory check (which follows symlinks) would - // misclassify it and silently skip it. const dir = withTemp(); const migrationsDir = join(dir, "supabase", "migrations"); mkdirSync(migrationsDir, { recursive: true }); @@ -107,14 +100,10 @@ describe("listLocalMigrations", () => { }); it.effect("sorts by UTF-8 byte order, not JS's default UTF-16 code-unit order", () => { - // Entries sort byte-wise over each name's UTF-8 encoding. A BMP private-use - // character (U+E000, single UTF-16 code unit `0xE000`) and a supplementary-plane - // character (U+1F600, a surrogate pair starting `0xD83D`) reverse order between - // the two schemes: JS's default `Array.prototype.sort()` ranks the surrogate pair - // first (`0xD83D < 0xE000`), while byte order — which preserves codepoint order — - // ranks U+1F600 (`> U+FFFF`) after U+E000. A migrations directory with such - // filenames must replay in byte order, or a dependent migration could apply out - // of order. + // U+E000 (BMP private-use, single UTF-16 code unit 0xE000) and U+1F600 (supplementary-plane, + // a surrogate pair starting 0xD83D) sort in opposite order under UTF-16 code units vs. UTF-8 + // bytes: JS's default `.sort()` ranks the surrogate pair first (0xD83D < 0xE000), while byte + // order (which preserves codepoint order) ranks U+1F600 after U+E000. const dir = withTemp(); const migrationsDir = join(dir, "supabase", "migrations"); mkdirSync(migrationsDir, { recursive: true }); @@ -145,9 +134,6 @@ describe("listLocalMigrations", () => { }); it.effect("fails (instead of returning []) when the migrations path is unreadable", () => { - // `supabase/migrations` exists but is a file, not a directory — the lister - // aborts with `failed to read directory` rather than treating it as "no - // migrations". const dir = withTemp(); const migrationsPath = join(dir, "supabase", "migrations"); mkdirSync(join(dir, "supabase"), { recursive: true }); diff --git a/apps/cli/src/command-internal/migration-pending.ts b/apps/cli/src/command-internal/migration-pending.ts index aeccf723e5..d2ca89463a 100644 --- a/apps/cli/src/command-internal/migration-pending.ts +++ b/apps/cli/src/command-internal/migration-pending.ts @@ -2,13 +2,12 @@ import { bold } from "./colors.ts"; import { sortMigrationPathsByVersion } from "./migration-history.ts"; /** - * Local migration filenames are `<digits>_<name>.sql`. - * `ListLocalMigrations` guarantees every path in `localMigrations` matches, so the - * version capture group is always present. + * Local migration filenames are `<digits>_<name>.sql`; callers guarantee every path in + * `localMigrations` matches, so the version capture group is always present. */ const MIGRATE_FILE_PATTERN = /^([0-9]+)_(.*)\.sql$/u; -/** Last path segment, mirroring `filepath.Base`. */ +/** Last path segment of `path`. */ const baseName = (path: string): string => { const normalized = path.replace(/[/\\]+$/u, ""); const slash = Math.max(normalized.lastIndexOf("/"), normalized.lastIndexOf("\\")); @@ -25,16 +24,15 @@ export const ERR_MISSING_LOCAL = "Remote migration versions not found in local migrations directory."; /** - * The outcome of comparing local migration files against the remote - * `schema_migrations` history. Pure 1:1 port of `FindPendingMigrations`. + * The outcome of comparing local migration files against the remote `schema_migrations` + * history. * - * - `ok` — `pending` are the local migration paths to apply (those - * beyond the remote history, in order). - * - `missing-local` — remote has versions with no local file (`ErrMissingLocal`). - * `versions` are the offending remote versions. - * - `missing-remote`— local has files ordered before the remote head - * (`ErrMissingRemote`). `paths` are the out-of-order local - * migration paths. + * - `ok` — `pending` are the local migration paths to apply (those beyond the remote history, + * in order). + * - `missing-local` — remote has versions with no local file. `versions` are the offending + * remote versions. + * - `missing-remote` — local has files ordered before the remote head. `paths` are the + * out-of-order local migration paths. */ export type PendingMigrations = | { readonly kind: "ok"; readonly pending: ReadonlyArray<string> } @@ -42,10 +40,9 @@ export type PendingMigrations = | { readonly kind: "missing-remote"; readonly paths: ReadonlyArray<string> }; /** - * Two-pointer reconciliation of local migration paths vs remote applied versions. - * Mirrors `FindPendingMigrations`, including its **string** comparison of - * versions (`remote == local` / `remote < local`). - * Both sides must agree on ordering, so `localMigrations` is re-sorted by version. + * Two-pointer reconciliation of local migration paths vs remote applied versions, comparing + * versions as strings (`remote == local` / `remote < local`). Both sides must agree on ordering, + * so `localMigrations` is re-sorted by version. */ export function findPendingMigrations( localMigrations: ReadonlyArray<string>, @@ -59,7 +56,7 @@ export function findPendingMigrations( while (i < remoteMigrations.length && j < sortedLocal.length) { const remote = remoteMigrations[i]!; const filename = baseName(sortedLocal[j]!); - // ListLocalMigrations guarantees a match, so the capture group is present. + // Callers guarantee a match, so the capture group is present. const local = MIGRATE_FILE_PATTERN.exec(filename)![1]!; if (remote === local) { i++; @@ -68,12 +65,10 @@ export function findPendingMigrations( missing.push(remote); i++; } else { - // Include out-of-order local migrations. unapplied.push(sortedLocal[j]!); j++; } } - // Ensure all remote versions exist on local. if (j === sortedLocal.length) { missing.push(...remoteMigrations.slice(i)); } @@ -88,10 +83,8 @@ export function findPendingMigrations( } /** - * Computes the `--include-all` pending set when reconciliation reports - * `missing-remote`. Mirrors `GetPendingMigrations` includeAll branch: - * the out-of-order paths first, then the - * local migrations beyond `len(remote)+len(diff)`. + * Computes the `--include-all` pending set when reconciliation reports `missing-remote`: the + * out-of-order paths first, then the local migrations beyond `remoteCount + diff.length`. */ export function includeAllPending( localMigrations: ReadonlyArray<string>, @@ -105,7 +98,6 @@ export function includeAllPending( return [...diff, ...sortedLocal.slice(remoteCount + diff.length)]; } -/** `suggestIgnoreFlag`. */ export function suggestIgnoreFlag(paths: ReadonlyArray<string>): string { return ( "\nRerun the command with --include-all flag to apply these migrations:\n" + diff --git a/apps/cli/src/command-internal/migration-pending.unit.test.ts b/apps/cli/src/command-internal/migration-pending.unit.test.ts index 1d78c21f0a..7c827a0803 100644 --- a/apps/cli/src/command-internal/migration-pending.unit.test.ts +++ b/apps/cli/src/command-internal/migration-pending.unit.test.ts @@ -24,17 +24,13 @@ describe("findPendingMigrations", () => { }); it("is up to date when one version is a string prefix of another (#6036)", () => { - // Not limited to long timestamps: any prefix pair inverts, because - // `10_name.sql` sorts before `1_name.sql` by name ('0' < '_') while remote - // reads back "1" before "10". + // Any prefix pair inverts this way: `10_name.sql` sorts before `1_name.sql` by name ('0' < + // '_'), while remote reads back "1" before "10". const result = findPendingMigrations(local("10", "1"), ["1", "10"]); expect(result).toEqual({ kind: "ok", pending: [] }); }); it("is up to date when an 8-digit and a 14-digit version share a prefix (#6036)", () => { - // Local files arrive in name order, where `20260420010000_name.sql` precedes - // `20260420_name.sql` ('0' < '_') — the reverse of the version order - // `schema_migrations` is read back in. const result = findPendingMigrations(local("20260420010000", "20260420"), [ "20260420", "20260420010000", @@ -82,10 +78,9 @@ describe("findPendingMigrations", () => { describe("includeAllPending", () => { it("slices the version-ordered list, not the name-ordered one (#6036)", () => { - // Local files arrive name-ordered as [20, 1, 2]; version order is [1, 2, 20]. - // With "2" applied, the diff is [1] and the slice must resume at "20". - // Indexing the name-ordered list instead would return "2" — already applied — - // and silently drop "20". + // Name order is [20, 1, 2]; version order is [1, 2, 20]. With "2" applied, the diff is [1] + // and the slice must resume at "20" — indexing the name-ordered list would return "2" + // (already applied) and silently drop "20". const locals = local("20", "1", "2"); const diff = ["supabase/migrations/1_name.sql"]; expect(includeAllPending(locals, 1, diff)).toEqual([ From cb26d73d379fae15594380825a93bf4a2f2508ab Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 12:40:31 +0100 Subject: [PATCH 16/56] chore(repo): trim comments, shard 07 (1776 -> 1160 comment lines) --- .../db-bootstrap/shadow-cache.ts | 353 ++++---------- .../db-bootstrap/shadow-cache.unit.test.ts | 12 +- .../db-bootstrap/shadow-database.ts | 418 +++++----------- .../db-bootstrap/shadow-database.unit.test.ts | 55 +-- .../db-bootstrap/start-database.ts | 135 ++--- .../db-bootstrap/start-local-database.ts | 124 ++--- .../db-bootstrap/templates/db-globals.sql.ts | 7 +- .../templates/db-initial-schema-13.sql.ts | 9 +- .../templates/db-initial-schema-14.sql.ts | 9 +- .../db-bootstrap/templates/db-restore.sh.ts | 16 +- .../db-bootstrap/templates/db-schema.sql.ts | 8 +- .../db-bootstrap/templates/db-supabase.sql.ts | 8 +- .../db-bootstrap/templates/db-webhook.sql.ts | 8 +- .../src/command-internal/db-config.errors.ts | 21 +- .../db-config.integration.test.ts | 43 +- .../src/command-internal/db-config.layer.ts | 258 +++++----- .../src/command-internal/db-config.parse.ts | 461 ++++++++---------- .../db-config.parse.unit.test.ts | 65 +-- .../src/command-internal/db-config.service.ts | 26 +- 19 files changed, 707 insertions(+), 1329 deletions(-) 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 865030418d..8ae52abc9c 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts @@ -1,9 +1,8 @@ /** - * Shadow baseline cache for `db diff`/`db pull`/catalog resolution. Snapshots the platform + * Shadow baseline cache for `db diff`/`db pull`/catalog resolution: snapshots the platform * baseline as a PGDATA tar under `${SUPABASE_HOME}/cache/shadow-baseline/` (keep 3, 2-day TTL). - * On by default; an explicitly set `SUPABASE_SHADOW_CACHE` that is not viper-true (`0`/`false`/ - * empty/garbage, from the shell env or the project dotenv) turns it off. A cache miss or - * anomaly never fails the run except when the shadow does not come back after a cold export. + * On by default; set `SUPABASE_SHADOW_CACHE` to a falsy value to disable it. A cache miss or + * anomaly never fails the run, except when the shadow doesn't come back after a cold export. */ import { createHash, scryptSync } from "node:crypto"; @@ -64,30 +63,18 @@ type Spawner = ChildProcessSpawner["Service"]; export const SHADOW_CACHE_ENV = "SUPABASE_SHADOW_CACHE"; /** - * Internal-only "the cache cannot be used" signal. Deliberately NOT a `Data.TaggedError`: it - * never reaches a user or telemetry — every producer is caught by - * {@link acquireShadowDatabase} (which falls back to a cold provision) or by - * {@link exportShadowBaseline} (which warns and continues uncached), so classifying it as a - * CLI error would be misleading, and would pollute the error-actionability vocabulary with a - * failure the user can neither see nor act on. + * Internal "cache unusable" signal, not a `Data.TaggedError`: it never reaches a user or + * telemetry, since every producer is caught by {@link acquireShadowDatabase} (falls back to a + * cold provision) or {@link exportShadowBaseline} (warns and continues uncached). */ interface ShadowCacheUnavailable { readonly reason: string; /** - * `true` only when the failure implicates the TAR'S CONTENTS — today, exactly three producers, - * all in {@link warmShadow}: an archive whose header stream is missing a required entry — - * the PGDATA cluster file or the baseline marker; one whose marker vouches for a DIFFERENT cache - * key than the filename it is stored under (both checked before any container is created); and a - * restored cluster that started but never - * accepted connections (the readiness wait). The wrong-key case is the one where the bytes may - * be a perfectly good snapshot — of another key — so what is discarded is only the MISNAMED - * COPY, which is exactly right: nothing else can be keyed by this filename. - * Everything else (a `docker create`/`cp`/`start` - * failure — daemon outage, - * port collision, or even a corrupt archive's failed extraction) leaves the tar in place: an - * infra failure says nothing about the tar, and a genuinely corrupt one is atomically - * REPLACED by the cold fallback's own export in the same run, so deleting up front would only - * throw away a valid ~15s-to-rebuild baseline on transient Docker failures. + * `true` only when the failure implicates the tar's own contents: a missing required entry, a + * marker stamped with a different cache key, or a restored cluster that never became ready. Any + * other failure (a `docker create`/`cp`/`start` failure, a corrupt-archive extraction) leaves + * the tar in place — an infra failure says nothing about the tar's contents, and a genuinely + * corrupt one gets replaced by the cold fallback's own export. */ readonly tarSuspect?: boolean; } @@ -97,10 +84,6 @@ const shadowCacheUnavailable = ( opts: { readonly tarSuspect?: boolean } = {}, ): ShadowCacheUnavailable => ({ reason, ...opts }); -// --------------------------------------------------------------------------- -// Cache key -// --------------------------------------------------------------------------- - /** One of the three PG15+ one-shot migrate jobs, as the cache key sees it. */ interface ShadowCacheServiceInput { readonly enabled: boolean; @@ -141,9 +124,8 @@ export interface ShadowCacheKeyInputs { /** `supabase/roles.sql`'s contents, `""` when absent. */ readonly rolesSql: string; /** - * `[db.vault]` secrets — names AND values, both of which land in `vault.secrets`. Only - * RESOLVED entries are hashed ({@link shadowCacheKey}), because the upsert skips - * unresolved ones entirely — see the loop's own comment there. + * `[db.vault]` secrets — names and values, both of which land in `vault.secrets`. Only + * resolved entries are hashed ({@link shadowCacheKey}), since the upsert skips unresolved ones. */ readonly vault: ReadonlyArray<VaultSecret>; readonly services: { @@ -152,16 +134,10 @@ export interface ShadowCacheKeyInputs { readonly auth: ShadowCacheServiceInput; }; /** - * The resolved JWKS string (`ShadowDbSetupInput.jwks`, an `Effect` the caller resolves - * lazily) that realtime's one-shot tenant-seed job bakes into the cluster - * (`buildRealtimeEnv`'s `jwks` field, `db-setup.ts`) — a config change under - * `auth.third_party` changes this value without touching anything else in this struct, so it - * needs its own field rather than folding into `services.realtime`. Hashed ONLY when - * `services.realtime` is enabled AND `majorVersion >= 15` — the exact compound gate - * {@link resolveDbSetupPrelude} itself uses to decide whether to resolve (and therefore - * whether the one-shot job ever consumes) this same effect during a real setup: a disabled or - * pre-PG15 realtime never reaches the job that reads it. `""` when excluded, mirroring - * {@link ShadowCacheServiceInput.image}. + * Realtime's resolved JWKS, baked into the cluster by its one-shot tenant-seed job. Kept as its + * own field because an `auth.third_party` config change alone changes this value. Hashed only + * when `services.realtime` is enabled and `majorVersion >= 15` — the same gate the real setup + * uses to decide whether the job ever reads it; `""` otherwise. */ readonly jwks: string; } @@ -205,27 +181,20 @@ function canonicalJson(value: unknown): string { } /** - * The two-state behavior `applyApiPrivileges` (`db-setup.ts`) actually derives from - * `api.auto_expose_new_tables`' tri-state: unset and explicit `true` both keep the grants and bake - * the exact same cluster; only an explicit `false` execs - * {@link START_REVOKE_API_PRIVILEGES_SQL}. Hashing the raw tri-state would split unset from - * `true` into different keys and force a spurious ~90MB re-snapshot for a config edit that changes - * nothing on disk. + * The two-state behavior `applyApiPrivileges` derives from the tri-state + * `api.auto_expose_new_tables`: unset and explicit `true` both keep the grants; only `false` + * revokes them. Hashing the raw tri-state would split unset from `true` into different keys for a + * config edit that changes nothing on disk. */ const effectiveShadowApiGrantsKept = (value: Option.Option<boolean>): boolean => Option.getOrElse(value, () => true); /** - * The cache key: a 16-hex-char (64-bit) scrypt prefix over a fixed field order. 64 bits is - * ample for a per-settings global cache whose only cost of a collision would be a wrong baseline - * — and every genuinely divergent input is in the payload, so a collision needs an actual hash - * collision, not a missed field. Short enough to read in a filename. - * - * scrypt, not a fast hash: the payload embeds `jwt_secret`, `root_key`, `db_password`, and - * resolved vault values (excluding them would fingerprint the wrong PGDATA), and the key lands - * in filenames, where a fast hash invites offline brute-force toward those secrets - * (CodeQL js/insufficient-password-hash). The fixed salt is deliberate: the key must stay - * deterministic across runs, or the cache never hits. + * The cache key: a 16-hex-char (64-bit) scrypt prefix over a fixed field order. 64 bits is ample + * for a global cache where a collision only costs a wrong baseline. scrypt, not a fast hash, + * because the payload embeds secrets (`jwt_secret`, `root_key`, `db_password`, vault values) and + * the key lands in filenames, where a fast hash would invite offline brute-force + * (CodeQL js/insufficient-password-hash). The salt is fixed so the key stays deterministic across runs. */ export function shadowCacheKey(inputs: ShadowCacheKeyInputs): string { // JSON-encode unrestricted strings so a raw newline cannot forge the next payload line. @@ -233,9 +202,8 @@ export function shadowCacheKey(inputs: ShadowCacheKeyInputs): string { const lines: Array<string> = [ `postgres_image=${quoted(inputs.postgresImage)}`, `major_version=${inputs.majorVersion}`, - // Host publish port is deliberately excluded: it is not baked into PGDATA, and - // pg-delta next allocates an ephemeral port per shadow. Hashing it would miss - // every warm hit on that path. Restore always uses the current run's port. + // The host publish port is excluded: it isn't baked into PGDATA, and pg-delta next allocates + // an ephemeral port per shadow, so hashing it would miss every warm hit. `jwt_secret=${quoted(inputs.jwtSecret)}`, `jwt_expiry=${inputs.jwtExpiry}`, `root_key=${quoted(inputs.rootKey)}`, @@ -253,16 +221,13 @@ export function shadowCacheKey(inputs: ShadowCacheKeyInputs): string { : `service=${name} enabled=false`, ); } - // Realtime's resolved JWKS — see the field's own doc comment for the compound - // enabled+majorVersion gate (mirrors `service=realtime`'s own `enabled` exclusion above, plus - // the PG15+ gate the one-shot job itself is behind). + // Realtime's resolved JWKS; see the field's doc comment for the enabled+majorVersion gate. lines.push( inputs.services.realtime.enabled && inputs.majorVersion >= 15 ? `realtime_jwks=${quoted(inputs.jwks)}` : "realtime_jwks=excluded", ); - // Storage's migration pin — same compound enabled+majorVersion gate as the JWKS line above, - // because the consuming one-shot job (`startInitSchema15`) is behind the same gate. + // Storage's migration pin, gated the same way as the JWKS line above. lines.push( inputs.services.storage.enabled && inputs.majorVersion >= 15 ? `storage_target_migration=${quoted(inputs.storageTargetMigration)}` @@ -272,36 +237,20 @@ export function shadowCacheKey(inputs: ShadowCacheKeyInputs): string { for (const secret of inputs.vault .filter((secret) => secret.resolved) .sort((left, right) => (left.name < right.name ? -1 : left.name > right.name ? 1 : 0))) { - // JSON-encoded tuple, not `name=value`: both halves are unrestricted strings, so a bare - // `=` join would let (`a=b`, `c`) and (`a`, `b=c`) collide — and a value containing a - // newline could forge a whole extra payload line. + // JSON-encoded tuple, not `name=value`: a bare `=` join would let (`a=b`, `c`) and (`a`, + // `b=c`) collide, and a newline in the value could forge an extra payload line. lines.push(`vault=${JSON.stringify([secret.name, secret.value])}`); } - // Last, raw (it can contain anything, including newlines) — mirroring `setupInputsToken`, - // which also appends `roles.sql` verbatim at the end of its own payload. + // Appended last and raw since it can contain anything, including newlines. const payload = `${lines.join("\n")}\nroles_sql=\n${inputs.rolesSql}`; return scryptSync(payload, "supabase-shadow-cache-key", 32).toString("hex").slice(0, 16); } /** - * Resolves {@link ShadowCacheKeyInputs} from the same run input the shadow container - * itself is built from, plus `supabase/roles.sql` off disk. The service enabled flags come from - * `setup.config` (NOT the `*EnabledForSetup` fields, which only gate JWKS resolution) because - * `setupDatabase`'s own one-shot job gates read exactly those config fields. - * - * Returns `Option.none` (never a failure) for the two conditions that make caching - * unavailable — an OrioleDB cluster (whose state is partly external, see the body's own - * comment) and an unreadable `roles.sql` — so this function's OWN error channel carries - * nothing but `E`: the `input.setup.jwks` effect below is `yield*`ed unguarded (only when the - * consuming realtime job is actually reachable — see the field's own doc comment), and a real - * JWKS failure must fail this whole acquire exactly as it would have failed a real setup, never - * be folded into a cache-miss the way {@link ShadowCacheUnavailable} failures are - * elsewhere in this module. Keeping the two failure modes on separate channels (`Option.none` - * vs. a genuine `Effect` failure) is what lets the caller (`acquireShadowDatabase`) - * degrade the former to an uncached shadow while letting the latter propagate, without an `as` - * cast to tell them apart. + * Resolves {@link ShadowCacheKeyInputs}, reading `supabase/roles.sql` off disk. Returns + * `Option.none` (never a failure) when caching is unavailable (OrioleDB, unreadable `roles.sql`), + * so a genuine JWKS resolution failure surfaces on `E` and the caller can tell the two apart. */ - const resolveShadowCacheKeyInputs = <E>( input: ShadowSetupInput<E>, opts: ShadowCacheOpts = {}, @@ -328,16 +277,12 @@ const resolveShadowCacheKeyInputs = <E>( if (rolesSql === undefined) return Option.none(); const overrides = input.setup.serviceVersionOverrides; - // The same registry rewrite `runStartMigrateJob`'s `ensureImagesCached` applies - // when the job actually runs (same `projectEnvValues`-then-ambient precedence) — see + // Same registry rewrite the real migrate job applies when it runs — see // {@link ShadowCacheServiceInput.image}. const resolveJobImage = (image: string): string => getRegistryImageUrl(image, input.setup.projectEnvValues); - // The exact compound gate {@link resolveDbSetupPrelude} uses to decide whether the - // realtime one-shot job's JWKS effect is reached (and therefore run) at all during a real - // setup — see `db-setup.ts`. Gating on anything looser here would resolve (and risk failing - // on) an effect a real cold provision at this same `majorVersion`/`enabled` combination would - // never have touched. + // Same compound gate a real cold provision uses to decide whether the realtime job's JWKS + // effect ever runs. const realtimeConsumesJwks = input.setup.majorVersion >= 15 && input.setup.config.realtime.enabled; const jwks = realtimeConsumesJwks ? yield* input.setup.jwks : ""; @@ -346,10 +291,8 @@ const resolveShadowCacheKeyInputs = <E>( majorVersion: input.db.major_version, jwtSecret: input.jwtSecret, jwtExpiry: input.jwtExpiry, - // The EFFECTIVE value, not the raw input: `buildShadowPostgresContainerSpec` - // (`postgres.service.ts`) falls back to the embedded default when unset, so hashing `""` - // would fail to re-key if that security-sensitive default ever rotates between CLI - // releases. + // The effective value: the container spec falls back to an embedded default when unset, + // so hashing `""` would miss a re-key if that default ever changes. rootKey: input.rootKey ?? POSTGRES_DEFAULT_ROOT_KEY, dbPassword: input.password, dbSettings: input.db.settings, @@ -376,10 +319,6 @@ const resolveShadowCacheKeyInputs = <E>( } satisfies ShadowCacheKeyInputs); }); -// --------------------------------------------------------------------------- -// The tar artifact -// --------------------------------------------------------------------------- - /** Filename prefix shared by every key's snapshot — the handle the retention sweep enumerates by. */ const SHADOW_BASELINE_TAR_PREFIX = "shadow-baseline-"; @@ -400,9 +339,8 @@ export function shadowBaselineTarFileName(key: string): string { } /** - * Whether `fileName` is a published baseline snapshot (`shadow-baseline-<key>.tar`). Pure and - * deliberately conservative: only this module's own prefix AND suffix, so partials - * (`…tar.<pid>.partial`) and any unrelated file in the cache dir are never eviction candidates. + * Whether `fileName` is a published baseline snapshot (`shadow-baseline-<key>.tar`). Checks the + * exact prefix and suffix, so partials (`…tar.<pid>.partial`) are never eviction candidates. */ export function isShadowBaselineTar(fileName: string): boolean { return ( @@ -460,10 +398,8 @@ const forgetShadowBaselineTar = ( /** * Whether `fileName` is one of {@link exportPgDataTar}'s in-flight temp files - * (`shadow-baseline-<key>.tar.<pid>.partial`). Pure name check only — whether it is ABANDONED - * (crashed/SIGKILLed writer, whose `Effect.onError` cleanup never ran) is an mtime question the - * sweep answers separately, so a concurrent writer's live temp file is never a candidate by name - * alone. + * (`shadow-baseline-<key>.tar.<pid>.partial`). A name-only check; whether it's abandoned is an + * mtime question the sweep answers separately, so a live writer's temp file is never a candidate. */ export function isShadowBaselinePartial(fileName: string): boolean { return /^shadow-baseline-[0-9a-f]{16}\.tar\.\d+\.partial$/u.test(fileName); @@ -473,10 +409,9 @@ export function isShadowBaselinePartial(fileName: string): boolean { const SHADOW_PARTIAL_ABANDON_MS = 5 * 60 * 1000; /** - * Removes abandoned `.partial` temp files (see {@link isShadowBaselinePartial}) — the one - * artifact a SIGKILLed/crashed cold export leaves behind that nothing else ever cleans: later - * runs use their own pid in the temp name, and the tar retention sweep deliberately ignores - * `.partial` names — best-effort throughout. + * Removes abandoned `.partial` temp files (see {@link isShadowBaselinePartial}) left behind by a + * crashed cold export — later runs use their own pid, and the retention sweep ignores `.partial` + * names, so nothing else ever cleans these up. Best-effort throughout. */ const sweepAbandonedShadowBaselinePartials = <E>(input: ShadowSetupInput<E>): Effect.Effect<void> => Effect.gen(function* () { @@ -540,10 +475,6 @@ const touchShadowBaselineTar = (fs: FileSystem.FileSystem, tarPath: string): Eff yield* fs.utimes(tarPath, now, now); }).pipe(Effect.orElseSucceed(() => undefined)); -// --------------------------------------------------------------------------- -// Container primitives the cache adds on top of `shadow-database.ts` -// --------------------------------------------------------------------------- - /** * `docker <verb> <id>`, resolving to {@link ShadowCacheUnavailable} on anything but a clean * exit. Both verbs this is used for (`stop`, `start`) are steps the export cannot proceed without, @@ -586,30 +517,20 @@ const awaitShadowReady = <E>( ), ); -// --------------------------------------------------------------------------- -// Cold export -// --------------------------------------------------------------------------- - /** - * Serializes same-process cold exports. Two shadows provisioned concurrently in one process - * (pg-delta next's plan shadows) can both reach the export step, and with an equal key they - * would race on the same `<tar>.<pid>.partial` temp path — `exportPgDataTar` scopes its - * temp name by pid alone. One permit makes that interleaving impossible; cross-process writers - * were never affected (distinct pids). + * Serializes same-process cold exports. Two shadows provisioned concurrently in one process with + * an equal key would race on the same `<tar>.<pid>.partial` temp path, since `exportPgDataTar` + * scopes that name by pid alone; cross-process writers are unaffected (distinct pids). */ const shadowExportMutex = Semaphore.makeUnsafe(1); /** - * Ensures the tar's global cache directory exists, delegates the actual export to - * {@link exportPgDataTar} (`pgdata-snapshot.ts` — see that function's own doc comment for - * the atomic-publish mechanics), then applies the LRU + TTL retention rule. Runs under - * {@link shadowExportMutex}. + * Creates the tar's cache directory, delegates to {@link exportPgDataTar} for the export, then + * applies LRU + TTL retention. Runs under {@link shadowExportMutex}. * - * `skipIfPublished` dedupes same-key sibling exports: when the tar was absent at acquire time, - * one published while this fiber waited on the permit is a sibling's snapshot of this same - * baseline. It must be `false` on the warm-fallback cold path, where a tar deliberately - * retained despite an unusable restore is sitting at this path waiting to be atomically - * replaced. + * `skipIfPublished` dedupes same-key sibling exports (a tar published by a sibling while this + * fiber waited on the permit); set it `false` on the warm-fallback cold path, where an unusable + * tar is retained at this path waiting to be replaced. */ const writeShadowBaselineTar = <E>( spawner: Spawner, @@ -705,20 +626,15 @@ const exportShadowBaseline = <E>( } }); -// --------------------------------------------------------------------------- -// Acquire / release -// --------------------------------------------------------------------------- - export interface ShadowCacheOpts { /** `sync --no-cache`: neither restore nor publish, regardless of the env gate. */ readonly bypassCache?: boolean; /** Effective webhooks/`pg_net` policy — hashed so migrate/declarative snapshots cannot mix. */ readonly webhooks?: SetupDatabaseOptions["webhooks"]; /** - * Key inputs a caller already resolved via {@link peekShadowBaseline}, so - * {@link acquireShadowDatabase} does not resolve them a second time. Resolution can - * include a live JWKS discovery request (realtime on PG15+). Must have been computed from the - * same `input`/`opts` pair, or the acquire keys against the wrong snapshot. + * Key inputs a caller already resolved via {@link peekShadowBaseline}, to skip resolving them + * again (resolution can include a live JWKS discovery request). Must come from the same + * `input`/`opts` pair, or the acquire keys against the wrong snapshot. */ readonly precomputedKeyInputs?: ShadowCacheKeyInputs; } @@ -736,13 +652,8 @@ export type ShadowBaselinePeek = /** * Answers "what would {@link acquireShadowDatabase} do for this input right now?" without - * creating a container. Callers use it to choose an orchestration (pg-delta next's plan - * provisioning picks parallel / baseline-handoff / sequential), never to skip the acquire's own - * re-checks: the answer can go stale between peek and acquire, and the acquire re-deciding on - * current disk state keeps that race merely suboptimal rather than incorrect. - * - * The error channel is the key resolution's own `E` (a JWKS resolution failure) — same rationale - * as {@link acquireShadowDatabase}. + * creating a container, so callers can choose an orchestration strategy up front. The answer can + * go stale between peek and acquire; callers must not use it to skip the acquire's own re-checks. */ export const peekShadowBaseline = <E>( input: ShadowSetupInput<E>, @@ -771,20 +682,17 @@ export const peekShadowBaseline = <E>( }); /** - * What `Effect.acquireUseRelease`'s `acquire` hands the `use` phase: the container, whether its - * cluster already carries the platform baseline, and the snapshot step to run once a fresh - * baseline is in place. Release needs nothing extra — every shadow this module hands out is - * removed the same way an uncached one is. + * What `acquireUseRelease`'s `acquire` hands the `use` phase: the container, whether its cluster + * already carries the platform baseline, and the snapshot step to run once a fresh baseline is in + * place. Release removes every shadow the same way, cached or not. */ export interface ShadowAcquiredHandle extends ShadowBaselineState { readonly containerId: string; /** - * The resolved shadow-baseline cache key this handle's cluster is keyed under — present - * exactly when the acquisition was cache-eligible (a cold export or a warm restore), absent - * for an uncached, `bypassCache`d, or uncachable one. Two handles carrying the SAME key share - * the same tar's lineage: one either restored it or exported it this run, so their clusters - * are physical clones of each other. `pgdelta-next-shadow.layer.ts` reads it to decide - * whether pg-delta's same-database-identity guard must be bypassed for a plan's two shadows. + * The cache key this handle's cluster is keyed under — present exactly when the acquisition + * was cache-eligible (a cold export or a warm restore). Two handles carrying the same key are + * physical clones of each other's cluster; pg-delta reads this to decide whether its + * same-database-identity guard must be bypassed for a plan's two shadows. */ readonly snapshotKey?: string; } @@ -806,14 +714,12 @@ const uncachedShadow = <E>( /** * A cold, cache-enabled shadow: today's container plus the export step at the baseline seam. * - * `autoRemove: false` is the one and only container-shape difference the cache introduces, and it - * is forced: the export has to `docker stop` the container and `docker start` it again, and Docker - * destroys an `--rm` container the moment it exits. Release still removes it with `docker rm -f - * -v`, so the container's lifetime is unchanged — see - * {@link CreateShadowDatabaseInput.autoRemove}. + * `autoRemove: false` is forced: the export must `docker stop`/`start` the container, and Docker + * destroys an `--rm` container the moment it exits. Release still removes it explicitly, so its + * lifetime is unchanged — see {@link CreateShadowDatabaseInput.autoRemove}. * - * `skipIfPublished` must reflect whether the tar was absent when this cold acquisition began — - * see {@link writeShadowBaselineTar}. + * `skipIfPublished` must reflect whether the tar was absent when this acquisition began — see + * {@link writeShadowBaselineTar}. */ const coldCachedShadow = <E>( spawner: Spawner, @@ -845,15 +751,10 @@ const coldCachedShadow = <E>( const SHADOW_CACHE_KEY_PATTERN = /^[0-9a-f]{16}$/u; /** - * The warm-path warning's wording for a rejected snapshot. Names WHICH of the two content failures - * happened, because they mean different things to whoever reads the line: a missing entry is a - * broken or hand-placed artifact, while a wrong key is a real snapshot of another configuration - * sitting under this one's filename (a copied cache directory, a renamed file) — and only the - * misnamed copy is being discarded, not that other key's own tar. - * - * The marker's own bytes are NOT echoed verbatim: they come from a file this run did not write, - * capped at a KiB but otherwise arbitrary, and stderr is not the place to render them. Only a token - * that is shaped like a cache key is shown. + * Wording for the warm-path warning on a rejected snapshot, distinguishing a missing entry + * (broken or hand-placed artifact) from a wrong key (a real snapshot of another configuration + * under this filename). The marker's raw bytes are never echoed — only a token shaped like a + * cache key is shown, since the marker comes from a file this run didn't write. */ const describeShadowArchiveProblem = (problem: PgDataArchiveProblem): string => Match.valueTags(problem, { @@ -868,22 +769,11 @@ const describeShadowArchiveProblem = (problem: PgDataArchiveProblem): string => }); /** - * The warm path proper: verify the snapshot tar really carries a baselined cluster, create the - * shadow with it unpacked into it before it starts ({@link CreateShadowDatabaseInput.restoreArchive}), - * then wait for the restored Postgres. Every failure resolves to - * {@link ShadowCacheUnavailable}, which the caller turns into the escape hatch. - * - * The readiness failure removes the container here rather than leaving it to the caller, because - * the caller's fallback creates a REPLACEMENT container and the suspect one must be gone by then - * (it holds the shadow's published port). - * - * The readiness wait is explicitly `Effect.interruptible`: this whole function runs inside - * `Effect.acquireUseRelease`'s uninterruptible `acquire` ({@link withShadowDatabase}), and - * while the restore itself is short (~2s of `docker create` + `docker cp -`), a restored container - * that starts but never accepts connections would otherwise pin a Ctrl-C for the full - * `healthTimeoutSeconds` budget — exactly the swallowed-SIGINT shape `prepareShadowSource`'s - * own doc comment (`shadow-source.ts`) was restructured to avoid. `Effect.onInterrupt` - * removes the container on that path, so re-enabling interruption cannot leak it. + * The warm path: verify the tar carries a baselined cluster, restore it into a new shadow, then + * wait for Postgres; every failure resolves to {@link ShadowCacheUnavailable}. The readiness + * check removes the container here so the caller's replacement can reuse its published port, and + * stays `Effect.interruptible` so a hung container can't pin Ctrl-C for the full health-timeout + * budget — `Effect.onInterrupt` removes it on that path. */ const warmShadow = <E>( spawner: Spawner, @@ -892,17 +782,11 @@ const warmShadow = <E>( tarPath: string, ): Effect.Effect<ShadowAcquiredHandle, ShadowCacheUnavailable, Output | DbConnection> => Effect.gen(function* () { - // An archive that unpacks cleanly but carries the wrong thing is the ONE corruption the restore - // itself cannot report: `docker cp -` extracts whatever it is given, the entrypoint skips - // `initdb` (or runs one over an empty PGDATA), readiness passes, and this function would hand - // back `baselinePresent: true` for a cluster that never saw `setupDatabase` — the caller - // then skips it too and diffs against a BARE database, silently producing wrong SQL. The same - // shape hides a second lie: a fully baselined snapshot of ANOTHER key, copied over this key's - // filename, restores just as cleanly while carrying different roles/vault values/service - // schema. So the tar's own headers are scanned BEFORE anything is created (locally, no Docker) - // for the cluster file, for the baseline marker this module stamps immediately before every - // export, AND for that marker's key — see {@link validatePgDataArchive}. A read failure - // is infra and leaves the tar in place; either verdict implicates its CONTENTS. + // A tar that unpacks cleanly but carries the wrong cluster is the one corruption the restore + // can't report itself — readiness would still pass. So the headers are scanned before any + // container is created, checking the cluster file, this module's baseline marker, and the + // marker's key — see {@link validatePgDataArchive}. A read failure is infra and leaves the + // tar in place; either verdict implicates its contents. const problem = yield* validatePgDataArchive(input.fs, tarPath, key).pipe( Effect.mapError((cause) => shadowCacheUnavailable(cause.reason)), ); @@ -922,7 +806,7 @@ const warmShadow = <E>( ), ); yield* awaitShadowReady(spawner, input, containerId, "restored shadow").pipe( - // The ONE failure that implicates the tar's contents: the restored cluster started but + // The one failure that implicates the tar's contents: the restored cluster started but // never accepted connections — see {@link ShadowCacheUnavailable.tarSuspect}. Effect.mapError((cause) => shadowCacheUnavailable(cause.reason, { tarSuspect: true })), Effect.tapError(() => removeShadowDatabase(spawner, containerId)), @@ -939,29 +823,12 @@ const warmShadow = <E>( }); /** - * `Effect.acquireUseRelease`'s `acquire` for every shadow-provisioning call site that runs the - * platform baseline (`db diff`'s migra/pg-delta branch, `db pull`'s migration diff, and - * pg-delta's scoped shadows) — see - * {@link withShadowDatabase} for the acquire/use/release wrapper, and - * `pgdelta-next-shadow.layer.ts` for the scoped `acquireRelease` form next uses so the - * container outlives provision (the engine keeps using the URL after this returns). - * - * An explicitly falsey {@link SHADOW_CACHE_ENV} (set, but not viper-true) is the - * uncached create. Otherwise — including when the variable is unset, the default — it - * restores this key's snapshot (warm) or creates one and exports the baseline (cold). - * - * Runs inside `acquireUseRelease`'s uninterruptible `acquire`, same as - * `createShadowDatabase` — a warm restore adds ~2s of uninterruptible work (a `docker cp -`); - * its readiness wait re-enables interruption explicitly (see {@link warmShadow}), and the - * multi-second baseline/migration sequence stays in the interruptible `use` phase exactly as - * before. - * - * The `E` in the error channel is {@link resolveShadowCacheKeyInputs}'s own JWKS - * resolution alone (see that function's doc comment): every OTHER failure this function's own - * body can produce while computing the key or restoring the snapshot is caught and degraded — an - * unusable cache root to the plain uncached shadow (with a warning), anything on the warm path to - * a cold provision — a genuine JWKS failure is the one case that must reach the caller instead, - * since a real cold provision at this input would have failed the same way. + * `Effect.acquireUseRelease`'s `acquire` for every shadow that runs the platform baseline (`db + * diff`, `db pull`, pg-delta's scoped shadows) — see {@link withShadowDatabase} for the + * acquire/use/release wrapper. An explicitly falsy {@link SHADOW_CACHE_ENV} skips the cache + * entirely; otherwise it restores this key's snapshot (warm) or exports a fresh baseline (cold). + * The `E` error channel carries only a genuine JWKS resolution failure — every other failure here + * degrades to an uncached shadow or a cold provision instead of propagating. */ export const acquireShadowDatabase = <E>( spawner: Spawner, @@ -986,15 +853,11 @@ export const acquireShadowDatabase = <E>( ); if (Option.isNone(keyInputs)) return yield* uncachedShadow(spawner, input); - // The cache root must be usable BEFORE committing to the cached lifecycle: the cold path - // drops `--rm` and pays a stop → export → restart cycle whose write is already doomed when - // this directory cannot be written (an unwritable or root-squashed `SUPABASE_HOME`) — and - // with the cache on by default, every affected invocation would pay that cycle just to warn. - // The mkdir is the exact one the export performs (which keeps its own as a safety net for a - // root that vanishes mid-run), but alone it is not a sufficient probe: recursive mkdir on an - // ALREADY-EXISTING directory creates nothing and succeeds regardless of permission, so the - // `access(W_OK)` check is what catches a pre-existing read-only root (EACCES for the current - // user, EROFS on a read-only mount, a root-squashing NFS server's denial). + // The cache root must be usable before committing to the cached lifecycle: the cold path + // drops `--rm` and pays a stop/export/restart cycle that's already doomed if this directory + // can't be written. The mkdir alone isn't a sufficient probe — it succeeds on an + // already-existing directory regardless of permission — so `access(W_OK)` catches a + // pre-existing read-only root (EACCES, EROFS, a root-squashing NFS server). const cacheDir = shadowBaselineCacheDir(input.path); const cacheRoot = yield* Effect.result( input.fs @@ -1030,10 +893,9 @@ export const acquireShadowDatabase = <E>( `Warning: cached shadow baseline unusable (${cause.reason}); recreating.\n`, "stderr", ); - // Delete ONLY when the failure implicates the tar's contents (a restored cluster that - // came up broken) — see {@link ShadowCacheUnavailable.tarSuspect} for why an - // infra or extraction failure leaves it in place (the cold fallback's own export - // republishes over a genuinely bad tar anyway). + // Delete only when the failure implicates the tar's contents — see + // {@link ShadowCacheUnavailable.tarSuspect}; an infra failure leaves it in place, since + // the cold fallback's own export republishes over a genuinely bad tar anyway. if (cause.tarSuspect === true) { yield* forgetShadowBaselineTar(input.fs, tarPath); } @@ -1051,12 +913,11 @@ export const acquireShadowDatabase = <E>( }); /** - * Acquire/use/release for a platform-baseline shadow. `acquireUseRelease` registers - * removal in the same uninterruptible continuation as a successful create, so a - * SIGINT between those steps cannot leak the container. A create that fails - * after `docker create` still has the pre-existing leak window. Health-wait and - * migrate stay in `use` so they stay interruptible. A JWKS failure during key - * resolve propagates from acquire rather than degrading to an uncached shadow. + * Acquire/use/release for a platform-baseline shadow. `acquireUseRelease` registers removal in + * the same uninterruptible continuation as a successful create, so a SIGINT between those steps + * can't leak the container — a create that fails after `docker create` still has the pre-existing + * leak window. Health-wait and migrate stay in `use`, and a JWKS failure during key resolution + * propagates from acquire rather than degrading to an uncached shadow. */ export const withShadowDatabase = <E, A, E2, R2>( spawner: Spawner, diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts index 97c8d501b4..09336501bb 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.unit.test.ts @@ -160,9 +160,6 @@ describe("shadowCacheKey", () => { }); it("collapses auto_expose_new_tables to the behavior applyApiPrivileges actually takes", () => { - // `applyApiPrivileges` (`db-setup.ts`) returns early for unset AND explicit `true`, so - // those two bake the identical cluster and must share a snapshot instead of forcing a ~90MB - // re-export; only an explicit `false` execs the revoke SQL and earns its own key. const base = baseKeyInputs(); const unset = shadowCacheKey({ ...base, autoExposeNewTables: Option.none() }); const explicitTrue = shadowCacheKey({ ...base, autoExposeNewTables: Option.some(true) }); @@ -239,9 +236,7 @@ describe("shadowCacheKey", () => { it("cannot collide scalar fields across line boundaries", () => { const base = baseKeyInputs(); - // A newline embedded in one unrestricted scalar must not be able to forge the next - // payload line: rootKey `p\ndb_password="q"` + password `r` vs rootKey `p` + a password - // whose tail mimics the same text. + // rootKey's tail mimics a `db_password` line to try to forge the next payload line. const left = shadowCacheKey({ ...base, rootKey: 'p"\ndb_password="q', @@ -262,7 +257,7 @@ describe("shadowCacheKey", () => { vault: [...base.vault, { name: "pending", value: "", resolved: false }], }); expect(withUnresolved).toBe(shadowCacheKey(base)); - // A RESOLVED empty value does land in the cluster, so it must re-key. + // A resolved empty value still lands in the cluster and must re-key. const withResolvedEmpty = shadowCacheKey({ ...base, vault: [...base.vault, { name: "pending", value: "", resolved: true }], @@ -272,8 +267,7 @@ describe("shadowCacheKey", () => { it("cannot collide vault name/value pairs across the tuple boundary", () => { const base = baseKeyInputs(); - // `name=a=b, value=c` vs `name=a, value=b=c` — a bare `=`-joined encoding serializes both - // as `vault=a=b=c`. + // `name=a=b, value=c` and `name=a, value=b=c` would collide under a bare `=`-joined encoding. const left = shadowCacheKey({ ...base, vault: [{ name: "a=b", value: "c", resolved: true }], 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 3629cac1f6..0fe6d07083 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts @@ -1,28 +1,15 @@ /** - * The shadow-database provisioning primitives: create, health-wait, platform-baseline setup, - * and migrations replay. These are the low-level, dependency-light building blocks — the - * composed diff/pull shape (`prepareShadowSource`, with its migra declarative-schema - * branch) lives in `commands/db/shared/shadow-source.ts` instead, so this module and - * the rest of `shared/db-bootstrap/` never pull in the diff engines. + * The shadow-database provisioning primitives: create, health-wait, platform-baseline setup, and + * migrations replay. Exposed individually rather than fused, since callers compose different + * subsets — `migration squash` needs create -> health-wait -> connect -> setup only, while `db + * diff --use-pgadmin` needs create -> health-wait -> migrations replay. The composed diff/pull + * shape lives in `commands/db/shared/shadow-source.ts` instead, so this module never pulls in the + * diff engines. * - * Exposed as individual primitives rather than one fused function because the callers compose - * different subsets: `migration squash` needs create -> health-wait -> connect -> setup (no - * `CREATE_TEMPLATE`, no migrations at that point), while `db diff --use-pgadmin` (see - * `diff.handler.ts`'s pgadmin branch) needs create -> health-wait -> migrations replay. - * - * A note on the shadow container's own addressing, since it's the one genuinely surprising - * empirical fact this whole module depends on: the shadow container is created with NO name - * (Docker auto-generates one) and NO network alias (`buildShadowPostgresContainerSpec`), - * unlike every other container this codebase creates. The PG15+ one-shot setup jobs - * (`setupDatabase` -> `initSchema15`) still need SOME hostname to reach it over the - * shared Docker network, though — Go passes `container[:12]` (the container id's own 12-char - * short form) as that hostname (`diff.go:172`, `squash.go:96`). This was verified empirically - * against a real Docker daemon (matching Go's exact container-creation shape: no `--name`, no - * `--network-alias`, joined to a user-defined network via `NetworkMode` alone): `docker - * inspect`'s `NetworkSettings.Networks.<net>.DNSNames` lists BOTH the auto-generated name AND - * the 12-char short id, and a sibling container on the same network successfully resolved and - * authenticated against Postgres using ONLY the short id as hostname. So `dbHost: - * container.slice(0, 12)` below is not a guess — it is the exact mechanism Go itself relies on. + * The shadow container is created with no name and no network alias, unlike every other + * container this codebase creates. Its PG15+ one-shot setup jobs still need a hostname to reach + * it over the shared Docker network, so `dbHost: container.slice(0, 12)` (the container id's own + * 12-char short form) is used instead. */ import { @@ -85,11 +72,9 @@ const errMessage = (e: unknown): string => : String(e); /** - * Creating, connecting to, setting up, or migrating the shadow database failed. Kept in - * `shared/db-bootstrap/` (not `commands/db/shared/pgdelta.errors.ts`'s - * `DeclarativeShadowDbError`) so these primitives stay usable by future callers outside - * the `db diff`/`db pull` family (`migration squash`, `db diff --use-pgadmin`) without pulling - * in a pg-delta-family-specific error type — see this module's own header. + * Creating, connecting to, setting up, or migrating the shadow database failed. Kept here rather + * than in a pg-delta-family-specific error type so these primitives stay usable by callers + * outside the `db diff`/`db pull` family, like `migration squash`. */ export class ShadowDbError extends Data.TaggedError("ShadowDbError")<{ readonly message: string; @@ -138,36 +123,24 @@ const shadowContainerReason = (reason: ContainerError["reason"]): ShadowDbError[ /** * Required to bypass the pg_cron check - * (https://github.com/citusdata/pg_cron/blob/main/pg_cron.sql#L3). Go's `CREATE_TEMPLATE` - * (`apps/cli-go/internal/db/diff/diff.go:164`). + * (https://github.com/citusdata/pg_cron/blob/main/pg_cron.sql#L3). */ export const SHADOW_CREATE_TEMPLATE_SQL = "CREATE DATABASE contrib_regression TEMPLATE postgres"; -/** - * Go's `ConnectShadowDatabase`'s fixed timeout — 10 seconds, EVERY real Go caller - * (`apps/cli-go/internal/db/diff/diff.go:187,200`, `internal/migration/squash/squash.go:91`) - * passes the same `10*time.Second` literal. - */ +/** Fixed connect-retry timeout for the shadow database, in seconds. */ const SHADOW_CONNECT_TIMEOUT_SECONDS = 10; -/** - * Go's `NewBackoffPolicy(ctx, timeout)` (`apps/cli-go/internal/db/start/start.go:192-198`): a - * 1-second constant delay, capped at `timeout` (in whole seconds) retries after the initial - * attempt. - */ +/** Constant 1-second delay between connect retries, capped at {@link SHADOW_CONNECT_TIMEOUT_SECONDS} retries. */ const SHADOW_CONNECT_SCHEDULE = Schedule.max([ Schedule.spaced("1 seconds"), Schedule.recurs(SHADOW_CONNECT_TIMEOUT_SECONDS), ]); /** - * Port of Go's `ConnectShadowDatabase` (`apps/cli-go/internal/db/diff/diff.go:153-161`): a - * SECOND, independent connect-retry loop layered ON TOP OF the container health wait the - * caller already ran (`start.WaitForHealthyService`) — a healthy Postgres healthcheck doesn't - * guarantee the very next connection attempt succeeds instantly, so Go retries the connect - * itself too, constant 1s backoff, up to {@link SHADOW_CONNECT_TIMEOUT_SECONDS} retries. - * Scoped: the returned session's connection closes when the caller's scope closes, matching - * Go's `defer conn.Close(context.Background())` at each real call site. + * A second, independent connect-retry loop layered on top of the container health wait the + * caller already ran — a healthy Postgres healthcheck doesn't guarantee the next connection + * attempt succeeds instantly. Scoped: the session's connection closes when the caller's scope + * closes. */ export const connectShadowDatabase = ( cfg: PgConnInput, @@ -181,41 +154,30 @@ export const connectShadowDatabase = ( }); /** - * Input to {@link createShadowDatabase} — the subset of the real `db` container's own - * bootstrap inputs the shadow variant needs, plus its own host port. See - * {@link ShadowPostgresContainerSpecInput} (the container-spec shape this wraps) for - * the field-by-field Go citations. + * Input to {@link createShadowDatabase} — the subset of the real `db` container's own bootstrap + * inputs the shadow variant needs, plus its own host port. See + * {@link ShadowPostgresContainerSpecInput} for the container-spec shape this wraps. */ export interface CreateShadowDatabaseInput extends ShadowPostgresContainerSpecInput { - /** Go's `Config.ProjectId` — merged onto the shadow's own labels (`DockerStart`'s unconditional label assignment) and the network-create call, matching every other container this codebase creates. */ + /** Merged onto the shadow's own labels and the network-create call, same as every other container this codebase creates. */ readonly projectId: string; readonly isBitbucketPipeline: boolean; readonly workdir: string; readonly extraHosts: ReadonlyArray<string>; /** - * Set ONLY by the shadow baseline cache's warm path (`shadow-cache.ts`): a previously exported - * PGDATA tar to unpack into the container between `docker create` and `docker start`, so the - * `supabase/postgres` entrypoint finds an initialized data directory and skips `initdb` plus - * the whole platform baseline. Everything else about the container — including `--rm` and the - * project-labels-only label set — is identical to an uncached shadow, so a restored shadow is - * still a throwaway container removed on release. - * - * Delivered as {@link StartContainerSpec.preStartArchives}; see that field's doc comment - * for why the tar-stream form of `docker cp` is the only one that works here. + * Set only by the shadow baseline cache's warm path: a previously exported PGDATA tar to + * unpack into the container between `docker create` and `docker start`, so the entrypoint + * finds an initialized data directory and skips `initdb` and the platform baseline. Everything + * else about the container is identical to an uncached shadow. Delivered as + * {@link StartContainerSpec.preStartArchives}. */ readonly restoreArchive?: NonNullable<StartContainerSpec["preStartArchives"]>[number]; /** - * Set ONLY by the shadow baseline cache's COLD path (`shadow-cache.ts`), to `false`. That path - * has to `docker stop` the container mid-run to take a coherent disk-level PGDATA snapshot and - * then `docker start` it again — and Docker removes an `AutoRemove` container the moment it - * exits, `docker stop` included (verified against Docker 29: the container is gone ~1-2s after - * the stop returns), which would leave nothing to restart. - * - * The container is still removed by `docker rm -f -v` on release exactly like every other - * shadow, so the only externally visible difference is what a SIGKILLed CLI leaves behind: a - * stopped shadow container carrying the usual project labels — which `supabase stop` sweeps — - * rather than nothing. Omitted (i.e. Go's `--rm`) on the warm path and whenever the cache is - * off, since neither ever stops the container. + * Set only by the shadow baseline cache's cold path, to `false`: that path must `docker + * stop`/`start` the container mid-run to snapshot it, and Docker removes an auto-removed + * container the moment it exits, leaving nothing to restart. Still removed by `docker rm -f -v` + * on release, so a SIGKILLed CLI leaves a stopped container behind (swept by `supabase stop`) + * instead of nothing. */ readonly autoRemove?: boolean; } @@ -227,29 +189,12 @@ export interface ShadowDatabaseHandle { } /** - * Port of Go's `CreateShadowDatabase` (`apps/cli-go/internal/db/diff/diff.go:138-151`): - * ensures the local Docker network exists (Go's `DockerStart` calls - * `DockerNetworkCreateIfNotExists` on EVERY invocation, unlike the `start`/`reset` - * compositions, which hoist this to run once per orchestrated run — `db diff`/`db pull` have - * no such orchestrator, so this mirrors Go's own per-call behavior instead), then creates + - * starts the shadow container. + * Ensures the local Docker network exists, then creates and starts the shadow container. * - * Leak window (deliberate Go parity, not a bug — the canonical explanation every call site - * below cross-references): every real caller runs this whole function as the `acquire` of an - * `Effect.acquireUseRelease` whose `release` is {@link removeShadowDatabase} (see - * `diff.handler.ts`/`pull.handler.ts`'s call sites). Effect only - * registers `release` once `acquire` itself resolves successfully; an `acquire` that fails - * partway through — `docker create` having already succeeded, but the LATER `docker - * cp`/`docker start` step inside {@link createContainer} then failing - * (`container-lifecycle.ts`) — has, by definition, nothing for `release` to tear down, so the - * already-created container is never removed here. This matches Go exactly: `DockerStart` - * returns `(resp.ID, err)` from the SAME function that calls `ContainerCreate` then - * `ContainerStart` (`apps/cli-go/internal/utils/docker.go:420-436`), and - * `PrepareShadowSource`/`CreateShadowDatabase`'s own Go callers only register their `defer - * DockerRemove(shadow)` AFTER a successful return — on a `DockerStart` error, the returned id - * is discarded before that `defer` is ever reached (`internal/db/diff/shadow.go:38-41`), - * leaking the container identically. Not worth a bespoke "clean up whatever `docker create` - * already made" path just to be stricter than Go's own upstream behavior here. + * Leak window: this runs as the `acquire` of an `Effect.acquireUseRelease` whose `release` is + * {@link removeShadowDatabase}, which only registers once `acquire` resolves successfully. An + * `acquire` that fails after `docker create` but before the container finishes starting leaves it + * running with nothing to remove it — accepted as rare rather than worth a bespoke cleanup path. */ export const createShadowDatabase = ( spawner: Spawner, @@ -269,23 +214,17 @@ export const createShadowDatabase = ( }), ), ); - // Both overrides are the shadow baseline cache's and nobody else's: a warm restore adds one - // `docker cp -` between `docker create` and `docker start` (no argv change at all), and a cold - // cache-enabled provision drops `--rm` so the container survives its own snapshot's `docker - // stop`. The spec builder itself stays on Go's `autoRemove: true` default. See each field's - // doc comment on {@link CreateShadowDatabaseInput}. + // 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); const spec: StartContainerSpec = { ...baseSpec, ...(input.autoRemove === undefined ? {} : { autoRemove: input.autoRemove }), ...(input.restoreArchive === undefined ? {} : { preStartArchives: [input.restoreArchive] }), }; - // The shadow container has no name (Docker auto-generates one) and no network alias — - // see this module's own header for why that's still enough for the shadow's own one-shot - // setup jobs to reach it. The pgsodium root key itself (PG15+ only) never touches host - // disk at all — it's delivered straight into the container via `docker cp` - // ({@link StartContainerSpec.secretFiles}, `container-lifecycle.ts`), same as every - // other container's secrets. + // No name or network alias — see this module's own header. The pgsodium root key (PG15+ + // only) is delivered straight into the container via `docker cp` + // ({@link StartContainerSpec.secretFiles}), same as every other container's secrets. const containerOpts: ContainerOpts = { projectId: input.projectId, isBitbucketPipeline: input.isBitbucketPipeline, @@ -305,20 +244,10 @@ export const createShadowDatabase = ( }); /** - * Port of Go's `utils.DockerRemove(shadow)` as called by every shadow caller - * (`apps/cli-go/internal/db/diff/diff.go:217`, `shadow.go:45,103`, - * `internal/migration/squash/squash.go:87`): `RemoveOptions{RemoveVolumes: true, Force: - * true}` via `docker rm -f -v <id>`. Best-effort for the OVERALL operation — Go's own - * `DockerRemove` swallows the removal's ERROR RETURN (it has no return value at all), so a - * failure here must never mask whatever the caller was doing with the shadow — but it does - * NOT swallow the message: Go prints `"Failed to remove container:", containerId, err` to - * stderr on failure (`apps/cli-go/internal/utils/docker.go:442-449`), so this does the same - * before continuing. That includes a failure to even launch/collect the removal itself (the - * container CLI missing, a disconnected runtime, a stream-read error) — Go's single - * `Docker.ContainerRemove` SDK call folds every one of those causes into the same `err` it - * prints, so this catches {@link spawnContainerCli}/exit-code-collection failures the same way - * {@link restartSatelliteService} does (`restart-services.ts`), via - * {@link describeContainerCliFailure}, rather than discarding them unreported. + * `docker rm -f -v <id>`. Best-effort for the overall operation — a removal failure must never + * mask whatever the caller was doing with the shadow — but the failure is still reported to + * stderr rather than swallowed silently, including a failure to even launch or collect the + * removal itself. */ export const removeShadowDatabase = ( spawner: Spawner, @@ -347,18 +276,17 @@ export const removeShadowDatabase = ( } }); -/** A live shadow database left running for the caller to diff against and remove. Mirrors Go's `ShadowSource`. */ +/** A live shadow database left running for the caller to diff against and remove. */ export interface ShadowSourceResult { - /** Container id; the caller MUST remove it (`removeShadowDatabase`) when done. */ + /** Container id; the caller must remove it with {@link removeShadowDatabase} when done. */ readonly container: string; /** The diff source Postgres URL (the provisioned shadow). */ readonly sourceUrl: string; /** - * When set, replaces the diff target with a second database on the SAME shadow container - * (`contrib_regression`, cloned from `postgres` by `CREATE_TEMPLATE` during shadow setup — - * see {@link setupShadowConn}) with declarative schemas applied, so the user's local - * DB itself is never diffed directly in that branch. Only ever set by - * `shadow-source.ts`'s `prepareShadowSource`. + * When set, replaces the diff target with a second database on the same shadow container + * (`contrib_regression`, cloned from `postgres` during shadow setup — see + * {@link setupShadowConn}) with declarative schemas applied, so the user's local DB is never + * diffed directly in that branch. */ readonly targetUrlOverride: string | undefined; } @@ -375,24 +303,20 @@ interface ShadowConnectionInput extends CreateShadowDatabaseInput { /** * {@link ShadowConnectionInput} plus the platform-baseline setup fields - * {@link setupDatabase}/`migrateShadowDatabase`/`setupShadowDatabase` need — - * the full shape {@link shadowRunInputFromLocalContainerInputs} returns. Named here - * (CLI-1969) rather than as an `Omit<...>` of a diff/pull-specific type, so `migration squash` - * — which has none of the diff/pull-specific fields (`targetLocal`/`schemaPaths`/ - * `migrationMode`/…) — can consume the promoted function's return value directly, with no `as` - * cast. `shadow-source.ts`'s `PrepareShadowSourceInput<E>` extends this with - * those extra fields instead of duplicating the `setup` field itself. + * {@link setupDatabase}/`migrateShadowDatabase`/`setupShadowDatabase` need — the full shape + * {@link shadowRunInputFromLocalContainerInputs} returns. Named here rather than as an + * `Omit<...>` of a diff/pull-specific type, so `migration squash` (which has none of those + * fields) can consume the promoted function's return value directly, with no `as` cast. */ export interface ShadowSetupInput<E> extends ShadowConnectionInput { readonly setup: ShadowDbSetupInput<E>; } /** - * Memoizes `effect`'s first SUCCESS; failures are never cached, so a retry re-runs the real - * effect. Deliberately not `Effect.cached` (which returns `Effect<Effect<A, E>>` and needs an - * effectful construction site — {@link shadowRunInputFromLocalContainerInputs} is a plain - * function) and not concurrency-guarded: the two consumers of the one field this wraps (`jwks` — - * see its construction inside that function) evaluate sequentially on the same fiber. + * Memoizes `effect`'s first success; failures are never cached, so a retry re-runs the real + * effect. Not `Effect.cached`, since that needs an effectful construction site and + * {@link shadowRunInputFromLocalContainerInputs} is a plain function; not concurrency-guarded, + * since the two consumers of the field this wraps evaluate sequentially on the same fiber. */ export function memoizeSuccess<A, E>(effect: Effect.Effect<A, E>): Effect.Effect<A, E> { let succeeded: Effect.Effect<A, E> | undefined; @@ -410,31 +334,14 @@ export function memoizeSuccess<A, E>(effect: Effect.Effect<A, E>): Effect.Effect } /** - * Adapts {@link LocalDbContainerInputs} (`local-container-inputs.ts`, the SAME - * config/image/JWKS resolution prelude `db start`/`db reset` share) plus the caller's own - * already-loaded `config.toml` slice into {@link ShadowSetupInput} — every field - * `shadow-source.ts`'s `prepareShadowSource` or `migration squash`'s own shadow - * composition need EXCEPT the diff/pull-specific - * ones (`targetLocal`/`schemaPaths`/`migrationMode`/…, left to each call site). - * Promoted here from - * `commands/db/shared/shadow-source.ts` (CLI-1969, hoist-before-duplicate): `migration - * squash` needs this same shadow run-input shape, but importing the `db`-family-scoped - * `shadow-source.ts` would drag its whole pg-delta/migra/declarative stack into a - * command that has no diff engine at all. + * Adapts {@link LocalDbContainerInputs} plus the caller's own already-loaded `config.toml` slice + * into {@link ShadowSetupInput}, covering every field `shadow-source.ts` or `migration squash`'s + * shadow composition need except the diff/pull-specific ones left to each call site. Promoted out + * of `shadow-source.ts` so `migration squash` can reuse this shape without importing that file's + * whole diff-engine stack. * - * On `db diff --linked`/`db pull` (linked), the caller passes its own resolved ref straight - * through to `buildLocalDbContainerInputs` (its own `projectRef` parameter — see - * that function's doc comment), which threads it into `loadLocalProjectContext` -> - * `loadCliConfig({ projectRef })`. So the shadow's OWN container config (image, JWT - * secret, root key, `db.settings`, service enabled-for-setup flags, sourced from - * `localInputs.context.config`/`postgresSpecBase`) reflects the matching `[remotes.<ref>]` - * override, same as `toml` (the caller's own `readDbToml(..., linkedRef)` result, - * which feeds `pgDelta`/vault/`apiAutoExposeNewTables` below) — matching Go's own uniform - * remote-merge on the linked path (`LoadConfig` seeds `flags.ProjectRef` before every field - * read). The two config reads still go through independent remote-merge implementations - * (`@supabase/config`'s `applyRemoteOverride` for `localInputs.context.config`; - * `db-config.toml-read.ts`'s own TOML-based merge for `toml`) rather than a single - * shared decode — unifying those is a larger, out-of-scope refactor, not a per-command gap. + * On a linked ref, the container config and `toml` are resolved through two independent + * remote-merge implementations rather than a shared decode — a known duplication, not a bug. */ export function shadowRunInputFromLocalContainerInputs( localInputs: LocalDbContainerInputs, @@ -476,14 +383,10 @@ export function shadowRunInputFromLocalContainerInputs( majorVersion: localInputs.setup.majorVersion, config: localInputs.setup.config, webhooksEnabled: toml.webhooksEnabled, - // NOT `localInputs.setup.dbUrl` — that carries the REGULAR local container's own - // hardcoded-"postgres" password (`local-config-values.ts`'s `DEFAULT_DB_PASSWORD`), - // for a DIFFERENT container. The shadow's own one-shot setup jobs - // (`buildShadowSetupDatabaseInput`) only ever consume this `dbUrl` to extract a - // password (`startInternalDbPassword`) for the SHADOW they actually run against, so - // it must carry the SAME resolved `toml.password` the shadow container itself is - // initialized with (see `buildShadowPostgresContainerSpec`) — otherwise a non-default - // `[db] password` authenticates against the wrong secret and every setup job fails. + // Not `localInputs.setup.dbUrl`, which carries the regular local container's own + // hardcoded password for a different container. This must carry the shadow's own + // resolved `toml.password`, or a non-default `[db] password` authenticates against the + // wrong secret and every setup job fails. dbUrl: toPostgresURL({ host: localInputs.context.hostname, port: toml.shadowPort, @@ -492,12 +395,10 @@ export function shadowRunInputFromLocalContainerInputs( database: "postgres", }), jwtSecret: localInputs.setup.jwtSecret, - // Memoized: with the shadow baseline cache enabled this effect is evaluated TWICE on a - // cold run — once by `resolveShadowCacheKeyInputs` (`shadow-cache.ts`) for the cache - // key, once by `resolveDbSetupPrelude` for the baseline itself — and third-party - // JWKS discovery can be a real network request. Memoizing the first success keeps the run - // to one request AND guarantees the published snapshot carries the exact value its key was - // computed from, even if the issuer rotates mid-run. + // Memoized: with the shadow cache enabled this effect is evaluated twice on a cold run + // (once for the cache key, once for the baseline), and JWKS discovery can be a real + // network request. Memoizing the first success keeps the run to one request and keeps the + // published snapshot consistent with its own key. jwks: memoizeSuccess(localInputs.setup.jwks), apiUrl: localInputs.setup.apiUrl, authExternalUrl: localInputs.setup.authExternalUrl, @@ -534,16 +435,11 @@ export const shadowConnConfig = (input: ShadowConnFields): PgConnInput => ({ }); /** - * Port of Go's `setupShadowConn` (`apps/cli-go/internal/db/diff/diff.go:171-179`): - * {@link setupDatabase} (Go's `SetupDatabase`) against an already-connected shadow, - * dialed at `input.dbHost` = `container.slice(0, 12)` (see this module's own header), then - * unconditionally {@link SHADOW_CREATE_TEMPLATE_SQL} — every real Go caller of - * `setupShadowConn` itself (`SetupShadowDatabase`/`MigrateShadowDatabase` below) always - * creates the template database; a future caller that only needs the bare `SetupDatabase` - * step (`migration squash`, which calls `start.SetupDatabase` DIRECTLY, bypassing - * `setupShadowConn` entirely — `squash.go:96`) calls {@link setupDatabase} on its own - * instead, so this function stays the exact `setupShadowConn` shape without a parameter for - * a branch no real caller of THIS function takes. + * Runs {@link setupDatabase} against an already-connected shadow, dialed at `input.dbHost` = + * `container.slice(0, 12)` (see this module's own header), then unconditionally creates + * {@link SHADOW_CREATE_TEMPLATE_SQL}'s template database. A caller that only needs the bare + * setup step (`migration squash`) calls {@link setupDatabase} directly instead of going through + * this function. */ export const setupShadowConn = ( spawner: Spawner, @@ -556,9 +452,8 @@ export const setupShadowConn = ( > => Effect.gen(function* () { yield* setupDatabase(spawner, input, options).pipe( - // The baseline's batched SQL files check their own connection out of the pool; - // failing to acquire one is a shadow CONNECT failure, like - // `connectShadowDatabase`'s, never a setup/statement failure. + // A batched SQL file's own pooled connection can fail to acquire — treat that as a shadow + // connect failure, not a setup/statement failure. Effect.catchTag("DbConnectError", (cause) => Effect.fail(new ShadowDbError({ message: cause.message, reason: "connect" })), ), @@ -567,12 +462,10 @@ export const setupShadowConn = ( }); /** - * {@link setupShadowConn}'s trailing {@link SHADOW_CREATE_TEMPLATE_SQL} step on its - * own (Go's `setupShadowConn`'s second half, `diff.go:178`). Split out because it is the ONE part - * of Go's `setupShadowConn` a warm shadow-cache hit still has to run: the cache's PGDATA snapshot - * is taken strictly BEFORE this statement (`shadow-cache.ts`), so a restored cluster carries the - * platform baseline but no `contrib_regression`, and the template database must be recreated even - * though the baseline itself is skipped. + * {@link setupShadowConn}'s trailing {@link SHADOW_CREATE_TEMPLATE_SQL} step on its own. Split + * out because it's the one part a warm shadow-cache hit still has to run: the cache's PGDATA + * snapshot is taken before this statement, so a restored cluster carries the platform baseline + * but no `contrib_regression`. */ const createShadowTemplateDatabase = ( session: DbSession, @@ -588,15 +481,11 @@ const createShadowTemplateDatabase = ( ); /** - * Shared fields both {@link setupShadowDatabase} and {@link migrateShadowDatabase} - * need to resolve JWKS/images and run {@link setupDatabase} — derived from `db-setup.ts`'s - * `FreshDbSetupInput` (the exact same shape `runFreshDbSetup` resolves for the real - * local `db` container) rather than hand-copied, so the two never silently drift: swap - * `experimental` (which only `startSetupLocalDatabase`'s trailing `MigrateAndSeed` call - * needs — irrelevant to the shadow's `SetupDatabase`-only pipeline, see {@link - * SetupDatabaseInput}'s own doc comment) for the two fields the shadow's own caller - * (`shadow-source.ts`) resolves from an already-loaded `config.toml` instead - * (`apiAutoExposeNewTables`/`vault`), threaded straight through here rather than re-read. + * Shared fields {@link setupShadowDatabase} and {@link migrateShadowDatabase} need to resolve + * JWKS/images and run {@link setupDatabase} — derived from `db-setup.ts`'s `FreshDbSetupInput` + * rather than hand-copied, so the two never silently drift. Swaps `experimental` (irrelevant to + * the shadow's setup-only pipeline) for the two fields (`apiAutoExposeNewTables`/`vault`) the + * shadow's caller resolves from an already-loaded `config.toml` instead. */ export type ShadowDbSetupInput<E> = Omit<FreshDbSetupInput<E>, "experimental"> & { readonly webhooksEnabled: SetupDatabaseInput["webhooksEnabled"]; @@ -609,7 +498,7 @@ interface ShadowSetupRunInput<E> { readonly fs: FileSystem.FileSystem; readonly path: Path.Path; readonly workdir: string; - /** Go's `Config.ProjectId` — labels the shadow's own PG15+ one-shot migrate job containers, same as the real local `db` container's — see {@link SetupDatabaseInput.projectId}'s own doc comment. */ + /** Labels the shadow's own PG15+ one-shot migrate job containers, same as the real local `db` container's. */ readonly projectId: string; readonly container: string; readonly networkId: string; @@ -619,12 +508,10 @@ interface ShadowSetupRunInput<E> { } /** - * Builds a {@link SetupDatabaseInput} for {@link setupDatabase} out of an - * already-connected shadow session plus the resolved images/JWKS prelude — exported so a - * future caller that only needs `SetupDatabase` directly (`migration squash`, which calls - * Go's `start.SetupDatabase` without going through `setupShadowConn` at all — see {@link - * setupShadowConn}'s own doc comment) can build this same shape without duplicating the - * `container[:12]` dbHost derivation. + * Builds a {@link SetupDatabaseInput} for {@link setupDatabase} out of an already-connected + * shadow session plus the resolved images/JWKS prelude — exported so a caller that calls + * {@link setupDatabase} directly (`migration squash`) can build this same shape without + * duplicating the `container[:12]` dbHost derivation. */ export const buildShadowSetupDatabaseInput = <E>( input: ShadowSetupRunInput<E>, @@ -638,8 +525,8 @@ export const buildShadowSetupDatabaseInput = <E>( config: input.setup.config, webhooksEnabled: input.setup.webhooksEnabled, majorVersion: input.setup.majorVersion, - // Go's `container[:12]` — see this module's own header for why this resolves as a - // hostname at all despite the shadow container having no name/alias. + // The container id's 12-char short form — see this module's own header for why this resolves + // as a hostname. dbHost: input.container.slice(0, 12), projectId: input.projectId, networkId: input.networkId, @@ -660,22 +547,14 @@ export const buildShadowSetupDatabaseInput = <E>( }); /** - * Port of Go's `SetupShadowDatabase` (`apps/cli-go/internal/db/diff/diff.go:181-193`): - * connects to the shadow (Go's `ConnectShadowDatabase`, {@link connectShadowDatabase}) - * FIRST, THEN resolves the setup prelude (JWKS/pinned image names, {@link - * resolveDbSetupPrelude}) and runs {@link setupShadowConn} — the platform - * baseline plus the template database, no user migrations. Connect-then-setup, matching Go's - * own `SetupShadowDatabase` (which dials `ConnectShadowDatabase` before ever calling - * `start.SetupDatabase`, `diff.go:186-192`) and this same module's `runFreshDbSetup` - * (`db-setup.ts`) for the real local `db` container: an unconnectable shadow must surface a - * connect error immediately, not pay for JWKS work first. The connection is closed once this - * resolves (Go's `defer conn.Close(...)`), matching `Effect.scoped`'s finalizer running at the - * end of this function rather than leaking a `Scope.Scope` requirement to the caller. + * Connects to the shadow, then resolves the setup prelude (JWKS/pinned image names) and runs + * {@link setupShadowConn} — the platform baseline plus the template database, no user + * migrations. Connect-then-setup so an unconnectable shadow surfaces a connect error immediately + * rather than paying for JWKS work first. The connection closes once this resolves. * - * `baseline` defaults to {@link SHADOW_BASELINE_COLD}, i.e. exactly the sequence above. - * A warm shadow-cache hit skips the prelude + `SetupDatabase` (the restored cluster already - * has them) and only recreates `contrib_regression`; a cache-enabled COLD provision snapshots - * between the baseline and the template, matching {@link migrateShadowDatabaseWith}. + * `baseline` defaults to {@link SHADOW_BASELINE_COLD}. A warm cache hit skips the prelude and + * setup (the restored cluster already has them) and only recreates `contrib_regression`; a + * cache-enabled cold provision snapshots between the baseline and the template. */ export const setupShadowDatabase = <E>( spawner: Spawner, @@ -699,9 +578,8 @@ export const setupShadowDatabase = <E>( buildShadowSetupDatabaseInput(input, setupSession, resolved), options, ).pipe( - // The baseline's batched SQL files check their own connection out of the pool; - // failing to acquire one is a shadow CONNECT failure, like - // `connectShadowDatabase`'s, never a setup/statement failure. + // A batched SQL file's own pooled connection can fail to acquire — treat that as a + // shadow connect failure, not a setup/statement failure. Effect.catchTag("DbConnectError", (cause) => Effect.fail(new ShadowDbError({ message: cause.message, reason: "connect" })), ), @@ -718,7 +596,7 @@ export const setupShadowDatabase = <E>( buildShadowSetupDatabaseInput(input, session, resolved), options, ).pipe( - // Same connect-vs-setup classification as the snapshot branch above. + // Same connect-failure classification as the snapshot branch above. Effect.catchTag("DbConnectError", (cause) => Effect.fail(new ShadowDbError({ message: cause.message, reason: "connect" })), ), @@ -729,32 +607,24 @@ export const setupShadowDatabase = <E>( ); /** - * What an `acquire` hands the `use` phase about the shadow cluster's CONTENTS — the seam the warm - * shadow-container cache (`shadow-cache.ts`) needs and nothing else uses. - * - * Deliberately a value the acquire OWNS and returns (alongside the container id), not an - * `afterBaseline` callback threaded down through `prepareShadowSource`: the cache is the - * only party that knows whether a cluster already carries a baseline and what to do once a fresh - * one exists, so both answers travel together with the container the cache handed over. - * {@link SHADOW_BASELINE_COLD} is what every uncached caller passes. + * What `acquire` hands the `use` phase about the shadow cluster's contents — the seam the warm + * shadow-container cache needs and nothing else uses. A value the acquire owns and returns + * (rather than a callback threaded down separately), since the cache is the only party that + * knows both whether a cluster already carries a baseline and what to do once a fresh one + * exists. {@link SHADOW_BASELINE_COLD} is what every uncached caller passes. */ export interface ShadowBaselineState { /** - * `true` only on a warm cache hit: the cluster already carries the platform baseline - * (`setupDatabase`'s init schema + API privileges + vault + `roles.sql`), restored from - * the cache's own PGDATA snapshot, so re-running it would be wasted work at best and a - * double-applied baseline at worst. + * `true` only on a warm cache hit: the cluster already carries the platform baseline, restored + * from the cache's own PGDATA snapshot, so re-running it would double-apply it. */ readonly baselinePresent: boolean; /** - * `true` ONLY for a cache-enabled COLD provision — the one state whose - * {@link snapshotBaseline} really stops the container. This is what - * {@link migrateShadowDatabase} keys its session structure on: the baseline session must - * be closed before a real snapshot (a disk-level export severs any live backend), but when no - * snapshot will run, splitting sessions would be a gratuitous behavior change — a reconnect - * picks up role-level defaults `roles.sql` may have just installed (e.g. `ALTER ROLE postgres - * SET statement_timeout`), which an uncached single session never exposes to migrations. - * Uncached and warm runs keep one session. + * `true` only for a cache-enabled cold provision — the one state whose + * {@link snapshotBaseline} actually stops the container. {@link migrateShadowDatabase} splits + * its session only for this state: the baseline session must close before a disk-level + * snapshot, but otherwise a reconnect would pick up role-level defaults `roles.sql` just + * installed, which a single session never exposes to migrations. */ readonly snapshotRequired: boolean; /** @@ -775,32 +645,14 @@ const SHADOW_BASELINE_COLD: ShadowBaselineState = { }; /** - * Port of Go's `MigrateShadowDatabase` (`apps/cli-go/internal/db/diff/diff.go:195-209`): - * lists local migrations FIRST (Go's `migration.ListLocalMigrations`, fails fast on a bad - * migrations directory before any DB connection is even attempted), THEN connects (Go's - * `ConnectShadowDatabase`), THEN resolves the setup prelude (JWKS/pinned image names, {@link - * resolveDbSetupPrelude}) and sets up the platform baseline + template database ({@link - * setupShadowConn}), then applies every listed migration (Go's - * `migration.ApplyMigrations`). Connect-then-setup (not the reverse) matches Go's own - * `MigrateShadowDatabase` (`diff.go:195-209`) and this same module's `runFreshDbSetup` - * (`db-setup.ts`) for the real local `db` container — see {@link setupShadowDatabase}'s - * own doc comment for why the ordering matters. Connection closed once this resolves, matching - * Go's `defer conn.Close(...)`. - * - * `baseline` defaults to {@link SHADOW_BASELINE_COLD}, i.e. exactly the sequence above. - * A warm shadow-cache hit passes a state whose `baselinePresent` is `true`, which skips the - * prelude + `SetupDatabase` steps (the restored cluster already has them) and goes straight to - * the template database and the user migrations; a COLD cache-enabled provision passes the same - * cold sequence plus a `snapshotBaseline` step between the baseline and the template database. + * Lists local migrations first, so a bad migrations directory fails before any DB connection, + * then connects, resolves the setup prelude, and runs {@link setupShadowConn} (platform baseline + * plus template database) before applying every listed migration. Connect-then-setup for the + * same reason as {@link setupShadowDatabase}. * - * Only the SNAPSHOTTING cold branch (`baseline.snapshotRequired`) splits sessions: there the - * baseline runs in its own scope, its session is CLOSED before - * {@link ShadowBaselineState.snapshotBaseline} (the disk-level PGDATA snapshot stops - * the container, which severs any live backend), and the template database + migrations run on a - * second session. Every OTHER state — uncached (cache off / `--no-cache` / OrioleDB) and warm — - * keeps the established single session: see - * {@link ShadowBaselineState.snapshotRequired} for why the split must not leak into the - * uncached path. The SQL every path issues is unchanged. + * `baseline` defaults to {@link SHADOW_BASELINE_COLD}. A warm hit skips the prelude and setup; a + * cold cache-enabled provision snapshots between the baseline and the template. Only that + * snapshotting branch splits sessions — see {@link ShadowBaselineState.snapshotRequired}. */ const migrateShadowDatabaseWith = <E>( spawner: Spawner, @@ -833,9 +685,8 @@ const migrateShadowDatabaseWith = <E>( buildShadowSetupDatabaseInput(input, setupSession, resolved), setupOptions, ).pipe( - // The baseline's batched SQL files check their own connection out of the pool; - // failing to acquire one is a shadow CONNECT failure, like - // `connectShadowDatabase`'s, never a setup/statement failure. + // A batched SQL file's own pooled connection can fail to acquire — treat that as a + // shadow connect failure, not a setup/statement failure. Effect.catchTag("DbConnectError", (cause) => Effect.fail(new ShadowDbError({ message: cause.message, reason: "connect" })), ), @@ -854,7 +705,7 @@ const migrateShadowDatabaseWith = <E>( buildShadowSetupDatabaseInput(input, session, resolved), setupOptions, ).pipe( - // Same connect-vs-setup classification as the snapshot branch above. + // Same connect-failure classification as the snapshot branch above. Effect.catchTag("DbConnectError", (cause) => Effect.fail(new ShadowDbError({ message: cause.message, reason: "connect" })), ), @@ -868,9 +719,8 @@ const migrateShadowDatabaseWith = <E>( pending, (message) => new ShadowDbError({ message, reason: "database" }), ).pipe( - // A batch runs on its own pooled connection: failing to acquire it is a - // shadow CONNECT failure (same classification as `connectShadowDatabase`), - // never a `"database"` statement failure. + // A batch runs on its own pooled connection; failing to acquire it is a connect + // failure, never a `"database"` statement failure. Effect.catchTag("DbConnectError", (cause) => Effect.fail(new ShadowDbError({ message: cause.message, reason: "connect" })), ), diff --git a/apps/cli/src/command-internal/db-bootstrap/shadow-database.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/shadow-database.unit.test.ts index 7ec3ff5c67..2b0fe943e7 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-database.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-database.unit.test.ts @@ -65,11 +65,9 @@ function mockDbConnection(session: DbSession) { } /** - * A `DbConnection` whose `connect` fails with `DbConnectError` on - * the first `failTimes` calls, then succeeds with `session` on every call - * after that (`failTimes: Number.POSITIVE_INFINITY` never succeeds at all) — - * for pinning {@link connectShadowDatabase}'s retry-schedule ATTEMPT - * COUNT precisely, not merely "it eventually succeeds"/"it eventually fails". + * A `DbConnection` whose `connect` fails with `DbConnectError` on the first `failTimes` calls, + * then succeeds with `session` on every call after that. Lets tests pin + * {@link connectShadowDatabase}'s retry-schedule attempt count precisely. */ function mockFlakyDbConnection(session: DbSession, failTimes: number) { let attempts = 0; @@ -114,10 +112,8 @@ function mockSpawner() { Effect.sync(() => { const args = command._tag === "StandardCommand" ? command.args : []; spawned.push(args); - // `ensureNetwork` probes with `network inspect` before ever creating one — report - // it as missing so a `createShadowDatabase` call actually reaches `network create`, - // rather than short-circuiting on the pre-check the way an always-exit-0 mock would (the - // ONLY caller of this mock that ever spawns `network`/`create` args at all). + // `ensureNetwork` probes with `network inspect` first; report it as missing so + // `createShadowDatabase` actually reaches `network create` instead of short-circuiting. const exitCode = args[0] === "network" && args[1] === "inspect" ? 1 : 0; const stdout = args[0] === "create" ? "shadow-container-id-0123456789abcdef" : ""; return ChildProcessSpawner.makeHandle({ @@ -139,10 +135,8 @@ function mockSpawner() { } /** - * A spawner that fails to even launch a process — for both `docker` and - * `podman` — mirroring the "daemon not on PATH" scenario `health-check.unit.test.ts` - * scripts for `waitForHealthyServices`. Every `spawner.spawn` call fails - * before ever returning a handle, so `spawnContainerCliWithRuntime` + * A spawner that fails to even launch a process, for both `docker` and `podman`. Every + * `spawner.spawn` call fails before returning a handle, so `spawnContainerCliWithRuntime` * exhausts both runtimes and surfaces `ContainerRuntimeNotFoundError`. */ function mockUnspawnableSpawner() { @@ -204,12 +198,7 @@ describe("createShadowDatabase / removeShadowDatabase", () => { expect(networkCreateIdx).toBeLessThan(createIdx); expect(mock.spawned[createIdx]).not.toContain("--name"); expect(mock.spawned[createIdx]).toContain("--rm"); - // Go's `NewContainerConfig("-c", "max_worker_processes=0")` splice - // (`CreateShadowDatabase`, `diff.go:140`) is not a bare docker flag — it's rendered - // into the entrypoint script's own `docker-entrypoint.sh postgres -D /etc/postgresql - // <args>` line (the script is the LAST `docker create` argv element, `cmd`'s second - // entry). Assert it lands there, not merely that the literal string appears somewhere - // in argv. + // The flag is rendered into the entrypoint script, the last `docker create` argv element. const script = mock.spawned[createIdx]?.at(-1) ?? ""; expect(script).toContain( `exec docker-entrypoint.sh postgres -D /etc/postgresql ${SHADOW_ENTRYPOINT_ARGS}`, @@ -310,11 +299,6 @@ describe("connectShadowDatabase", () => { const resolvedSession = yield* Fiber.join(fiber); expect(resolvedSession).toBe(session); - // 3 failed attempts, then the 4th that finally succeeds — pins the EXACT - // attempt count (a `Schedule.min` regression would also "eventually - // succeed" here, since 3 retries is well under either combinator's - // ceiling — see the always-failing case below for the test that - // actually distinguishes `min` from `max`). expect(mock.attempts).toBe(4); }).pipe(Effect.provide(mock.layer)); }, @@ -324,13 +308,6 @@ describe("connectShadowDatabase", () => { "gives up after exactly 11 attempts (1 initial + 10 retries) instead of retrying forever — pins Schedule.max over Schedule.min", () => { const { session } = fakeSession(); - // Never succeeds — pegs the retry schedule to its hard 10-retry ceiling. - // `Schedule.max` (the correct combinator: recur while BOTH inputs can - // still recur) stops here because `Schedule.recurs(10)` is exhausted. A - // regression to `Schedule.min` (recur while EITHER input can still - // recur) would keep recurring forever on `Schedule.spaced`'s unbounded - // side, so this fiber would never complete — `Fiber.join` below would - // hang/time out rather than resolve, catching exactly that swap. const mock = mockFlakyDbConnection(session, Number.POSITIVE_INFINITY); return Effect.gen(function* () { const fiber = yield* connectShadowDatabase(shadowConnConfig).pipe( @@ -511,8 +488,6 @@ describe("buildShadowSetupDatabaseInput", () => { }, }, ); - // Go's `container[:12]` — the future callers this was exported for (`migration - // squash`) need this exact same derivation, not a re-implementation. expect(built.dbHost).toBe("shadow-conta"); expect(built.session).toBe(session); expect(built.workdir).toBe("/proj"); @@ -801,9 +776,7 @@ describe("setupShadowDatabase / migrateShadowDatabase", () => { () => { const workdir = tempRoot.current; const mock = mockSpawner(); - // One shared, ordered log — recording both events into separate booleans (the prior - // version of this test) would still pass if the two steps were swapped, since both - // would still end up `true`; only an ordered log actually proves the sequence. + // An ordered log, not separate booleans, so the events must occur in this order to pass. const events: Array<string> = []; const dbConnection = Layer.succeed(DbConnection, { connect: () => @@ -823,9 +796,7 @@ describe("setupShadowDatabase / migrateShadowDatabase", () => { return realFs.readDirectory(dir, opts); }, }); - // No `supabase/migrations` directory exists — Go's `ListLocalMigrations` on a - // missing dir resolves to an empty list (not an error), so this exercises the - // ordering guarantee (list BEFORE connect) rather than a failure path. + // No `supabase/migrations` directory exists. yield* migrateShadowDatabase(mock.spawner, { fs, path, @@ -842,12 +813,6 @@ describe("setupShadowDatabase / migrateShadowDatabase", () => { }, setup: baseShadowSetup(), }); - // ONE connect, matching Go's single-connection flow: the default baseline state - // (`SHADOW_BASELINE_COLD`) requires no snapshot, so baseline + template + - // migrations all share one session — the split-session shape is reserved for the - // cache's own snapshotting cold provision (`snapshotRequired: true`), whose disk-level - // export must close the session before stopping the container. The ordering under test - // is unaffected — the migration listing still precedes the connect. expect(events).toEqual(["list", "connect"]); }).pipe( Effect.provide( 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 274d8eb870..44f0c8ee82 100644 --- a/apps/cli/src/command-internal/db-bootstrap/start-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/start-database.ts @@ -1,51 +1,19 @@ /** - * Strict 1:1 port of Go's `StartDatabase` (`apps/cli-go/internal/db/start/start.go:133-190`) — - * the ONE function both `supabase start` (`commands/start/start.handler.ts`) and `db start` - * (`commands/db/start/start.handler.ts`) call to bring up the local Postgres container. Hoisted - * here as CLI-1954's own follow-up fix: both callers used to run their own independently-typed - * ~200-line copy of this exact sequence, with no test comparing them — the highest-drift-risk - * shape available in a codebase whose whole contract is byte-level Go parity. A future change to - * Go's `StartDatabase` now only has one TS home to update. + * The one function both `supabase start` and `db start` call to bring up the local Postgres + * container, so the two callers share one sequence to test and maintain instead of + * independently-typed copies that could drift. * - * Exact Go call order: pre-create volume-existence probe (+ the `fromBackup`-on-an-existing-volume - * guard) -> image resolve + network ensure (Go's `DockerStart` resolves the image, THEN creates - * the network, both strictly - * ahead of container create — `docker.go:363-386` — so NEITHER one ever runs on a request the - * volume guard above already rejected) -> Postgres container create+start -> health wait - * (swallowed ONLY when `fromBackup` is set — "restoring a large backup may take longer than 2 - * minutes") -> the fresh-volume `SetupLocalDatabase`-equivalent pipeline (skipped IN FULL when - * `fromBackup` is set) -> `initCurrentBranch`, unconditionally (the LAST line of `StartDatabase`, - * reached on every path that doesn't already return/fail above). + * Call order: pre-create volume-existence probe (+ the `fromBackup`-on-an-existing-volume guard) + * -> image resolve + network ensure -> container create+start -> health wait (swallowed only when + * `fromBackup` is set) -> the fresh-volume setup pipeline (skipped entirely when `fromBackup` is + * set) -> `initCurrentBranch`, unconditionally, on every path that doesn't already return or fail + * above. `--ignore-health-check` and rollback are the caller's own concern, not this function's. * - * Deliberately has ZERO knowledge of `--ignore-health-check` — matching Go exactly: that flag is - * `internal/start/start.go`'s `Run()`'s own concern, entirely OUTSIDE `StartDatabase` (Go's - * `StartDatabase` has no `ignoreHealthCheck` parameter at all). `supabase start`'s own caller - * wraps the WHOLE call to {@link startDatabase} in its own `Effect.result` and decides - * whether to downgrade an unhealthy-Postgres failure to a warning and continue with the REST of - * its own bring-up (the other ~13 services) — this function only ever propagates that failure - * bare, exactly like Go's `StartDatabase` returning it to `run()` unfiltered. Rollback - * (`rollbackStart`) is ALSO the caller's own concern, not this function's — matching Go, - * where `DockerRemoveAll` lives in `Run()` (both `db/start/start.go`'s own `Run` and - * `internal/start/start.go`'s `Run`), never inside `StartDatabase` itself. - * - * Two inputs are caller-supplied `Effect`s rather than plain values, because their TIMING - * relative to this function's own body genuinely differs between callers (not just a stylistic - * choice — see each field's own doc comment below for the Go citation): - * - `resolvePostgresImage` — `db start` has no pre-pull pass at all (Go's `db start` binary has - * none either), so it resolves the registry candidate lazily, right here, exactly where Go's - * `DockerStart` would; `supabase start` already resolved it as part of its own batched - * `ensureImagesCached` pre-pull, before bring-up even starts, and just threads that value - * through. - * - `setup.jwks` — `db start` has no earlier use for JWKS at all, so it resolves it lazily, - * conditionally (only when reached AND `majorVersion >= 15` AND `realtime.enabled` — Go's - * `initSchema`, `start.go:243-254`, only ever reaches `initSchema15`'s `ResolveJWKS` call on - * PG15+; the PG13/14 branch, `InitSchema14`, never touches JWKS at all), matching Go's own - * `initSchema15`-local `ResolveJWKS` call (`internal/db/start/start.go:337-341`) exactly; `supabase start` - * resolves JWKS once, unconditionally, near the top of its OWN prelude (feeding its - * long-running Realtime/GoTrue/PostgREST containers too — `internal/start/start.go:274-277`) - * and reuses that SAME already-resolved value here rather than re-resolving (a second resolve - * could re-sign an asymmetric JWT with a different `exp`, disagreeing with the value already - * baked into those containers' envs). + * `resolvePostgresImage` and `setup.jwks` are caller-supplied `Effect`s, not plain values, because + * their timing differs between callers: `db start` resolves both lazily, right where they're + * needed, while `supabase start` already resolved them earlier in its own prelude (for its + * pre-pull and its other services respectively) and just threads the same values through — see + * each field's own doc comment. */ import { Data, Effect, type FileSystem, type Path, Result } from "effect"; @@ -97,14 +65,10 @@ import { type Spawner = ChildProcessSpawner["Service"]; /** - * Go's `StartDatabase` `fromBackup` guard (`start.go:170-172`): the local Postgres volume - * already exists AND `fromBackup` was passed. Restoring into an already-provisioned volume - * would silently no-op (or worse, mix a restored dump with whatever data the volume already - * has) — Go refuses outright rather than guessing which the caller wants. Raised BEFORE any - * container is created (no `docker create`/`docker start` happens on this path). Only ever - * reachable via `db start` (the sole caller that ever sets `postgresSpec.fromBackup`). - * Exported only so the exhaustive actionability guard can inspect its declaration; - * runtime callers observe it through {@link StartDatabaseError}. + * The local Postgres volume already exists and `fromBackup` was passed. Restoring into an + * already-provisioned volume would silently no-op or mix a restored dump with existing data, so + * this refuses outright before any container is created. Exported only so the exhaustive + * actionability guard can inspect its declaration; runtime callers see it via {@link StartDatabaseError}. */ class StartBackupVolumeExistsError extends Data.TaggedError("StartBackupVolumeExistsError")<{ readonly message: string; @@ -139,14 +103,12 @@ export interface StartDatabaseInput<E> { readonly dbContainerId: string; readonly dbPort: number; readonly containerOpts: ContainerOpts; - /** Fed straight to `buildPostgresStartContainerSpec` — `fromBackup` (if set) drives BOTH the restore-entrypoint variant and the backup-volume-exists guard below. */ + /** Fed straight to `buildPostgresStartContainerSpec` — `fromBackup` (if set) drives both the restore-entrypoint variant and the backup-volume-exists guard below. */ readonly postgresSpec: Omit<PostgresStartServiceInput, "image">; /** - * Lazy — evaluated right where Go's `DockerStart` would resolve it. See this module's header. - * Fixed to `ImagePrepullError` (not generic `E`): both callers' real implementations - * either never fail (`supabase start`'s already-resolved `Effect.succeed`) or fail with exactly - * this error (`db start`'s own `ensureImagesCached` call) — already part of this - * function's own fixed {@link StartDatabaseError} union. + * Lazy — evaluated right here rather than pre-resolved. See this module's header. Fixed to + * `ImagePrepullError` (not generic `E`) since both callers' real implementations either never + * fail or fail with exactly this error, already part of {@link StartDatabaseError}. */ readonly resolvePostgresImage: Effect.Effect<string, ImagePrepullError>; readonly dbHealthTimeoutSeconds: number; @@ -154,17 +116,16 @@ export interface StartDatabaseInput<E> { readonly webhooksEnabled: boolean; readonly setup: FreshDbSetupInput<E>; /** - * Caller's `utils.NoBackupVolume` equivalent for `rollbackStart`. Fired once - * after pre-create refuse guards pass. Skipped on those guards so rollback cannot - * treat leftover sibling volumes as this run's fresh data. + * Reports whether the volume was freshly created, for the caller's own rollback logic. Fired + * once after the pre-create refuse guards pass, so rollback never treats a leftover sibling + * volume as this run's fresh data. */ readonly onFreshVolumeResolved: (isFreshVolume: boolean) => void; } /** - * Runs the exact Go `StartDatabase` sequence — see this module's header for the full call order - * and for why `resolvePostgresImage`/`setup.jwks` are caller-supplied `Effect`s rather than plain - * values. + * See this module's header for the full call order and for why `resolvePostgresImage`/ + * `setup.jwks` are caller-supplied `Effect`s rather than plain values. */ export const startDatabase = <E>( spawner: Spawner, @@ -183,20 +144,16 @@ export const startDatabase = <E>( Effect.gen(function* () { const output = yield* Output; - // Go's pre-create volume-existence check (`internal/db/start/start.go:165-167`) — MUST run - // before Postgres's own volume gets created below, AND before the network is created too: - // `docker volume create`/`docker network create` are both idempotent, so creating either - // first would make "did this volume already exist" unobservable, and would leave a Docker - // network behind even for a request the guard below is about to reject outright — Go's own - // `VolumeInspect` and the guard both run strictly BEFORE `DockerStart`, which is the ONLY - // place Go ever creates the network (`docker.go:363-386`). + // Must run before the volume or network are created: both `docker volume create` and + // `docker network create` are idempotent, so creating either first would make "did this + // volume already exist" unobservable and could leave a network behind for a request the + // guard below is about to reject. const isFreshVolume = !(yield* volumeExists(spawner, input.dbContainerId)); const fromBackup = input.postgresSpec.fromBackup; if (!isFreshVolume && fromBackup !== undefined) { - // Go's `StartDatabase` (`start.go:170-172`): a `--from-backup` restore into an - // already-provisioned volume is refused outright, BEFORE any container or network is - // created — and before freshness is published, so rollback cannot prune it. + // 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", @@ -216,11 +173,8 @@ export const startDatabase = <E>( input.onFreshVolumeResolved(isFreshVolume); - // Go's `DockerStart` (`docker.go:363-386`): image resolve, THEN network create, both - // strictly ahead of container create — hoisted here to run ONCE per `start` run instead of - // once per container (Go's own repeated per-container call is a no-op after the first, see - // `ensureNetwork`'s own doc comment), but kept in Go's own relative position: - // after the volume probe/guard above, never before it. + // Runs once per `start` run rather than once per container — see `ensureNetwork`'s own doc + // comment — but kept after the volume probe/guard above, never before it. yield* ensureNetwork(spawner, input.networkId, { [CLI_PROJECT_LABEL]: input.projectId, [COMPOSE_PROJECT_LABEL]: input.projectId, @@ -241,18 +195,15 @@ export const startDatabase = <E>( }, ).pipe(Effect.result); if (Result.isFailure(postgresHealthResult)) { - // Go's `StartDatabase` (`start.go:179-181`): `WaitForHealthyService`'s error is discarded - // ONLY when `len(fromBackup) > 0` — the log dump to stderr already happened inside - // `waitForHealthyServices` regardless of this branch. Any OTHER failure propagates - // BARE — this function has no `--ignore-health-check` knowledge at all, see this module's - // header for why that's entirely the caller's concern. + // Discarded only when `fromBackup` is set — the log dump to stderr already happened + // 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); } } - // Go's `if utils.NoBackupVolume && len(fromBackup) == 0 { SetupLocalDatabase(...) }` - // (`start.go:184-188`) — SKIPPED IN FULL when `fromBackup` is set, not merely reduced: no + // Skipped entirely when `fromBackup` is set, not merely reduced: no // initSchema/ApplyApiPrivileges/vault/roles.sql/MigrateAndSeed on that path at all. if (isFreshVolume && fromBackup === undefined) { yield* runFreshDbSetup(spawner, { @@ -263,9 +214,8 @@ export const startDatabase = <E>( networkId: input.networkId, hostname: input.hostname, dbPort: input.dbPort, - // Go's own `StartDatabase` -> `SetupLocalDatabase(ctx, "", ...)` call - // (`start.go:185`) — every pending migration, no `db reset`-only seed - // override (`db start` has neither `--no-seed` nor `--sql-paths`). + // Every pending migration, no seed override — `db start` has neither `--no-seed` nor + // `--sql-paths`. version: "", seedFlags: { noSeed: false, sqlPaths: [] }, setup: input.setup, @@ -281,8 +231,7 @@ export const startDatabase = <E>( }); } - // Go's `initCurrentBranch` (`db/start/start.go:189`) — the LAST line of `StartDatabase`, - // reached on every path that doesn't already return/fail above: a fresh volume, a non-fresh - // restart, AND a swallowed `fromBackup` health-check timeout. + // Reached on every path that doesn't already return or fail above: a fresh volume, a + // non-fresh restart, and a swallowed `fromBackup` health-check timeout. yield* startInitCurrentBranch(input.fs, input.path, input.workdir); }); 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 5063dd255c..96a9ec5363 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 @@ -1,28 +1,15 @@ /** - * A plain local-database start — Go's `start.Run(ctx, "", fsys)` (the DB-only start path - * `supabase db start` and, on the declarative `--local` paths, `ensureLocalDatabaseStarted` - * both call — `apps/cli-go/cmd/db_schema_declarative.go:149-158`, deleted in CLI-1970; last - * present at commit 7b469f5b3). Hoisted out of `commands/db/start/start.handler.ts` (CLI-1970) - * so it is callable in-process by any Effect context that provides the services below, following - * the same precedent `reset-local-database.ts` established for `db reset`: resolves every - * service it needs itself (self-contained, not passed in by the caller), emits progress via - * `output.raw` only, and never flushes telemetry — that stays the top-level command's own - * concern (`db start`'s handler wraps this call in its own `Effect.ensuring(telemetryState.flush)`; - * the declarative seam's `ensureLocalDatabaseStarted` does not flush at all, matching Go, where - * `ensureLocalDatabaseStarted` runs inside the OUTER `db schema declarative generate`/`sync` - * command's own `PersistentPostRun`, not a second, independent one). + * A plain local-database start — the shared entrypoint `supabase db start` and the declarative + * `--local` paths' `ensureLocalDatabaseStarted` both call. Self-contained: resolves every service + * it needs itself, emits progress via `output.raw` only, and never flushes telemetry — that stays + * the top-level command's own concern. * - * Unlike `resetLocalDatabase`, "the database is already running" is a normal, successful - * outcome here, not a failure — and the two real callers want different observable behavior on - * that outcome: `db start`'s own handler prints "Postgres database is already running." (Go's - * `db start` binary), while the declarative seam's `ensureLocalDatabaseStarted` must stay - * silent (Go's `ensureLocalDatabaseStarted` never runs `start.Run` at all when - * `AssertSupabaseDbIsRunning` reports the container is up, so nothing it wraps ever prints - * anything on that path). So this function itself never prints the terminal-state line — it - * returns a {@link StartLocalDatabaseResult} discriminator and leaves the print (or lack of - * one) to the caller, exactly like `db start`'s own pre-existing `output.success` on completion is - * also the caller's concern (see this module's own precedent: `startDatabase`'s header, - * "Rollback ... is ALSO the caller's concern, not this function's"). + * Unlike `resetLocalDatabase`, "the database is already running" is a normal, successful outcome + * here, not a failure — and the two real callers want different observable behavior on it: `db + * start` prints "Postgres database is already running.", while the declarative seam stays silent. + * So this function never prints the terminal-state line itself; it returns a + * {@link StartLocalDatabaseResult} discriminator and leaves the print (or lack of one) to the + * caller. */ import { Effect, FileSystem, Option, Path } from "effect"; @@ -104,12 +91,11 @@ interface StartLocalDatabaseResult { /** * Starts the local Postgres database, or no-ops when it is already running. See this module's - * own header for the full design rationale. Mirrors Go's `start.Run(ctx, "", fsys)` — the DB-only - * `internal/db/start.Run`, not the full `supabase start` stack. + * own header for the full design rationale. * - * `fromBackupFlag` is `db start`'s own `--from-backup` value, resolved against the CALLER's cwd + * `fromBackupFlag` is `db start`'s own `--from-backup` value, resolved against the caller's cwd * before being passed in; the declarative seam's `ensureLocalDatabaseStarted` always calls with - * no argument, matching Go's `start.Run(ctx, "", fsys)` empty-backup-path literal. + * no argument. */ export const startLocalDatabase = Effect.fnUntraced(function* (fromBackupFlag?: string) { const output = yield* Output; @@ -120,47 +106,34 @@ export const startLocalDatabase = Effect.fnUntraced(function* (fromBackupFlag?: const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const networkIdFlag = yield* NetworkIdFlag; - // Config is loaded first thing: a missing config is tolerated (defaults), but a present - // config that is malformed, references an undecryptable `encrypted:` secret, or fails - // validation aborts before any container work. `checkDbToml` is that exact - // load+validate — call it here (not via `isLocalDbRunning`'s best-effort read, which - // swallows config errors) so a start fails fast on a broken config. + // Config is loaded first thing: a missing config is tolerated (defaults), but a present config + // that is malformed, references an undecryptable `encrypted:` secret, or fails validation + // aborts before any container work. `checkDbToml` does that load+validate, not + // `isLocalDbRunning`'s best-effort read, which swallows config errors. const dbTomlValues = yield* checkDbToml(fs, path, cliSettings.workdir); - // Threaded into `rollbackStart`'s own `dockerRemoveAll` teardown — - // `--debug` gates that function's `Pruned …:` stderr reports, matching - // `supabase start`'s own handler — and into - // `buildLocalDbContainerInputs`'s own `setup.debug`, so a failed fresh-volume - // Realtime/Storage/Auth migrate job tees its own stderr (`db-setup.ts`'s - // `runStartMigrateJob`). Resolved with the `SUPABASE_DEBUG` shell/project-`.env` - // fallback, not the bare flag: every Go debug read on this path went through - // `viper.GetBool("DEBUG")` under `AutomaticEnv`. + // Threaded into `rollbackStart`'s own teardown (gates its `Pruned …:` stderr reports) and into + // `buildLocalDbContainerInputs`'s own `setup.debug`, so a failed fresh-volume migrate job tees + // its own stderr. Resolved with the `SUPABASE_DEBUG` shell/project-`.env` fallback, not the + // bare flag. const debug = yield* resolveDebugWithProjectEnv(dbTomlValues.projectEnv); - // The rest of config loading — full config decode/resolution (`loadLocalProjectContext`) - // plus the eager duration-field validation right below — ALSO runs before the - // already-running check, so a malformed `auth.*` duration field must fail this call even - // when Postgres is already running, not just on a fresh start. + // The rest of config loading — full decode/resolution plus the eager duration-field + // validation right below — also runs before the already-running check, so a malformed + // `auth.*` duration field must fail this call even when Postgres is already running. const context = yield* loadLocalProjectContext( cliSettings.workdir, (message) => new DbConfigLoadError({ message }), ); - // `projectId`/`hostname` are NOT destructured under their bare names here — the not-running - // branch below passes this SAME `context` into `buildLocalDbContainerInputs` as its - // `preloadedContext` param (reused, not reloaded — a second `loadLocalProjectContext` - // call would run `@supabase/config`'s `loadCliConfig` again, which unconditionally - // prints deprecated-config-section WARN lines to stderr, doubling them for one invocation), - // and that function returns the SAME context back verbatim as `inputs.context`, later - // destructured under `context.projectId`/`context.hostname` — re-declaring those same bare - // names here, in this same function scope, would still collide with that later - // destructuring. `hostnameForValidation` is still needed here, for the eager, discarded - // `resolveLocalConfigValues` call further down. + // This same `context` is passed into `buildLocalDbContainerInputs` below as + // `preloadedContext`, since a second `loadCliConfig` call would double-print + // deprecated-config-section warnings; that function returns the same context back verbatim. + // `hostnameForValidation` here still feeds the discarded `resolveLocalConfigValues` call below. const { config, projectEnvValues, loaded, hostname: hostnameForValidation } = context; - // Every duration config field — including these 5 — is decoded in the same single, - // unconditional config-load pass, before Docker is touched (or even whether Postgres is - // already running is checked) at all. Discarding the parsed values: only the fail-fast - // behavior matters here. + // Every duration config field is decoded in this same unconditional pass, before Docker is + // touched or the already-running check runs. The parsed values are discarded; only the + // fail-fast behavior matters. const authDocForValidation = asRecord(loaded?.document?.["auth"]); const resolvedEmailForValidation = yield* wrapDbConfigOverride("auth.email", () => resolveAuthEmail(config.auth.email, authDocForValidation, projectEnvValues), @@ -582,15 +555,12 @@ export const startLocalDatabase = Effect.fnUntraced(function* (fromBackupFlag?: ); } - // Closes an entire recurring class of gaps in the battery above, rather than adding another - // one-off field check: `resolveLocalConfigValues` is the SAME resolver - // `buildLocalDbContainerInputs` calls again below, in the not-running branch, to build - // the REAL `values` the container bring-up needs — calling it EAGERLY here too, before the - // already-running shortcut, forces its internal decode-time throws (auth.captcha, - // auth.jwt_secret length, auth.signing_keys_path, api.tls cert/key reads, - // auth.external.* required-field validation, auth.email/notification template reads) to - // surface early. Its result is discarded here — only the fail-fast behavior matters — and - // `buildLocalDbContainerInputs` below re-resolves the REAL `values`. + // `resolveLocalConfigValues` is the same resolver `buildLocalDbContainerInputs` calls again + // below to build the real `values`. Calling it eagerly here too forces its internal + // decode-time throws (auth.captcha, jwt_secret length, signing_keys_path, api.tls cert/key + // reads, auth.external required fields, email/notification template reads) to surface before + // the already-running shortcut. The result is discarded; `buildLocalDbContainerInputs` below + // re-resolves the real values. yield* Effect.try({ try: () => resolveLocalConfigValues( @@ -606,9 +576,8 @@ export const startLocalDatabase = Effect.fnUntraced(function* (fromBackupFlag?: }), }); - // If the db container is already up, tell the caller and stop here. Runs AFTER the config - // load/validation above, matching the established order of loading config before the - // already-running check. + // If the db container is already up, tell the caller and stop here. Runs after the config + // load/validation above. const running = yield* isLocalDbRunning( spawner, fs, @@ -620,7 +589,7 @@ export const startLocalDatabase = Effect.fnUntraced(function* (fromBackupFlag?: return { status: "already-running" } satisfies StartLocalDatabaseResult; } - // Resolve a relative `--from-backup` against the CALLER's cwd, captured before any workdir + // Resolve a relative `--from-backup` against the caller's cwd, captured before any workdir // change. An empty `--from-backup ""` is a normal no-backup start, so treat it as absent // rather than joining it to a directory path. const fromBackup = @@ -630,11 +599,10 @@ export const startLocalDatabase = Effect.fnUntraced(function* (fromBackupFlag?: ? fromBackupFlag : path.join(runtimeInfo.cwd, fromBackupFlag); - // Not running → bring up the container natively. `context` (loaded eagerly above) is - // threaded through as `preloadedContext` — no `projectRef`/`remoteOverrideKeys` (`db start` - // never has either) — so this call reuses it instead of calling - // `loadLocalProjectContext` a second time, which would otherwise double-print any - // deprecated-config-section stderr warning for this single invocation. + // Not running → bring up the container natively. `context` (loaded eagerly above) is threaded + // through as `preloadedContext` so this call reuses it instead of calling + // `loadLocalProjectContext` a second time, which would double-print deprecated-config-section + // warnings. const inputs = yield* buildLocalDbContainerInputs( spawner, cliSettings.workdir, @@ -666,8 +634,8 @@ export const startLocalDatabase = Effect.fnUntraced(function* (fromBackupFlag?: // Runs the exact start-database sequence (network -> volume probe -> container create+start // -> health wait -> fresh-volume setup -> `_current_branch`) — shared with `supabase start`, - // see `startDatabase`'s own header. Any failure rolls back via the SAME - // `Effect.onError` wrapper `supabase start` uses. + // see `startDatabase`'s own header. Any failure rolls back via the same `Effect.onError` + // wrapper `supabase start` uses. yield* startDatabase(spawner, { fs, path, diff --git a/apps/cli/src/command-internal/db-bootstrap/templates/db-globals.sql.ts b/apps/cli/src/command-internal/db-bootstrap/templates/db-globals.sql.ts index cef33a82a3..cf4247ac31 100644 --- a/apps/cli/src/command-internal/db-bootstrap/templates/db-globals.sql.ts +++ b/apps/cli/src/command-internal/db-bootstrap/templates/db-globals.sql.ts @@ -1,9 +1,6 @@ /** - * Transcribed verbatim from `apps/cli-go/internal/utils/templates/globals.sql` - * (Go `//go:embed templates/globals.sql`, `apps/cli-go/internal/utils/misc.go:67-68`, - * exported as `utils.GlobalsSql`). Run via `ExecBatch` against a fresh PG<=14 local - * database as the first step of `initSchema` (`apps/cli-go/internal/db/start/start.go:243-251`). - * Not a Go `text/template`. Do not hand-edit — re-transcribe from the Go source if it changes. + * Baseline SQL run via `ExecBatch` against a fresh PG<=14 local database, as the first step of + * `initSchema`. */ export const START_DB_GLOBALS_SQL = `-- -- PostgreSQL database cluster dump diff --git a/apps/cli/src/command-internal/db-bootstrap/templates/db-initial-schema-13.sql.ts b/apps/cli/src/command-internal/db-bootstrap/templates/db-initial-schema-13.sql.ts index 401c02a7cf..513bfe0054 100644 --- a/apps/cli/src/command-internal/db-bootstrap/templates/db-initial-schema-13.sql.ts +++ b/apps/cli/src/command-internal/db-bootstrap/templates/db-initial-schema-13.sql.ts @@ -1,11 +1,6 @@ /** - * Transcribed verbatim from `apps/cli-go/internal/utils/templates/initial_schemas/13.sql` - * (Go `//go:embed templates/initial_schemas/13.sql`, `apps/cli-go/internal/utils/config.go:51-52`, - * exported as `utils.InitialSchemaPg13Sql`). Run via `ExecBatch` against a fresh - * `db.major_version == 13` local database by `InitSchema14` - * (`apps/cli-go/internal/db/start/start.go:256-266`), right after - * {@link START_DB_GLOBALS_SQL}. Not a Go `text/template`. Do not hand-edit — - * re-transcribe from the Go source if it changes. + * Baseline SQL run via `ExecBatch` against a fresh `db.major_version == 13` local database, + * right after {@link START_DB_GLOBALS_SQL}. */ export const START_DB_INITIAL_SCHEMA_13_SQL = `-- -- PostgreSQL database dump diff --git a/apps/cli/src/command-internal/db-bootstrap/templates/db-initial-schema-14.sql.ts b/apps/cli/src/command-internal/db-bootstrap/templates/db-initial-schema-14.sql.ts index 9cbea78b2a..4c6135a66a 100644 --- a/apps/cli/src/command-internal/db-bootstrap/templates/db-initial-schema-14.sql.ts +++ b/apps/cli/src/command-internal/db-bootstrap/templates/db-initial-schema-14.sql.ts @@ -1,11 +1,6 @@ /** - * Transcribed verbatim from `apps/cli-go/internal/utils/templates/initial_schemas/14.sql` - * (Go `//go:embed templates/initial_schemas/14.sql`, `apps/cli-go/internal/utils/config.go:53-54`, - * exported as `utils.InitialSchemaPg14Sql`). Run via `ExecBatch` against a fresh - * PG14 (`db.major_version != 13`, `<= 14`) local database by `InitSchema14` - * (`apps/cli-go/internal/db/start/start.go:256-266`), right after - * {@link START_DB_GLOBALS_SQL}. Not a Go `text/template`. Do not hand-edit — - * re-transcribe from the Go source if it changes. + * Baseline SQL run via `ExecBatch` against a fresh PG14 (`db.major_version != 13`, `<= 14`) local + * database, right after {@link START_DB_GLOBALS_SQL}. */ export const START_DB_INITIAL_SCHEMA_14_SQL = `-- -- PostgreSQL database dump diff --git a/apps/cli/src/command-internal/db-bootstrap/templates/db-restore.sh.ts b/apps/cli/src/command-internal/db-bootstrap/templates/db-restore.sh.ts index 3ff4da2154..7c86ae3e82 100644 --- a/apps/cli/src/command-internal/db-bootstrap/templates/db-restore.sh.ts +++ b/apps/cli/src/command-internal/db-bootstrap/templates/db-restore.sh.ts @@ -1,14 +1,10 @@ /** - * Transcribed verbatim from `apps/cli-go/internal/db/start/templates/restore.sh` - * (Go `//go:embed templates/restore.sh`, `apps/cli-go/internal/db/start/start.go:40-41`, - * exported as `restoreScript`). Heredoc'd into `/docker-entrypoint-initdb.d/migrate.sh` - * by the Postgres container's entrypoint ONLY when `--from-backup` is set - * (`StartDatabase`, `apps/cli-go/internal/db/start/start.go:143-159`) — restores roles - * then schema from the bind-mounted `/etc/backup.sql`, then runs - * `/etc/postgresql.schema.sql` (the initial schema, written by the same entrypoint) as a - * post-init step so a restored database still gets Supabase's roles/passwords applied. - * Not a Go `text/template`. Do not hand-edit — re-transcribe from the Go source if it - * changes. + * Heredoc'd into `/docker-entrypoint-initdb.d/migrate.sh` by the Postgres container's entrypoint + * only when `--from-backup` is set — restores roles then schema from the bind-mounted + * `/etc/backup.sql`, then runs `/etc/postgresql.schema.sql` (the initial schema, written by the + * same entrypoint) as a post-init step so a restored database still gets Supabase's + * roles/passwords applied. `${…}` sequences are escaped as `\${…}` so shell variable expansion + * stays literal instead of being interpolated by JS. */ export const START_DB_RESTORE_SH = `#!/bin/sh set -eu diff --git a/apps/cli/src/command-internal/db-bootstrap/templates/db-schema.sql.ts b/apps/cli/src/command-internal/db-bootstrap/templates/db-schema.sql.ts index 8906628fd7..9428646624 100644 --- a/apps/cli/src/command-internal/db-bootstrap/templates/db-schema.sql.ts +++ b/apps/cli/src/command-internal/db-bootstrap/templates/db-schema.sql.ts @@ -1,10 +1,4 @@ -/** - * Transcribed verbatim from `apps/cli-go/internal/db/start/templates/schema.sql` - * (Go `//go:embed templates/schema.sql`, `apps/cli-go/internal/db/start/start.go:33-35`). - * Not a Go `text/template` — embedded byte-for-byte into the Postgres - * container's entrypoint heredoc (`NewContainerConfig`, `start.go:63-116`) for - * PG >= 15. Do not hand-edit — re-transcribe from the Go source if it changes. - */ +/** Embedded byte-for-byte into the Postgres container's entrypoint heredoc for PG >= 15. */ export const START_DB_SCHEMA_SQL = `\\set pgpass \`echo "$PGPASSWORD"\` \\set jwt_secret \`echo "$JWT_SECRET"\` \\set jwt_exp \`echo "$JWT_EXP"\` diff --git a/apps/cli/src/command-internal/db-bootstrap/templates/db-supabase.sql.ts b/apps/cli/src/command-internal/db-bootstrap/templates/db-supabase.sql.ts index 88e3c80747..44a330af10 100644 --- a/apps/cli/src/command-internal/db-bootstrap/templates/db-supabase.sql.ts +++ b/apps/cli/src/command-internal/db-bootstrap/templates/db-supabase.sql.ts @@ -1,10 +1,6 @@ /** - * Transcribed verbatim from `apps/cli-go/internal/db/start/templates/_supabase.sql` - * (Go `//go:embed templates/_supabase.sql`, `apps/cli-go/internal/db/start/start.go:38-39`). - * Not a Go `text/template` — embedded byte-for-byte into the Postgres - * container's entrypoint heredoc (`NewContainerConfig`, `start.go:63-116`), for - * both the PG >= 15 and PG <= 14 branches. Do not hand-edit — re-transcribe - * from the Go source if it changes. + * Embedded byte-for-byte into the Postgres container's entrypoint heredoc, for both the PG >= 15 + * and PG <= 14 branches. */ export const START_DB_SUPABASE_SQL = `CREATE DATABASE _supabase WITH OWNER postgres; diff --git a/apps/cli/src/command-internal/db-bootstrap/templates/db-webhook.sql.ts b/apps/cli/src/command-internal/db-bootstrap/templates/db-webhook.sql.ts index 9a3da49df6..9f4545ddaa 100644 --- a/apps/cli/src/command-internal/db-bootstrap/templates/db-webhook.sql.ts +++ b/apps/cli/src/command-internal/db-bootstrap/templates/db-webhook.sql.ts @@ -1,10 +1,4 @@ -/** - * Transcribed verbatim from `apps/cli-go/internal/db/start/templates/webhook.sql` - * (Go `//go:embed templates/webhook.sql`, `apps/cli-go/internal/db/start/start.go:36-37`). - * Not a Go `text/template` — embedded byte-for-byte into the Postgres - * container's entrypoint heredoc (`NewContainerConfig`, `start.go:63-116`) for - * PG >= 15 only. Do not hand-edit — re-transcribe from the Go source if it changes. - */ +/** Embedded byte-for-byte into the Postgres container's entrypoint heredoc for PG >= 15 only. */ export const START_DB_WEBHOOK_SQL = `BEGIN; -- Create supabase_functions schema diff --git a/apps/cli/src/command-internal/db-config.errors.ts b/apps/cli/src/command-internal/db-config.errors.ts index 414c66fa0a..4d8fe9a781 100644 --- a/apps/cli/src/command-internal/db-config.errors.ts +++ b/apps/cli/src/command-internal/db-config.errors.ts @@ -7,10 +7,7 @@ import { statusCodeActionability, } from "../shared/telemetry/error-actionability.ts"; -/** - * `--db-url` could not be parsed as a Postgres connection string. Mirrors Go's - * `pgconn.ParseConfig` failure in `flags.ParseDatabaseConfig`. - */ +/** `--db-url` could not be parsed as a Postgres connection string. */ export class DbConfigParseUrlError extends Data.TaggedError("DbConfigParseUrlError")<{ readonly message: string; }> { @@ -102,10 +99,8 @@ export class DbConfigUnbanStatusError extends Data.TaggedError("DbConfigUnbanSta } /** - * The linked project's direct database host is unreachable (IPv6-only) and no - * connection pooler is configured. Byte-matches Go's - * `"IPv6 is not supported on your current network"` with the `supabase link` - * suggestion. + * The linked project's direct database host is unreachable (IPv6-only) and no connection pooler + * is configured. The message text is exact: `"IPv6 is not supported on your current network"`. */ export class DbConfigIpv6Error extends Data.TaggedError("DbConfigIpv6Error")<{ readonly message: string; @@ -124,9 +119,8 @@ export class DbConfigIpv6Error extends Data.TaggedError("DbConfigIpv6Error")<{ } /** - * Failed to connect to the linked project as the temporary login role after the - * pooler refresh backoff was exhausted. Mirrors `initPoolerLogin` final - * `backoff.RetryNotify` failure. + * Failed to connect to the linked project as the temporary login role after the pooler refresh + * backoff was exhausted. */ export class DbConfigConnectTempRoleError extends Data.TaggedError("DbConfigConnectTempRoleError")<{ readonly message: string; @@ -138,9 +132,8 @@ export class DbConfigConnectTempRoleError extends Data.TaggedError("DbConfigConn } /** - * The configured pooler connection string does not match the linked project ref - * or its domain falls outside the active profile (MITM guard). Mirrors the - * `nil`-returning validation branches of `GetPoolerConfig`. + * The configured pooler connection string does not match the linked project ref, or its domain + * falls outside the active profile (MITM guard). */ export class DbConfigPoolerLoginError extends Data.TaggedError("DbConfigPoolerLoginError")<{ readonly message: string; diff --git a/apps/cli/src/command-internal/db-config.integration.test.ts b/apps/cli/src/command-internal/db-config.integration.test.ts index 2b106ae9cc..767f04a909 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -154,8 +154,8 @@ describe("dbConfigResolver (local + db-url)", () => { user: "postgres", password: "hunter2", database: "postgres", - // The resolver attaches the connect-failure suggestion context (Go's - // ambient CurrentProfile) to every resolved connection. + // The resolver attaches the connect-failure suggestion context to every resolved + // connection. suggestionContext: { dashboardUrl: "https://supabase.com/dashboard", profileName: "supabase", @@ -169,7 +169,6 @@ describe("dbConfigResolver (local + db-url)", () => { }); it.effect("local mode: honors SUPABASE_SERVICES_HOSTNAME for the connection host", () => { - // Dev-container / remote-Docker parity (Go's utils.Config.Hostname). process.env["SUPABASE_SERVICES_HOSTNAME"] = "host.docker.internal"; const dir = withWorkdir(); return resolve(dir, localFlags).pipe( @@ -233,8 +232,6 @@ describe("dbConfigResolver (local + db-url)", () => { }); it.effect("db-url mode: a passwordless local url fills the password from config", () => { - // Go's ConnectLocalPostgres fills an empty password from `[db].password` - // for local connections, so a passwordless local DSN still authenticates. const dir = withWorkdir(["[db]", "port = 54322", 'password = "hunter2"', ""].join("\n")); return resolve(dir, dbUrlFlags("postgres://postgres@127.0.0.1:54322/postgres")).pipe( Effect.tap((r) => @@ -276,8 +273,6 @@ describe("dbConfigResolver (local + db-url)", () => { return resolve(dir, dbUrlFlags(url)).pipe( Effect.tap((r) => Effect.sync(() => { - // Go's `pgconn.ParseConfig` keeps both in `pgconn.Config`; the URL - // parser must not discard the query string. expect(r.conn.sslmode).toBe("verify-full"); expect(r.conn.options).toBe("reference=abcdefghijklmnop"); rmSync(dir, { recursive: true, force: true }); @@ -291,7 +286,6 @@ describe("dbConfigResolver (local + db-url)", () => { return resolve(dir, dbUrlFlags("host=pg.example.com port=6543 user=admin dbname=app")).pipe( Effect.tap((r) => Effect.sync(() => { - // Go's `pgconn.ParseConfig` accepts keyword/value DSNs, not just URLs. expect(r.conn.host).toBe("pg.example.com"); expect(r.conn.port).toBe(6543); expect(r.conn.user).toBe("admin"); @@ -332,12 +326,9 @@ describe("dbConfigResolver (linked config ordering)", () => { it.effect( "validates the ref-merged config before any network work (Go ParseDatabaseConfig order)", () => { - // `ParseDatabaseConfig` runs LoadProjectRef → LoadConfig → NewDbConfigWithPassword, - // so an invalid `[remotes.<ref>]`-merged db.major_version - // fails as a config error before the TCP probe / pooler / Management API. The - // ref is sourced from the config's top-level project_id; the matching remote - // block sets an unsupported major_version. If validation happened after the - // connection work, mockDbConnection.connect() would die first. + // The ref is sourced from the config's top-level project_id; the matching remote block + // sets an unsupported major_version. If validation happened after the connection work, + // `mockDbConnection.connect()` would die first. const ref = "abcdefghijklmnopqrst"; const dir = withWorkdir( [ @@ -372,10 +363,8 @@ describe("dbConfigResolver (linked config ordering)", () => { ); it.effect("surfaces a project-ref read failure instead of reporting not-linked", () => { - // `ParseDatabaseConfig`'s linked branch uses the hard LoadProjectRef, - // which returns `failed to load project ref` on a real `.temp/project-ref` read error - // rather than masking it as not-linked. With no project_id / - // env and the ref file seeded as a DIRECTORY, the resolver must surface that. + // The ref file is seeded as a directory (not a file), with no project_id or env fallback, + // to force a real read error. const dir = withWorkdir(); mkdirSync(join(dir, "supabase", ".temp", "project-ref"), { recursive: true }); return resolve(dir, linkedFlags).pipe( @@ -831,11 +820,10 @@ describe("dbConfigResolver (linked config ordering)", () => { }); }); -// CLI P1 fix (codex review, db-config.types.ts:81): an explicit -// `--project-ref`/`linkedProjectRef` on a NON-ad-hoc `db` command must -// independently unlock the Management API pooler fetch on an IPv4-only -// network — it must not stay confined to the workdir's saved -// `.temp/pooler-url` the way the plain `--linked` default path is. +// An explicit `--project-ref`/`linkedProjectRef` on a non-ad-hoc `db` command must +// independently unlock the Management API pooler fetch on an IPv4-only network — it must not +// stay confined to the workdir's saved `.temp/pooler-url` the way the plain `--linked` default +// path is. describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProjectRef)", () => { it.effect( "an unlinked workdir + explicit --project-ref resolves via the API pooler config, honoring the ambient password with no login-role mint", @@ -917,8 +905,6 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje }, }); expect(r.ref).toEqual(Option.some(ref)); - // Only the pooler-config GET fires — no `cli/login-role` POST, since - // the ambient password takes precedence once the pooler is resolved. expect(requests).toEqual([ { method: "GET", path: `/v1/projects/${ref}/config/database/pooler` }, ]); @@ -1028,9 +1014,6 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje }, }); expect(r.ref).toEqual(Option.some(targetRef)); - // The saved URL is read (not skipped — `ignoreSavedUrl` stays tied to - // `adHocProjectRef` only) but rejected by the tenant-ref check, so a - // second-chance API fetch for `targetRef` follows. expect(requests).toEqual([ { method: "GET", path: `/v1/projects/${targetRef}/config/database/pooler` }, ]); @@ -1053,10 +1036,6 @@ describe("dbConfigResolver (--project-ref pooler fetch decoupled from adHocProje it.effect( "the plain --linked path (no --project-ref) keeps the IPv6 error when no pooler URL is saved", () => { - // Pins the untouched default path: `linkedProjectRef` is absent, so - // `fetchPoolerFromApi` stays false and an unreachable direct host with no - // saved `.temp/pooler-url` still fails with Go's IPv6 suggestion — the fix - // only widens the explicit `--project-ref` path, not this default one. const ref = "plainlinkedrefabcdef"; const dir = withWorkdir( [`project_id = "${ref}"`, "[db]", "major_version = 15", ""].join("\n"), diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index 035f7d450b..14f6b9ec45 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -66,8 +66,7 @@ const unbanErrorMapper = mapHttpError({ statusMessage: (status, body) => `unexpected remove bans status ${status}: ${body}`, }); -/** `utils.IsLocalDatabase`. Compares against the resolved local - * services hostname (`utils.Config.Hostname`), not a hard-coded loopback. */ +/** Compares against the resolved local services hostname, not a hard-coded loopback. */ function isLocalDatabase( host: string, localHost: string, @@ -78,7 +77,7 @@ function isLocalDatabase( return host === localHost && (port === dbPort || port === shadowPort); } -/** Best-effort TCP reachability probe (Go dials direct host:5432 with a 5s timeout). */ +/** Best-effort TCP reachability probe with a 5s timeout. */ const tcpReachable = (host: string, port: number): Effect.Effect<boolean> => Effect.callback<boolean>((resume) => { const socket = net.connect({ host, port }); @@ -93,12 +92,11 @@ const tcpReachable = (host: string, port: number): Effect.Effect<boolean> => Effect.timeoutOrElse({ duration: TCP_PROBE_TIMEOUT, orElse: () => Effect.succeed(false) }), ); -// POST /v1/projects/{ref}/cli/login-role → mint a temporary postgres role. -// The Management API client is built lazily via `CommandPlatformApiFactory.make` -// (not the eager `CommandPlatformApi` stack), so the access token is resolved -// only here — when a temp role is actually minted. `--linked --password` returns -// before reaching this, so it stays auth-free (`NewDbConfigWithPassword`); -// `--local` / `--db-url` never build this layer at all. +// POST /v1/projects/{ref}/cli/login-role → mint a temporary postgres role. The Management API +// client is built lazily via `CommandPlatformApiFactory.make` (not the eager +// `CommandPlatformApi` stack), so the access token is resolved only here — when a temp role is +// actually minted. `--linked --password` returns before reaching this, so it stays auth-free; +// `--local`/`--db-url` never build this layer at all. const initLoginRole = Effect.fnUntraced(function* (ref: string, conn: PgConnInput) { const output = yield* Output; const api = yield* (yield* CommandPlatformApiFactory).make; @@ -120,9 +118,8 @@ const listAndUnban = Effect.fnUntraced(function* (ref: string) { .pipe(Effect.catch(unbanErrorMapper)); }); -// Verify-connect with backoff while the pooler refreshes the temp password -// (`initPoolerLogin` → `backoff.RetryNotify`). On attempt ≥ 3, clear any -// network ban on the requester (Go's notify callback). +// Verify-connect with backoff while the pooler refreshes the temp password. On attempt ≥ 3, +// clear any network ban on the requester. const waitForTempRole = Effect.fnUntraced(function* ( ref: string, conn: PgConnInput, @@ -131,15 +128,12 @@ const waitForTempRole = Effect.fnUntraced(function* ( const dbConn = yield* DbConnection; const debug = yield* DebugLogger; const attempt = (n: number): Effect.Effect<void, DbConfigError, CommandPlatformApiFactory> => - // The temp-role probe always targets the remote Supavisor pooler, so it - // connects with TLS (Go's pooler path goes through `ConnectByUrl`) and - // honors `--dns-resolver` (`ConnectByConfigStream` installs the DoH - // resolver for this remote connect too). + // The temp-role probe always targets the remote Supavisor pooler, so it connects with TLS + // and honors `--dns-resolver`. Effect.scoped(dbConn.connect(conn, { isLocal: false, dnsResolver }).pipe(Effect.asVoid)).pipe( Effect.catch((cause) => { - // `backoff.WithMaxRetries(b, 8)` allows 8 retries after the - // initial attempt → 9 total attempts. `n` is 1-based, so give up only - // after attempt 9 (`n > MAX_RETRIES`), not at attempt 8. + // 8 retries after the initial attempt allows 9 total attempts. `n` is 1-based, so give + // up only after attempt 9 (`n > MAX_RETRIES`), not at attempt 8. if (n > MAX_RETRIES) { return Effect.fail( new Errors.DbConfigConnectTempRoleError({ @@ -148,21 +142,17 @@ const waitForTempRole = Effect.fnUntraced(function* ( }), ); } - // Mirrors Go's notify callback: from the 3rd failure onward, clear any - // network ban on the requester. NOTE: Go's exponential backoff applies - // ±50% jitter (RandomizationFactor=0.5); we use a deterministic curve — - // intentional, jitter only matters under concurrent pooler refreshes. + // From the 3rd failure onward, clear any network ban on the requester. Uses a + // deterministic backoff curve rather than jittered, since jitter only matters under + // concurrent pooler refreshes. const unban = n >= 3 ? listAndUnban(ref) : Effect.void; const delayMs = Math.min( Duration.toMillis(BACKOFF_INITIAL) * 1.5 ** (n - 1), Duration.toMillis(BACKOFF_MAX), ); return Effect.gen(function* () { - // Go runs the unban inside `backoff.RetryNotify`'s notify callback, - // which cannot abort the retry — `NewErrorCallback` only logs a callback - // error and continues. So a transient - // ban-list/unban failure must NOT propagate out of the retry loop; log it - // to --debug like Go, then discard. + // A transient ban-list/unban failure must not propagate out of the retry loop; log + // it to --debug, then discard. yield* unban.pipe( Effect.tapError((banError) => debug.debug(banError.message)), Effect.ignore, @@ -177,9 +167,8 @@ const waitForTempRole = Effect.fnUntraced(function* ( }); /** - * Parse + validate the configured pooler connection string. Returns `None` - * (treated as "no pooler", → IPv6 error) on any validation failure, matching - * `GetPoolerConfig`, which logs and returns `nil`. + * Parse + validate the configured pooler connection string. Returns `None` (treated as "no + * pooler" → IPv6 error) on any validation failure. */ const poolerConfigFrom = Effect.fnUntraced(function* ( ref: string, @@ -193,12 +182,12 @@ const poolerConfigFrom = Effect.fnUntraced(function* ( return Option.none(); }); -// Resolve the DB password with viper's precedence: `--password` flag → -// `SUPABASE_DB_PASSWORD` shell env → project `.env*` value. `loadProjectEnv` -// already excludes shell-set keys, so the shell value still wins over the file. -// `workdir` is an explicit parameter (never `CommandSettings.workdir`) so callers -// whose real workdir has diverged from that cwd-walked value (e.g. `bootstrap`, -// after its own `process.chdir`) still resolve against the correct directory. +// Resolve the DB password with this precedence: `--password` flag → `SUPABASE_DB_PASSWORD` +// shell env → project `.env*` value. `loadProjectEnv` already excludes shell-set keys, so the +// shell value still wins over the file. `workdir` is an explicit parameter (never +// `CommandSettings.workdir`) so callers whose real workdir has diverged from that cwd-walked +// value (e.g. `bootstrap`, after its own `process.chdir`) still resolve against the correct +// directory. const resolveDbPassword = Effect.fnUntraced(function* ( passwordFlag: Option.Option<string>, workdir: string, @@ -215,11 +204,9 @@ const resolveDbPassword = Effect.fnUntraced(function* ( }); /** - * Resolve the IPv4 transaction pooler connection for `ref` (Go's - * `GetPoolerConfig` + `initPoolerLogin`). Returns `None` when no pooler URL is - * configured or it fails validation (`GetPoolerConfig` returns nil), so the - * caller can keep the original error. With a password, uses it directly; without - * one, mints a temp login role and verify-connects through the pooler. + * Resolve the IPv4 transaction pooler connection for `ref`. Returns `None` when no pooler URL is + * configured or it fails validation, so the caller can keep the original error. With a password, + * uses it directly; without one, mints a temp login role and verify-connects through the pooler. * * `workdir`/`poolerHost` are explicit parameters (see {@link resolveDbPassword}). */ @@ -229,10 +216,9 @@ const resolvePoolerConn = Effect.fnUntraced(function* ( poolerHost: string, dnsResolver: "native" | "https", password: string, - // `ResolvePoolerConfigForFallback` (container-fallback only) falls back to - // the Management API's primary pooler config when no `.temp/pooler-url` is saved; - // the resolve-time IPv6 path (`NewDbConfigWithPassword` → `GetPoolerConfig`) uses - // the saved URL only and errors otherwise, so this defaults off. + // The container-fallback path falls back to the Management API's primary pooler config when + // no `.temp/pooler-url` is saved; the resolve-time IPv6 path uses the saved URL only and + // errors otherwise, so this defaults off. fetchFromApi = false, // For an ad-hoc `--project-id` ref the saved `.temp/pooler-url` belongs to the // (possibly different) linked workdir, so ignore it and resolve the pooler for @@ -243,11 +229,10 @@ const resolvePoolerConn = Effect.fnUntraced(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const debug = yield* DebugLogger; - // Linked-path read: merge the `[remotes.<ref>]` override (Go's pooler - // resolution runs after LoadConfig(ref) already merged), so this matches the - // ref-aware read on the main linked branch rather than validating base config. - // For an ad-hoc `--project-id` ref, skip the saved workdir pooler URL because - // it belongs to the linked project, not necessarily the explicit ref. + // Linked-path read: merge the `[remotes.<ref>]` override, so this matches the ref-aware read + // on the main linked branch rather than validating base config. For an ad-hoc `--project-id` + // ref, skip the saved workdir pooler URL because it belongs to the linked project, not + // necessarily the explicit ref. const tomlValues = yield* readDbToml(fs, path, workdir, ref, { resolveVaultSecrets, }); @@ -256,9 +241,8 @@ const resolvePoolerConn = Effect.fnUntraced(function* ( : Option.getOrUndefined(tomlValues.poolerConnectionString); if (connectionString === undefined) { if (!fetchFromApi) return Option.none<PgConnInput>(); - // No saved pooler URL → fetch the primary pooler config from the Management - // API (`GetPoolerConfigPrimary`). Any API failure - // means "no fallback", so swallow it to `None`. + // No saved pooler URL → fetch the primary pooler config from the Management API. Any API + // failure means "no fallback", so swallow it to `None`. const api = yield* (yield* CommandPlatformApiFactory).make; const configsOpt = yield* api.v1.getPoolerConfig({ ref }).pipe(Effect.option); if (Option.isNone(configsOpt)) return Option.none<PgConnInput>(); @@ -296,19 +280,16 @@ const resolvePoolerConn = Effect.fnUntraced(function* ( }); /** - * Resolves the linked project's connection: dial the direct host, and — when - * unreachable (the common case, since new Supabase projects have IPv6-only - * direct DB hosts) — transparently fall back to the project's IPv4 transaction - * pooler (`flags.NewDbConfigWithPassword`). + * Resolves the linked project's connection: dial the direct host, and — when unreachable (the + * common case, since new Supabase projects have IPv6-only direct DB hosts) — transparently fall + * back to the project's IPv4 transaction pooler. * - * `workdir`/`projectHost`/`poolerHost` are explicit parameters rather than read - * from `CommandSettings` so this is safely callable from a context whose real - * workdir has diverged from `CommandSettings.workdir`'s cwd-walked value — e.g. - * `bootstrap`, whose own `process.chdir` happens after that layer is built (see - * `bootstrap.handler.ts`'s workdir comments). Exported as `resolveLinkedConn` - * so `bootstrap` can call it directly with its own local `workdir`/`projectRef`/ - * `created.dbPassword`, without going through `DbConfigResolver`/ - * `ProjectRefResolver` (both keyed off the ambient, potentially-stale + * `workdir`/`projectHost`/`poolerHost` are explicit parameters rather than read from + * `CommandSettings`, so this is safely callable from a context whose real workdir has diverged + * from `CommandSettings.workdir`'s cwd-walked value — e.g. `bootstrap`, whose own + * `process.chdir` happens after that layer is built. `bootstrap` calls this directly with its + * own local `workdir`/`projectRef`/`created.dbPassword`, without going through + * `DbConfigResolver`/`ProjectRefResolver` (both keyed off the ambient, potentially-stale * `CommandSettings.workdir`). */ export const resolveLinkedConn = Effect.fnUntraced(function* ( @@ -365,15 +346,14 @@ export const resolveLinkedConn = Effect.fnUntraced(function* ( return yield* initLoginRole(ref, base); } - // Direct host unreachable (IPv6-only network) → try the pooler. For an ad-hoc - // `--project-id` ref the command already holds a Management API token, so fall - // back to the API pooler config (and ignore the workdir's saved pooler URL) - // rather than failing with the IPv6 "run supabase link" suggestion. An explicit - // `--project-ref` on a non-ad-hoc `db` command keeps `ignoreSavedUrl` at the - // saved-URL-first default (the tenant-mismatch check in `poolerConfigFrom` still - // rejects a stale saved URL for a DIFFERENT ref), but independently requests the - // same API fetch via `fetchPoolerFromApi` so an unlinked or mismatched-tenant - // workdir doesn't dead-end in the IPv6 "run supabase link" error. + // Direct host unreachable (IPv6-only network) → try the pooler. For an ad-hoc `--project-id` + // ref the command already holds a Management API token, so fall back to the API pooler config + // (and ignore the workdir's saved pooler URL) rather than failing with the IPv6 suggestion. An + // explicit `--project-ref` on a non-ad-hoc `db` command keeps `ignoreSavedUrl` at the + // saved-URL-first default (the tenant-mismatch check in `poolerConfigFrom` still rejects a + // stale saved URL for a different ref), but independently requests the same API fetch via + // `fetchPoolerFromApi` so an unlinked or mismatched-tenant workdir doesn't dead-end in the + // IPv6 error. const poolerConn = yield* resolvePoolerConn( ref, workdir, @@ -404,14 +384,12 @@ export const dbConfigLayer = Layer.effect( const debug = yield* DebugLogger; const output = yield* Output; const dbConn = yield* DbConnection; - // `resolveLinkedConn`/`resolvePoolerConn` (etc.) are standalone functions - // that yield their own `FileSystem`/`Path`/`DebugLogger`/`Output`/ - // `DbConnection` (so bootstrap can call them directly from its own - // ambient context). Calling them from here would otherwise leak those - // services into `resolve`/`resolvePoolerFallback`'s R (the interface promises - // `never`), so every call site below re-closes the gap by additionally - // providing this layer of already-resolved values alongside - // `linkedDbResolverRuntimeLayer`. + // `resolveLinkedConn`/`resolvePoolerConn` (etc.) are standalone functions that yield their + // own `FileSystem`/`Path`/`DebugLogger`/`Output`/`DbConnection` (so bootstrap can call them + // directly from its own ambient context). Calling them from here would otherwise leak those + // services into `resolve`/`resolvePoolerFallback`'s R (the interface promises `never`), so + // every call site below re-closes the gap by additionally providing this layer of + // already-resolved values alongside `linkedDbResolverRuntimeLayer`. const localAmbientServices = Layer.mergeAll( Layer.succeed(FileSystem.FileSystem, fs), Layer.succeed(Path.Path, path), @@ -420,10 +398,9 @@ export const dbConfigLayer = Layer.effect( Layer.succeed(DbConnection, dbConn), ); - // Profile context for the connect-failure suggestion (`SetConnectSuggestion` - // reads the ambient `CurrentProfile`). Snapshot it once - // and attach it to every resolved connection so the driver layer can render Go's - // hint on a refused/auth/IPv6 connect error. + // Profile context for the connect-failure suggestion. Snapshot it once and attach it to + // every resolved connection so the driver layer can render a hint on a refused/auth/IPv6 + // connect error. const suggestionContext: ConnectSuggestionContext = { dashboardUrl: cliSettings.dashboardUrl, profileName: cliSettings.profile, @@ -442,18 +419,17 @@ export const dbConfigLayer = Layer.effect( Layer.succeed(WorkdirFlag, yield* WorkdirFlag), Layer.succeed(OutputFlag, yield* OutputFlag), Layer.succeed(DebugFlag, yield* DebugFlag), - // `linkedDbResolverRuntimeLayer`'s platform-API factory provides a DoH - // fetch layer that reads `DnsResolverFlag`; snapshot it so the lazily - // built linked stack stays fully self-provided (`resolve`'s R stays `never`). + // `linkedDbResolverRuntimeLayer`'s platform-API factory provides a DoH fetch layer that + // reads `DnsResolverFlag`; snapshot it so the lazily built linked stack stays fully + // self-provided (`resolve`'s R stays `never`). Layer.succeed(DnsResolverFlag, yield* DnsResolverFlag), Layer.succeed(RuntimeInfo, yield* RuntimeInfo), Layer.succeed(Analytics, yield* Analytics), Layer.succeed(TelemetryRuntime, yield* TelemetryRuntime), Layer.succeed(Tty, yield* Tty), Layer.succeed(Output, yield* Output), - // The per-command identity stitcher, shared with the linked stack's lazy - // platform-API factory + linked-project cache (Go's single root-context - // `sync.Once`). Provided to this layer by each command runtime. + // The per-command identity stitcher, shared with the linked stack's lazy platform-API + // factory + linked-project cache. Provided to this layer by each command runtime. Layer.succeed(IdentityStitch, yield* IdentityStitch), // Optional (absent in handler tests): the lazy rebuild of // `commandSettingsLayer` reads it for explicit `--profile` detection, so @@ -476,14 +452,12 @@ export const dbConfigLayer = Layer.effect( const resolve = (flags: DbConfigFlags) => Effect.gen(function* () { const resolveVaultSecrets = flags.resolveVaultSecrets ?? true; - // Config is read per branch, NOT unconditionally up front: the linked branch - // resolves the ref first and reads the `[remotes.<ref>]`-merged config (below). - // A base read here would validate base config (db.major_version, deno_version, - // …) before the ref is known, failing a linked run Go accepts (Go validates - // the merged config after LoadProjectRef). Only `--db-url`/`--local` read base - // config — Go's direct/local `LoadConfig`, which never merges a remote block. - // `utils.Config.Hostname` (`GetHostname()`): honors - // `SUPABASE_SERVICES_HOSTNAME` / a tcp `DOCKER_HOST` in dev-container or + // Config is read per branch, not unconditionally up front: the linked branch resolves + // the ref first and reads the `[remotes.<ref>]`-merged config (below). A base read here + // would validate base config (db.major_version, deno_version, …) before the ref is + // known, failing a linked run that should succeed once the ref is known. Only + // `--db-url`/`--local` read base config, since neither merges a remote block. + // Honors `SUPABASE_SERVICES_HOSTNAME` / a tcp `DOCKER_HOST` in dev-container or // remote-Docker setups, defaulting to 127.0.0.1. const localHost = getHostname(); @@ -492,10 +466,8 @@ export const dbConfigLayer = Layer.effect( const tomlValues = yield* readDbToml(fs, path, cliSettings.workdir, undefined, { resolveVaultSecrets, }); - // Go's direct path runs `LoadConfig` before `pgconn.ParseConfig`, - // so the project `.env*` files - // populate the environment that the libpq `PG*` fallbacks read. Layer the - // project env under the shell env (`loadProjectEnv` already excludes + // The project `.env*` files populate the environment that the libpq `PG*` fallbacks + // read. Layer the project env under the shell env (`loadProjectEnv` already excludes // shell-set keys, so the shell still wins) and feed it to the parser. const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); const conn = parseConnectionString(flags.dbUrl.value, layeredParseEnv(projectEnv)); @@ -515,10 +487,9 @@ export const dbConfigLayer = Layer.effect( tomlValues.port, tomlValues.shadowPort, ); - // Go routes a local direct URL through `ConnectLocalPostgres`, - // which fills an empty password from the local - // `[db].password` config so a passwordless local DSL like - // `postgresql://postgres@127.0.0.1:54322/postgres` still authenticates. + // A local direct URL fills an empty password from the local `[db].password` config, + // so a passwordless local DSN like `postgresql://postgres@127.0.0.1:54322/postgres` + // still authenticates. return { conn: isLocal && conn.password.length === 0 @@ -528,32 +499,26 @@ export const dbConfigLayer = Layer.effect( }; } - // --linked. The lazy Management API runtime (project-ref resolver + lazy - // platform API factory) is provided here at runtime so it is only built on - // this branch — `--local` and `--db-url` never touch it. The factory resolves - // the access token only on first use (minting a temp role), so a - // `--linked --password` invocation stays auth-free, matching Go. + // --linked. The lazy Management API runtime (project-ref resolver + lazy platform API + // factory) is provided here at runtime so it is only built on this branch — + // `--local`/`--db-url` never touch it. The factory resolves the access token only on + // first use (minting a temp role), so a `--linked --password` invocation stays + // auth-free. if (flags.connType === "linked") { const linked = yield* Effect.gen(function* () { const projectRef = yield* ProjectRefResolver; - // Go's ParseDatabaseConfig resolves the linked ref via the HARD `LoadProjectRef` — - // load-or-fail with no - // prompt, format validation, and `failed to load project ref` on a real - // `.temp/project-ref` read error. Use `loadProjectRef` (not the soft - // `resolveOptional`, which swallows that read error to None): an unlinked - // workdir fails with ErrNotLinked, a bad ref with the invalid-ref error, and an - // unreadable ref file surfaces the filesystem problem — matching Go for every - // caller of this resolver (`test db --linked`, dump, declarative). + // Load-or-fail with no prompt: use `loadProjectRef` (not the soft + // `resolveOptional`, which swallows a read error to `None`) — an unlinked workdir + // fails with a not-linked error, a bad ref with the invalid-ref error, and an + // unreadable ref file surfaces the filesystem problem, for every caller of this + // resolver (`test db --linked`, dump, declarative). const ref = yield* projectRef.loadProjectRef(flags.linkedProjectRef ?? Option.none()); - // `ParseDatabaseConfig` runs `LoadProjectRef` → `LoadConfig` → - // `NewDbConfigWithPassword`, so - // the `[remotes.<ref>]`-merged config (e.g. an unsupported remote - // `db.major_version` / `edge_runtime.deno_version`) is validated as a pure - // config error BEFORE any network work. The base read in `resolve` above - // only validates remote `project_id`s, not the ref-merged block — so - // validate the merged config here, before `resolveLinked`'s TCP probe / - // pooler / temp-role Management API calls, rather than letting those mask - // (or run side effects ahead of) the real config error. + // The `[remotes.<ref>]`-merged config (e.g. an unsupported remote + // `db.major_version`/`edge_runtime.deno_version`) is validated as a pure config + // error before any network work. The base read in `resolve` above only validates + // remote `project_id`s, not the ref-merged block — so validate the merged config + // here, before the TCP probe / pooler / temp-role Management API calls, rather than + // letting those mask (or run side effects ahead of) the real config error. yield* readDbToml(fs, path, cliSettings.workdir, ref, { resolveVaultSecrets, }); @@ -574,12 +539,10 @@ export const dbConfigLayer = Layer.effect( fetchPoolerFromApi: Option.isSome(flags.linkedProjectRef ?? Option.none()), }, ); - // NB: the linked-project telemetry cache (GET /v1/projects/{ref}) is NOT - // issued here. The reference caches it in a post-run hook - // (`ensureProjectGroupsCached`) — i.e. AFTER the - // command's own API calls — so each linked command owns that GET in its - // post-run finalizer (see e.g. advisors/query handlers). Issuing it mid- - // resolve reordered the request log ahead of the command's GETs. + // The linked-project telemetry cache (GET /v1/projects/{ref}) is not issued here: + // it's cached in a post-run hook, after the command's own API calls, so each linked + // command owns that GET in its post-run finalizer. Issuing it mid-resolve would + // reorder the request log ahead of the command's GETs. return { conn: resolved, ref }; }).pipe( Effect.provide( @@ -610,13 +573,11 @@ export const dbConfigLayer = Layer.effect( }; }); - // `RunWithPoolerFallback`: when a - // linked dump's pg_dump container fails with an IPv6 connectivity error (the - // direct host is reachable from the CLI process but not from inside Docker), it - // resolves the project's IPv4 transaction pooler and retries once. This exposes - // that pooler resolution (`ResolvePoolerConfigForFallback`) for the dump - // handler to invoke on demand. Returns `None` when the path is not pooler-eligible - // (`--linked` only) or no pooler URL is configured, so the caller keeps the + // When a linked dump's pg_dump container fails with an IPv6 connectivity error (the direct + // host is reachable from the CLI process but not from inside Docker), it resolves the + // project's IPv4 transaction pooler and retries once. This exposes that pooler resolution + // for the dump handler to invoke on demand. Returns `None` when the path is not + // pooler-eligible (`--linked` only) or no pooler URL is configured, so the caller keeps the // original container error. const resolvePoolerFallback = (flags: DbConfigFlags) => Effect.gen(function* () { @@ -631,8 +592,8 @@ export const dbConfigLayer = Layer.effect( const password = adHocProjectRef ? (Option.getOrUndefined(flags.password ?? Option.none()) ?? "") : yield* resolveDbPassword(flags.password ?? Option.none(), cliSettings.workdir); - // Container-fallback: fetch the primary pooler config from the Management API - // when no `.temp/pooler-url` is saved (`ResolvePoolerConfigForFallback`). + // Container-fallback: fetch the primary pooler config from the Management API when + // no `.temp/pooler-url` is saved. return yield* resolvePoolerConn( ref, cliSettings.workdir, @@ -653,9 +614,8 @@ export const dbConfigLayer = Layer.effect( ); }); - // Attach the connect-failure suggestion context to every resolved connection in - // one place (Go sets it ambiently via `CurrentProfile`), so each connecting - // command inherits `SetConnectSuggestion` hint without per-call-site wiring. + // Attach the connect-failure suggestion context to every resolved connection in one place, + // so each connecting command inherits the hint without per-call-site wiring. const withSuggestion = (conn: PgConnInput): PgConnInput => ({ ...conn, suggestionContext, diff --git a/apps/cli/src/command-internal/db-config.parse.ts b/apps/cli/src/command-internal/db-config.parse.ts index 92b36b7837..75ca603477 100644 --- a/apps/cli/src/command-internal/db-config.parse.ts +++ b/apps/cli/src/command-internal/db-config.parse.ts @@ -6,25 +6,22 @@ import type { PgConnInput } from "./db-connection.service.ts"; import { pgpassPassword } from "./pgpass.ts"; import { pgServiceSettings } from "./pgservicefile.ts"; -/** Go's `pgconn` default direct Postgres port. */ +/** The default direct Postgres port. */ const DIRECT_PORT = 5432; /** - * Environment lookup used for libpq `PG*` fallbacks. Injected so the resolver can - * layer the project `.env*` files under the shell environment, mirroring Go's - * `LoadConfig` (`godotenv.Load`) populating `os.Environ` before `pgconn.ParseConfig` - * reads `PGHOST`/`PGPASSWORD`/`PGSSLMODE`/… (`internal/utils/flags/db_url.go:59-68`). - * Defaults to `process.env` so the pure call sites (and the pooler path, whose - * connection string is fully specified) keep their existing behavior. + * Environment lookup used for libpq `PG*` fallbacks. Injected so the resolver can layer the + * project `.env*` files under the shell environment before reading + * `PGHOST`/`PGPASSWORD`/`PGSSLMODE`/…. Defaults to `process.env` so the pure call sites (and the + * pooler path, whose connection string is fully specified) keep their existing behavior. */ export type ParseEnv = (name: string) => string | undefined; const processEnv: ParseEnv = (name) => process.env[name]; /** - * The `sslmode` values pgconn's `configTLS` accepts; any other value is a parse - * error (`"sslmode is invalid"`), so the DSN is rejected rather than treated as - * `prefer`. + * The `sslmode` values libpq accepts; any other value is a parse error + * (`"sslmode is invalid"`), so the DSN is rejected rather than treated as `prefer`. */ const VALID_SSLMODES = new Set([ "disable", @@ -35,13 +32,11 @@ const VALID_SSLMODES = new Set([ "verify-full", ]); -// pgconn's `notRuntimeParams` (`pgconn@v1.14.3/config.go:287-322`): connection -// settings that are NOT forwarded to the server as startup `RuntimeParams`. Everything -// else in a DSN (e.g. `search_path`, `statement_timeout`, `application_name`) is a -// runtime param Go's `ToPostgresURL` re-appends. `options` is technically a runtime -// param but is carried as its own field here (Supavisor pooler routing), so it is -// excluded from this collection to avoid emitting it twice. `dbname`/`hostaddr` are -// structural and handled separately. +// Connection settings that are not forwarded to the server as startup runtime params. +// Everything else in a DSN (e.g. `search_path`, `statement_timeout`, `application_name`) is a +// runtime param re-appended to the connection URL. `options` is technically a runtime param but +// is carried as its own field here (Supavisor pooler routing), so it's excluded here to avoid +// emitting it twice. `dbname`/`hostaddr` are structural and handled separately. const NOT_RUNTIME_PARAMS = new Set([ "host", "hostaddr", @@ -69,14 +64,11 @@ const NOT_RUNTIME_PARAMS = new Set([ ]); /** - * Collect the startup `RuntimeParams`, mirroring pgconn: every key not in - * `NOT_RUNTIME_PARAMS` is forwarded to the server (and so to pg-delta via - * `ToPostgresURL`). pgconn builds these from the *fully merged* settings — - * `mergeSettings(defaultSettings, envSettings, serviceSettings, connStringSettings)` - * (`pgconn/config.go:249-322`) — so a `pg_service.conf` entry's `search_path` or - * `PGAPPNAME → application_name` (`config.go:423`) are runtime params too, not just - * the connection-string query. Merge in pgconn's precedence (env → service → - * connString, last write wins). Returns `undefined` when there are none. + * Collect the startup runtime params: every key not in `NOT_RUNTIME_PARAMS` is forwarded to the + * server (and so to pg-delta). Built from the fully merged settings, so a `pg_service.conf` + * entry's `search_path` or `PGAPPNAME` → `application_name` are runtime params too, not just the + * connection-string query. Merged in libpq precedence (env → service → connString, last write + * wins). Returns `undefined` when there are none. */ function collectRuntimeParams( connStringEntries: Iterable<readonly [string, string]>, @@ -87,11 +79,11 @@ function collectRuntimeParams( const add = (key: string, value: string): void => { if (!NOT_RUNTIME_PARAMS.has(key)) params[key] = value; }; - // env: the only PG* var pgconn maps into RuntimeParams is PGAPPNAME → application_name - // (the rest are connection settings in `notRuntimeParams`). Empty is treated as unset. + // env: the only PG* var mapped into runtime params is PGAPPNAME → application_name (the rest + // are connection settings). Empty is treated as unset. const appName = libpqEnv(env, "PGAPPNAME"); if (appName !== undefined) add("application_name", appName); - // service: pgconn copies every service key verbatim into the merged settings, so its + // service: every service key is copied verbatim into the merged settings, so its // non-connection keys (search_path, application_name, …) are runtime params. if (serviceSettings !== undefined) { for (const [key, value] of serviceSettings) add(key, value); @@ -102,11 +94,10 @@ function collectRuntimeParams( } /** - * Resolve libpq client-certificate settings (`sslcert`/`sslkey`/`sslpassword`) with - * pgconn's connection-string → service → `PG*` precedence. pgconn's `configTLS` - * loads `sslcert`+`sslkey` into the client TLS certificate and requires **both or - * neither** (`pgconn/config.go:710-711`); `sslpassword` decrypts an encrypted key. - * Returns `"invalid"` when exactly one of cert/key is present (a pgconn parse error). + * Resolve libpq client-certificate settings (`sslcert`/`sslkey`/`sslpassword`) with the + * connection-string → service → `PG*` precedence. `sslcert`+`sslkey` load the client TLS + * certificate and require both or neither; `sslpassword` decrypts an encrypted key. Returns + * `"invalid"` when exactly one of cert/key is present. */ function resolveClientCert( get: (key: string) => string | null | undefined, @@ -125,24 +116,23 @@ function resolveClientCert( return { sslcert, sslkey, ...(sslpassword !== undefined ? { sslpassword } : {}) }; } -/** Whether a resolved sslmode is present and not one pgconn accepts. */ +/** Whether a resolved sslmode is present and not one libpq accepts. */ function isInvalidSslmode(sslmode: string | null | undefined): boolean { return ( sslmode !== null && sslmode !== undefined && sslmode.length > 0 && !VALID_SSLMODES.has(sslmode) ); } -/** Read a libpq `PG*` env var, treating empty as unset (pgconn's `parseEnvSettings`). */ +/** Read a libpq `PG*` env var, treating empty as unset. */ function libpqEnv(env: ParseEnv, name: string): string | undefined { const value = env(name); return value !== undefined && value.length > 0 ? value : undefined; } /** - * libpq's default host when the connection string omits one. Mirrors pgconn's - * `defaultHost` (`defaults.go`): on non-Windows it returns the first existing - * common unix-socket directory, else `localhost`; Windows always uses - * `localhost`. `PGHOST` (applied by the callers) takes priority over this. + * libpq's default host when the connection string omits one: on non-Windows, the first existing + * common unix-socket directory, else `localhost`; Windows always uses `localhost`. `PGHOST` + * (applied by the callers) takes priority over this. */ function defaultLibpqHost(): string { if (process.platform === "win32") return "localhost"; @@ -153,10 +143,9 @@ function defaultLibpqHost(): string { } /** - * Resolve the libpq `PGPORT` fallback. An unset/empty value (`undefined` from - * `libpqEnv`) uses the default 5432, a numeric value is used, and a present - * non-numeric value returns `undefined` so the caller rejects the DSN — pgconn's - * `parsePort` reports an `invalid port` parse error rather than defaulting. + * Resolve the libpq `PGPORT` fallback. An unset/empty value uses the default 5432, a numeric + * value is used, and a present non-numeric value returns `undefined` so the caller rejects the + * DSN as an invalid port rather than defaulting. */ function libpqPort(raw: string | undefined): number | undefined { if (raw === undefined) return DIRECT_PORT; @@ -172,15 +161,12 @@ function unbracketIpv6(host: string): string { const CONNECT_TIMEOUT_INVALID = Symbol("connect-timeout-invalid"); /** - * Resolve the libpq `connect_timeout` (seconds). `raw` must already have the - * absent-vs-present distinction made by the caller: `null`/`undefined` means the - * setting was absent (unset → driver applies Go's 10s/2s default), while any string - * — including `""` — is a *present* connection-string value. pgconn keeps a present - * `connect_timeout` and runs `parseConnectTimeoutSetting`, which errors on a - * non-integer (including empty), so a present non-numeric value returns the failure - * sentinel. `0` parses to a zero duration (not an error), treated as unset so the - * default applies. An empty `PGCONNECT_TIMEOUT` env var is dropped by the caller - * (pgconn ignores empty `PG*` vars), so it never reaches here as `""`. + * Resolve the libpq `connect_timeout` (seconds). `raw` must already have the absent-vs-present + * distinction made by the caller: `null`/`undefined` means the setting was absent (unset → the + * driver applies its own default), while any string — including `""` — is a present + * connection-string value, parsed as an integer; a present non-numeric value returns the failure + * sentinel. `0` parses to a zero duration, treated as unset so the default applies. An empty + * `PGCONNECT_TIMEOUT` env var is dropped by the caller, so it never reaches here as `""`. */ function libpqConnectTimeout( raw: string | null | undefined, @@ -192,10 +178,9 @@ function libpqConnectTimeout( } /** - * Sentinel returned when a `service` is requested but cannot be resolved (missing - * service file, unknown service, or a malformed file). pgconn fails the whole - * parse in that case (`config.go:253`), so the caller surfaces a parse error - * rather than silently connecting to the defaults. + * Sentinel returned when a `service` is requested but cannot be resolved (missing service file, + * unknown service, or a malformed file), so the caller surfaces a parse error rather than + * silently connecting to the defaults. */ const SERVICE_RESOLUTION_FAILED = Symbol("service-resolution-failed"); @@ -206,21 +191,17 @@ function defaultServiceFilePath(): string | undefined { } /** - * Resolve pgservice settings, mirroring pgconn (`config.go:250-256`): when a - * `service` is set (connection string `service=`/`?service=`, else `PGSERVICE`), - * read the service file (connection string `servicefile=`/`?servicefile=`, then - * `PGSERVICEFILE`, then `~/.pg_service.conf`) and return the named section's - * settings (with `dbname` already remapped to `database`). Returns `undefined` - * when no service is requested, or the failure sentinel when a requested service - * cannot be resolved. The resolved settings sit above env/defaults but below the - * explicit connection-string fields. + * Resolve pgservice settings: when a `service` is set (connection string `service=`/`?service=`, + * else `PGSERVICE`), read the service file (connection string + * `servicefile=`/`?servicefile=`, then `PGSERVICEFILE`, then `~/.pg_service.conf`) and return the + * named section's settings (with `dbname` already remapped to `database`). Returns `undefined` + * when no service is requested, or the failure sentinel when a requested service cannot be + * resolved. The resolved settings sit above env/defaults but below the explicit + * connection-string fields. * - * pgconn records a connection-string `service` key unconditionally and merges it - * over `PGSERVICE` (`config.go:504,406`), so a *present* connStr `service` (even - * empty) overrides the env var; an empty service then fails resolution - * (`GetService("")` → not found → parse error), rather than silently using - * `PGSERVICE`/defaults. So `connStringService` is `null`/`undefined` only when the - * key is absent. + * A present connection-string `service` (even empty) overrides the env var, and an empty service + * then fails resolution rather than silently falling back to `PGSERVICE`/defaults, so + * `connStringService` is `null`/`undefined` only when the key is absent. */ function resolveServiceSettings( connStringService: string | null | undefined, @@ -234,15 +215,14 @@ function resolveServiceSettings( if (service === undefined) { return undefined; } - // A present-but-empty connString `service=` overrides PGSERVICE and fails - // resolution in pgconn (`GetService("")` → not found), so reject the parse. + // A present-but-empty connString `service=` overrides PGSERVICE and fails resolution, so + // reject the parse. if (service.length === 0) { return SERVICE_RESOLUTION_FAILED; } - // A present connString `servicefile` (even empty) overrides PGSERVICEFILE - // unconditionally (pgconn `config.go:504,256`); an empty path then fails - // `ReadServicefile("")` → parse error. Only an absent key falls back to - // PGSERVICEFILE then the default `~/.pg_service.conf`. + // A present connString `servicefile` (even empty) overrides PGSERVICEFILE unconditionally; an + // empty path then fails resolution. Only an absent key falls back to PGSERVICEFILE then the + // default `~/.pg_service.conf`. const servicefile = connStringServicefile !== undefined ? connStringServicefile @@ -254,34 +234,27 @@ function resolveServiceSettings( } /** - * A service setting: the raw value (including an intentional empty string) when the - * key is present, else `undefined`. Unlike env vars, pgconn does **not** empty-skip - * service settings — `parseServiceSettings` copies them verbatim and `mergeSettings` - * merges them unconditionally over env (`config.go:401-411` vs the empty-skip in - * `parseEnvSettings` `config.go:436-441`). So a present-but-empty service value - * (e.g. `password=` to suppress `PGPASSWORD` → `.pgpass`, or `connect_timeout=` to - * force a parse error) overrides env. Returning `""` here makes the callers' `??` - * chains honor that, since `??` preserves the empty string. + * A service setting: the raw value (including an intentional empty string) when the key is + * present, else `undefined`. Unlike env vars, service settings are not empty-skipped, so a + * present-but-empty value (e.g. `password=` to suppress `PGPASSWORD` → `.pgpass`, or + * `connect_timeout=` to force a parse error) overrides env. Returning `""` here makes the + * callers' `??` chains honor that, since `??` preserves the empty string. */ function serviceValue(settings: Map<string, string> | undefined, key: string): string | undefined { return settings?.get(key); } /** - * Resolve a libpq password with pgconn's precedence (`mergeSettings` plus the - * `config.Password == ""` `.pgpass` fallback, `config.go:264-379`): a password - * supplied by the connection string — **even an explicit empty one** - * (`user:@host`, `?password=`, `password=`) — overrides `PGPASSWORD`, because the - * connection-string settings are merged over the env settings; an absent password - * falls back to `PGPASSWORD`. Either way, an empty resolved value then falls - * through to `.pgpass`. `connStringPassword` is `undefined` only when the string - * did not specify a password key at all. `host`/`port` are the primary host: - * pgconn keys `.pgpass` off `config.Host` (the first fallback host). + * Resolve a libpq password with libpq's precedence plus the `.pgpass` fallback: a password + * supplied by the connection string — even an explicit empty one (`user:@host`, `?password=`, + * `password=`) — overrides `PGPASSWORD`, since connection-string settings merge over env; an + * absent password falls back to `PGPASSWORD`. Either way, an empty resolved value falls through + * to `.pgpass`. `connStringPassword` is `undefined` only when the string didn't specify a + * password key at all. `host`/`port` key `.pgpass` off the primary (first fallback) host. * - * `passfile` is the connection string's `passfile=` setting (URL query or DSN - * keyword), if any. pgconn honors it ahead of `PGPASSFILE`/the default `~/.pgpass` - * (`config.go:293,369-377`); it is consumed only for password resolution and never - * emitted as a runtime param (pgconn's `notRuntimeParams`). + * `passfile` is the connection string's `passfile=` setting (URL query or DSN keyword), if any. + * It's honored ahead of `PGPASSFILE`/the default `~/.pgpass`; consumed only for password + * resolution, never emitted as a runtime param. */ function resolveLibpqPassword( connStringPassword: string | undefined, @@ -297,13 +270,11 @@ function resolveLibpqPassword( } /** - * Zip a comma-separated host list with a comma-separated port list into the - * ordered dial targets, mirroring pgconn's per-host fallback expansion - * (`config.go:326-362`): hosts and ports are split independently, and a host with - * no matching port reuses the first port (`ports[0]`). A non-numeric (or empty) - * port is a `parsePort` error, surfaced as `undefined` so the caller rejects the - * DSN. `hostString`/`portString` carry the bare hosts and ports only — for a URL, - * the structural `host:port` segments are pre-split by `parseHostPortSegment`. + * Zip a comma-separated host list with a comma-separated port list into the ordered dial + * targets: hosts and ports are split independently, and a host with no matching port reuses the + * first port. A non-numeric (or empty) port is surfaced as `undefined` so the caller rejects the + * DSN. `hostString`/`portString` carry the bare hosts and ports only — for a URL, the structural + * `host:port` segments are pre-split by `parseHostPortSegment`. */ function buildHostList( hostString: string, @@ -315,10 +286,8 @@ function buildHostList( for (let i = 0; i < hosts.length; i++) { const portRaw = i < ports.length ? ports[i]! : ports[0]!; if (!/^\d+$/.test(portRaw)) return undefined; - // pgconn's `parsePort` rejects ports outside 1..65535 (`config.go:784-793`), so - // `0`/`70000` are parse errors rather than being deferred to the driver/OS. This - // is the single chokepoint every port path (query, structural, PGPORT) funnels - // through, matching pgconn's per-host `parsePort` call (`config.go:337`). + // Ports outside 1..65535 are parse errors rather than being deferred to the driver/OS. This + // is the single chokepoint every port path (query, structural, PGPORT) funnels through. const port = Number(portRaw); if (port < 1 || port > 65535) return undefined; list.push({ host: hosts[i]!, port }); @@ -368,40 +337,32 @@ function parseHostPortSegment(segment: string): { host: string; port: string } { } /** - * Parse a Postgres connection string into a `PgConnInput`. Mirrors Go's - * `pgconn.ParseConfig` (`apps/cli-go/internal/utils/flags/db_url.go:64`), which - * accepts **both** the WHATWG `postgres(ql)://…` URL form and the libpq - * keyword/value DSN form (`host=… dbname=… user=…`, including unix-socket paths). - * Returns `undefined` on any malformed input so callers can surface a redacted - * parse error instead of crashing with an unhandled defect. + * Parse a Postgres connection string into a `PgConnInput`. Accepts both the WHATWG + * `postgres(ql)://…` URL form and the libpq keyword/value DSN form (`host=… dbname=… user=…`, + * including unix-socket paths). Returns `undefined` on any malformed input so callers can + * surface a redacted parse error instead of crashing with an unhandled defect. * - * `sslmode` and the libpq `options` startup parameter are preserved (Go keeps - * them in `pgconn.Config`): `options` carries the legacy Supavisor - * `?options=reference=<ref>` tenant routing, and `sslmode` controls TLS. + * `sslmode` and the libpq `options` startup parameter are preserved: `options` carries the + * legacy Supavisor `?options=reference=<ref>` tenant routing, and `sslmode` controls TLS. * - * `env` supplies the libpq `PG*` fallbacks; pass a lookup that layers the project - * `.env*` files under the shell env to match Go's `LoadConfig`-before-parse order. + * `env` supplies the libpq `PG*` fallbacks; pass a lookup that layers the project `.env*` files + * under the shell env so they apply before the parse. */ export function parseConnectionString( value: string, env: ParseEnv = processEnv, ): PgConnInput | undefined { const trimmed = value.trim(); - // Match pgconn's dispatch (`config.go:236`): only a literal `postgres://` / - // `postgresql://` prefix is parsed as a URL; everything else is a libpq - // keyword/value DSN. So a mistyped scheme like `https://host/db` falls through - // to the DSN parser, which rejects it (no `key=value`) → the caller surfaces a - // redacted parse error rather than connecting to a bogus host. + // Only a literal `postgres://`/`postgresql://` prefix is parsed as a URL; everything else is + // a libpq keyword/value DSN. A mistyped scheme like `https://host/db` falls through to the DSN + // parser, which rejects it (no `key=value`) rather than connecting to a bogus host. if (trimmed.startsWith("postgres://") || trimmed.startsWith("postgresql://")) { return parseUrlConnectionString(value, env); } return parseKeywordValueDsn(trimmed, env); } -/** - * Layers a project `.env*` lookup under the shell environment: shell presence wins - * over the project file, matching Go's `LoadConfig`-before-`pgconn.ParseConfig` order. - */ +/** Layers a project `.env*` lookup under the shell environment: shell presence wins over the project file. */ export function layeredParseEnv(projectEnv: Readonly<Record<string, string>>): ParseEnv { return (name) => process.env[name] ?? projectEnv[name]; } @@ -411,10 +372,9 @@ export type PoolerConfigResult = | { readonly _tag: "invalid"; readonly reason: string }; /** - * Parse + validate a Supabase transaction-pooler URL. Mirrors Go's - * `GetPoolerConfig`: strip the dashboard password placeholder, require the - * project ref in the tenant user/options, verify the pooler domain belongs to - * the active profile, and force transaction-pooler port 5432. + * Parse + validate a Supabase transaction-pooler URL: strip the dashboard password placeholder, + * require the project ref in the tenant user/options, verify the pooler domain belongs to the + * active profile, and force transaction-pooler port 5432. */ export function poolerConfigFromConnectionString( ref: string, @@ -466,23 +426,21 @@ export function poolerConfigFromConnectionString( /** Parse the WHATWG `postgres(ql)://` URL form. */ function parseUrlConnectionString(value: string, env: ParseEnv): PgConnInput | undefined { const trimmed = value.trim(); - // pgconn accepts libpq multi-host failover URLs (`postgres://h1:5432,h2:5433/db`, - // `config.go:166,326-362`), which WHATWG `new URL()` rejects (the comma'd - // host:port is not a valid authority). Hand-extract the authority so we can split - // the host list ourselves, then normalize the URL down to its first host so - // `new URL()` still parses the userinfo, path, and query exactly as before. + // libpq accepts multi-host failover URLs (`postgres://h1:5432,h2:5433/db`), which WHATWG + // `new URL()` rejects (the comma'd host:port is not a valid authority). Hand-extract the + // authority so we can split the host list ourselves, then normalize the URL down to its first + // host so `new URL()` still parses the userinfo, path, and query exactly as before. const authority = urlAuthority(trimmed); - // Go's `net/url` splits userinfo from host on the last `@`; literal `@` in a - // password must be percent-encoded, so the last `@` is the real boundary. + // Userinfo splits from host on the last `@`; a literal `@` in a password must be + // percent-encoded, so the last `@` is the real boundary. const atIdx = authority.lastIndexOf("@"); const userinfoRaw = atIdx === -1 ? "" : authority.slice(0, atIdx); const hostPortRaw = atIdx === -1 ? authority : authority.slice(atIdx + 1); const segments = splitHostPortList(hostPortRaw); const multiHost = segments.length > 1; - // pgconn accepts a port-only authority (`postgres://:5433/db`): `net.SplitHostPort` - // yields an empty host + the port, so the host falls back to PGHOST/default while - // the port is kept (`config.go:464-488`). WHATWG `new URL()` throws on an empty - // host with a port, so route that through the same hand-split path as multi-host. + // libpq accepts a port-only authority (`postgres://:5433/db`): an empty host + the port, with + // the host falling back to PGHOST/default while the port is kept. WHATWG `new URL()` throws on + // an empty host with a port, so route that through the same hand-split path as multi-host. const firstSegmentHost = parseHostPortSegment(segments[0]!).host; const emptyHostAuthority = !multiHost && firstSegmentHost.length === 0 && hostPortRaw.length > 0; const useHandSplit = multiHost || emptyHostAuthority; @@ -514,18 +472,15 @@ function parseUrlConnectionString(value: string, env: ParseEnv): PgConnInput | u // Keep it inside the try so a bad escape yields a normal parse failure // rather than an untyped defect (CWE-209-safe: the caller redacts the URL). const query = url.searchParams; - // pgconn's `parseURLSettings` runs the query-param loop **last** and sets - // `settings[k] = v` unconditionally (`config.go:499-505`), so a libpq URL query - // setting (`?host=`, `?port=`, `?dbname=`, `?user=`, `?password=`) overrides the - // structural userinfo/host/path **even when empty** — a present-but-empty - // `?dbname=` yields an empty database, distinct from an absent param. So branch - // on `query.has(key)` (present, even ""), not on a non-empty check. `searchParams` - // already percent-decodes, so query values are used verbatim. - - // A URL that omits a field falls back to the libpq `PG*` env vars and then the - // libpq defaults, matching pgconn's - // `mergeSettings(defaultSettings, envSettings, connStringSettings)`. - // Resolve a pgservice (`?service=`/`PGSERVICE`) before applying defaults; its + // Query-param settings are applied last and unconditionally, so a libpq URL query setting + // (`?host=`, `?port=`, `?dbname=`, `?user=`, `?password=`) overrides the structural + // userinfo/host/path even when empty — a present-but-empty `?dbname=` yields an empty + // database, distinct from an absent param. So branch on `query.has(key)` (present, even ""), + // not on a non-empty check. `searchParams` already percent-decodes, so query values are used + // verbatim. + + // A URL that omits a field falls back to the libpq `PG*` env vars and then the libpq + // defaults. Resolve a pgservice (`?service=`/`PGSERVICE`) before applying defaults; its // settings sit above env/defaults but below the explicit URL fields. const serviceSettings = resolveServiceSettings( query.get("service"), @@ -547,8 +502,8 @@ function parseUrlConnectionString(value: string, env: ParseEnv): PgConnInput | u : structuralUser.length > 0 ? structuralUser : (svc("user") ?? defaultOsUser(env)); - // libpq fills `sslmode` from the service, then `PGSSLMODE`, when the connection - // string omits it (pgconn's merge order), before the TLS-mode default. + // libpq fills `sslmode` from the service, then `PGSSLMODE`, when the connection string + // omits it, before the TLS-mode default. const sslmode = url.searchParams.get("sslmode") ?? svc("sslmode") ?? libpqEnv(env, "PGSSLMODE") ?? null; if (isInvalidSslmode(sslmode)) { @@ -560,26 +515,25 @@ function parseUrlConnectionString(value: string, env: ParseEnv): PgConnInput | u svc("sslrootcert") ?? libpqEnv(env, "PGSSLROOTCERT") ?? null; - // libpq client cert (query, service, or PGSSLCERT/PGSSLKEY/PGSSLPASSWORD); both - // or neither (pgconn config.go:710-711), else this is a parse error. + // libpq client cert (query, service, or PGSSLCERT/PGSSLKEY/PGSSLPASSWORD); both or neither, + // else this is a parse error. const clientCert = resolveClientCert((key) => url.searchParams.get(key), svc, env); if (clientCert === "invalid") { return undefined; } const options = url.searchParams.get("options") ?? svc("options") ?? null; - // Every other query setting (e.g. search_path, statement_timeout) is a startup - // runtime param Go forwards to the server / pg-delta. + // Every other query setting (e.g. search_path, statement_timeout) is a startup runtime + // param forwarded to the server / pg-delta. const runtimeParams = collectRuntimeParams(query, serviceSettings, env); - // A `passfile=` setting (query or service) points `.pgpass` resolution at a - // non-default file (pgconn `config.go:293`); non-empty wins over `PGPASSFILE`. - // A present `passfile=` (even empty) overrides PGPASSFILE/default; a present-empty - // value then resolves to no `.pgpass` (pgconn's `ReadPassfile("")` fails) → - // empty password. Only an absent param falls back to the service value. + // A `passfile=` setting (query or service) points `.pgpass` resolution at a non-default + // file; a present `passfile=` (even empty) overrides PGPASSFILE/default, and a present-empty + // value then resolves to no `.pgpass` → empty password. Only an absent param falls back to + // the service value. const passfileQuery = url.searchParams.get("passfile"); const passfile = passfileQuery !== null ? passfileQuery : svc("passfile"); - // libpq `connect_timeout` (query, service, or `PGCONNECT_TIMEOUT`). A *present* - // query value (even empty) overrides service/env and is parsed (empty → error, - // pgconn's `parseConnectTimeoutSetting`); only an absent query param falls back. + // libpq `connect_timeout` (query, service, or `PGCONNECT_TIMEOUT`). A present query value + // (even empty) overrides service/env and is parsed (empty → error); only an absent query + // param falls back. const connectTimeoutRaw = url.searchParams.has("connect_timeout") ? url.searchParams.get("connect_timeout") : (svc("connect_timeout") ?? libpqEnv(env, "PGCONNECT_TIMEOUT")); @@ -588,10 +542,8 @@ function parseUrlConnectionString(value: string, env: ParseEnv): PgConnInput | u return undefined; } - // Structural hosts/ports become pgconn's comma-joined `settings["host"]` / - // `settings["port"]`. WHATWG `URL.hostname` keeps the brackets around an IPv6 - // literal (`[::1]`); Go's `url.Hostname()` returns the unbracketed host (only - // re-adding brackets when formatting via `ToPostgresURL`), so strip them. For a + // Structural hosts/ports become comma-joined `host`/`port` settings. WHATWG `URL.hostname` + // keeps the brackets around an IPv6 literal (`[::1]`), so strip them before rejoining. For a // multi-host URL the per-segment host/port were already split out by hand. const structuralHosts = useHandSplit ? segments.map((s) => parseHostPortSegment(s).host).filter((h) => h.length > 0) @@ -604,11 +556,9 @@ function parseUrlConnectionString(value: string, env: ParseEnv): PgConnInput | u ? [url.port] : []; - // A present `?host=` (even empty) overrides the structural host verbatim - // (pgconn copies it into `settings["host"]` unconditionally, `config.go:499-505`, - // and an empty value is a literal empty host — it does NOT re-fall-back to - // PGHOST/default). Only an absent param falls back to structural → service → - // PGHOST → default. + // A present `?host=` (even empty) overrides the structural host verbatim, and an empty + // value is a literal empty host — it does not fall back to PGHOST/default. Only an absent + // param falls back to structural → service → PGHOST → default. const hostQuery = query.get("host"); const hostString = hostQuery !== null @@ -616,11 +566,10 @@ function parseUrlConnectionString(value: string, env: ParseEnv): PgConnInput | u : structuralHosts.length > 0 ? structuralHosts.join(",") : (svc("host") ?? libpqEnv(env, "PGHOST") ?? defaultLibpqHost()); - // pgconn copies a `?port=` query value verbatim into `settings["port"]` and the - // fallback builder splits it on commas, parsing each segment (`config.go:326-340`), - // so a multi-host URL may carry a comma-separated port list (`?port=5432,5433`). - // Reject only an empty `?port=` or a segment that is not numeric; `buildHostList` - // then zips and range-checks each. `url.port` is always digits. + // A `?port=` query value is copied verbatim, and a multi-host URL may carry a + // comma-separated port list (`?port=5432,5433`). Reject only an empty `?port=` or a segment + // that is not numeric; `buildHostList` then zips and range-checks each. `url.port` is always + // digits. const portQuery = query.get("port"); if ( portQuery !== null && @@ -645,12 +594,10 @@ function parseUrlConnectionString(value: string, env: ParseEnv): PgConnInput | u } const primary = hostList[0]!; - // A present `?dbname=` (even empty) overrides the URL path verbatim (pgconn - // connects with an empty database — there is no `database` default). pgconn also - // accepts `database` as an alias for `dbname` (its query/DSN `nameMap`, - // `config.go:495-497`), copied into `settings["database"]`; prefer `dbname` when - // both appear (Go's map iteration has no defined precedence). Only an absent - // param falls back to the path → service → PGDATABASE → resolved user. + // A present `?dbname=` (even empty) overrides the URL path verbatim — connecting with an + // empty database, since there's no `database` default. `database` is also accepted as an + // alias for `dbname`; prefer `dbname` when both appear. Only an absent param falls back to + // the path → service → PGDATABASE → resolved user. const dbnameQuery = query.get("dbname") ?? query.get("database"); const structuralDb = decodeURIComponent(url.pathname.replace(/^\//, "")); const database = @@ -660,11 +607,11 @@ function parseUrlConnectionString(value: string, env: ParseEnv): PgConnInput | u ? structuralDb : (svc("database") ?? libpqEnv(env, "PGDATABASE") ?? user); - // Password precedence (pgconn): the query loop runs last, so `?password=` - // overrides the userinfo password. A `:` in the raw userinfo marks a present - // (possibly empty) userinfo password — `user:@host` — which WHATWG `url.password` - // cannot distinguish from an absent one (`user@host`), so detect it from the - // raw string. `resolveLibpqPassword` then applies the PGPASSWORD/`.pgpass` rules. + // Password precedence: the query is applied last, so `?password=` overrides the userinfo + // password. A `:` in the raw userinfo marks a present (possibly empty) userinfo password — + // `user:@host` — which WHATWG `url.password` cannot distinguish from an absent one + // (`user@host`), so detect it from the raw string. `resolveLibpqPassword` then applies the + // PGPASSWORD/`.pgpass` rules. const connStringPassword = query.has("password") ? (query.get("password") ?? "") : userinfoRaw.includes(":") @@ -701,10 +648,9 @@ function parseUrlConnectionString(value: string, env: ParseEnv): PgConnInput | u } /** - * Parse a libpq keyword/value DSN per the connection-string rules: whitespace- - * separated `keyword = value` pairs, with single-quoted values and backslash - * escapes. Unknown keywords are ignored. Defaults mirror libpq/pgconn: the user - * falls back to the OS account, the database to the user, and the port to 5432. + * Parse a libpq keyword/value DSN: whitespace-separated `keyword = value` pairs, with + * single-quoted values and backslash escapes. Unknown keywords are ignored. Defaults follow + * libpq: the user falls back to the OS account, the database to the user, and the port to 5432. */ function parseKeywordValueDsn(value: string, env: ParseEnv): PgConnInput | undefined { const params = new Map<string, string>(); @@ -722,10 +668,9 @@ function parseKeywordValueDsn(value: string, env: ParseEnv): PgConnInput | undef if (value[i] !== "=") return undefined; i++; while (i < n && isSpace(value[i]!)) i++; - // Value: single-quoted (with `\` escapes) or bare (until whitespace). pgconn's - // `parseDSNSettings` unescapes **only** `\\`→`\` and `\'`→`'`; a backslash before - // any other char is preserved (`config.go:539-566`), so Windows cert paths like - // `C:\certs\root.pem` and literal `\n` in a password survive intact. (A `\'` + // Value: single-quoted (with `\` escapes) or bare (until whitespace). Only `\\`→`\` and + // `\'`→`'` are unescaped; a backslash before any other char is preserved, so Windows cert + // paths like `C:\certs\root.pem` and literal `\n` in a password survive intact. (A `\'` // inside a quoted value is data, not the closing quote.) const isEscapedChar = (j: number): boolean => value[j] === "\\" && j + 1 < n && (value[j + 1] === "\\" || value[j + 1] === "'"); @@ -742,28 +687,25 @@ function parseKeywordValueDsn(value: string, env: ParseEnv): PgConnInput | undef i++; } else { while (i < n && !isSpace(value[i]!)) { - // pgconn's unquoted scan advances past any `\`, then errors with - // "invalid backslash" when the escape has no following char - // (`config.go:539-543`), so a lone trailing backslash is a parse error. + // The unquoted scan advances past any `\`, then errors when the escape has no + // following char, so a lone trailing backslash is a parse error. if (!isEscapedChar(i) && value[i] === "\\" && i + 1 >= n) return undefined; if (isEscapedChar(i)) i++; val += value[i]; i++; } } - // pgconn rejects an empty keyword with "invalid dsn" (`config.go:578-580`); a - // leading `=value` or whitespace-only key must fail, not be silently dropped. - // (Reachable only after a `=` was consumed, so this is exactly the empty-key case.) + // An empty keyword is a parse error; a leading `=value` or whitespace-only key must fail, + // not be silently dropped. (Reachable only after a `=` was consumed, so this is exactly the + // empty-key case.) if (key.length === 0) return undefined; - // pgconn remaps `dbname`→`database` at parse time (`config.go:574-582`), so both - // aliases share one settings slot and the last occurrence in the DSN wins. + // `dbname` is remapped to `database` at parse time, so both aliases share one settings slot + // and the last occurrence in the DSN wins. params.set(key === "dbname" ? "database" : key, val); } - // Omitted fields fall back to libpq `PG*` env vars and then the libpq defaults, - // matching pgconn's `mergeSettings(defaultSettings, envSettings, connStringSettings)`. - // A libpq DSN also accepts comma-separated multi-host failover - // (`host=h1,h2 port=5432,5433`, `config.go:326-362`), zipped by `buildHostList`. - // Resolve a pgservice (`service=`/`PGSERVICE`); its settings sit above + // Omitted fields fall back to libpq `PG*` env vars and then the libpq defaults. A libpq DSN + // also accepts comma-separated multi-host failover (`host=h1,h2 port=5432,5433`), zipped by + // `buildHostList`. Resolve a pgservice (`service=`/`PGSERVICE`); its settings sit above // env/defaults but below the explicit DSN keywords. const serviceSettings = resolveServiceSettings( params.get("service"), @@ -773,14 +715,13 @@ function parseKeywordValueDsn(value: string, env: ParseEnv): PgConnInput | undef if (serviceSettings === SERVICE_RESOLUTION_FAILED) return undefined; const svc = (key: string): string | undefined => serviceValue(serviceSettings, key); - // pgconn v1.14.3 has no `hostaddr` support: it stores `hostaddr` only as a runtime - // param and builds `config.Host` solely from `settings["host"]` (`config.go:326,364`), - // so a `hostaddr`-only DSN dials `defaultHost()` (`defaults.go:15`), never the address. - // Don't use `hostaddr` as a host fallback (it would dial a different endpoint than Go). + // No `hostaddr` support: it's stored only as a runtime param, so a `hostaddr`-only DSN dials + // the default host, never the address. Don't use `hostaddr` as a host fallback — it would dial + // a different endpoint than the reference driver. const hostString = params.get("host") ?? svc("host") ?? libpqEnv(env, "PGHOST") ?? defaultLibpqHost(); - // Explicit empty/non-numeric `port=` is a parse error (pgconn's `parsePort`); an - // absent `port` falls back to the service, then `PGPORT`, then the libpq default. + // Explicit empty/non-numeric `port=` is a parse error; an absent `port` falls back to the + // service, then `PGPORT`, then the libpq default. const portParam = params.get("port"); let portString: string; if (portParam !== undefined) { @@ -801,34 +742,34 @@ function parseKeywordValueDsn(value: string, env: ParseEnv): PgConnInput | undef svc("database") ?? libpqEnv(env, "PGDATABASE") ?? (user.length > 0 ? user : "postgres"); - // libpq fills `sslmode` from the service, then `PGSSLMODE`, when the DSN omits it - // (pgconn's merge order), before the TLS-mode default. + // libpq fills `sslmode` from the service, then `PGSSLMODE`, when the DSN omits it, before the + // TLS-mode default. const sslmode = params.get("sslmode") ?? svc("sslmode") ?? libpqEnv(env, "PGSSLMODE"); if (isInvalidSslmode(sslmode)) return undefined; const sslrootcert = params.get("sslrootcert") ?? svc("sslrootcert") ?? libpqEnv(env, "PGSSLROOTCERT"); - // libpq client cert (keyword, service, or PG*); both or neither (config.go:710-711). + // libpq client cert (keyword, service, or PG*); both or neither. const clientCert = resolveClientCert((key) => params.get(key), svc, env); if (clientCert === "invalid") return undefined; const options = params.get("options") ?? svc("options"); - // Every other keyword setting (e.g. search_path, statement_timeout) is a startup - // runtime param Go forwards to the server / pg-delta. + // Every other keyword setting (e.g. search_path, statement_timeout) is a startup runtime + // param forwarded to the server / pg-delta. const runtimeParams = collectRuntimeParams(params, serviceSettings, env); - // A `passfile=` setting (keyword or service) points `.pgpass` resolution at a - // non-default file (pgconn `config.go:293`); non-empty wins over `PGPASSFILE`. - // A present `passfile=` (even empty) overrides PGPASSFILE/default (see URL branch). + // A `passfile=` setting (keyword or service) points `.pgpass` resolution at a non-default + // file; non-empty wins over `PGPASSFILE`. A present `passfile=` (even empty) overrides + // PGPASSFILE/default (see URL branch). const passfileParam = params.get("passfile"); const passfile = passfileParam !== undefined ? passfileParam : svc("passfile"); - // libpq `connect_timeout` (keyword, service, or `PGCONNECT_TIMEOUT`). A *present* - // keyword (even empty) overrides service/env and is parsed (empty → error); only - // an absent keyword falls back. + // libpq `connect_timeout` (keyword, service, or `PGCONNECT_TIMEOUT`). A present keyword (even + // empty) overrides service/env and is parsed (empty → error); only an absent keyword falls + // back. const connectTimeoutRaw = params.has("connect_timeout") ? params.get("connect_timeout")! : (svc("connect_timeout") ?? libpqEnv(env, "PGCONNECT_TIMEOUT")); const connectTimeout = libpqConnectTimeout(connectTimeoutRaw); if (connectTimeout === CONNECT_TIMEOUT_INVALID) return undefined; - // Password precedence (pgconn): a `password=` entry — even empty — overrides the - // service and PGPASSWORD; an empty resolved value then falls through to `.pgpass`. + // Password precedence: a `password=` entry — even empty — overrides the service and + // PGPASSWORD; an empty resolved value then falls through to `.pgpass`. const password = resolveLibpqPassword( params.has("password") ? params.get("password")! : svc("password"), primary.host, @@ -855,20 +796,16 @@ function parseKeywordValueDsn(value: string, env: ParseEnv): PgConnInput | undef } /** - * libpq's default user when the connection string omits one. Mirrors `pgconn`'s - * `mergeSettings(defaultSettings, envSettings, connStringSettings)` - * (`config.go:249`): `PGUSER` (an env setting) takes priority over the OS account - * (`defaultSettings` → `user.Current()`), while an explicit `user=`/userinfo in - * the connection string still wins over both (handled by the callers). The final - * `"postgres"` guard covers minimal environments where neither is available. + * libpq's default user when the connection string omits one: `PGUSER` (an env setting) takes + * priority over the OS account, while an explicit `user=`/userinfo in the connection string + * still wins over both (handled by the callers). The final `"postgres"` guard covers minimal + * environments where neither is available. * - * pgconn ignores **empty** `PG*` env vars (`parseEnvSettings` only records a value - * when non-empty, `config.go:436-441`), so an empty `PGUSER` falls through to the OS - * account. The OS account is `user.Current().Username` (`defaults.go:21-23`) — the - * passwd entry for the effective uid, **not** the `$USER`/`$USERNAME` env vars (those - * are never consulted by pgconn; only `PGUSER` is an env override). Node's - * `os.userInfo().username` is the faithful analogue; it can throw when there is no - * passwd entry, mirroring Go's ignored-error path → the `"postgres"` guard. + * Empty `PG*` env vars are ignored, so an empty `PGUSER` falls through to the OS account — the + * passwd entry for the effective uid, not the `$USER`/`$USERNAME` env vars (those are never + * consulted; only `PGUSER` is an env override). Node's `os.userInfo().username` is the faithful + * analogue; it can throw when there is no passwd entry, falling through to the `"postgres"` + * guard. */ function osAccountUsername(): string | undefined { try { @@ -884,25 +821,21 @@ function defaultOsUser(env: ParseEnv): string { } /** - * Mask the password in a connection string for safe inclusion in error output - * (CWE-209): a malformed `--db-url` often still carries a secret. Pure string - * replacement (not `URL.toString()`, which would percent-encode the literal - * `[REDACTED]`) covers URL userinfo (`://user:secret@`), the malformed-but- - * credential-bearing URL case, and libpq keyword/value DSNs (`password=…` / - * `password='…'`). + * Mask the password in a connection string for safe inclusion in error output (CWE-209): a + * malformed `--db-url` often still carries a secret. Pure string replacement (not + * `URL.toString()`, which would percent-encode the literal `[REDACTED]`) covers URL userinfo + * (`://user:secret@`), the malformed-but-credential-bearing URL case, and libpq keyword/value + * DSNs (`password=…` / `password='…'`). * - * The URL-userinfo password span is greedy (`.*`) so it consumes a literal `@` or - * `/` inside a hand-typed password; the lookahead anchors the redaction boundary on - * the **last** `@` before the authority terminator (`/`, `?`, `#`, or end), so - * `postgres://user:p@ss/word@host/db` redacts the whole password rather than leaking - * a fragment. Where it cannot disambiguate it over-redacts, which is the safe - * direction for CWE-209 (over-redaction is fine; leaking is the bug). + * The URL-userinfo password span is greedy (`.*`) so it consumes a literal `@` or `/` inside a + * hand-typed password; the lookahead anchors the redaction boundary on the last `@` before the + * authority terminator (`/`, `?`, `#`, or end), so `postgres://user:p@ss/word@host/db` redacts + * the whole password rather than leaking a fragment. Where it cannot disambiguate it + * over-redacts, which is the safe direction for CWE-209. * - * The keyword-DSN `password=` branch matches a properly closed `'…'` value first - * (preserving any trailing `key=value` pairs), then an **unterminated** opening - * quote through end-of-string (a malformed `password='secret with spaces …` whose - * value has no closing quote — redact to EOL rather than leaking past the first - * space), then a bare unquoted token. + * The keyword-DSN `password=` branch matches a properly closed `'…'` value first (preserving any + * trailing `key=value` pairs), then an unterminated opening quote through end-of-string (redact + * to EOL rather than leaking past the first space), then a bare unquoted token. */ export function redactConnectionString(value: string): string { return value diff --git a/apps/cli/src/command-internal/db-config.parse.unit.test.ts b/apps/cli/src/command-internal/db-config.parse.unit.test.ts index 46ddd181df..fdefee3402 100644 --- a/apps/cli/src/command-internal/db-config.parse.unit.test.ts +++ b/apps/cli/src/command-internal/db-config.parse.unit.test.ts @@ -9,8 +9,7 @@ import { redactConnectionString, } from "./db-config.parse.ts"; -// Mirrors the parser's default-user resolution: PGUSER (env) else the actual OS -// account (os.userInfo().username, NOT $USER/$USERNAME) else "postgres". +// The parser's own default-user resolution: PGUSER, else the OS account, else "postgres". const osAccount = (() => { try { return userInfo().username || undefined; @@ -71,7 +70,6 @@ describe("parseConnectionString (URL form)", () => { process.env["PGPORT"] = "6543"; process.env["PGDATABASE"] = "envdb"; try { - // Password/port/database omitted from the URL → taken from PG* env. expect(parseConnectionString("postgresql://alice@db.example.com")).toEqual({ host: "db.example.com", port: 6543, @@ -79,7 +77,6 @@ describe("parseConnectionString (URL form)", () => { password: "env-secret", database: "envdb", }); - // Explicit URL fields override the env defaults (connStringSettings win). expect(parseConnectionString("postgresql://alice:pw@db.example.com:5555/appdb")).toEqual({ host: "db.example.com", port: 5555, @@ -96,8 +93,6 @@ describe("parseConnectionString (URL form)", () => { }); it("honors libpq query params (host/dbname) over the structural URL (pgconn parity)", () => { - // pgconn's parseURLSettings merges query params last, so ?host=/socket wins - // over the (empty) URL host, and a unix-socket host is used verbatim. expect(parseConnectionString("postgresql:///postgres?host=/var/run/postgresql")).toEqual({ host: "/var/run/postgresql", port: 5432, @@ -105,7 +100,6 @@ describe("parseConnectionString (URL form)", () => { password: "", database: "postgres", }); - // A query dbname overrides the URL path. expect( parseConnectionString("postgresql://postgres:pw@db.example.com:6543/ignored?dbname=real"), ).toEqual({ @@ -145,7 +139,6 @@ describe("parseConnectionString (URL form)", () => { const parsed = parseConnectionString( "postgres://u:pw@h/db?search_path=tenant&statement_timeout=5000&sslmode=require&options=reference%3Dabc", ); - // search_path/statement_timeout → runtimeParams; sslmode/options stay dedicated. expect(parsed?.runtimeParams).toEqual({ search_path: "tenant", statement_timeout: "5000" }); expect(parsed?.options).toBe("reference=abc"); expect(parsed).not.toHaveProperty("runtimeParams.options"); @@ -189,7 +182,6 @@ describe("parseConnectionString (URL form)", () => { expect(parsed?.sslcert).toBe("/c/client.crt"); expect(parsed?.sslkey).toBe("/c/client.key"); expect(parsed?.sslpassword).toBe("secret"); - // sslcert/sslkey are connection settings, never forwarded as runtime params. expect(parsed).not.toHaveProperty("runtimeParams"); }); @@ -216,7 +208,6 @@ describe("parseConnectionString (URL form)", () => { it("rejects a non-numeric or empty ?port= query override (pgconn parsePort error)", () => { expect(parseConnectionString("postgresql://host/db?port=abc")).toBeUndefined(); - // An explicit empty port override overrides the structural port and is invalid. expect(parseConnectionString("postgresql://db.example.com/app?port=")).toBeUndefined(); }); @@ -224,7 +215,6 @@ describe("parseConnectionString (URL form)", () => { const prev = process.env["PGPORT"]; process.env["PGPORT"] = "abc"; try { - // No port in the URL or DSN → falls back to PGPORT, which is invalid → reject. expect(parseConnectionString("postgresql://host/db")).toBeUndefined(); expect(parseConnectionString("host=pg.example.com user=admin")).toBeUndefined(); } finally { @@ -252,7 +242,6 @@ describe("parseConnectionString (URL form)", () => { process.env["PGSSLMODE"] = "verify-full"; try { expect(parseConnectionString("postgres://u:pw@h:5432/db")?.sslmode).toBe("verify-full"); - // An explicit query sslmode still wins over PGSSLMODE. expect(parseConnectionString("postgres://u:pw@h:5432/db?sslmode=disable")?.sslmode).toBe( "disable", ); @@ -263,8 +252,6 @@ describe("parseConnectionString (URL form)", () => { }); it("rejects a non-Postgres URL scheme instead of connecting to a bogus host", () => { - // pgconn only treats `postgres://`/`postgresql://` as a URL (config.go:236); - // any other scheme is parsed as a keyword/value DSN, which fails. expect(parseConnectionString("https://db.example.com/app")).toBeUndefined(); expect(parseConnectionString("mysql://user:pw@host:3306/app")).toBeUndefined(); }); @@ -309,9 +296,6 @@ describe("parseConnectionString (libpq keyword/value DSN)", () => { const prev = process.env["PGUSER"]; process.env["PGUSER"] = "pg_role"; try { - // No user= keyword: PGUSER wins over USER/USERNAME, and the database - // defaults to that resolved user — matching pgconn's - // mergeSettings(defaultSettings, envSettings, connStringSettings) order. expect(parseConnectionString("host=pg.example.com")).toEqual({ host: "pg.example.com", port: 5432, @@ -319,9 +303,7 @@ describe("parseConnectionString (libpq keyword/value DSN)", () => { database: "pg_role", password: "", }); - // An explicit user= still wins over PGUSER (connStringSettings override env). expect(parseConnectionString("host=h user=explicit")?.user).toBe("explicit"); - // The URL form without userinfo also honors PGUSER. expect(parseConnectionString("postgresql://localhost/mydb")?.user).toBe("pg_role"); } finally { if (prev === undefined) delete process.env["PGUSER"]; @@ -348,7 +330,6 @@ describe("parseConnectionString (libpq keyword/value DSN)", () => { password: "env-secret", database: "envdb", }); - // Explicit keywords override the env defaults. expect(parseConnectionString("host=h port=1234 user=admin dbname=db password=pw")).toEqual({ host: "h", port: 1234, @@ -368,7 +349,6 @@ describe("parseConnectionString (libpq keyword/value DSN)", () => { const prev = process.env["PGHOST"]; delete process.env["PGHOST"]; try { - // No host= and no PGHOST → libpq default (a unix-socket dir or "localhost"). expect(parseConnectionString("user=admin")?.host).toMatch(/^(\/|localhost)/); } finally { if (prev === undefined) delete process.env["PGHOST"]; @@ -386,10 +366,7 @@ describe("parseConnectionString (libpq keyword/value DSN)", () => { }); describe("empty-password precedence (pgconn parity)", () => { - // pgconn merges the connection-string password over PGPASSWORD, so an explicit - // *empty* password (`user:@host`, `?password=`, `password=`) suppresses - // PGPASSWORD and then falls through to `.pgpass` (config.go:264-379). Point - // PGPASSFILE at a temp file we control and set PGPASSWORD to prove which one wins. + // Points PGPASSFILE at a temp file we control and sets PGPASSWORD, to prove which one wins. let tmp: string; let pgpassPath: string; const prev: Record<string, string | undefined> = {}; @@ -442,7 +419,6 @@ describe("empty-password precedence (pgconn parity)", () => { it("falls through to an empty password when neither PGPASSWORD nor .pgpass match", () => { delete process.env["PGPASSWORD"]; - // No matching .pgpass line for this host → empty. expect(parseConnectionString("postgres://alice:@other.example.com:6543/appdb")?.password).toBe( "", ); @@ -473,11 +449,10 @@ describe("multi-host failover (pgconn parity)", () => { }); it("zips hosts to the (compacted) port list exactly as pgconn does", () => { - // pgconn drops empty ports before zipping (config.go:462-488), so a host that - // omits a port does NOT inherit the previous host's port — it takes the next - // entry in the compacted port list, and only a host past the end reuses ports[0]. - // For `h1:5432,h2,h3:5544` the port list is [5432, 5544]: h1→5432, h2→5544, - // h3 (index 2, past the end)→ports[0]=5432. This is pgconn's quirk, matched 1:1. + // Empty ports are dropped before zipping, so a host that omits a port takes the next entry + // in the compacted port list rather than inheriting the previous host's port; only a host + // past the end reuses `ports[0]`. For `h1:5432,h2,h3:5544` the port list is `[5432, 5544]`: + // h1→5432, h2→5544, h3 (past the end)→ports[0]=5432. expect(parseConnectionString("postgres://u:pw@h1:5432,h2,h3:5544/db")).toMatchObject({ host: "h1", port: 5432, @@ -520,9 +495,8 @@ describe("multi-host failover (pgconn parity)", () => { }); describe("passfile= DSN setting (pgconn parity)", () => { - // pgconn honors a connection-string `passfile=` ahead of PGPASSFILE/the default - // (`config.go:293,369-377`). Point PGPASSFILE at one file and `passfile=` at a - // different one to prove the connection-string setting wins. + // Points PGPASSFILE at one file and `passfile=` at a different one, to prove the + // connection-string setting wins. let tmp: string; let customPath: string; const prev: Record<string, string | undefined> = {}; @@ -570,8 +544,6 @@ describe("passfile= DSN setting (pgconn parity)", () => { }); it("a present-but-empty passfile= suppresses PGPASSFILE (→ empty password)", () => { - // pgconn: present-empty passfile overrides PGPASSFILE, then ReadPassfile("") fails - // → no .pgpass lookup → empty password (not the env-file credential). expect( parseConnectionString("postgres://alice@db.example.com:6543/appdb?passfile=")?.password, ).toBe(""); @@ -583,10 +555,9 @@ describe("passfile= DSN setting (pgconn parity)", () => { }); describe("injected env lookup (project dotenv parity)", () => { - // The resolver layers the project `.env*` files under the shell env and passes a - // lookup into the parser, mirroring Go's LoadConfig-before-ParseConfig order - // (`internal/utils/flags/db_url.go:59-68`). A field omitted from the DSN is then - // filled from the injected env, not just `process.env`. + // The resolver layers the project `.env*` files under the shell env and passes a lookup into + // the parser. A field omitted from the DSN is then filled from the injected env, not just + // `process.env`. it("fills omitted URL fields from the injected env (PGPASSWORD/PGSSLMODE/PGHOST)", () => { const env = (name: string): string | undefined => ({ PGPASSWORD: "dotenv-pw", PGSSLMODE: "require", PGHOST: "dotenv-host" })[name]; @@ -621,10 +592,9 @@ describe("injected env lookup (project dotenv parity)", () => { }); describe("pgservice resolution (pgconn parity)", () => { - // pgconn resolves a `service=`/`PGSERVICE` against the service file and merges - // its settings between env and the explicit connection-string fields - // (config.go:250-256). dbname is remapped to database. An unresolvable service - // is a hard parse error. + // A `service=`/`PGSERVICE` resolves against the service file and merges its settings between + // env and the explicit connection-string fields. `dbname` is remapped to `database`. An + // unresolvable service is a hard parse error. let tmp: string; let servicefile: string; @@ -701,7 +671,6 @@ describe("pgservice resolution (pgconn parity)", () => { describe("keyword/value DSN backslash handling (pgconn parity)", () => { it("preserves backslashes before ordinary chars (Windows cert paths)", () => { - // pgconn unescapes only \\ and \', so a Windows path keeps its backslashes. expect( parseConnectionString("host=h dbname=d user=u sslrootcert=C:\\certs\\root.pem")?.sslrootcert, ).toBe("C:\\certs\\root.pem"); @@ -808,14 +777,11 @@ describe("pgconn parse refinements", () => { it("treats an empty connection-string service= as explicit (parse error), not PGSERVICE", () => { const env = (name: string): string | undefined => (name === "PGSERVICE" ? "prod" : undefined); - // Empty ?service= overrides PGSERVICE and fails resolution (pgconn GetService("")). expect(parseConnectionString("postgres://host/db?service=", env)).toBeUndefined(); expect(parseConnectionString("host=h user=u service=", env)).toBeUndefined(); }); it("uses the OS account (not $USER/$USERNAME) when PGUSER is empty/absent", () => { - // pgconn reads user.Current().Username for the default, never $USER/$USERNAME; - // a divergent $USER must be ignored. Empty PGUSER falls through to the OS account. const env = (name: string): string | undefined => ({ PGUSER: "", USER: "not-the-account", USERNAME: "not-the-account" })[name]; expect(parseConnectionString("postgres://host/db", env)?.user).toBe(osUser); @@ -823,10 +789,8 @@ describe("pgconn parse refinements", () => { }); it("rejects an empty connection-string connect_timeout but ignores an empty env var", () => { - // Present-but-empty ?connect_timeout= / connect_timeout= is a parse error. expect(parseConnectionString("postgres://u:p@h/db?connect_timeout=")).toBeUndefined(); expect(parseConnectionString("host=h user=u connect_timeout=")).toBeUndefined(); - // An empty PGCONNECT_TIMEOUT env var is unset → no error, default applies. const env = (name: string): string | undefined => name === "PGCONNECT_TIMEOUT" ? "" : undefined; expect(parseConnectionString("postgres://u:p@h/db", env)).not.toHaveProperty( @@ -875,7 +839,6 @@ describe("database= alias and empty service values (pgconn parity)", () => { : name === "PGPASSFILE" ? join(tmp, "no-pgpass") : undefined; - // Empty service password overrides PGPASSWORD; no .pgpass match → "". expect(parseConnectionString(`postgres:///?service=s&servicefile=${sf}`, env)?.password).toBe( "", ); diff --git a/apps/cli/src/command-internal/db-config.service.ts b/apps/cli/src/command-internal/db-config.service.ts index 6d90062a55..76cab7ed47 100644 --- a/apps/cli/src/command-internal/db-config.service.ts +++ b/apps/cli/src/command-internal/db-config.service.ts @@ -31,8 +31,8 @@ export type DbConfigError = | DbConfigLoadError | ProjectRefNotLinkedError | InvalidProjectRefError - // Hard linked-ref load surfaces a real `.temp/project-ref` read error (Go's - // `failed to load project ref`) instead of masking it as not-linked. + // A hard linked-ref load surfaces a real `.temp/project-ref` read error instead of masking it + // as not-linked. | ProjectRefReadError | DbConfigLoginRoleNetworkError | DbConfigLoginRoleStatusError @@ -48,28 +48,24 @@ export type DbConfigError = | DbConnectError // The `--linked` path resolves the access token lazily via // `CommandPlatformApiFactory.make` (only when minting a temp login role), so the - // auth-required / invalid-token / api-config errors surface from the resolver - // effect — not a layer-build channel. `--linked --password` skips `make` - // entirely and never raises these (`NewDbConfigWithPassword`). + // auth-required / invalid-token / api-config errors surface from the resolver effect — not a + // layer-build channel. `--linked --password` skips `make` entirely and never raises these. | CommandPlatformApiFactoryError // The lazy linked runtime rebuilds `commandSettingsLayer`, whose strict // profile resolution can fail inside the resolver effect the same way. | ProfileLoadError; -// The `--linked` path builds a lazy Management API runtime (so `--local` / -// `--db-url` never resolve an access token) and provides ALL of its own -// requirements from the resolver's captured context, so `resolve`'s R stays -// `never`. Access-token resolution is deferred to first API use, so its -// auth-required error surfaces through the resolver effect (folded into +// The `--linked` path builds a lazy Management API runtime (so `--local`/`--db-url` never +// resolve an access token) and provides every one of its own requirements from the resolver's +// captured context, so `resolve`'s R stays `never`. Access-token resolution is deferred to +// first API use, so its auth-required error surfaces through the resolver effect (folded into // `DbConfigError`) rather than a layer-build error channel. interface DbConfigResolverShape { readonly resolve: (flags: DbConfigFlags) => Effect.Effect<ResolvedDbConfig, DbConfigError>; /** - * Resolves the IPv4 transaction pooler connection for a linked dump's - * container-level fallback (`RunWithPoolerFallback` → - * `ResolvePoolerConfigForFallback`). Returns `None` when the path is not - * pooler-eligible (`--linked` only) or no pooler URL is configured, so the - * caller keeps the original error. + * Resolves the IPv4 transaction pooler connection for a linked dump's container-level + * fallback. Returns `None` when the path is not pooler-eligible (`--linked` only) or no + * pooler URL is configured, so the caller keeps the original error. */ readonly resolvePoolerFallback: ( flags: DbConfigFlags, From 7fd38cc4cd0008e01b2bc77579e20ce4e93df032 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 12:40:55 +0100 Subject: [PATCH 17/56] chore(repo): trim comments, shard 21 (1569 -> 987 comment lines) --- .../src/commands/config/push/push.format.ts | 161 +++---- .../config/push/push.format.unit.test.ts | 17 +- .../src/commands/config/push/push.handler.ts | 290 ++++-------- .../config/push/push.integration.test.ts | 432 +++++------------- .../commands/config/push/push.live.test.ts | 8 +- .../src/commands/config/push/push.paths.ts | 16 +- .../cli/src/commands/config/push/push.plan.ts | 49 +- .../config/push/push.plan.unit.test.ts | 20 +- .../config/push/push.registry-names.ts | 23 +- .../src/commands/config/push/push.secret.ts | 42 +- .../config/push/push.secret.unit.test.ts | 14 +- .../src/commands/config/push/push.secrets.ts | 10 +- .../config/push/push.secrets.unit.test.ts | 7 +- .../src/commands/config/push/push.types.ts | 10 +- .../commands/db/advisors/advisors.command.ts | 7 +- .../commands/db/advisors/advisors.errors.ts | 8 +- .../commands/db/advisors/advisors.format.ts | 32 +- .../db/advisors/advisors.format.unit.test.ts | 26 +- .../commands/db/advisors/advisors.handler.ts | 32 +- .../db/advisors/advisors.integration.test.ts | 50 +- .../commands/db/advisors/advisors.layers.ts | 36 +- apps/cli/src/commands/db/diff/diff.command.ts | 20 +- .../db/diff/diff.declarative.e2e.test.ts | 29 +- apps/cli/src/commands/db/diff/diff.errors.ts | 23 +- apps/cli/src/commands/db/diff/diff.handler.ts | 243 ++++------ 25 files changed, 508 insertions(+), 1097 deletions(-) diff --git a/apps/cli/src/commands/config/push/push.format.ts b/apps/cli/src/commands/config/push/push.format.ts index a9a28d0cf1..37cfb4a02e 100644 --- a/apps/cli/src/commands/config/push/push.format.ts +++ b/apps/cli/src/commands/config/push/push.format.ts @@ -13,35 +13,20 @@ import { comparePaths, pathIn } from "./push.paths.ts"; import { PUSH_RESOURCES, pushResponseBlock, type PushResource } from "./push.plan.ts"; import type { PushSecretReport } from "./push.secrets.ts"; -/** The v2 response blocks `config push` actually reads from — derived from `PUSH_RESOURCES` - * rather than a hand-kept list, so a block `config diff`/`config pull` care about but push never - * touches (`pooler`, `realtime`) never inflates the summary's "not returned" caveat. */ +/** Response blocks `config push` writes to, derived from `PUSH_RESOURCES` so a block only + * `config diff`/`config pull` care about never inflates the "not returned" caveat below. */ const PUSH_RESPONSE_BLOCKS: ReadonlySet<string> = new Set( PUSH_RESOURCES.map((resource) => pushResponseBlock(resource)), ); /** - * Pure formatters and payload builders for `config push` — no Effect, no - * services, unit-testable in isolation. Consumes only the shapes an - * encoder's `PushEncoded` result carries (`encoded`/`unencodable`/ - * `extras`/`forced`, declared structurally below rather than imported, so - * this module never depends on `push.encoders.ts`), `PushSecretReport`, - * `PushResource`, and `../config.format.ts`'s shared helpers. + * Pure formatters and payload builders for `config push` — no Effect, no services, + * unit-testable in isolation. * - * Every non-constant string interpolated into TEXT output goes through - * `configRenderPath` (which itself sanitizes via - * `sanitizeInlineName`) — path segments are unconstrained - * declared-config-key strings (an `sms.test_otp` phone number, a - * `[remotes.*]` name), so a hostile value could otherwise emit raw ANSI or - * forge output lines. Secret VALUES never reach this module: every secret - * input is typed `PushSecretReport` (`PushSecretDecision` with - * `plaintext` omitted) — callers cannot even accidentally pass a plaintext - * through. - * - * `service`/`PushResource` values are OPAQUE IDENTIFIERS (dotted keys - * mirroring `config.toml` paths, plus the fixed `"experimental.webhooks"`), - * never themselves a config path — never rendered through - * `configRenderPath`. + * Every value interpolated into text output goes through `configRenderPath`/`sanitizeInlineName`, + * since path segments come from unconstrained declared config keys and could otherwise inject + * ANSI escapes. Secret values never reach this module: inputs are always `PushSecretReport`, + * which omits `plaintext`. */ /** A declared change this encoder could not structurally express, with why. */ @@ -80,19 +65,16 @@ const RESOURCE_DISPLAY_NAME: Readonly<Record<PushResource, string>> = { storage: "Storage", }; -/** Global path order, applied wherever entries from more than one resource's encoder output get - * concatenated (`pushNotes`/`pushPayload`) — each encoder already sorts its OWN - * `unencodable`/`forced` list, but the handler appends those lists across resources in push order, - * not path order. */ +/** Re-sorts entries the handler concatenates across resources (`pushNotes`/`pushPayload`); each + * encoder already sorts its own list, but concatenation is in push order, not path order. */ function sortByPath<T extends { readonly path: ReadonlyArray<string> }>( entries: ReadonlyArray<T>, ): ReadonlyArray<T> { return [...entries].sort((a, b) => comparePaths(a.path, b.path)); } -/** One `<path> [<label>]` / `local:` / `remote:` block, always followed by a blank line — the - * same shape `configRenderChangeLines` uses for ordinary property changes, so appending any - * mix of these directly after that renderer's output never introduces (or omits) a blank line. */ +/** One `<path> [<label>]` / `local:` / `remote:` block, always ending in a blank line, matching + * `configRenderChangeLines`'s shape so the two can be concatenated without blank-line drift. */ function renderBlock( path: ReadonlyArray<string>, label: string, @@ -103,17 +85,9 @@ function renderBlock( } /** - * `[secret]` blocks — rendered BEFORE the confirmation prompt, so a credential - * that will not be sent (`not_set`, or a `send` secret whose own container - * ended up unencodable) is disclosed alongside one that will (`send` AND - * actually placed in the request body), inside the same resource block. - * `unchanged`/`gated` decisions never render (nothing changed, or the - * secret's container is off — already silent the same way a `disabled` - * resource status is). `secretsEncoded` is the encoder's own record of which - * `send` decisions it actually placed a plaintext for — a container can - * carry a `send` decision and still drop it as `unencodable` (its - * required-together group unresolvable), so a `send` status alone never - * implies the secret is being sent. + * `[secret]` blocks, rendered before the confirmation prompt so a credential that won't be sent + * is disclosed alongside one that will. A `send` status alone doesn't mean the secret reached the + * request body — check `secretsEncoded`, since its container can still drop it as unencodable. */ function renderSecretBlocks( secrets: ReadonlyArray<PushSecretReport>, @@ -160,8 +134,8 @@ function renderExtraBlocks(extras: ReadonlyArray<PushExtra>): string { } /** `[group-write]` blocks — an undeclared companion the target endpoint required alongside a - * declared change, sent at its config schema default because the remote didn't return a current - * value for it (see `pushNotes`' matching note). */ + * declared change, sent at its config schema default since the remote returned no current value + * (see `pushNotes`'s matching note). */ function renderForcedBlocks(forced: ReadonlyArray<PushForced>): string { return forced .map((entry) => @@ -177,26 +151,21 @@ function renderForcedBlocks(forced: ReadonlyArray<PushForced>): string { export interface PushUpdatingLineInput { readonly resource: PushResource; - /** The routed changes this write actually communicated (already narrowed by the caller). */ + /** The routed changes this write actually communicated, already narrowed by the caller. */ readonly changes: ReadonlyArray<ConfigChange>; - /** The resource's full secret-decision list; only `send`/`not_set` entries render. Callers with - * no secrets (every resource but `auth`) simply pass `[]`. */ + /** Only `send`/`not_set` entries render. Callers with no secrets pass `[]`. */ readonly secrets: ReadonlyArray<PushSecretReport>; - /** The encoder result's own `secretsEncoded` — which `send` decisions the request body actually - * placed a plaintext for. A `send` decision whose path is absent here renders as NOT being - * sent, because its container was dropped as `unencodable` (see `renderSecretBlocks`). */ + /** Paths the request body actually placed a plaintext for. A `send` decision whose path is + * absent here renders as not sent (see `renderSecretBlocks`). */ readonly secretsEncoded: ReadonlyArray<ReadonlyArray<string>>; readonly extras: ReadonlyArray<PushExtra>; readonly forced: ReadonlyArray<PushForced>; } /** - * The `Updating <resource> service with config:` block for a resource with at - * least one pushable difference. `changes` renders through the same - * per-property format `config diff` uses; secret/content/group-write blocks - * follow, each in the same 3-line-plus-blank-line shape, so the combined - * block always ends on a blank line (there is always at least one line item, - * since callers only reach this when there is something to write). + * The `Updating <resource> service with config:` block for a resource with at least one pushable + * difference. `changes` renders through the same per-property format `config diff` uses; the + * combined block always ends on a blank line. */ export function pushUpdatingLine(input: PushUpdatingLineInput): string { return ( @@ -214,9 +183,8 @@ export function pushUpToDateLine(resource: PushResource): string { } /** - * The `Remote <resource> config has N difference(s) config push cannot - * write...` line — a pushable difference existed, but every one of it ended - * up `unencodable` (`count`), so nothing was written and no prompt ran. + * The `Remote <resource> config has N difference(s) config push cannot write...` line — every + * pushable difference ended up `unencodable`, so nothing was written and no prompt ran. */ export function pushNotPushableLine(resource: PushResource, count: number): string { return `Remote ${RESOURCE_DISPLAY_NAME[resource]} config has ${configPlural(count, "difference", "differences")} config push cannot write (see notes below).\n`; @@ -228,8 +196,8 @@ export interface PushNotesInput { readonly unsupported: ReadonlyArray<ReadonlyArray<string>>; /** Declared paths an encoder could not structurally express, with why. */ readonly unencodable: ReadonlyArray<PushUnencodable>; - /** Count of `changeSet.unmanaged` entries NOT already covered by a disabled resource's own - * `Note:`-free `disabled` status — count only, the full list stays in the payload. */ + /** Count of `changeSet.unmanaged` entries not already covered by a disabled resource's own + * `disabled` status — count only; the full list stays in the payload. */ readonly unmanagedCount: number; /** Undeclared companion values actually written at their config default (only from resources * whose write ran). */ @@ -313,25 +281,20 @@ export interface PushPayloadInput { readonly unencodable: ReadonlyArray<PushUnencodable>; readonly forced: ReadonlyArray<PushForced>; /** `changeSet.unmanaged`, unfiltered — the note above is count-only, the payload keeps the - * full list (including paths under a gated-off resource). */ + * full list. */ readonly unmanaged: ReadonlyArray<ReadonlyArray<string>>; - /** Count of `unmanaged` entries NOT already covered by a disabled resource's own `disabled` - * status — the same gate-filtered count `pushNotes`'s note reports, used here so the - * json/stream-json summary sentence agrees with the stderr note instead of counting the - * unfiltered `unmanaged` list, which the payload's own `unmanaged` field keeps in full. */ + /** Gate-filtered count matching `pushNotes`'s note, so the summary sentence agrees with the + * stderr note even though `unmanaged` above stays unfiltered. */ readonly unmanagedCount: number; - /** Every declared secret's decision, unfiltered — partitions `changeSet.masked` across all - * six buckets below (`gated` is now included, unlike the pre-fix-pass payload). */ + /** Every declared secret's decision, unfiltered — partitions `changeSet.masked` across all six + * buckets below. */ readonly secrets: ReadonlyArray<PushSecretReport>; - /** Whether the auth resource's write actually ran — decides whether a `status: "send"` secret - * lands in `sent`/`unencodable` (write ran) or `skipped` (declined, or auth not written for - * any other reason): the payload reports what was OBSERVED to happen, not the pre-prompt - * decision. */ + /** Whether the auth write ran — decides whether a `status: "send"` secret lands in + * `sent`/`unencodable` or `skipped`; reports what was observed, not the pre-prompt decision. */ readonly authWriteRan: boolean; - /** The auth encoder's own `secretsEncoded` — the secret paths a write actually placed a - * plaintext for. NOT every `status: "send"` decision: a container can carry a `send` decision - * and still end up dropped as `unencodable` (its group unresolvable), so `sent` must read from - * here rather than from the raw decision list — meaningful only when `authWriteRan`. */ + /** The auth encoder's own `secretsEncoded` — paths a write actually placed a plaintext for. A + * `send` decision can still be dropped as `unencodable`, so `sent` reads from here rather than + * the raw decision list. Meaningful only when `authWriteRan`. */ readonly secretsSent: ReadonlyArray<ReadonlyArray<string>>; /** Addon cost prompts (`auth_mfa_phone`, `auth_mfa_web_authn`) declined this run. */ readonly declinedAddons: ReadonlyArray<string>; @@ -340,12 +303,9 @@ export interface PushPayloadInput { } /** - * One-line json/stream-json `message` summarizing what a push did, with a - * caveat sentence appended for anything withheld — modeled on - * `configDiffSummaryMessage` (`../diff/diff.format.ts`), so an agent - * echoing `.message` never reports success while some declared property was - * left unpushed. Caveats are appended in a fixed order, one sentence per - * non-empty category. + * One-line json/stream-json `message` summarizing what a push did, with a caveat sentence + * appended for anything withheld, so an agent echoing `.message` never reports success while a + * declared property went unpushed. */ export function pushSummaryMessage(input: PushPayloadInput): string { const updated = input.services.filter((service) => service.status === "updated"); @@ -414,9 +374,10 @@ export function pushSummaryMessage(input: PushPayloadInput): string { /** * The structured result for `--output-format json|stream-json`. - * `project_ref`/`services[].service`/`services[].status` keep the existing - * contract; everything else is additive. Paths are segment arrays — a record - * key may itself contain a `.`. + * + * `project_ref`/`services[].service`/`services[].status` are the established contract; + * everything else is additive. Paths are segment arrays since a record key may itself contain a + * `.`. */ export function pushPayload(input: PushPayloadInput): Record<string, unknown> { const byStatus = (status: PushSecretReport["status"]) => @@ -443,11 +404,8 @@ export function pushPayload(input: PushPayloadInput): Record<string, unknown> { unchanged: byStatus("unchanged"), not_set: byStatus("not_set"), gated: byStatus("gated"), - // A `send` decision the auth write ran but did NOT place in the request - // body — its container was dropped as `unencodable` (its - // required-together group unresolvable), even while OTHER auth fields - // still triggered the write. Distinct from `skipped`, which means the - // write itself never ran. + // A `send` decision whose container was dropped as unencodable even though the write ran — + // distinct from `skipped`, where the write itself never ran. unencodable: input.authWriteRan ? sendDecisions .map((secret) => secret.path) @@ -461,23 +419,13 @@ export function pushPayload(input: PushPayloadInput): Record<string, unknown> { }; } -// --- branch/project target detection (CLI-2168) ----------------------------- -// -// Pure formatters and payload builders for `config push`'s target-echo and -// branch confirmation — no Effect, no services, unit-testable in isolation. -// Every interpolated ref/name goes through `formatNamedRef` -// (`sanitizeInlineName` underneath), so an API-provided branch/project +// Every interpolated ref/name goes through `formatNamedRef`, so an API-provided branch/project // name can't inject ANSI/OSC/newline controls into the terminal. /** - * The target-echo block, printed to stderr before any further network call. - * Only the NO-NAME-AVAILABLE degradation shape — a plain project whose name - * could not be resolved — stays byte-identical to the pre-CLI-2168 - * `Pushing config to project: <ref>` text (existing tests pin exactly that - * shape). The plain-project SUCCESS path text is NOT byte-identical to the - * old behavior: it now also shows the resolved name whenever one is - * available, which for a real project is always, since `name` is a required - * API field. + * The target-echo block, printed to stderr before any further network call. The no-name + * degradation shape (a project whose name could not be resolved) stays exactly + * `Pushing config to project: <ref>`, pinned by existing tests. */ export function configPushTargetLines(target: ConfigPushTarget): string { if (target.kind === "project") { @@ -511,10 +459,9 @@ export function configPushBranchPromptLabel( : `Do you want to push config to branch "${sanitizeInlineName(target.branch)}" (${ref})?${hint}`; } -/** Additive machine-payload fields describing the resolved target - * (CLI-2168/CLI-2289). `is_branch` is omitted (not `false`) when the target - * couldn't be determined at all — asserting `false` would be as dishonest as - * asserting `true`; an absent key is the correct "we don't know" signal. */ +/** Additive machine-payload fields describing the resolved target. `is_branch` is omitted, not + * `false`, when the target couldn't be determined — an absent key is the correct "unknown" + * signal. */ export function configPushPayloadFields(target: ConfigPushTarget): { readonly is_branch?: boolean; readonly branch?: string; diff --git a/apps/cli/src/commands/config/push/push.format.unit.test.ts b/apps/cli/src/commands/config/push/push.format.unit.test.ts index ec6ade72dd..15c44d4975 100644 --- a/apps/cli/src/commands/config/push/push.format.unit.test.ts +++ b/apps/cli/src/commands/config/push/push.format.unit.test.ts @@ -172,9 +172,6 @@ describe("pushUpdatingLine", () => { }); test("a send secret whose container was dropped as unencodable renders as NOT being sent, not as a pending update", () => { - // `secretsEncoded` omits the path — the container carrying it turned out - // unencodable — so the pre-prompt block must never imply it is being - // sent, even though `status` is still `send`. expect( pushUpdatingLine({ resource: "auth", @@ -608,9 +605,6 @@ describe("pushSummaryMessage", () => { }); test('base: "Nothing was pushed" (not "Nothing to push") when every service is up_to_date/disabled but something is unsupported', () => { - // The base sentence must never claim the project "already matches the - // declared properties" in the same breath as reporting that a declared - // property could not be pushed — self-contradictory. expect( pushSummaryMessage({ ...EMPTY_SUMMARY_INPUT, @@ -672,9 +666,8 @@ describe("pushSummaryMessage", () => { }); test("caveat: unmanagedCount drives the sentence even when it diverges from unmanaged.length", () => { - // `unmanagedCount` (gate-filtered, matching `pushNotes`' stderr note) can be smaller - // than the payload's own unfiltered `unmanaged` list — a gated-off resource's own `unmanaged` - // entries are excluded from the count but stay in the full list (D5). + // `unmanagedCount` is gate-filtered (matching `pushNotes`'s note): a gated-off resource's + // own `unmanaged` entries are excluded from the count but stay in the full list. expect( pushSummaryMessage({ ...EMPTY_SUMMARY_INPUT, @@ -878,10 +871,8 @@ describe("pushPayload", () => { }); test("'sent' reads from secretsSent, not from the raw 'send' decision list — a send decision whose container was dropped lands in 'unencodable', never in 'sent'", () => { - // The write ran (authWriteRan: true) and `SENT_SECRET`'s status is - // still `send`, but the encoder that would have carried it reported - // nothing in `secretsSent` — its container must have been dropped as - // `unencodable` instead (asserted separately at the encoder layer). + // The write ran but `secretsSent` reports nothing for `SENT_SECRET`'s path — its container + // must have been dropped as `unencodable` instead. const payload = pushPayload({ ...BASE_INPUT, secretsSent: [] }); expect(payload["secrets"]).toMatchObject({ sent: [], diff --git a/apps/cli/src/commands/config/push/push.handler.ts b/apps/cli/src/commands/config/push/push.handler.ts index a332de4032..585e0f2d58 100644 --- a/apps/cli/src/commands/config/push/push.handler.ts +++ b/apps/cli/src/commands/config/push/push.handler.ts @@ -109,8 +109,8 @@ import { resolveAuthSecrets, type PushSecretDecision } from "./push.secrets.ts"; import type { ConfigPushFlags } from "./push.command.ts"; import type { ConfigPushServiceResult } from "./push.types.ts"; -/** The `services[].changes` union (D8): encoded paths ∪ content extras ∪ secret paths the write - * ACTUALLY sent, path-sorted. `sentSecretPaths` is `[]` for a declined/skipped write. */ +/** The `services[].changes` union: encoded paths ∪ content extras ∪ secret paths the write + * actually sent, path-sorted. `sentSecretPaths` is `[]` for a declined/skipped write. */ function pushServiceChanges( encoded: PushEncoded<unknown>, sentSecretPaths: ReadonlyArray<ReadonlyArray<string>>, @@ -120,10 +120,9 @@ function pushServiceChanges( .sort(comparePaths); } -/** `services[].changes`, `secrets.sent`, and `secrets.skipped` must all read from the same - * source: the encoder's own `secretsEncoded` — the container that carries a `send` decision can - * still drop it as `unencodable`, so the raw decision list alone over-counts what a write actually - * placed in the body. */ +/** `services[].changes`, `secrets.sent`, and `secrets.skipped` must all read from the encoder's + * own `secretsEncoded` — a container carrying a `send` decision can still drop it as + * `unencodable`, so the raw decision list alone over-counts what was actually sent. */ function pushSentSecretPaths(encoded: PushEncoded<unknown>): ReadonlyArray<ReadonlyArray<string>> { return encoded.secretsEncoded ?? []; } @@ -141,9 +140,8 @@ const mapPushBranchResolveError = mapHttpError({ statusMessage: unexpectedStatusMessage, }); -/** Error construction for `resolveConfigTarget` (`command-internal/project-target.ts`, shared with - * `config diff`/`config pull`), keeping `config push`'s own tagged error classes; the - * message wording is shared there. */ +/** Wires `resolveConfigTarget` (shared with `config diff`/`config pull`) to `config push`'s own + * tagged error classes. */ const configTargetErrors = configTargetErrorsFor({ notLinked: ConfigPushBranchNotLinkedError, parentRefInvalid: ConfigPushParentRefInvalidError, @@ -160,102 +158,55 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - // `--project-ref` accepts a project ref, or the name (or UUID) of a branch - // of the linked project — `link`'s/`config diff`'s settled vocabulary - // (CLI-2167/CLI-2289). An empty `--project-ref` value is absent, mirroring - // the resolver's own rule. + // `--project-ref` accepts a project ref, or the name (or UUID) of a branch of the linked + // project. An empty value is treated as absent, mirroring the resolver's own rule. const requestedRef = Option.filter(flags.projectRef, (value) => value.length > 0); - // Written once ref resolution succeeds, so the linked-project cache - // finalizer below only fires for invocations that got that far — mirrors - // `diff.handler.ts`'s `resolvedRef` pattern (CLI Invariant #1): - // every failure path from here on, including branch/UUID resolution, - // stays inside this file's single `Effect.ensuring`-wrapped block below. + // Set once ref resolution succeeds, so the linked-project cache write below only fires for + // invocations that got that far. let resolvedRef: string | undefined; yield* Effect.gen(function* () { - // 0. The resolved `--workdir`/`SUPABASE_WORKDIR` must exist and be a - // directory before anything else touches it. The project-root probe, - // project-env load, and private-key collection immediately below used to - // run BEFORE this check, in the outer function body — harmless for a - // missing directory (`loadProjectEnv` tolerates `NotFound`), but not - // for a `--workdir` that names a regular FILE: `loadProjectEnv` - // does not tolerate ENOTDIR, so it surfaced a confusing - // "failed to read environment file: ..." error instead of this one, and - // it did so OUTSIDE the `Effect.ensuring(telemetryState.flush)` wrapper - // below. Moved here so both failure shapes are caught by the same check, - // and telemetry flushes for either one. + // 0. The resolved `--workdir`/`SUPABASE_WORKDIR` must exist and be a directory before + // anything else touches it: a workdir naming a regular file makes `loadProjectEnv` throw + // ENOTDIR with a confusing "failed to read environment file" error instead of this one. yield* validateWorkdirIsDirectory(cliSettings.workdir, fs).pipe( Effect.mapError((error) => new ConfigPushWorkdirError({ message: error.message })), ); - // `--yes` OR `SUPABASE_YES`. `config push` imports `supabase/.env` before - // the confirmation prompt reads the yes flag, so a `SUPABASE_YES` set only - // in `supabase/.env` auto-confirms. Resolve against the project env, not - // just the flag + shell env. Load it from the resolved project root - // (climbing only when `cliSettings.workdir` was defaulted, same as - // `loadCliConfig` below — an explicit `--workdir`/`SUPABASE_WORKDIR` is - // authoritative and never climbs, see `shouldSearchAncestors`), so a - // push from a subdirectory of a defaulted workdir still reads the project - // root's `supabase/.env`. - // Resolved against `cliSettings.workdir` — the same root the project-ref - // resolver and the linked-project cache use — so `--workdir ../other` - // pushes `../other`'s config.toml, never the invoking directory's file to - // another root's linked project. + // `--yes`/`SUPABASE_YES` resolves against the project env (not just the flag + shell env), so + // a `SUPABASE_YES` set only in `supabase/.env` auto-confirms. The project root climbs to find + // it only when `--workdir` was defaulted; an explicit `--workdir ../other` pushes that + // directory's own config.toml without climbing to another root's linked project. const projectRoot = (yield* findCliProjectRoot(cliSettings.workdir, { search: shouldSearchAncestors(cliSettings), })) ?? cliSettings.workdir; const projectEnv = yield* loadProjectEnv(fs, path, projectRoot); const yes = yield* resolveYesWithProjectEnv(projectEnv); - // dotenvx private keys for decrypting `encrypted:` secrets, from the shell - // + project env — same source/precedence as `db-config.toml-read.ts` - // (`process.env` wins over `supabase/.env`). + // dotenvx private keys for decrypting `encrypted:` secrets, from the shell + project env; + // `process.env` wins over `supabase/.env`, matching `db-config.toml-read.ts`. const dotenvPrivateKeys = collectDotenvPrivateKeys({ ...projectEnv, ...process.env }); - // Only reached by `assertDecryptableSecrets` below for an `env(VAR)` literal that - // survives `loaded.document`'s own (`@supabase/config`) interpolation pass unresolved — i.e. - // when this wider env source resolves `VAR` but `@supabase/config`'s - // narrower one (`supabase/.env`/`.env.local` only) didn't. Practically - // unreachable in the same narrow way the CLI-1489 comment below already documents for - // non-secret fields; kept for parity with the shared function's other caller - // (`db-config.toml-read.ts`, whose pre-interpolation document relies on this). + // Reached only when an `env(VAR)` literal survives `@supabase/config`'s own (narrower) + // interpolation pass unresolved but this wider shell+project-env lookup can still resolve it. const secretEnvLookup = (name: string): string | undefined => process.env[name] ?? projectEnv[name]; - // 0.5. An explicit `--workdir`/`SUPABASE_WORKDIR` that holds no project - // fails HERE, before target resolution burns a branch-name/UUID lookup's - // network round trip — a pure `fs.exists` probe with no schema decode, - // so it does not touch the "only ONE decode may ever run" invariant step - // 2 below relies on. A DEFAULTED workdir is untouched (today, `config - // push` in a config-less directory with no linked project fails with the - // not-linked error from step 1, not a config error) — deliberately kept, - // since making this check unconditional would be an established-behavior - // change outside this fix's scope. Message is identical to the step-2 - // `loaded === null` branch below (same builder), so the user-visible - // failure text is unchanged, only earlier in time. + // 0.5. An explicit `--workdir`/`SUPABASE_WORKDIR` with no project fails here, before a + // branch-name/UUID lookup burns a network round trip. A defaulted workdir is untouched: in a + // config-less directory with no linked project, it instead fails with step 1's not-linked + // error. Uses the same error message builder as the step-2 `loaded === null` branch. yield* requireExplicitWorkdirProject(cliSettings).pipe( Effect.mapError((error) => new ConfigPushLoadConfigError({ message: error.message })), ); - // 1. Resolve the push target. `--project-ref` accepts a project ref, or - // the name (or UUID) of a branch of the linked project (CLI-2167/CLI-2289) — - // `resolveConfigTarget` (`command-internal/project-target.ts`, Hoist Before You - // Duplicate, shared with `config diff`/`config pull`). This is ALSO - // where `resolvedRef` is set, so every one of the shared resolver's - // failure paths (not linked, invalid parent, not found, not ready, - // network/status) still flushes telemetry and, once a ref is known, - // writes the linked-project cache. + // 1. Resolve the push target. `resolvedRef` is set here so every failure path from the shared + // resolver still flushes telemetry and, once a ref is known, writes the linked-project cache. // - // Deliberately runs BEFORE the config load below: a `[remotes.<name>]` - // overlay is merged INSIDE `loadCliConfig` itself (driven by - // `projectRef`) before its one full schema decode, and only ONE decode - // may ever run — a base document that's schema-invalid without its - // overlay must never be evaluated on its own, or a config that's only - // valid once the matching remote applies would be wrongly rejected. A - // branch name/UUID resolution may therefore cost a network round trip - // before a malformed `config.toml` is caught — an accepted, narrow - // tradeoff (matches this command's own pre-CLI-2168 behavior, which - // always resolved before loading). + // Runs before the config load below: a `[remotes.<name>]` overlay is merged inside + // `loadCliConfig` before its one schema decode, so a base document that's invalid without its + // overlay must never be decoded on its own — this can cost a network round trip before a + // malformed `config.toml` is caught. const { ref, branch } = yield* resolveConfigTarget( requestedRef, configTargetErrors, @@ -263,22 +214,12 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF ); resolvedRef = ref; - // 2. Load config.toml with the resolved ref (TOML parse error aborts - // before any network call). A matching `[remotes.<name>]` block's - // overlay is merged before decode in the SAME call — see the note - // above. + // 2. Load config.toml with the resolved ref (a TOML parse error aborts before any network + // call); a matching `[remotes.<name>]` overlay merges before decode in the same call. // - // NOTE (CLI-1489): `config push` needs the fully decoded config (every - // service subset), so it uses `loadLocalConfig` (`../config.load.ts`, - // shared with `config diff`/`config pull`) rather than the tolerant - // `db-config.toml-read.ts` subtree reader. The underlying - // `loadCliConfig` raises `CliConfigParseError` on `env(...)` refs over - // numeric/bool fields; `loadLocalConfig` catches it (and a - // duplicate-remote/missing-file failure) and converts it to this - // family's own tagged error via the shared message shapes — including - // the ancestor-search decision (`shouldSearchAncestors`), so an - // explicit `--workdir`/`SUPABASE_WORKDIR` with no project here never - // silently falls back to an ancestor project's config (CLI-2285). + // Uses `loadLocalConfig` (needs the fully decoded config) rather than the tolerant + // `db-config.toml-read.ts` subtree reader, converting its parse/duplicate-remote/missing-file + // failures into this family's own tagged error. const loaded = yield* loadLocalConfig( cliSettings, ref, @@ -293,24 +234,12 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF } const config = loaded.config; - // 3. Assert every `config.Secret`-typed `encrypted:` value in the - // document (not just auth.*) can be decrypted — this must run before the - // cost matrix is fetched or any service is touched. An undecryptable - // secret anywhere in the document (even one `config push` never itself - // pushes, e.g. `studio.openai_api_key`) aborts here with a - // `failed to parse config: <cause>` message, before any remote service - // is read or updated. + // 3. Assert every `encrypted:` value in the document can be decrypted, even fields `config + // push` never itself pushes — this must run before the cost matrix or any service is touched. // - // `loaded.document` has already had deprecated - // `auth.external.{linkedin,slack}` blocks stripped by `@supabase/config` - // (`normalizeDeprecatedExternalProviders`), but the decrypt hook runs at - // decode time — before the later `external.validate()` deletes those - // blocks — so an `encrypted:` secret hiding in one of them still aborts - // the load. Fold `removedDeprecatedExternalProviders` back into a - // synthetic `auth.external` view and scan that too, reusing the same - // path list rather than a second scanner. `loadCliConfig` always - // populates this field with a (possibly empty) record — never - // `undefined` — so no fallback is needed here. + // Deprecated `auth.external.{linkedin,slack}` blocks are stripped from `loaded.document` + // before this decode, so scan `removedDeprecatedExternalProviders` too, or a secret hiding in + // one of them would skip the check. const secretError = assertDecryptableSecrets(loaded.document, secretEnvLookup, dotenvPrivateKeys) ?? assertDecryptableSecrets( @@ -325,14 +254,10 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF // Config lives at <projectRoot>/supabase/config.{toml,json}. const configProjectRoot = dirname(dirname(loaded.path)); - // 4. Email content validation runs during config load, before any network - // call. Unconditional regardless of `config.auth.enabled` (CLI-2314, - // review round): that flag is the local-only GoTrue Docker toggle and no - // longer gates whether the `auth` resource is pushed (`push.plan.ts`'s - // `pushResourceEnabled`) — gating this load the same way it used - // to would silently push empty template/notification content over a - // real hosted customization whenever `auth.enabled = false`, even though - // every other declared `auth.*` field is pushed normally in that case. + // 4. Email content validation runs during config load, before any network call, and is + // unconditional regardless of `config.auth.enabled` — that flag only toggles the local GoTrue + // Docker service and doesn't gate whether `auth` is pushed, so gating this load too would + // silently push empty content over a real hosted customization. const authEmailContent = yield* Effect.try({ try: () => loadAuthEmailContent(configProjectRoot, config.auth.email), catch: (cause) => @@ -341,26 +266,14 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF }), }); - // 5. Determine the push target (plain project vs. branch vs. unknown) - // and, for a CONFIRMED branch, gate the push behind an explicit - // confirmation before any further network call — including the cost - // matrix below (CLI-2168). A target resolved from an EXPLICIT - // `--project-ref <name-or-uuid>` this invocation (`branch`, from the - // shared resolver above) skips the prompt: the user already expressed - // same-invocation intent, so re-confirming the exact string they just - // typed is friction with no safety benefit. The target-echo line below - // always prints regardless of kind. + // 5. Determine the push target and, for a confirmed branch, gate the push behind an explicit + // confirmation before any further network call. A target resolved from an explicit + // `--project-ref <name-or-uuid>` this invocation skips the prompt, since the user already + // expressed same-invocation intent. // - // `resolveConfigTarget` (shared with `config diff`/`config pull`, - // neither of which needs a branch's PARENT ref) returns only the raw - // `branch` string the user named, not its resolved parent. A UUID - // target genuinely has no parent to give (`GET /v1/branches/{id}` - // resolves it alone) — `{kind: "uuid"}`. A NAME target's parent WAS - // resolved internally to look it up, just not returned; re-deriving it - // here via `resolveLinkedParentRef()` is a second LOCAL-ONLY read - // (env/cache/file, no network) of the exact same chain that just - // resolved moments ago, so it can only disagree if something rewrote - // the linked state mid-command — safe to treat as unreachable. + // `resolveConfigTarget` returns only the raw branch name, not its resolved parent, so a name + // target's parent is re-derived here via a second local-only read (env/cache/file, no + // network) of the same chain that just resolved. let knownBranch: ConfigPushKnownBranch | undefined; if (branch !== undefined) { if (BRANCH_UUID_PATTERN.test(branch)) { @@ -380,11 +293,8 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF output, yes, configPushBranchPromptLabel(target), - // Deliberately `false` (unlike this file's other prompts, which - // default `true`): an unattended run (CI, an agent, a script) - // without `--yes` must safely decline a branch mutation rather than - // silently proceed. `--yes`/`SUPABASE_YES` (`yes`, resolved above) - // is the intended override. + // Defaults `false`, unlike this file's other prompts: an unattended run without `--yes` + // must decline a branch mutation rather than silently proceed. false, ); if (!proceed) { @@ -398,9 +308,7 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF // 6. Cost matrix (drives cost-aware prompts). const cost = yield* getCostMatrix(ref); - // keep(name): the shared confirmation-prompt helper handles all modes, - // including scanning piped stdin on a non-TTY before falling back to - // the default. + // `promptYesNo` scans piped stdin on a non-TTY before falling back to the default. const keep = (name: string) => Effect.gen(function* () { const item = cost.get(name); @@ -411,9 +319,8 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF return yield* promptYesNo(output, yes, title, true); }); - // 7. Read the project's effective configuration once — replaces the - // former five per-service `GET /v1/...` calls. No spinner (matches the - // rest of this command's stderr progress lines). + // 7. Read the project's effective configuration in one call. No spinner, matching the rest + // of this command's stderr progress lines. const response = yield* api.executeRaw(operationDefinitions.v2GetProjectConfig, { ref }).pipe( Effect.mapError( (cause) => @@ -439,10 +346,8 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF }), ), ); - // A 200 response whose body isn't even a JSON object is an API-response - // problem, not something `fromApiProjectConfig` should have to reject - // via its own typed error — checked once, up front, so every read below - // can index `responseJson` directly. + // A non-object body is an API-response problem, not something `fromApiProjectConfig` should + // reject via its own typed error — checked once so reads below can index directly. if (!isRecord(responseJson)) { return yield* new ConfigPushConfigReadNetworkError({ message: "failed to read project config: response body is not a JSON object", @@ -450,11 +355,9 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF }); } - // 8. Convert the response and classify against the local projection. - // A response the registry cannot narrow, or a local document it cannot - // canonicalize, is a typed `ProjectConfigParseError`; anything else is a - // defect (`configProjectConfigTry`, shared with `config - // diff`/`config pull`). + // 8. Convert the response and classify against the local projection. A response the registry + // cannot narrow, or a local document it cannot canonicalize, is a typed + // `ProjectConfigParseError`; anything else is a defect. const remote = yield* configProjectConfigTry(() => fromApiProjectConfig(responseJson)); const data = responseJson["data"]; @@ -478,10 +381,8 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF // 9. Route pushable changes to their v1 write endpoint and resolve every // declared secret's send/unchanged/not_set/gated status. const plan = planConfigPush(changeSet); - // Defensive: the document-wide decrypt-or-abort pre-check above (step 3) - // is expected to make this unreachable — kept as a typed failure, in the - // same `failed to parse config: <cause>` shape, rather than an uncaught - // throw, in case that invariant is ever violated (see push.secret.ts). + // Defensive: step 3's decrypt-or-abort check is expected to make this unreachable, but stays + // a typed failure rather than an uncaught throw in case that invariant is ever violated. const secrets = yield* Effect.try({ try: () => resolveAuthSecrets({ @@ -499,9 +400,8 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF }); const now = new Date(yield* Clock.currentTimeMillis); - // Whether each resource's local gate is on — computed once, both for the - // resource loop below and for excluding a gated-off resource's own - // `unmanaged` entries from the summary note (D5). + // Whether each resource's local gate is on, computed once for the resource loop below and + // for excluding a gated-off resource's own `unmanaged` entries from the summary note. const resourceEnabled: Readonly<Record<PushResource, boolean>> = { api: pushResourceEnabled("api", config, local), "db.settings": pushResourceEnabled("db.settings", config, local), @@ -519,11 +419,9 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF let authWriteRan = false; let secretsSent: ReadonlyArray<ReadonlyArray<string>> = []; - // 10. Prints the resource's `Updating ... with config:` block (or the - // up-to-date/not-pushable line), prompts, writes, and returns the - // service's result. `secretsForResource` is the resource's full - // (unfiltered) secret-decision list — `pushUpdatingLine` renders - // only `send`/`not_set` entries, so non-auth resources simply pass `[]`. + // 10. Prints the resource's `Updating ...`/up-to-date/not-pushable line, prompts, writes, and + // returns the result. `secretsForResource` is the resource's full secret-decision list; only + // `auth` needs to pass anything besides `[]`. function applyResource<Body, E, R>( resource: PushResource, changes: ReadonlyArray<ConfigChange>, @@ -570,25 +468,11 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF }); } - // 11. Six resources, in the established push order. A resource whose - // response block was omitted from the read is `unavailable` — nothing is - // compared, nothing is written (S5/D2); the `Comparison scope:` line - // above already explains why. Otherwise, a gated-off resource is - // `disabled` — today (CLI-2314) this can fire for two of the six: - // `db.network_restrictions`, whose own `enabled` flag is a genuine - // hosted-side management opt-out (the projection's disabled-sentinel - // prune still removes its `allowed_cidrs`/`allowed_cidrs_v6` siblings - // before diffing, so `plan.changesByResource` never has anything left to - // route to it while the flag is off); and `db.ssl_enforcement`, gated on - // simple presence (`pushResourceEnabled`'s - // `local.db?.ssl_enforcement !== undefined` case) rather than a decoded - // `enabled` value — the stock `supabase init` template ships this block - // commented out, so a fresh project hits `disabled` here by default. - // `auth` and `storage` always return `true` from - // `pushResourceEnabled` now — their local `enabled` toggle only - // controls a Docker service the Management API has no concept of, so - // this branch can no longer fire for either — and a gated-on resource - // dispatches to its own encoder/write pair. + // 11. Six resources, in the established push order. A missing response block makes a + // resource `unavailable` (nothing compared or written); a gated-off local `enabled` flag + // makes it `disabled` — this can only happen for `db.network_restrictions` and + // `db.ssl_enforcement`, since `auth`/`storage`'s local `enabled` toggle only controls a + // Docker service the Management API has no concept of. for (const resource of PUSH_RESOURCES) { if (scope.missing.includes(pushResponseBlock(resource))) { services.push({ service: resource, status: "unavailable", changes: [] }); @@ -687,10 +571,8 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF break; } case "auth": { - // MFA addon cost filter runs before anything about auth is - // printed: a declined paid addon carries an explicit disable when - // the remote currently has it on, or is simply dropped otherwise - // (`applyMfaAddonDecline`, D12). + // MFA addon cost filter runs before anything about auth is printed: a declined paid + // addon carries an explicit disable when the remote currently has it on, or is dropped. let changes = plan.changesByResource.auth; for (const gate of PUSH_ADDON_GATES) { if (pushAddonPromptNeeded(changes, gate, remote) && !(yield* keep(gate.costKey))) { @@ -770,8 +652,8 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF services.push({ service: "experimental.webhooks", status: "updated", - // No registry-comparable path backs this write, but the summary's property - // count (and a JSON consumer inspecting `changes`) must still see it (finding 5). + // No registry path backs this write, but the summary's property count (and a JSON + // consumer inspecting `changes`) must still see it. changes: [["experimental", "webhooks", "enabled"]], }); } else { @@ -779,19 +661,15 @@ export const configPush = Effect.fn("config.push")(function* (flags: ConfigPushF } } - // 12. Notes (stderr, after the resource loop) — declared properties with - // no API field, declared-but-unencodable properties, declared-but- - // unmanaged properties (count only, excluding a gated-off resource's own - // entries), forced companion defaults, empty/unresolved credentials, and - // the hands-off remote-only count. + // 12. Notes (stderr, after the resource loop): unsupported, unencodable, and unmanaged + // (count only, excluding a gated-off resource's own entries) declared properties, forced + // companion defaults, empty/unresolved credentials, and the hands-off remote-only count. const unmanagedCount = changeSet.unmanaged.filter((changePath) => { const resourceForPath = pushResourceForPath(changePath); return resourceForPath === "unsupported" || resourceEnabled[resourceForPath]; }).length; - // An `unavailable` auth resource never read (or wrote) any credential — - // the "not pushed" framing this note carries is specific to a credential - // whose OWN value was empty/unresolved, which doesn't apply when the - // whole resource was never compared to begin with. + // An `unavailable` auth resource never read any credential, so the "not pushed" framing + // below doesn't apply — that's specific to a credential whose own value was unresolved. const authUnavailable = services.find((service) => service.service === "auth")?.status === "unavailable"; const notes = pushNotes({ diff --git a/apps/cli/src/commands/config/push/push.integration.test.ts b/apps/cli/src/commands/config/push/push.integration.test.ts index f883a1a6e9..33343dc1af 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -46,7 +46,8 @@ function writeConfig(toml: string): void { /** The shared v2 project-config fixture (schema-default baseline) — see `config-fixtures.ts`. */ const v2Response = v2ProjectConfigResponse; -/** Digest a plaintext exactly the way `resolveAuthSecrets` compares against — for building a remote response whose digest matches (or deliberately mismatches) a local secret value. */ +/** Digests a plaintext the same way `resolveAuthSecrets` compares against, for building a remote + * response whose digest matches or mismatches a local secret value. */ function digestOf(plaintext: string): string { const digest = secretDigestHex(REF, plaintext, []); if (digest === undefined) { @@ -55,13 +56,13 @@ function digestOf(plaintext: string): string { return digest; } -// Shared test vector — same one `vault-decrypt.unit.test.ts` and -// `push.secret.unit.test.ts` use. Decrypts to the plaintext "value". +// Shared test vector (also used by `vault-decrypt.unit.test.ts`/`push.secret.unit.test.ts`); +// decrypts to the plaintext "value". const DOTENVX_PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; const DOTENVX_ENCRYPTED_VALUE = "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; -/** Save/restore `DOTENV_PRIVATE_KEY` around a test — mirrors the SUPABASE_YES pattern below. */ +/** Save/restore `DOTENV_PRIVATE_KEY` around a test. */ function withDotenvPrivateKey<A, E, R>( value: string | undefined, effect: Effect.Effect<A, E, R>, @@ -79,10 +80,8 @@ function withDotenvPrivateKey<A, E, R>( ); } -// Schema-valid response for the postgrest PATCH when it's routed through the -// real typed client (`setup()` below) — same shape `V1UpdatePostgrestServiceConfigOutput` -// requires for its own GET counterpart; the response body itself is never -// asserted on, only its schema-validity. +// Schema-valid response for the postgrest PATCH when routed through the real typed client +// (`setup()` below); the body itself is never asserted on, only its schema-validity. const POSTGREST_WRITE_RESPONSE = { db_schema: "", db_extra_search_path: "", @@ -118,10 +117,9 @@ function authWriteResponseFixture(): Record<string, unknown> { return fixture; } -// CLI-2168/CLI-2289 branch-target fixtures — every ref below is exactly 20 -// lowercase letters (`BRANCH_PROJECT_REF_PATTERN`), distinct from -// `REF` and from each other, so the same test file can model a branch, its -// parent, and an unrelated project simultaneously. +// Branch-target fixtures — every ref below is exactly 20 lowercase letters +// (`BRANCH_PROJECT_REF_PATTERN`), distinct from `REF` and from each other, so this file can model +// a branch, its parent, and an unrelated project simultaneously. const BRANCH_REF = "cccccccccccccccccccc"; const PARENT_REF = "pppppppppppppppppppp"; const OTHER_PARENT_REF = "qqqqqqqqqqqqqqqqqqqq"; @@ -130,13 +128,10 @@ const UUID_TARGET_REF = "rrrrrrrrrrrrrrrrrrrr"; const BRANCH_UUID = "11111111-1111-4111-8111-111111111111"; /** - * Schema-valid `V1GetProjectOutput` fixture (CLI-2168's live target-detection - * probe). Every existing (plain-project) scenario relies on this being the - * DEFAULT `project` route response with an EMPTY `name` — `normalizeApiName` - * folds that to `undefined`, so the target-echo line degrades to the - * pre-CLI-2168 bare `Pushing config to project: <ref>\n` text those - * scenarios already pin. Dedicated CLI-2168 tests below override `name` - * to prove the named-project path separately. + * Schema-valid `V1GetProjectOutput` fixture for the live target-detection probe. The default + * `project` route response has an empty `name`, which `normalizeApiName` folds to `undefined`, so + * the target-echo line degrades to the bare `Pushing config to project: <ref>\n` text most + * scenarios pin. Tests below override `name` to exercise the named-project path. */ const PUSH_TEST_PROJECT = { id: REF, @@ -170,7 +165,7 @@ const BRANCH_LIST_ITEM = { with_data: false, }; -/** `V1GetABranch` body for a branch-name `--project-ref` lookup (CLI-2289). */ +/** `V1GetABranch` body for a branch-name `--project-ref` lookup. */ const BRANCH_BY_NAME = { id: BRANCH_UUID, name: "staging", @@ -184,7 +179,7 @@ const BRANCH_BY_NAME = { with_data: false, }; -/** `V1GetABranchConfig` body for a UUID `--project-ref` lookup (CLI-2289). */ +/** `V1GetABranchConfig` body for a UUID `--project-ref` lookup. */ const BRANCH_CONFIG = { ref: UUID_TARGET_REF, postgres_version: "15", @@ -208,12 +203,10 @@ function writeLinkedProjectCacheFile(json: Record<string, unknown>): void { } /** - * Real-client setup, routed by URL — for scenarios whose only writes are - * `api` (PATCH /postgrest) and `db.settings` (PUT /config/database/postgres), - * the two update endpoints whose response schema is simple enough to satisfy - * without a hand-decoded ~200-field record. The v2 project-config read - * (`GET /v2/projects/{ref}/config`) always goes through this same mock, via - * `executeRaw` hitting the real URL. + * Real-client setup, routed by URL — for scenarios writing only `api` (PATCH /postgrest) and + * `db.settings` (PUT /config/database/postgres), whose response schemas are simple enough to + * satisfy without hand-decoding a ~200-field record. The v2 project-config read always goes + * through this same mock. */ function setup(opts: { readonly toml: string; @@ -224,7 +217,7 @@ function setup(opts: { /** `V1UpdateStorageConfigOutput` is `Schema.Void` — the response body is never decoded, so any status/body pair proves the point. */ readonly storagePatch?: { status: number; body: unknown } | "fail"; /** Defaults to a schema-valid `authWriteResponseFixture()` — override the body/status to - * exercise a failure, while still validating the auth PATCH's REQUEST body through the real + * exercise a failure, while still validating the auth PATCH's request body through the real * typed client (`V1UpdateAuthServiceConfigInput`). */ readonly authPatch?: { status: number; body: unknown } | "fail"; readonly format?: "text" | "json" | "stream-json"; @@ -243,22 +236,18 @@ function setup(opts: { readonly explicitWorkdir?: boolean; /** Analytics mock for tests asserting on captured telemetry events. */ readonly analytics?: ReturnType<typeof mockAnalytics>; - // CLI-2168/CLI-2289 — live target-detection probe and branch-name/UUID - // resolution. Defaults keep every existing (plain-project) scenario - // working without opting in: the project probe succeeds with an unnamed - // project, and the branch-lookup endpoints degrade to "not found"/empty - // rather than hanging or decode-erroring. `"fail"` simulates a transport - // failure (distinct from an explicit status code) for the hard-failure - // scenarios. + // Live target-detection probe and branch-name/UUID resolution. Defaults keep every + // plain-project scenario working without opting in: the project probe succeeds with an + // unnamed project, and branch-lookup endpoints degrade to "not found"/empty rather than + // hanging or decode-erroring. `"fail"` simulates a transport failure, distinct from a status. readonly project?: { status: number; body: unknown } | "fail"; readonly branchList?: { status: number; body: unknown }; readonly branchByName?: { status: number; body: unknown } | "fail"; readonly branchById?: { status: number; body: unknown }; /** - * `cliSettings.projectId` override — defaults to `Option.some(REF)`. - * CLI-2168/CLI-2289 scenarios pass `Option.none()` so ref resolution falls - * through to the `.temp/project-ref` file, or `Option.some(<ref>)` to model - * an env override distinct from any linked-state files. + * `cliSettings.projectId` override — defaults to `Option.some(REF)`. Pass `Option.none()` so + * ref resolution falls through to the `.temp/project-ref` file, or `Option.some(<ref>)` to + * model an env override distinct from any linked-state files. */ readonly projectId?: Option.Option<string>; }) { @@ -322,9 +311,8 @@ function setup(opts: { return Effect.succeed(jsonResponse(request, p.status, p.body)); } const pathname = new URL(url).pathname; - // CLI-2168's live target-detection probe: a bare project ref defaults - // to a schema-valid, unnamed project, so every existing (plain-project) - // scenario keeps working without opting in. + // Live target-detection probe: a bare project ref defaults to a schema-valid, unnamed + // project, so plain-project scenarios keep working without opting in. if (/^\/v1\/projects\/[a-z0-9-]+$/.test(pathname)) { if (opts.project === "fail") { return Effect.fail(transportFailure(request)); @@ -332,8 +320,8 @@ function setup(opts: { const p = opts.project ?? { status: 200, body: PUSH_TEST_PROJECT }; return Effect.succeed(jsonResponse(request, p.status, p.body)); } - // CLI-2289's branch resolution + the best-effort branch-name lookup — - // defaults degrade to "not found"/empty rather than hanging. + // Branch resolution + the best-effort branch-name lookup — defaults degrade to + // "not found"/empty rather than hanging. if (/^\/v1\/projects\/[a-z0-9-]+\/branches$/.test(pathname)) { const b = opts.branchList ?? { status: 200, body: [] }; return Effect.succeed(jsonResponse(request, b.status, b.body)); @@ -349,10 +337,8 @@ function setup(opts: { const b = opts.branchById ?? { status: 404, body: {} }; return Effect.succeed(jsonResponse(request, b.status, b.body)); } - // Anything else (network-restrictions/ssl/webhooks) — succeed with an - // empty body; scenarios that write to one of those use `setupService()` - // below instead (their typed responses have too many required fields - // to hand-author). + // Anything else (network-restrictions/ssl/webhooks) — succeed with an empty body; + // scenarios writing to one of those use `setupService()` instead. return Effect.succeed(jsonResponse(request, 200, {})); }, }); @@ -384,11 +370,8 @@ function setup(opts: { describe("config push integration", () => { it.live("pushes local config (text) and surfaces a PATCH failure", () => { - // Regression test for the encoder's sparse body: `encodeApiBody` - // omits every unchanged key entirely (no `undefined`-valued keys), so - // this now goes through the REAL typed client — a body carrying only - // `max_rows` must still clear `V1UpdatePostgrestServiceConfigInput`'s - // schema before the mocked 500 status is even reached. + // Uses the real typed client: the sparse body (`encodeApiBody` omits unchanged keys) must + // clear `V1UpdatePostgrestServiceConfigInput`'s schema before the mocked 500 is reached. const { layer, out } = setup({ toml: `project_id = "test"\n[api]\nmax_rows = 2000\n`, yes: true, @@ -529,8 +512,7 @@ max_rows = 1000 }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - // D13: the scope line prints on every run, not just when a block is - // missing (family consistency with `config diff`/`config pull`). + // The scope line prints on every run, not just when a block is missing. expect(out.stderrText).toContain( "Comparison scope: api, auth, database, pooler, realtime, storage", ); @@ -549,10 +531,6 @@ max_rows = 1000 return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); expect(out.stderrText).toContain("Updating API service with config:"); - // push.types.ts: a `skipped` service's `changes` still carries what the - // declined write would have communicated — visible here as the - // per-property block the confirmation prompt printed before the - // decline (`api.max_rows`, the only routed change this run). expect(out.stderrText).toContain("api.max_rows [update]"); expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( false, @@ -560,11 +538,8 @@ max_rows = 1000 }).pipe(Effect.provide(layer)); }); - // The next several tests exercise prompt/env-resolution behavior, not api - // body sparseness, but perform a real api write through the REAL typed - // client (`setup()`) — the encoder's sparse body must clear - // `V1UpdatePostgrestServiceConfigInput`'s schema on every one of these - // paths, not just the happy-path test above. + // These tests exercise prompt/env-resolution behavior but still write through the real typed + // client, so the encoder's sparse body must clear the schema on every path here too. it.live("auto-confirms with --yes (echoes the prompt)", () => { const { layer, out } = setup({ @@ -632,8 +607,6 @@ max_rows = 1000 }); it.live("honors SUPABASE_YES set directly in the shell environment", () => { - // Complements the .env-file case above: a shell-exported SUPABASE_YES - // (never written to supabase/.env) auto-confirms too. const prev = process.env["SUPABASE_YES"]; process.env["SUPABASE_YES"] = "true"; const { layer, api } = setup({ @@ -688,12 +661,6 @@ max_rows = 1000 it.live( "does not climb to an ancestor project's config when --workdir names a subdirectory with no config of its own", () => { - // CLI-2285 regression: an explicit --workdir is authoritative and must - // never let `loadCliConfig`/`findCliProjectRoot` climb past it — a - // `config push --workdir ./sub` from a project whose subdirectory has - // no supabase/ of its own must not silently push over an unrelated - // PARENT project's config. The ancestor (tempRoot) genuinely has a - // valid config.toml and the subdirectory genuinely has none. const sub = join(tempRoot.current, "nested", "dir"); mkdirSync(sub, { recursive: true }); const { layer, api, telemetry } = setup({ @@ -708,14 +675,9 @@ max_rows = 1000 const rendered = JSON.stringify(exit); expect(rendered).toContain("ConfigPushLoadConfigError"); expect(rendered).toContain("file not found"); - // An EXPLICIT workdir never gets the ancestor-search-exhausted - // `supabase init` hint — it names the resolved directory instead, and - // points at the flag/env var that must change. expect(rendered).not.toContain("supabase init"); expect(rendered).toContain("--workdir/SUPABASE_WORKDIR"); expect(rendered).toContain(sub); - // A write command failing to load its OWN config must never reach - // any of the config-update endpoints it would otherwise PATCH/PUT. expect(api.requests.some((r) => r.method === "PATCH" || r.method === "PUT")).toBe(false); expect(api.requests).toHaveLength(0); expect(telemetry.flushed).toBe(true); @@ -724,9 +686,6 @@ max_rows = 1000 ); it.live("a defaulted workdir with no project still points at supabase init", () => { - // Complements the regression above: the message text for a DEFAULTED - // workdir must keep pointing at `supabase init` — only an EXPLICIT - // `--workdir`/`SUPABASE_WORKDIR` gets the resolved-path wording. const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ handler: (request) => Effect.succeed(jsonResponse(request, 200, { available_addons: [] })), @@ -754,9 +713,6 @@ max_rows = 1000 it.live( "an explicit --workdir naming a directory that does not exist at all fails before target resolution", () => { - // Distinct from the "exists but holds no project" regression above: - // this path was never created, so `validateWorkdirIsDirectory` - // must fail first, before target resolution or the config load. const missing = join(tempRoot.current, "does-not-exist"); const { layer, api } = setup({ toml: `project_id = "test"\n`, @@ -778,15 +734,6 @@ max_rows = 1000 it.live( "an explicit --workdir naming a regular file fails with the workdir error, not a confusing env-file error", () => { - // Sibling of the "does not exist" regression above: this path DOES - // exist, but as a plain file rather than a directory. Before this fix, - // the prologue reads (project-root probe, `supabase/.env` load, - // dotenvx private-key collection) ran BEFORE `validateWorkdirIsDirectory`, - // in the outer function body — outside the `Effect.ensuring(telemetryState.flush)` - // wrapper below. `loadProjectEnv` does not tolerate ENOTDIR, so a - // `--workdir` naming a file surfaced a confusing "failed to read - // environment file: ..." error instead of `ConfigPushWorkdirError`, - // and telemetry never flushed for it. Both must be fixed now. const notADirectory = join(tempRoot.current, "not-a-directory"); writeFileSync(notADirectory, ""); const { layer, api, telemetry } = setup({ @@ -888,9 +835,8 @@ max_rows = 1000 }); it.live("sends only the changed api key, leaving an undeclared leaf hands-off", () => { - // Regression test for the encoder's sparse body: only `schemas`/`db_schema` - // genuinely changes here, and that lone key must still clear - // `V1UpdatePostgrestServiceConfigInput`'s schema through the real client. + // Uses the real typed client: only `schemas`/`db_schema` changes here, and that lone key + // must still clear `V1UpdatePostgrestServiceConfigInput`'s schema. const { layer, api, out } = setup({ toml: `project_id = "test"\n[api]\nschemas = ["public", "graphql_public", "custom_schema"]\n`, format: "json", @@ -919,8 +865,6 @@ max_rows = 1000 status: "updated", changes: [["api", "schemas"]], }); - // The undeclared, differing `max_rows` is hands-off — reported as - // remote-only, never routed to a resource. expect(data["remote_only"]).toBe(1); }).pipe(Effect.provide(layer)); }); @@ -964,11 +908,9 @@ statement_timeout = "8s" it.live( "pushes site_url, sessions.timebox, mfa.phone.max_frequency, and password_requirements through the REAL typed client", () => { - // Regression test for the auth encoder's mapped value types: the REAL - // typed client validates the request against - // `V1UpdateAuthServiceConfigInput` before sending, so this pins - // `sessions_timebox`/`mfa_phone_max_frequency` as numbers (not the - // declared duration strings) alongside the plain string-mapped fields. + // The real typed client validates against `V1UpdateAuthServiceConfigInput`, pinning + // `sessions_timebox`/`mfa_phone_max_frequency` as numbers, not the declared duration + // strings. const toml = `project_id = "test" [auth] site_url = "https://example.com" @@ -997,11 +939,8 @@ max_frequency = "10s" ); it.live("pushes sms.otp_expiry as sms_otp_exp through the REAL typed client", () => { - // Regression test for the CLI-2316 auth-encoder leaf added alongside the - // new `auth.sms.otp_length`/`otp_expiry` schema fields: the v2 remote - // reports the platform default (`sms_otp_exp: 60`, from - // `v2ProjectConfigResponse`), so only the declared local override - // should ship. + // The v2 remote reports the platform default (`sms_otp_exp: 60`, from + // `v2ProjectConfigResponse`), so only the declared local override should ship. const toml = `project_id = "test" [auth.sms] otp_expiry = 120 @@ -1112,11 +1051,8 @@ otp_expiry = 120 it.live( "aborts with ConfigPushConfigEmptyError when the response carries no block at all (D2)", () => { - // Replaces the old "reports every block missing and pushes nothing" - // expectation: an entirely empty `attributes` means `scope.present` is - // empty, and per D2 the command must never silently treat that as - // "everything is a fresh write" — it aborts before touching any - // resource instead. + // An entirely empty `attributes` means `scope.present` is empty; the command must never + // treat that as "everything is a fresh write" and aborts before touching any resource. const { layer, out, api } = setup({ toml: `project_id = "test"\n`, yes: true, @@ -1141,13 +1077,10 @@ otp_expiry = 120 it.live( "a disabled storage.analytics's declared quota sibling surfaces as unmanaged, not pushed", () => { - // `storage.analytics.enabled` is an ordinary comparable path (CLI-2314) - // and matches the remote here (both `false`), so it produces no - // change. `max_namespaces` is the sibling `DISABLED_SENTINEL_PRUNES` - // still drops from the local projection while the container is - // disabled — declared locally and disagreeing with the remote's - // report, it must surface as unmanaged rather than vanish silently or - // get force-pushed. + // `storage.analytics.enabled` matches the remote (both `false`), so it produces no change. + // `max_namespaces` is still dropped from the local projection by `DISABLED_SENTINEL_PRUNES` + // while the container is disabled, so it must surface as unmanaged, not vanish or get + // force-pushed. const { layer, out, api } = setup({ toml: `project_id = "test"\n[storage.analytics]\nenabled = false\nmax_namespaces = 5\n`, format: "json", @@ -1194,14 +1127,6 @@ otp_expiry = 120 it.live( "a declared auth.oauth_server.enabled is pushed through the auth endpoint, no longer as unmanaged or unsupported", () => { - // Before CLI-2314, `fromConfigDocument` dropped the WHOLE - // `auth.oauth_server` subtree unconditionally, so a declared `enabled` - // never reached `changeSet.changes` and was reported `unmanaged`. A - // later step made `enabled` an ordinary comparable path, but - // `PUSH_UNSUPPORTED_PREFIXES` still routed it to the "no - // Management API field" note (`unsupported`). This is the final step: - // the v1 auth endpoint genuinely accepts `oauth_server_enabled`, so the - // leaf now pushes like any other auth field. const { layer, out, api } = setup({ toml: `project_id = "test"\n[auth.oauth_server]\nenabled = true\n`, format: "json", @@ -1255,18 +1180,10 @@ otp_expiry = 120 }); }); -// --------------------------------------------------------------------------- -// Gated services (auth / db.network_restrictions / db.ssl_enforcement / -// experimental) and secret handling. These mostly use the direct-service -// mock (no response-schema validation) because auth's typed write response -// has ~200 required fields (`V1UpdateAuthServiceConfigOutput`) that no test -// should have to hand-author; a raw HttpClient still serves the cost-matrix -// /billing/addons call, and `raw.v2GetProjectConfig` serves the effective -// config read. Storage's success-path write (`V1UpdateStorageConfigOutput` -// is `Schema.Void`) goes through the REAL client via `setup()` above instead, -// alongside its `api`/`db.settings` siblings — only its own failure-mapping -// test stays on the service mock, matching the other Update-error tests. -// --------------------------------------------------------------------------- +// Gated services (auth / db.network_restrictions / db.ssl_enforcement / experimental) and secret +// handling mostly use the direct-service mock, since auth's typed write response requires ~200 +// fields (`V1UpdateAuthServiceConfigOutput`) no test should have to hand-author. Storage's write +// goes through the real client instead, since its output schema is `Schema.Void`. function addonsHttpLayer( body: unknown = { available_addons: [] }, @@ -1311,10 +1228,9 @@ function setupService(opts: { const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm }); const apiMock = mockCommandPlatformApiService({ v1: { - // CLI-2168's live target-detection probe — defaults to a schema-valid, - // unnamed project so every gated-service scenario keeps working - // without opting in (mirrors `setup()`'s own default above); - // overridable via `opts.v1.getProject`. + // Live target-detection probe — defaults to a schema-valid, unnamed project so every + // gated-service scenario keeps working without opting in; overridable via + // `opts.v1.getProject`. getProject: () => Effect.succeed(PUSH_TEST_PROJECT), ...opts.v1, }, @@ -1459,8 +1375,6 @@ secret = "my-plaintext-secret" const input = update?.input as Record<string, unknown>; expect(input["security_captcha_secret"]).toBe("my-plaintext-secret"); expect(String(input["security_captcha_secret"])).not.toContain("hash:"); - // D6: no remote digest at all renders "(set)" / "(not set)", never the plaintext. - // D7: every block — including the last one before the prompt — ends on a blank line. expect(out.stderrText).toMatch( /\n\nauth\.captcha\.secret \[secret\]\n {2}local: {2}\(set\)\n {2}remote: \(not set\)\n\n/, ); @@ -1515,8 +1429,6 @@ secret = "${DOTENVX_ENCRYPTED_VALUE}" Effect.catchTag("ConfigPushLoadConfigError", (error) => Effect.succeed(error.message)), ); expect(message).toBe("failed to parse config: missing private key"); - // The guard runs during config load, before any network call — not - // even the cost-matrix (list-addons) request that normally runs first. expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)), ); @@ -1526,10 +1438,8 @@ secret = "${DOTENVX_ENCRYPTED_VALUE}" it.live( "aborts on an undecryptable secret config push never itself reads or pushes (CLI-1881)", () => { - // `studio.openai_api_key` is a `config.Secret` field that is still - // decrypted during config load — but no encoder in `push.encoders.ts` - // (api, db, auth, storage) ever reads `studio.*`, so this proves the - // pre-check is genuinely document-wide, not merely reachable via `auth.*`. + // No encoder in `push.encoders.ts` reads `studio.*`, so this proves the pre-check is + // document-wide. const toml = `project_id = "test" [studio] openai_api_key = "${DOTENVX_ENCRYPTED_VALUE}" @@ -1588,10 +1498,8 @@ secret = "${DOTENVX_ENCRYPTED_VALUE}" ); it.live("pushes storage when enabled and changed, with the features container absent", () => { - // Regression test for the encoder's sparse body: the storage encoder - // omits `features` entirely here, so the sparse body must clear - // `V1UpdateStorageConfigInput`'s schema through the REAL client — that - // sparseness is the whole point of the assertion below. + // Uses the real client: the storage encoder omits `features` entirely here, so the sparse + // body must clear `V1UpdateStorageConfigInput`'s schema. const { layer, api } = setup({ toml: `project_id = "test"\n[storage]\nfile_size_limit = "100MiB"\n`, yes: true, @@ -1705,9 +1613,8 @@ allowed_cidrs_v6 = ["::1/128"] it.live( "auth disabled locally with the remote at defaults reports up to date, not disabled (CLI-2314)", () => { - // `auth.enabled = false` only means "don't run local GoTrue" — it no - // longer gates the whole resource. With nothing else declared and the - // remote at schema defaults, there is genuinely no diff to push. + // `auth.enabled = false` means "don't run local GoTrue"; it doesn't gate the resource. With + // nothing else declared and the remote at defaults, there's genuinely no diff to push. const { layer, out, apiMock } = setupService({ toml: `project_id = "test"\n[auth]\nenabled = false\n`, format: "json", @@ -1730,8 +1637,7 @@ allowed_cidrs_v6 = ["::1/128"] it.live( "storage disabled locally with the remote at defaults reports up to date, not disabled (CLI-2314)", () => { - // Same as the auth case above: `storage.enabled = false` is a local - // Docker toggle, not a hosted management opt-out. + // `storage.enabled = false` is a local Docker toggle, not a hosted management opt-out. const { layer, out, apiMock } = setupService({ toml: `project_id = "test"\n[storage]\nenabled = false\n`, format: "json", @@ -1754,9 +1660,8 @@ allowed_cidrs_v6 = ["::1/128"] it.live( "auth disabled locally still pushes an explicitly declared hosted auth change (CLI-2314)", () => { - // Local GoTrue is off, but the user explicitly declared a real hosted - // SMTP setting that differs from the remote's — `config push` must - // still act on it; the resource is no longer gated on `auth.enabled`. + // Local GoTrue is off, but the declared hosted SMTP setting still differs from the + // remote's, so `config push` must act on it. const { layer, out, apiMock } = setupService({ toml: `project_id = "test" [auth] @@ -1804,10 +1709,8 @@ sender_name = "My Project" it.live( "auth disabled locally does not push an undeclared field that merely drifted from default (CLI-2314)", () => { - // Local never mentions captcha at all; the remote reports a real - // provider configured. Undeclared drift surfaces as remote-only - // (informational), never pushed — `auth.enabled = false` plays no role - // in that classification either way. + // Local never mentions captcha at all; the remote reports a provider configured, so this + // drift surfaces as remote-only, unaffected by `auth.enabled`. const { layer, out, apiMock } = setupService({ toml: `project_id = "test"\n[auth]\nenabled = false\n`, format: "json", @@ -1844,16 +1747,10 @@ sender_name = "My Project" it.live( "a v2 response without the data envelope aborts (D2) even though the diff itself would tolerate it", () => { - // `fromApiProjectConfig`'s own envelope-unwrapping (ADR 0019) tolerates - // a bare-attributes response with no `data` wrapper — but - // `push.handler.ts`'s own separate `data`/`attributes` extraction - // (used only for the scope line and the auth-secret comparison) does - // not replicate that fallback, so `scope.present` comes back empty for - // this shape even though the diff itself would have used the real - // values (same duplicated-extraction pattern as `diff.handler.ts`). - // Per D2, an empty `scope.present` is now a hard abort rather than a - // silent "push everything" — so this shape can never reach a write, - // even though the API always sends the `data` envelope in practice. + // `push.handler.ts`'s own `data`/`attributes` extraction (used for the scope line and + // auth-secret comparison) doesn't replicate `fromApiProjectConfig`'s envelope-unwrapping + // fallback, so `scope.present` comes back empty for a bare-attributes response — which is a + // hard abort, not a silent "push everything". const bareAttributes = v2Response().data.attributes; const { layer, out, apiMock } = setupService({ toml: `project_id = "test"\n[api]\nmax_rows = 2000\n`, @@ -1873,8 +1770,6 @@ sender_name = "My Project" }, ); - // -- secrets -------------------------------------------------------------- - it.live("a matching secret digest produces no auth write at all", () => { const toml = `project_id = "test" [auth.captcha] @@ -1945,7 +1840,6 @@ secret = "new-secret" expect(input["external_github_secret"]).toBe("new-secret"); expect(input["security_captcha_enabled"]).toBe(true); expect(input["security_captcha_provider"]).toBe("hcaptcha"); - // D6: a differing remote digest renders "(set — differs)", not a bare "(set)". expect(out.stderrText).toMatch( /\n\nauth\.external\.github\.secret \[secret\]\n {2}local: {2}\(set\)\n {2}remote: \(set — differs\)\n\n/, ); @@ -1970,10 +1864,6 @@ secret = "env(MISSING_CAPTCHA_SECRET)" expect(update).toBeDefined(); const input = update?.input as Record<string, unknown>; expect(input["security_captcha_secret"]).toBeUndefined(); - // D4/D6: disclosed inside the resource block, before the prompt, with - // the "unresolved env reference" wording — and (D7) the block still - // ends on a blank line even though it's the last thing this resource - // prints (the format-json push never echoes the prompt itself). expect(out.stderrText).toContain( "auth.captcha.secret [secret]\n local: (not set — empty or unresolved env reference; will not be pushed)\n remote: (not set)\n\n", ); @@ -1982,7 +1872,6 @@ secret = "env(MISSING_CAPTCHA_SECRET)" ); const success = out.messages.find((m) => m.type === "success"); const data = success?.data as Record<string, unknown>; - // D9: `not_set` (renamed from `not_sent`), and gated secrets have their own bucket. expect(data["secrets"]).toMatchObject({ sent: [], not_set: [["auth", "captcha", "secret"]], @@ -1991,8 +1880,6 @@ secret = "env(MISSING_CAPTCHA_SECRET)" }).pipe(Effect.provide(layer)); }); - // -- MFA cost-aware addon prompts ------------------------------------------ - it.live("declining the phone MFA addon drops only the MFA keys", () => { const toml = `project_id = "test" [auth] @@ -2044,9 +1931,6 @@ enroll_enabled = true it.live( "an enroll-only flip (verify_enabled absent) still prompts, and declining drops the change", () => { - // CLI-2313 (PR #6454 review): before this fix, the gate only looked at - // `verify_enabled` — an `enroll_enabled`-only flip skipped the prompt - // entirely and pushed the paid addon unconfirmed. const toml = `project_id = "test"\n[auth.mfa.phone]\nenroll_enabled = true\n`; const { layer, apiMock, out } = setupService({ toml, @@ -2140,9 +2024,8 @@ enroll_enabled = true }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - // A real-TTY confirm goes through `output.promptConfirm` (clack), which - // the mock resolves silently — it never echoes the label to stderr the - // way the `--yes`/non-TTY paths do, so assert on the recorded call. + // A real-TTY confirm goes through `output.promptConfirm` (clack), which the mock resolves + // silently without echoing to stderr, so assert on the recorded call instead. expect(out.promptConfirmCalls.map((call) => call.message)).toContain( "Enabling Phone MFA will cost you $75.00/ month. Keep it enabled?", ); @@ -2163,8 +2046,6 @@ enroll_enabled = true }).pipe(Effect.provide(layer)); }); - // -- write failures (exercise the remaining Update error mappers) --------- - it.live("a db.settings PUT failure fails the push", () => { const toml = `project_id = "test"\n[db.settings]\neffective_cache_size = "768MB"\n`; const { layer } = setupService({ @@ -2272,11 +2153,6 @@ allowed_cidrs_v6 = ["::/0"] }); }); -// --------------------------------------------------------------------------- -// Fix-pass scenarios (review adjudication rounds — architecture/security/DX). -// Each test cites the decision letter(s) it exercises. -// --------------------------------------------------------------------------- - describe("config push fix-pass scenarios", () => { it.live( "D1: an undeclared allowed_cidrs_v6 keeps the REMOTE v6 list, not a schema default", @@ -2492,8 +2368,6 @@ secret = "super-secret" status: "unavailable", changes: [], }); - // Every declared credential is reported withheld (`skipped`), never `sent` — - // the write never ran, so a "send"-worthy digest never reaches the wire. expect(data["secrets"]).toMatchObject({ sent: [], skipped: [["auth", "captcha", "secret"]], @@ -2503,11 +2377,8 @@ secret = "super-secret" ); it.live("the not-set credential note is suppressed when auth itself is unavailable", () => { - // Same missing-auth-block shape as the D2/S5 test above, but with a - // declared credential that would otherwise be reported `not_set` - // (empty/unresolved `env(...)`) rather than `send` — the note is - // specific to a credential whose OWN value was empty/unresolved, which - // doesn't apply when the whole resource was never compared. + // Same missing-auth-block shape as the D2/S5 test above, but with a credential that would + // otherwise be reported `not_set` rather than `send`. const toml = `project_id = "test" [auth] site_url = "https://example.com" @@ -2547,11 +2418,9 @@ secret = "env(MISSING_CAPTCHA_SECRET)" it.live( "not_pushable status renders correctly for a genuinely reachable unencodable case (an invalid byte size)", () => { - // `storage.file_size_limit` is schema-typed as a plain string, so an - // invalid byte-size expression survives config LOADING and only - // fails inside the encoder's own `ramInBytes` call — unlike the two - // D10 candidates above, this one is reachable end to end, and proves - // the `not_pushable` status/line/note render correctly. + // `storage.file_size_limit` is schema-typed as a plain string, so an invalid byte-size + // expression survives config loading and only fails inside the encoder's own `ramInBytes` + // call, making this case reachable end to end. const toml = `project_id = "test"\n[storage]\nfile_size_limit = "not-a-size"\n`; const { layer, apiMock, out } = setupService({ toml, format: "json" }); return Effect.gen(function* () { @@ -2584,11 +2453,9 @@ secret = "env(MISSING_CAPTCHA_SECRET)" it.live( "D12: declining the phone MFA addon sends explicit false disables when the remote already had it on", () => { - // Text mode (not json): the addon-decline prompt only ever consumes a - // real confirm answer in text mode — `keep()` short-circuits to the - // default (accept) for any machine format, so a decline can never be - // observed there. `declined_addons`' payload SHAPE is covered by the - // "emits a structured summary" test instead. + // Text mode: the addon-decline prompt only consumes a real confirm answer in text mode — + // `keep()` short-circuits to accept for any machine format. `declined_addons`'s payload + // shape is covered by the "emits a structured summary" test instead. const toml = `project_id = "test" [auth.mfa.phone] verify_enabled = true @@ -2619,15 +2486,8 @@ verify_enabled = true ); it.live("S1/D9: declining the auth prompt leaves the secret unsent (text mode)", () => { - // Text mode: `keep()` only ever consumes a real decline in text mode - // (json/stream-json short-circuit every prompt to the default accept — - // see the D12 comment above), so this asserts the write-side effect - // (no PATCH, no secret leaked) rather than the json payload; the - // `secrets.skipped` SHAPE for an unsent "send"-decided secret is proven - // by the "D2/S5" and "an empty/unresolved env()" tests above, whose - // `authWriteRan === false` comes from a different cause (`unavailable`, - // `not_set`) but exercises the identical payload branch - // (`push.format.ts`'s `authWriteRan ? sendDecisions... : []`). + // Text mode: `keep()` only consumes a real decline in text mode, so this asserts the + // write-side effect rather than the json payload. const toml = `project_id = "test" [auth.captcha] enabled = true @@ -2661,9 +2521,8 @@ secret = "irrelevant" }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - // Declaring `enabled = false` (a value the remote never reported) is - // itself a routed `local_only` change, so the write still runs — the - // gated secret must never ride along inside it. + // Declaring `enabled = false` is itself a routed `local_only` change, so the write still + // runs even though the gated secret must never ride along inside it. const update = apiMock.requests.find((r) => r.method === "updateAuthServiceConfig"); expect(update).toBeDefined(); const input = update?.input as Record<string, unknown>; @@ -2729,7 +2588,6 @@ content_path = "./templates-content-only/invite.html" const success = out.messages.find((m) => m.type === "success"); const data = success?.data as Record<string, unknown>; const services = data["services"] as ReadonlyArray<Record<string, unknown>>; - // D8: `changes` carries the content extra even though no registry-mapped leaf changed. expect(services.find((s) => s["service"] === "auth")).toEqual({ service: "auth", status: "updated", @@ -2775,12 +2633,9 @@ secret = "new-secret" it.live( "a container whose companion is unresolvable never lets its secret ride the write silently: the secret lands in unencodable, never sent", () => { - // `auth.hook.send_email.uri` is undeclared and the fixture's remote - // never reports `hook_send_email_uri` either, so the hook container's - // required-together group is incomplete — but `auth.site_url` is a - // genuine, independently-encodable change, so the auth write still - // runs. Before the fix, the hook's `secrets` decision (`status: "send"`) - // rode along into `secrets.sent` even though the hook body was dropped. + // `auth.hook.send_email.uri` is undeclared and the remote fixture never reports + // `hook_send_email_uri` either, so the hook's required-together group is incomplete — but + // `auth.site_url` is independently encodable, so the auth write still runs. const toml = `project_id = "test" [auth] site_url = "http://localhost:3000" @@ -2860,10 +2715,9 @@ secrets = "v1,whsec_abc" }); it.live("a webhook-only push counts as 1 property pushed, not 0 (finding 5)", () => { - // Every managed resource matches the fixture's schema-default remote - // (`BASE_DISABLED` declares nothing else), so `experimental.webhooks` - // is the only service that ends up `updated` — before the fix its - // `changes` was always `[]`, so the summary undercounted it as 0. + // Every managed resource matches the fixture's schema-default remote (`BASE_DISABLED` + // declares nothing else), so `experimental.webhooks` is the only service that ends up + // `updated`. const { layer, out } = setupService({ toml: `${BASE_DISABLED}[experimental.webhooks]\nenabled = true\n`, format: "json", @@ -2885,21 +2739,15 @@ secrets = "v1,whsec_abc" }); }); -// A config declaring a real `api` diff (matches the fixture's remote -// `max_rows: 1000`) — used by the branch/project target-detection (CLI-2168) -// and branch-name/UUID resolution (CLI-2289) scenarios below to prove a push -// actually proceeded, the same way the very first test in this file does. +// A config declaring a real `api` diff (matches the fixture's remote `max_rows: 1000`), used by +// the branch/project target-detection scenarios below to prove a push actually proceeded. const BRANCH_PUSH_TOML = `project_id = "test"\n[api]\nmax_rows = 2000\n`; /** - * The realistic "already ran `supabase link <branch>`" state (CLI-2168): - * `.temp/project-ref` holds the BRANCH's own ref, `.temp/linked-project.json` - * caches the PARENT (name "My App"), the live probe 404s (the ref is a - * branch, not a project), and the parent's branch list confirms it — so the - * target resolves to `{ kind: "branch", ref: BRANCH_REF, parentRef: - * PARENT_REF, parentName: "My App", branch: "feat-x" }`. `projectId: - * Option.none()` so ref resolution reads the `.temp/project-ref` file - * instead of the (env-equivalent) default. + * The realistic "already ran `supabase link <branch>`" state: `.temp/project-ref` holds the + * branch's own ref, `.temp/linked-project.json` caches the parent (name "My App"), the live probe + * 404s, and the parent's branch list confirms it — so the target resolves to `{ kind: "branch", + * ref: BRANCH_REF, parentRef: PARENT_REF, parentName: "My App", branch: "feat-x" }`. */ function setupLinkedBranchPush( opts: { @@ -2946,12 +2794,9 @@ describe("config push branch/project target detection (CLI-2168)", () => { it.live( "an empty project name from the live probe degrades to the bare-ref target-echo line", () => { - // `normalizeApiName` (push.branch-target.ts, shared by both the - // project-probe and branch-list call sites) folds an empty `name` - // into `undefined` before it ever reaches the target object — the - // same degradation every OTHER scenario in this file relies on via - // `PUSH_TEST_PROJECT`'s default empty name; this test pins it - // explicitly. + // `normalizeApiName` folds an empty `name` into `undefined` before it reaches the target + // object — the same degradation every other scenario in this file relies on via + // `PUSH_TEST_PROJECT`'s default empty name; this test pins it explicitly. const { layer, out } = setup({ toml: BRANCH_PUSH_TOML, yes: true, @@ -3039,9 +2884,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { expect(api.requests.some((r) => r.url.includes("/billing/addons"))).toBe(false); expect(api.requests.some((r) => r.url.includes("/v2/projects/"))).toBe(false); expect(api.requests.some((r) => r.url.includes("/postgrest"))).toBe(false); - // CLI Invariant #1: a declined branch gate still flushes - // telemetry and writes the linked-project cache, same as any other - // failure. + // A declined branch gate still flushes telemetry and writes the linked-project cache. expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cached).toBe(true); }).pipe(Effect.provide(layer)); @@ -3097,11 +2940,9 @@ describe("config push branch/project target detection (CLI-2168)", () => { if (Exit.isFailure(exit)) { const rendered = JSON.stringify(exit.cause); expect(rendered).toContain("ConfigPushCancelledError"); - // A machine-mode/non-TTY decline never renders the interactive - // prompt's own "(skip this check with --yes)" hint at all - // (`promptYesNo` returns the default silently) — the - // cancelled error's own `suggestion` field is the ONLY place a - // script/agent sees the --yes escape hatch. + // A machine-mode/non-TTY decline never renders the interactive prompt's own hint + // (`promptYesNo` returns the default silently) — the cancelled error's own + // `suggestion` field is the only place a script/agent sees the --yes escape hatch. expect(rendered).toContain("--yes"); expect(rendered).toContain("SUPABASE_YES"); } @@ -3282,19 +3123,12 @@ describe("config push branch/project target detection (CLI-2168)", () => { }, ); - // The "transport failure"/"500" tests above cover the `"unknown"` outcome - // for a genuine probe error; only the TIMEOUT-specific sub-case (a live - // probe that hangs rather than erroring) remains untested — both reach the - // identical `{ kind: "unknown" }` outcome, so this is a coverage gap in HOW - // "unknown" is reached, not in the behavior itself. A `TestClock`-driven - // proof of `BRANCH_LOOKUP_TIMEOUT`'s degradation was deliberately - // not added here: `configPush` does substantial real, unmocked - // filesystem I/O (project-root discovery, config.toml read, `.env` load) - // before it ever reaches the probe's `Effect.timeoutOrElse`, and that I/O - // settles on a real event-loop macrotask turn a virtual `TestClock` cannot - // provide — forcing it through would need either a real wall-clock wait - // (banned by this repo's flake-resistance policy) or an in-memory - // `FileSystem` fake diverging from every other scenario in this file. + // The tests above cover "unknown" for a genuine probe error; the timeout-specific sub-case + // remains untested. A `TestClock`-driven proof wasn't added: `configPush` does real, unmocked + // filesystem I/O before it reaches the probe's `Effect.timeoutOrElse`, and that I/O settles on + // a real macrotask a virtual `TestClock` can't drive — forcing it through would need a real + // wall-clock wait (banned by this repo's flake-resistance policy) or a diverging in-memory + // `FileSystem` fake. }); describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () => { @@ -3323,12 +3157,10 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () it.live( "--project-ref <branch-name> skips the branch confirmation prompt, with no --yes and no queued answer", () => { - // `knownBranch` is `{kind: "name", branchName, parentRef}` for a NAME - // target, so `push.handler.ts`'s `knownBranch === undefined` gate is - // never entered — no prompt at all. The real proof is `out.stderrText` - // never containing the branch-prompt label — `promptYesNo` - // always writes its label to stderr before reading any answer, on - // both a TTY and non-TTY, so its total absence is conclusive. + // `knownBranch` is `{kind: "name", branchName, parentRef}`, so `push.handler.ts`'s + // `knownBranch === undefined` gate is never entered. `promptYesNo` always writes its label + // to stderr before reading an answer, so its total absence in `out.stderrText` is + // conclusive proof no prompt ran. const { layer, out } = setup({ toml: BRANCH_PUSH_TOML, yes: false, @@ -3423,13 +3255,10 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () it.live( "--project-ref <uuid> never shows the branch confirmation prompt, on an unattended run with no --yes", () => { - // `knownBranch` is `{kind: "uuid"}` (defined, not `undefined`) for a - // UUID target — the same "explicit target this invocation" shape a - // branch NAME target gets, so `push.handler.ts`'s `target.kind === - // "branch" && knownBranch === undefined` gate is never entered: no - // prompt at all, even on a fully unattended run (no `--yes`, non-TTY, - // empty stdin). Per-service prompts (`keep()`) still default to - // `true` on empty non-TTY stdin, so the mutation still proceeds. + // `knownBranch` is `{kind: "uuid"}`, the same "explicit target this invocation" shape a + // branch name target gets, so `push.handler.ts`'s `knownBranch === undefined` gate is never + // entered. Per-service prompts (`keep()`) still default to `true` on empty non-TTY stdin, + // so the mutation still proceeds. const { layer, out, api } = setup({ toml: BRANCH_PUSH_TOML, yes: false, @@ -3465,9 +3294,8 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () expect(rendered).toContain('Branch \\"ghost\\" not found'); expect(rendered).toContain("supabase branches list"); expect(api.requests.some((r) => r.url.includes("/billing/addons"))).toBe(false); - // CLI Invariant #1: telemetry flushes even though ref - // resolution itself failed — but no ref was ever resolved, so the - // linked-project cache stays untouched. + // Telemetry flushes even though ref resolution failed; the linked-project cache stays + // untouched since no ref was ever resolved. expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cachedRef).toBeUndefined(); }).pipe(Effect.provide(layer)); @@ -3504,9 +3332,6 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () expect(rendered).toContain("ConfigPushBranchNotLinkedError"); expect(rendered).toContain('\\"somebranch\\"'); expect(api.requests).toHaveLength(0); - // CLI Invariant #1: fails purely from local file/env state, - // before any ref is resolved — telemetry still flushes, but the - // linked-project cache write is a no-op. expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cachedRef).toBeUndefined(); }).pipe(Effect.provide(layer)); @@ -3525,7 +3350,6 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () expect(rendered).toContain("ConfigPushParentRefInvalidError"); expect(rendered).toContain('\\"somebranch\\"'); expect(api.requests).toHaveLength(0); - // CLI Invariant #1: no ref ever resolved here either. expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cachedRef).toBeUndefined(); }).pipe(Effect.provide(layer)); @@ -3544,9 +3368,8 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () expect(rendered).toContain("ConfigPushBranchNotReadyError"); expect(rendered).toContain("has no project ref yet"); expect(api.requests.some((r) => r.url.includes("/billing/addons"))).toBe(false); - // This fails inside `resolveConfigTarget` itself (the - // placeholder ref is rejected before it's ever assigned to the - // handler's `resolvedRef`), so the cache write is still a no-op. + // Fails inside `resolveConfigTarget` before a ref is ever assigned, so the cache write is + // still a no-op. expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cachedRef).toBeUndefined(); }).pipe(Effect.provide(layer)); @@ -3584,9 +3407,8 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () }); describe("config push telemetry wiring", () => { - // Drives the exact `Command.withHandler` wiring (configPushHandler) - // rather than the bare handler: the safeFlags guard lives in the wiring, - // and nothing validates `--project-ref` before instrumentation fires. + // Drives the `Command.withHandler` wiring (`configPushHandler`), not the bare handler: the + // safeFlags guard lives in the wiring, and nothing validates `--project-ref` before it fires. const wiringLayer = (analytics: ReturnType<typeof mockAnalytics>, projectRef: string) => Layer.mergeAll( setup({ toml: `project_id = "test"\n`, yes: true, analytics }).layer, diff --git a/apps/cli/src/commands/config/push/push.live.test.ts b/apps/cli/src/commands/config/push/push.live.test.ts index d4fc19a030..6f59c3ef16 100644 --- a/apps/cli/src/commands/config/push/push.live.test.ts +++ b/apps/cli/src/commands/config/push/push.live.test.ts @@ -9,11 +9,9 @@ import { requireLiveSuccess, test, throwWithCleanup } from "../../../../tests/he const PUSH_EXIT_TIMEOUT_MS = 75_000; const DIFF_EXIT_TIMEOUT_MS = 20_000; -// Golden path only: a sparse config.toml declaring one property round-trips -// through push, `config diff` proves convergence, and the restore push is -// re-proven the same way (push exits 0 on "Nothing to push", so exit code -// alone cannot tell a restore from a silent no-op). Branch coverage lives in -// push.integration.test.ts. +// Golden path only: a sparse config.toml declaring one property round-trips through push, +// `config diff` proves convergence, and the restore push is re-proven the same way, since push +// exits 0 on "Nothing to push" too. Branch coverage lives in push.integration.test.ts. test("pushes one declared property, diff proves it landed, and a restore push puts the captured value back", async ({ cli, project, diff --git a/apps/cli/src/commands/config/push/push.paths.ts b/apps/cli/src/commands/config/push/push.paths.ts index 9fa87bdf27..2b4945a9be 100644 --- a/apps/cli/src/commands/config/push/push.paths.ts +++ b/apps/cli/src/commands/config/push/push.paths.ts @@ -1,8 +1,7 @@ /** * Generic config-path helpers shared by `config push`'s pure modules - * (push.encoders.ts, push.plan.ts, push.secrets.ts) and its handler. Kept in - * one place so every module compares/looks up paths the same way — see the - * architecture review's A4. + * (push.encoders.ts, push.plan.ts, push.secrets.ts) and its handler, so every module + * compares/looks up paths the same way. */ import type { ProjectConfig } from "@supabase/config"; @@ -54,14 +53,9 @@ export function comparePaths(a: ReadonlyArray<string>, b: ReadonlyArray<string>) } /** - * A container's own enabled state, read from the LOCAL (declared) projection - * only — never `remote`, since gating decides whether `config push` even - * attempts to write a container's fields at all, independently of the - * project's current state (mirrors `fromConfigDocument`'s own raw-presence - * mask and disabled-sentinel pruning). `undefined` means the container's - * enabled state cannot be determined — it is absent from `local`, or present - * without a boolean `enabled` field — and callers must never coerce that - * into `false`/`""`. + * A container's own enabled state, read from the local (declared) projection only, never + * `remote` — gating decides whether `config push` attempts to write a container's fields at all. + * `undefined` means the state can't be determined; callers must never coerce that into `false`. */ export function containerEnabled( local: ProjectConfig, diff --git a/apps/cli/src/commands/config/push/push.plan.ts b/apps/cli/src/commands/config/push/push.plan.ts index e56e235563..f59added79 100644 --- a/apps/cli/src/commands/config/push/push.plan.ts +++ b/apps/cli/src/commands/config/push/push.plan.ts @@ -20,7 +20,7 @@ export type PushResource = | "auth" | "storage"; -/** Push order — the order `config push` has always processed its services in. */ +/** Push order — the established order `config push` processes its services in. */ export const PUSH_RESOURCES: ReadonlyArray<PushResource> = [ "api", "db.settings", @@ -87,12 +87,9 @@ export function pushResponseBlock(resource: PushResource): "api" | "database" | } /** - * Longest-prefix lookup: resolves a comparable config path to the resource - * whose v1 endpoint can express it, or `"unsupported"` — both for a path - * declared-comparable but with no v1 field, and for a path outside every - * registered prefix (never expected for a path drawn from - * `changeSet.changes` — see this module's unit test's drift guard). Never - * `undefined`, so a resource lookup is total for every caller. + * Longest-prefix lookup: resolves a comparable config path to the resource whose v1 endpoint can + * express it, or `"unsupported"` for a path with no v1 field or outside every registered prefix. + * Never `undefined`, so a resource lookup is total for every caller. */ export function pushResourceForPath(path: ReadonlyArray<string>): PushResource | "unsupported" { for (const unsupportedPrefix of PUSH_UNSUPPORTED_PREFIXES) { @@ -158,18 +155,10 @@ export function planConfigPush(changeSet: ConfigChangeSet): PushPlan { } /** - * Whether a resource is even eligible to be pushed, given the decoded config - * (`db.network_restrictions`'s own `enabled` flag) and the local projection - * (`db.ssl_enforcement`'s declared presence — undeclared means the - * raw-presence mask already dropped the whole subtree). `api`, `db.settings`, - * `auth`, and `storage` have no such gate: `api`/`db.settings` never had one, - * and `auth.enabled`/`storage.enabled` no longer gate their resource either - * (CLI-2314, correcting a prior mistake) — that flag controls only the local - * GoTrue/Storage Docker service, with no Management API equivalent, so - * gating the whole resource on it silently dropped a user's declared - * hosted-auth/storage changes whenever they simply didn't run that service - * locally. `db.network_restrictions.enabled` stays a genuine gate: it is a - * real hosted-side management opt-out, not a local-service toggle. + * Whether a resource is even eligible to be pushed. `db.network_restrictions` gates on its own + * `enabled` flag (a real hosted-side opt-out); `db.ssl_enforcement` gates on declared presence in + * the local projection. `auth`/`storage`'s local `enabled` toggle controls only the Docker + * service, with no Management API equivalent, so it never gates their resource. */ export function pushResourceEnabled( resource: PushResource, @@ -210,12 +199,9 @@ export const PUSH_ADDON_GATES: ReadonlyArray<PushAddonGate> = [ ]; /** - * Whether an addon gate's cost-aware prompt should fire for this push: the - * routed change list turns on `verify_enabled` OR `enroll_enabled` (either - * flip is a new paid capability on its own — enrolment alone already starts - * SMS/WebAuthn charges), UNLESS the addon is already active on the project - * (`remote`'s `verify_enabled` is `true`), in which case there is no new - * cost to confirm. + * Whether an addon gate's cost-aware prompt should fire: the routed changes turn on + * `verify_enabled` or `enroll_enabled` (enrolment alone already starts SMS/WebAuthn charges), + * unless the addon is already active on the project (`remote`'s `verify_enabled` is `true`). */ export function pushAddonPromptNeeded( changes: ReadonlyArray<ConfigChange>, @@ -239,15 +225,10 @@ export function changesCommunicated( } /** - * Applies a declined paid-MFA-addon prompt to the routed auth change list. - * Drops the addon's `verify_enabled`/`enroll_enabled` changes; when the - * remote currently has either flag `true`, replaces them with synthetic - * `update` changes setting both to `false` instead — so the request body - * carries an explicit disable, leaving the project in the same state the - * user would get by disabling the addon directly. When the remote already - * has both flags `false` (or unset), the changes are simply dropped: - * omitting them leaves the remote's current (already disabled) state - * untouched. + * Applies a declined paid-MFA-addon prompt to the routed auth change list. Drops the addon's + * `verify_enabled`/`enroll_enabled` changes; when the remote currently has either flag `true`, + * replaces them with synthetic `update` changes setting both to `false`, so the request body + * carries an explicit disable rather than silently omitting a currently-enabled addon. */ export function applyMfaAddonDecline( changes: ReadonlyArray<ConfigChange>, diff --git a/apps/cli/src/commands/config/push/push.plan.unit.test.ts b/apps/cli/src/commands/config/push/push.plan.unit.test.ts index 98df60a13f..c439a1d368 100644 --- a/apps/cli/src/commands/config/push/push.plan.unit.test.ts +++ b/apps/cli/src/commands/config/push/push.plan.unit.test.ts @@ -1,7 +1,3 @@ -/** - * Unit tests for push.plan.ts. - */ - import type { CliConfig, ConfigChange, ConfigChangeSet, ProjectConfig } from "@supabase/config"; import { comparableProjectConfigPaths, getDefaultCliConfig } from "@supabase/config"; import { describe, expect, it } from "vitest"; @@ -136,13 +132,9 @@ describe("pushResourceForPath", () => { }); it("drift guard: every comparable config path resolves to a resource, or one of the two intentionally-unsupported prefixes", () => { - // `pushResourceForPath` never returns `undefined` (B12) — an - // unroutable path falls through to `"unsupported"` too, the same result - // an intentionally-listed prefix gets. So a plain `!== undefined` - // assertion here would never catch a new registry row this module - // forgot to route. Assert the stronger claim instead: every - // `"unsupported"` result must be explained by one of - // `PUSH_UNSUPPORTED_PREFIXES`, not by falling through unnoticed. + // An unroutable path also falls through to `"unsupported"`, the same result an + // intentionally-listed prefix gets, so a plain `!== undefined` assertion wouldn't catch a + // registry row this module forgot to route. for (const path of comparableProjectConfigPaths) { const resource = pushResourceForPath(path); const isIntentionallyUnsupported = PUSH_UNSUPPORTED_PREFIXES.some( @@ -247,10 +239,8 @@ describe("pushResourceEnabled", () => { }); it("is always true for auth and storage, regardless of their local enabled toggle (CLI-2314)", () => { - // `auth.enabled`/`storage.enabled` control only the local GoTrue/Storage - // Docker service — gating the whole resource on them silently dropped a - // user's declared hosted-auth/storage changes whenever they simply - // didn't run that service locally. + // `auth.enabled`/`storage.enabled` control only the local GoTrue/Storage Docker service; a + // resource-wide gate on them would drop a declared hosted change whenever unused locally. const base = getDefaultCliConfig(); const authOff: CliConfig = { ...base, auth: { ...base.auth, enabled: false } }; const storageOff: CliConfig = { ...base, storage: { ...base.storage, enabled: false } }; diff --git a/apps/cli/src/commands/config/push/push.registry-names.ts b/apps/cli/src/commands/config/push/push.registry-names.ts index ea867458a8..fe0a50abca 100644 --- a/apps/cli/src/commands/config/push/push.registry-names.ts +++ b/apps/cli/src/commands/config/push/push.registry-names.ts @@ -1,12 +1,10 @@ /** - * Registry-derived name lists for `config push`'s auth encoder — hoisted out - * of push.encoders.ts (B5) so their exact contents are pinned by one unit - * test rather than re-derived silently wherever they're used. + * Registry-derived name lists for `config push`'s auth encoder, pinned by one unit test rather + * than re-derived silently wherever they're used. * - * Every list below is computed from `projectConfigMappingRows` - * (`@supabase/config/internal`), never hand-copied, so a registry change - * that adds/removes a provider, hook, template, or notification is reflected - * here automatically. + * Every list is computed from `projectConfigMappingRows` (`@supabase/config/internal`), never + * hand-copied, so a registry change that adds/removes a provider, hook, template, or + * notification is reflected here automatically. */ import { projectConfigMappingRows } from "@supabase/config/internal"; @@ -58,13 +56,10 @@ export const PROVIDERS_WITH_EMAIL_OPTIONAL: ReadonlyArray<string> = EXTERNAL_PRO ); /** - * The SMS provider names. Derived from every `["auth","sms",name,"enabled"]` - * row whose `apiPath` is `["auth","sms_provider"]` — i.e. rows that select - * the active provider, rather than report a per-provider credential. This - * list's iteration order happens to match today's registry declaration - * order; that order is pinned by this module's own unit test, not guaranteed - * by the registry itself, so do not depend on it meaning "precedence" beyond - * what the test asserts. + * The SMS provider names, derived from every `["auth","sms",name,"enabled"]` row whose `apiPath` + * is `["auth","sms_provider"]` (rows that select the active provider, not report a per-provider + * credential). This list's iteration order is pinned by this module's own unit test, not + * guaranteed by the registry itself — do not depend on it meaning "precedence". */ export const SMS_PROVIDER_NAMES: ReadonlyArray<string> = (() => { const names: Array<string> = []; diff --git a/apps/cli/src/commands/config/push/push.secret.ts b/apps/cli/src/commands/config/push/push.secret.ts index 11a342ee16..288cb5c787 100644 --- a/apps/cli/src/commands/config/push/push.secret.ts +++ b/apps/cli/src/commands/config/push/push.secret.ts @@ -1,21 +1,9 @@ /** - * Secret-hashing rules for `config push`. + * Secret-hashing rules for `config push`. See {@link secretDigestHex} for exact behavior. * - * Rules: - * - Empty value → `undefined` (nothing to hash). - * - Value matching `^env\((.*)\)$` (unresolved env reference) → `undefined`. - * - Value starting with `encrypted:` (dotenvx ciphertext) → decrypt with - * `decryptSecret`, then hash the decrypted plaintext. - * - Otherwise → sha256Hmac(projectId, value), as a bare hex string. - * - * `config push`'s handler runs a document-wide decrypt-or-abort pre-check - * (`push.handler.ts`, reusing `assertDecryptableSecrets`) immediately - * after loading `config.toml` and before any network call. By the time the - * functions below run (deep in the auth secret resolution), decryption is - * therefore expected to always succeed. They still throw a - * `failed to parse config: <cause>` error rather than silently gating the - * secret out, in case that invariant is ever violated by a future field - * addition. + * `config push`'s handler runs a document-wide decrypt-or-abort pre-check before any network + * call, so decryption below is expected to always succeed; these functions still throw rather + * than silently gating the secret out, in case that invariant is ever violated. */ import { createHmac } from "node:crypto"; @@ -36,14 +24,10 @@ function decryptIfNeeded(value: string, dotenvPrivateKeys: ReadonlyArray<string> } /** - * Returns the bare-hex digest of a secret field, `sha256Hmac(projectRef, - * plaintext)`, or `undefined` for an empty value or an unresolved - * `env(...)` reference — the two cases the field is never sent for. The - * project ref is the HMAC key. `dotenvPrivateKeys` are the - * `DOTENV_PRIVATE_KEY`/`DOTENV_PRIVATE_KEY_*` values - * (`collectDotenvPrivateKeys`), used to decrypt an `encrypted:` value - * before hashing — the decrypted plaintext is always hashed, never the - * ciphertext. + * Returns the bare-hex digest of a secret field, `sha256Hmac(projectRef, plaintext)`, or + * `undefined` for an empty value or an unresolved `env(...)` reference — the two cases the field + * is never sent for. `dotenvPrivateKeys` decrypts an `encrypted:` value before hashing; the + * decrypted plaintext is always hashed, never the ciphertext. * * @throws When an `encrypted:` value cannot be decrypted with any key. */ @@ -59,12 +43,10 @@ export function secretDigestHex( } /** - * Resolves a secret field to the plaintext value an update request body - * sends — decrypting an `encrypted:` value with `dotenvPrivateKeys`, - * otherwise returning `value` unchanged. Callers gate on - * {@link secretDigestHex}'s result being defined before using this, so - * the empty/unresolved-`env()` cases never reach the request body regardless - * of what this returns for them. + * Resolves a secret field to the plaintext value an update request body sends: decrypts an + * `encrypted:` value with `dotenvPrivateKeys`, otherwise returns `value` unchanged. Callers gate + * on {@link secretDigestHex}'s result being defined first, so an empty or unresolved-`env()` + * value never reaches the request body regardless of what this returns. * * @throws When an `encrypted:` value cannot be decrypted with any key. */ diff --git a/apps/cli/src/commands/config/push/push.secret.unit.test.ts b/apps/cli/src/commands/config/push/push.secret.unit.test.ts index f5cea3afb5..d3812414fb 100644 --- a/apps/cli/src/commands/config/push/push.secret.unit.test.ts +++ b/apps/cli/src/commands/config/push/push.secret.unit.test.ts @@ -1,16 +1,11 @@ -/** - * Unit tests for push.secret.ts. - * - * The HMAC keys/values below were captured from the same `createHmac` the - * implementation uses; they lock the exact bare-hex digest. - */ - import { describe, expect, it } from "vitest"; import { secretDigestHex, secretPlaintext } from "./push.secret.ts"; -// Shared test vector — same one `vault-decrypt.unit.test.ts` uses. -// Decrypts to the plaintext "value". +// The HMAC keys/values below were captured from the same `createHmac` the implementation uses; +// they lock the exact bare-hex digest. + +// Shared test vector (also used by `vault-decrypt.unit.test.ts`); decrypts to "value". const PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; const ENCRYPTED_VALUE = "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; @@ -40,7 +35,6 @@ describe("secretDigestHex", () => { }); it("hashes a value that merely contains (but does not start with) 'encrypted:'", () => { - // Only the dotenvx prefix is special; an embedded substring is a real secret. expect(secretDigestHex("test", "not-encrypted:value", [])).toBe( secretDigestHex("test", "not-encrypted:value", []), ); diff --git a/apps/cli/src/commands/config/push/push.secrets.ts b/apps/cli/src/commands/config/push/push.secrets.ts index 61d03d74e1..6ad5c66958 100644 --- a/apps/cli/src/commands/config/push/push.secrets.ts +++ b/apps/cli/src/commands/config/push/push.secrets.ts @@ -75,12 +75,10 @@ export function resolveAuthSecrets(input: { } const remoteState = remoteStateFor(remoteAuthAttributes, apiKey); - // The secret's parent container gates it: it must be present in `local` - // with `enabled !== false`. Exact because `fromConfigDocument` has - // already applied every gate a push would apply — the raw-presence - // mask, the disabled-sentinel prune, and SMS-provider precedence. An - // undetermined container state (absent, or `enabled` not a boolean) - // gates the secret too — never coerced into "eligible". + // The secret's parent container gates it: it must be present in `local` with `enabled !== + // false`. `fromConfigDocument` has already applied every relevant gate (raw-presence mask, + // disabled-sentinel prune, SMS-provider precedence), so an undetermined container state + // (absent, or `enabled` not a boolean) gates the secret too, never coerced into "eligible". const parentPath = path.slice(0, -1); if (containerEnabled(local, parentPath) !== true) { decisions.push({ path, apiKey, status: "gated", remoteState }); diff --git a/apps/cli/src/commands/config/push/push.secrets.unit.test.ts b/apps/cli/src/commands/config/push/push.secrets.unit.test.ts index 3b83c74318..94240509cd 100644 --- a/apps/cli/src/commands/config/push/push.secrets.unit.test.ts +++ b/apps/cli/src/commands/config/push/push.secrets.unit.test.ts @@ -1,7 +1,3 @@ -/** - * Unit tests for push.secrets.ts. - */ - import type { CliConfig, ProjectConfig } from "@supabase/config"; import { getDefaultCliConfig } from "@supabase/config"; import { projectConfigMappingRows } from "@supabase/config/internal"; @@ -335,8 +331,7 @@ describe("resolveAuthSecrets", () => { it("gates when the container is present but its `enabled` field is not a boolean (never coerced to eligible)", () => { const config = buildFullyEnabledConfig(); - // No `enabled` key at all on the captcha container — an undetermined - // state, not an eligible one. + // No `enabled` key at all here — an undetermined state, not an eligible one. const local: ProjectConfig = { auth: { captcha: {} } }; const decisions = resolveAuthSecrets({ maskedPaths: [["auth", "captcha", "secret"]], diff --git a/apps/cli/src/commands/config/push/push.types.ts b/apps/cli/src/commands/config/push/push.types.ts index 41b0c22ba4..e971ba6379 100644 --- a/apps/cli/src/commands/config/push/push.types.ts +++ b/apps/cli/src/commands/config/push/push.types.ts @@ -3,10 +3,9 @@ import type { PushResource } from "./push.plan.ts"; /** * Outcome of pushing a single service's config to the linked project. * - * `service` and `status` keep their existing values (dotted keys mirroring - * `config.toml` paths, plus the fixed `experimental.webhooks` identifier — - * see `push.format.ts`'s doc comment for why `service` is an opaque - * identifier, not itself a config path); `changes` is additive. Status: + * `service` is a dotted key mirroring `config.toml` paths, plus the fixed + * `experimental.webhooks` identifier — see `push.format.ts`'s doc comment for why it's an opaque + * identifier, not itself a config path. Status: * - `updated` — a pushable difference existed, the user kept it, the write ran. * - `up_to_date` — no pushable difference existed for this resource. * - `skipped` — a pushable difference existed but the user declined the prompt. @@ -16,9 +15,6 @@ import type { PushResource } from "./push.plan.ts"; * - `not_pushable` — a pushable difference existed, but none of it could be * encoded into a request body (every routed change ended up * `unencodable`); nothing was written. - * - * There is no machine output for the previous per-service-subset `config - * push`; this shape backs the TS `json` / `stream-json` modes only. */ type ConfigPushServiceStatus = | "updated" diff --git a/apps/cli/src/commands/db/advisors/advisors.command.ts b/apps/cli/src/commands/db/advisors/advisors.command.ts index 8eda33e7a2..5abc8b5b1e 100644 --- a/apps/cli/src/commands/db/advisors/advisors.command.ts +++ b/apps/cli/src/commands/db/advisors/advisors.command.ts @@ -56,10 +56,9 @@ export const dbAdvisorsCommand = Command.make("advisors", config).pipe( level: flags.level, "fail-on": flags.failOn, }, - // type/level/fail-on are Flag.choice and are auto-detected as safe via - // `config` below; --db-url stays redacted (plain string, may carry secrets). - // --project-ref has no established telemetry-safety baseline either, so - // it stays redacted too. + // type/level/fail-on are auto-detected as safe via `config` below; --db-url stays + // redacted (may carry secrets), and --project-ref stays redacted too (no established + // telemetry-safety baseline). config, }), withJsonErrorHandling, diff --git a/apps/cli/src/commands/db/advisors/advisors.errors.ts b/apps/cli/src/commands/db/advisors/advisors.errors.ts index 4515e14718..bc1dff3e54 100644 --- a/apps/cli/src/commands/db/advisors/advisors.errors.ts +++ b/apps/cli/src/commands/db/advisors/advisors.errors.ts @@ -47,11 +47,9 @@ export class DbAdvisorsInvalidTokenError extends Data.TaggedError("DbAdvisorsInv readonly message: string; readonly suggestion: string; /** - * Copied from the wrapped `InvalidAccessTokenError`: an env-var token - * (`SUPABASE_ACCESS_TOKEN`) takes precedence over stored credentials, so - * `supabase login` cannot fix it — the remediation is to correct the env - * var. A stored (keyring/file) token, or an unknown source, is fixable by - * logging in again. + * An env-var token (`SUPABASE_ACCESS_TOKEN`) takes precedence over stored credentials, so + * `supabase login` cannot fix it — the remediation is to correct the env var. A stored + * (keyring/file) token, or an unknown source, is fixable by logging in again. */ readonly source?: "env" | "stored"; }> { diff --git a/apps/cli/src/commands/db/advisors/advisors.format.ts b/apps/cli/src/commands/db/advisors/advisors.format.ts index 8ee6254c9b..e67f380dbf 100644 --- a/apps/cli/src/commands/db/advisors/advisors.format.ts +++ b/apps/cli/src/commands/db/advisors/advisors.format.ts @@ -43,10 +43,9 @@ const asStringArray = (value: unknown): ReadonlyArray<string> => Array.isArray(value) ? value.map(asString) : []; /** - * Decodes a JSON value into a plain string field: an absent or `null` value is - * the zero value `""`; a present non-string (number/bool/object/array) throws. - * Any string value is accepted (the deliberate unknown-enum tolerance). Used - * only on the typed-API path, not the local `rows.Scan` path. + * Decodes a JSON value into a plain string field: an absent or `null` value is the zero value + * `""`; a present non-string (number/bool/object/array) throws. Any string value is accepted + * (an intentional unknown-enum tolerance), used only on the typed-API path, not `rows.Scan`. */ function requireApiString(value: unknown, field: string): string { if (value === undefined || value === null) return ""; @@ -178,18 +177,16 @@ function projectApiMetadata(value: unknown): Record<string, unknown> | undefined } /** - * Reads the advisors API response with plain string narrowing instead of the - * generated closed-enum schema (which would reject advisor names / metadata - * types the API can add): `name` / `level` / `facing` / category values pass - * through as raw strings. + * Reads the advisors API response with plain string narrowing instead of the generated + * closed-enum schema, which would reject advisor names/metadata types the API can add: `name` / + * `level` / `facing` / category values pass through as raw strings. * - * Structurally strict, though — a top-level non-object, a `lints` / - * `categories` / `metadata` / `fkey_columns` of the wrong JSON container type, - * or a non-object lint entry throws rather than surfacing as a non-zero - * failure. **Throws** on those so a malformed 200 body fails instead of being - * reported as "No issues found"; the caller maps the throw to the same - * `failed to fetch … advisors` error. A top-level `null` decodes to the zero - * value (no lints). + * Structurally strict, though: a top-level non-object, a wrongly-typed `lints`/`categories`/ + * `metadata`/`fkey_columns` container, or a non-object lint entry throws — so a malformed 200 + * body fails instead of being reported as "No issues found". A top-level `null` decodes to the + * zero value (no lints). + * + * @throws On structurally invalid input; the caller maps it to `failed to fetch … advisors`. */ export function apiResponseToAdvisorLints(parsed: unknown): ReadonlyArray<AdvisorLint> { if (parsed === null) return []; @@ -203,9 +200,8 @@ export function apiResponseToAdvisorLints(parsed: unknown): ReadonlyArray<Adviso } const lints: Array<AdvisorLint> = []; for (const entry of lintsRaw) { - // A null slice element decodes to the zero-value struct (all fields at - // their zero values), not a throw. Normalise null/undefined to an empty - // record so the field decoders produce zero values. + // A null slice element decodes to the zero-value struct, not a throw; normalize + // null/undefined to an empty record so the field decoders produce zero values. if (entry === null || entry === undefined) { lints.push({ name: "", diff --git a/apps/cli/src/commands/db/advisors/advisors.format.unit.test.ts b/apps/cli/src/commands/db/advisors/advisors.format.unit.test.ts index 7062d88f88..50719871fe 100644 --- a/apps/cli/src/commands/db/advisors/advisors.format.unit.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.format.unit.test.ts @@ -129,7 +129,6 @@ describe("apiResponseToAdvisorLints (maps Go TestApiResponseToLints)", () => { expect(lints[0]?.name).toBe("rls_disabled_in_public"); expect(lints[0]?.level).toBe("ERROR"); expect(lints[0]?.categories).toEqual(["SECURITY"]); - // Unknown metadata fields are dropped; known fields are kept in struct order. expect(Object.keys(lints[0]?.metadata as Record<string, unknown>)).toEqual([ "entity", "schema", @@ -145,16 +144,12 @@ describe("apiResponseToAdvisorLints (maps Go TestApiResponseToLints)", () => { }); it("returns an empty array for Go zero-value shapes (null, missing/null lints)", () => { - // A top-level null or an absent/null `lints` decodes to the zero value, - // yielding no lints (No issues found). expect(apiResponseToAdvisorLints(null)).toEqual([]); expect(apiResponseToAdvisorLints({})).toEqual([]); expect(apiResponseToAdvisorLints({ lints: null })).toEqual([]); }); it("null lints element becomes zero-value lint (Go encoding/json nil-slice decode parity)", () => { - // A null slice element decodes to the zero-value struct, not a throw. The - // zero lint has empty strings and null categories. const result = apiResponseToAdvisorLints({ lints: [ null, @@ -167,7 +162,6 @@ describe("apiResponseToAdvisorLints (maps Go TestApiResponseToLints)", () => { }, ], }); - // null → zero-value lint (not an error) expect(result).toHaveLength(2); expect(result[0]).toEqual({ name: "", @@ -180,14 +174,11 @@ describe("apiResponseToAdvisorLints (maps Go TestApiResponseToLints)", () => { remediation: "", cacheKey: "", }); - // valid sibling is preserved expect(result[1]?.name).toBe("rls_disabled"); expect(result[1]?.level).toBe("ERROR"); }); it("null categories element becomes empty string (Go encoding/json []string null-element parity)", () => { - // A null element inside a string array decodes to the zero string "", not - // a throw. const result = apiResponseToAdvisorLints({ lints: [{ name: "x", categories: [null, "SECURITY"] }], }); @@ -195,10 +186,6 @@ describe("apiResponseToAdvisorLints (maps Go TestApiResponseToLints)", () => { }); it("throws on structural shapes Go's typed decode rejects", () => { - // A type mismatch on a container field throws → non-zero failure (not "No - // issues found"). The previous tolerant parser wrongly coerced these to - // []. Keep the string-enum tolerance (above), but reject wrong-typed - // containers. expect(() => apiResponseToAdvisorLints("nope")).toThrow(); expect(() => apiResponseToAdvisorLints([])).toThrow(); expect(() => apiResponseToAdvisorLints({ lints: "nope" })).toThrow(); @@ -215,9 +202,6 @@ describe("apiResponseToAdvisorLints (maps Go TestApiResponseToLints)", () => { }); it("throws on scalar fields with the wrong JSON type (Go UnmarshalTypeError)", () => { - // The typed decode rejects a non-string for a string field and a - // non-string array element — even though it tolerates any string VALUE. - // The previous parser coerced 123 -> "123" via String(). expect(() => apiResponseToAdvisorLints({ lints: [{ name: 123 }] })).toThrow(); expect(() => apiResponseToAdvisorLints({ lints: [{ name: "x", level: true }] })).toThrow(); expect(() => apiResponseToAdvisorLints({ lints: [{ name: "x", categories: [1] }] })).toThrow(); @@ -227,8 +211,7 @@ describe("apiResponseToAdvisorLints (maps Go TestApiResponseToLints)", () => { }); it("treats absent scalar fields as the empty-string zero value (Go json)", () => { - // A missing field decodes to "" with no error; only present-but-wrong-type fails. - // `categories` absent → encoded as `null` (no omitempty on the field). + // `categories` absent encodes as `null` too (no omitempty on the field). const lints = apiResponseToAdvisorLints({ lints: [{ name: "only_name" }] }); expect(lints[0]).toEqual({ name: "only_name", @@ -244,17 +227,14 @@ describe("apiResponseToAdvisorLints (maps Go TestApiResponseToLints)", () => { }); it("collapses null and empty categories to null (Go nil-slice parity)", () => { - // null categories → "categories": null const fromNull = apiResponseToAdvisorLints({ lints: [{ name: "x", categories: null }], }); expect(fromNull[0]?.categories).toBeNull(); - // empty [] → "categories": null const fromEmpty = apiResponseToAdvisorLints({ lints: [{ name: "x", categories: [] }], }); expect(fromEmpty[0]?.categories).toBeNull(); - // populated → array preserved const fromPopulated = apiResponseToAdvisorLints({ lints: [{ name: "x", categories: ["SECURITY"] }], }); @@ -262,7 +242,6 @@ describe("apiResponseToAdvisorLints (maps Go TestApiResponseToLints)", () => { }); it("normalizes null fkey_columns elements to 0 (Go encoding/json float32 zero value)", () => { - // A null array element decodes to the zero value (0), not a throw. const result = apiResponseToAdvisorLints({ lints: [{ name: "x", metadata: { fkey_columns: [null, 2] } }], }); @@ -351,9 +330,6 @@ describe("encodeAdvisorLints (Go outputAndCheck byte parity)", () => { }); it("emits categories:null (key present, null value) when categories is null — Go nil []string parity", () => { - // No omitempty on `categories`; a null value encodes as - // `"categories": null`, not omitted. Verify the key is present AND the - // value is the literal `null` (not `[]` or absent). const lintWithNullCategories: AdvisorLint = { name: "n", title: "", diff --git a/apps/cli/src/commands/db/advisors/advisors.handler.ts b/apps/cli/src/commands/db/advisors/advisors.handler.ts index 79ff2d4044..241fb910ba 100644 --- a/apps/cli/src/commands/db/advisors/advisors.handler.ts +++ b/apps/cli/src/commands/db/advisors/advisors.handler.ts @@ -118,23 +118,19 @@ const runLinked = Effect.fnUntraced(function* ( const credentials = yield* CommandCredentials; const projectRefResolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; - // Every Management API response is wrapped in identity stitching; the - // raw-HTTP advisor GETs run the same stitch. One stitcher shared across both - // endpoint calls so it fires at most once per session. + // Every Management API response is wrapped in identity stitching, including the raw-HTTP + // advisor GETs; one stitcher is shared across both calls so it fires at most once per session. const { stitch } = yield* IdentityStitch; - // The linked-project cache is written whenever the project ref was resolved, - // even when the DB-config resolve below fails (e.g. the IPv6 error). Load - // the ref first (non-prompting `loadProjectRef`, honoring an explicit - // `--project-ref`; not-linked → empty ref → nothing to cache) and wrap - // everything after it in the cache finalizer. + // The linked-project cache is written whenever the project ref was resolved, even when the + // DB-config resolve below fails. Load the ref first (non-prompting, honoring an explicit + // `--project-ref`) and wrap everything after it in the cache finalizer. const ref = yield* projectRefResolver.loadProjectRef(flags.projectRef); return yield* Effect.gen(function* () { - // The host probe / login-role mint ("Initialising login role...") / pooler - // / IPv6 fallback. The linked lint-gathering path ignores the resolved - // config, so resolve-and-discard — purely for the side effects and - // early-failure ordering (before the token gate). + // The host probe / login-role mint / pooler / IPv6 fallback. The linked lint-gathering path + // ignores the resolved config — this runs purely for the side effects and early-failure + // ordering (before the token gate). yield* resolver.resolve({ dbUrl: Option.none(), connType: "linked", @@ -142,10 +138,8 @@ const runLinked = Effect.fnUntraced(function* ( linkedProjectRef: flags.projectRef, }); - // The access token is validated (env/keyring/file) against the `sbp_` - // pattern and fails before calling the API. `CommandCredentials.getAccessToken` - // is the validating equivalent: map a malformed token to the invalid-token - // error and an absent token to missing. + // The access token is validated against the `sbp_` pattern before calling the API: a + // malformed token maps to the invalid-token error, an absent token to missing. const tokenOpt = yield* credentials.getAccessToken.pipe( Effect.catchTag("InvalidAccessTokenError", (cause) => Effect.fail( @@ -231,10 +225,8 @@ const runAdvisors = Effect.fnUntraced(function* ( ); } - // `--project-ref` never implies `--linked` and must not be silently - // discarded on a non-linked target — see push.handler.ts's identical guard - // for the full TS-only rationale. advisors defaults to the local/db-url path - // (`runLocal`) whenever `--linked` isn't the resolved target selector. + // `--project-ref` never implies `--linked` and must not be silently discarded on a non-linked + // target (see push.handler.ts's identical guard). Defaults to the local/db-url path otherwise. if (Option.isSome(flags.projectRef) && target.connType !== "linked") { return yield* Effect.fail( new DbAdvisorsMutuallyExclusiveFlagsError({ diff --git a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts index 13a57fe295..0c5855e499 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -147,9 +147,8 @@ function mockProjectRef() { }), resolveForLink: () => Effect.succeed(VALID_REF), resolveOptional: () => Effect.succeed(Option.some(VALID_REF)), - // Gives an explicit `--project-ref` flag top precedence, same as Go's - // `flags.LoadProjectRef` — mirrors the real resolver so a test can prove the - // flag (not just the hardcoded fallback) drives the linked ref. + // Gives an explicit `--project-ref` flag top precedence, mirroring the real resolver so a + // test can prove the flag (not just the hardcoded fallback) drives the linked ref. loadProjectRef: (flagValue: Option.Option<string>) => Effect.sync(() => { calls.push("loadProjectRef"); @@ -457,11 +456,8 @@ describe("db advisors — local", () => { }).pipe(Effect.provide(layer)); }); - // ── Changed-based routing (explicitly-set flag, not its value) ─────────── - it.live("--linked=false routes to the linked branch (Changed, not value)", () => { - // "Changed" fires when the flag appears on the command line regardless of - // its value: `--linked=false` is still "explicitly set" → linked branch. + // "Changed" fires when the flag appears on the command line regardless of its value. const { layer, projectRef, cache } = setup({ args: ["--linked=false"], securityLints: [], @@ -488,7 +484,6 @@ describe("db advisors — local", () => { }); it.live("--local=false --linked fails with mutual-exclusion (sorted set [linked local])", () => { - // Both flags are Changed → mutual exclusion fires with cobra's sorted set. const { layer } = setup({ args: ["--local=false", "--linked"] }); return Effect.gen(function* () { const exit = yield* Effect.exit(dbAdvisors(flags())); @@ -502,7 +497,6 @@ describe("db advisors — local", () => { }); it.live("--local=false alone routes to the local branch (Changed local, connType=local)", () => { - // `--local=false` is Changed for `local` → connType="local". const { layer, out, cache } = setup({ rows: [], args: ["--local=false"] }); return Effect.gen(function* () { yield* dbAdvisors(flags()); @@ -543,7 +537,6 @@ describe("db advisors — linked", () => { expect(urls.some((u) => u.includes("/advisors/performance"))).toBe(true); expect(out.stdoutText).toContain("rls_disabled_in_public"); expect(out.stdoutText).toContain("unindexed_foreign_keys"); - // Linked runs write the linked-project cache. expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); @@ -551,9 +544,8 @@ describe("db advisors — linked", () => { it.live( "fetches advisors for the project given via --project-ref, overriding the workdir's own ref", () => { - // The fake resolver's own fallback (VALID_REF) represents whatever - // the workdir would resolve to absent the flag (e.g. .temp/project-ref) — - // the flag must win over it and drive both the API path and the cache. + // The fake resolver's own fallback (VALID_REF) represents whatever the workdir would + // resolve to absent the flag; the flag must win over it and drive both API and cache. const FLAG_REF = "flagflagflagflagflag"; const { layer, api, cache } = setup({ securityLints: [securityLint], @@ -563,9 +555,6 @@ describe("db advisors — linked", () => { yield* dbAdvisors( flags({ type: Option.some("security"), projectRef: Option.some(FLAG_REF) }), ); - // The request path itself must be scoped to the FLAG ref, not merely - // any /advisors/security hit — proving the flag (not the fallback) - // drove the API call the same way it drove the cache below. expect( api.requests.some((r) => r.url.includes(`/v1/projects/${FLAG_REF}/advisors/security`)), ).toBe(true); @@ -577,8 +566,7 @@ describe("db advisors — linked", () => { ); it.live("rejects --project-ref on the default local target", () => { - // advisors defaults to the local path when --linked isn't set — the guard - // must fire from the flag alone, with no explicit --local/--db-url needed. + // The guard fires from the flag alone; no explicit --local/--db-url is needed. const FLAG_REF = "flagflagflagflagflag"; const { layer, connection, api, cache } = setup({ rows: [] }); return Effect.gen(function* () { @@ -598,9 +586,7 @@ describe("db advisors — linked", () => { it.live( "resolves the linked DB config before fetching advisors (Go root PersistentPreRunE)", () => { - // The linked DB config is resolved (and on failure aborts) before the - // linked lint-gathering path hits the Management API — even though that - // path discards the connection. + // Resolved even though the linked lint-gathering path discards the connection. const { layer, resolver, api } = setup({ securityLints: [securityLint], args: ["--linked"], @@ -608,18 +594,15 @@ describe("db advisors — linked", () => { return Effect.gen(function* () { yield* dbAdvisors(flags({ type: Option.some("security") })); expect(resolver.resolveFlags.some((f) => f.connType === "linked")).toBe(true); - // The fetch still ran after a successful resolve. expect(api.requests.some((r) => r.url.includes("/advisors/security"))).toBe(true); }).pipe(Effect.provide(layer)); }, ); it.live("fails on the linked DB-config error before any advisor API call", () => { - // Unreachable direct host + no pooler: the DB-config resolve fails with the - // IPv6 error before the linked lint-gathering path runs, so the advisors - // API is never reached. But the ref was already loaded and cached - // unconditionally on the error path — so the linked-project cache is - // still written. + // The DB-config resolve fails before the linked lint-gathering path runs, so the advisors + // API is never reached — but the ref was already loaded and cached unconditionally on the + // error path. const { layer, api, cache } = setup({ ipv6Error: true, args: ["--linked"] }); return Effect.gen(function* () { const exit = yield* Effect.exit(dbAdvisors(flags())); @@ -628,15 +611,13 @@ describe("db advisors — linked", () => { expect(JSON.stringify(exit.cause)).toContain("IPv6 is not supported"); } expect(api.requests).toHaveLength(0); - // Cache written despite the DB-config failure (ref was loaded first). expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); it.live("runs the identity stitch on each advisor response (Go identityTransport)", () => { - // Every Management API response is wrapped in identity stitching. The - // raw-HTTP advisor path must run the same stitch (once per response) - // rather than silently skipping session-identity stitching. + // Every Management API response is wrapped in identity stitching; the raw-HTTP advisor path + // must run the same stitch, once per response. const { layer, identityStitch } = setup({ securityLints: [securityLint], performanceLints: [performanceLint], @@ -649,8 +630,7 @@ describe("db advisors — linked", () => { }); it.live("resolves the linked ref via the non-prompting load (Go LoadProjectRef)", () => { - // `--linked` must take the fail-fast/non-interactive path (`loadProjectRef`) - // rather than `resolve` (which opens a project picker on a TTY). + // `resolve` opens an interactive project picker on a TTY; `--linked` must avoid it. const { layer, projectRef } = setup({ securityLints: [securityLint], args: ["--linked"], @@ -719,8 +699,8 @@ describe("db advisors — linked", () => { }); it.live("fails on a 200 with a non-JSON content type (Go requires json header)", () => { - // The body is only decoded when Content-Type contains "json"; otherwise - // the fetcher returns the status-200 error. + // The body is only decoded when Content-Type contains "json"; otherwise this fails as a + // status-200 error. const { layer } = setup({ securityNonJson: true, args: ["--linked"] }); return Effect.gen(function* () { const exit = yield* Effect.exit(dbAdvisors(flags({ type: Option.some("security") }))); diff --git a/apps/cli/src/commands/db/advisors/advisors.layers.ts b/apps/cli/src/commands/db/advisors/advisors.layers.ts index f72454a931..e5b9698133 100644 --- a/apps/cli/src/commands/db/advisors/advisors.layers.ts +++ b/apps/cli/src/commands/db/advisors/advisors.layers.ts @@ -20,23 +20,18 @@ import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts * - **`--linked`** — raw-HTTP advisor GETs, project-ref resolution, and the * linked-project cache. * - * Deliberately does NOT use `managementApiRuntimeLayer`: that layer exposes - * an *eagerly* built `CommandPlatformApi`, which resolves an access token at layer - * construction. Merging it would make the auth-free `--local` path fail with a - * "token not provided" error before the handler runs (caught by the bundled-binary - * smoke test — CLAUDE.md invariant 5 / 7). + * Does not use `managementApiRuntimeLayer`: that layer eagerly builds `CommandPlatformApi`, + * resolving an access token at layer construction, which would make the auth-free `--local` path + * fail with a "token not provided" error before the handler runs. * - * Instead the project-ref resolver is given the **lazy** `commandPlatformApiFactoryLayer`, - * whose `make` is only forced by an interactive project-ref prompt. The linked path - * resolves the ref via the non-prompting `loadProjectRef`, which never forces the - * factory; the local path never resolves a project ref at all, so no token is - * resolved there either. + * Instead the project-ref resolver is given the lazy `commandPlatformApiFactoryLayer`, whose + * `make` is only forced by an interactive project-ref prompt. The linked path resolves the ref + * via the non-prompting `loadProjectRef`, which never forces the factory; the local path never + * resolves a project ref at all, so no token is resolved there either. * - * `commandSettingsLayer` is provided to each consumer that needs it (item 5: - * `Layer.provide` does not share to merge siblings); layers are memoised by - * reference so the config / credentials / HTTP instances are reused. Ambient - * services (`Analytics`, `RuntimeInfo`, `FileSystem`, `Output`, `Tty`, …) are - * satisfied by the root runtime. + * `commandSettingsLayer` is provided to each consumer that needs it, since `Layer.provide` does + * not share to merge siblings; layers are memoised by reference so the config / credentials / + * HTTP instances are reused. */ const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); const httpClient = httpClientLayer.pipe(Layer.provide(debugLoggerLayer)); @@ -79,13 +74,10 @@ export const dbAdvisorsRuntimeLayer = Layer.mergeAll( credentials, projectRef, linkedProjectCache, - // The one per-command identity stitcher, exposed at top level so the - // raw-HTTP advisor GETs can yield it. The SAME - // reference is provided to platformApiFactory / linkedProjectCache / dbConfig - // above, so memoisation makes the typed temp-role mint, the advisor GETs, the - // cache GET, and the linked DB-config stack all share one `stitchAttempted` - // guard — aliasing/persisting at most once. Its Analytics / TelemetryRuntime / - // FileSystem / Path deps are ambient (root runtime). + // The one per-command identity stitcher, exposed at top level so the raw-HTTP advisor GETs can + // yield it. The same reference is provided to platformApiFactory / linkedProjectCache / + // dbConfig above, so memoisation makes them all share one `stitchAttempted` guard, firing at + // most once. identityStitchLayer, telemetryStateLayer, commandRuntimeLayer(["db", "advisors"]), diff --git a/apps/cli/src/commands/db/diff/diff.command.ts b/apps/cli/src/commands/db/diff/diff.command.ts index 43bfa182ad..4c0bfacba7 100644 --- a/apps/cli/src/commands/db/diff/diff.command.ts +++ b/apps/cli/src/commands/db/diff/diff.command.ts @@ -8,10 +8,9 @@ import { dbDiff } from "./diff.handler.ts"; import { dbDiffRuntimeLayer } from "./diff.layers.ts"; const config = { - // The four engine flags are a mutually-exclusive group, and `--use-migra` - // defaults to true, so they are modelled as `Option` to track whether the flag - // was passed: the mutex check and `resolveDiffEngine`'s `useMigraChanged` key - // off whether the flag was passed, not its value. + // The four engine flags are a mutually-exclusive group, and `--use-migra` defaults to true, so + // they are modelled as `Option` to track whether the flag was passed: the mutex check and + // `resolveDiffEngine`'s `useMigraChanged` key off whether it was passed, not its value. useMigra: Flag.boolean("use-migra").pipe( Flag.withDescription("Use migra to generate schema diff."), Flag.optional, @@ -21,11 +20,10 @@ const config = { Flag.optional, ), usePgSchema: Flag.boolean("use-pg-schema").pipe( - // Deprecated in favor of the pg-delta engine (or the default migra engine) — - // a keep-in-Go exception (in-process stripe/pg-schema-diff library, no - // TS/container equivalent — see SIDE_EFFECTS.md). This description-only - // notice is not enforced by the flag framework — see diff.handler.ts's - // runtime warning for the enforced half of the deprecation. + // Deprecated in favor of the pg-delta engine (or the default migra engine): pg-schema-diff + // has no TS/container equivalent, so this stays proxied — see SIDE_EFFECTS.md. This + // description-only notice isn't enforced by the flag framework; see diff.handler.ts's + // runtime warning for the enforced half. Flag.withDescription( "Use pg-schema-diff to generate schema diff. Deprecated: use the pg-delta engine ([experimental.pgdelta] enabled = true / --use-pg-delta) or the default migra engine instead.", ), @@ -124,9 +122,7 @@ export const dbDiffCommand = Command.make("diff", config).pipe( file: flags.file, schema: flags.schema, }, - // TS-only flag with no Go telemetry-safety baseline; Go's nearest - // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most - // others) are unmarked, so it stays redacted. + // No established telemetry-safety baseline for --project-ref, so it stays redacted. aliases: { o: "output", f: "file", s: "schema" }, }), withJsonErrorHandling, diff --git a/apps/cli/src/commands/db/diff/diff.declarative.e2e.test.ts b/apps/cli/src/commands/db/diff/diff.declarative.e2e.test.ts index aa04ae0023..74f9214878 100644 --- a/apps/cli/src/commands/db/diff/diff.declarative.e2e.test.ts +++ b/apps/cli/src/commands/db/diff/diff.declarative.e2e.test.ts @@ -23,16 +23,11 @@ const DIFF_TEST_TIMEOUT_MS = DIFF_COMMAND_TIMEOUT_MS + LIFECYCLE_MARGIN_MS; -// CLI-1947 regression: pg-delta's `filterPublicBuiltInDefaults()` unconditionally -// treated PUBLIC's implicit built-in privilege as a no-op on both sides of a diff, -// so a declarative schema's `REVOKE ... FROM PUBLIC` on a function was silently -// dropped from the generated migration — exit code 0, no error, just a missing -// statement. Fixed upstream in @supabase/pg-delta@1.0.0-alpha.33 -// (supabase/pg-toolbelt#357). Verified directly against this repo's build: with -// the pre-fix pin (1.0.0-alpha.32) the migration below contains only the CREATE -// FUNCTION statement; the REVOKE is silently absent. This suite uses the local -// Docker-stack e2e coverage and never calls the Management API. See AGENTS.md's -// "E2e tests" section. +// Regression coverage: `filterPublicBuiltInDefaults()` treated PUBLIC's implicit built-in +// privilege as a no-op on both sides of a diff, so a declarative schema's `REVOKE ... FROM +// PUBLIC` on a function was silently dropped from the generated migration. Fixed upstream in +// @supabase/pg-delta@1.0.0-alpha.33. Uses local Docker-stack e2e coverage; see AGENTS.md's "E2e +// tests" section. describe("supabase db diff (e2e, pg-delta declarative privileges)", () => { let project: Awaited<ReturnType<typeof makeTempCliStackProject>> | undefined; @@ -63,10 +58,9 @@ describe("supabase db diff (e2e, pg-delta declarative privileges)", () => { ); requireCliSuccess(start, "start setup"); - // Minimal, deterministic repro: execute a fresh function's implicit PUBLIC - // EXECUTE grant, explicitly revoked, directly against the local database. - // `db query` is setup only; keep each statement in its own invocation - // because the legacy query command sends one prepared statement at a time. + // Minimal, deterministic repro: execute a fresh function's implicit PUBLIC EXECUTE grant, + // explicitly revoked, directly against the local database. `db query` is setup only; keep + // each statement in its own invocation, since it sends one prepared statement at a time. const createFunction = await runSupabase( [ "db", @@ -100,11 +94,8 @@ as $$ select 1; $$;`, expect(written, `no migration written; stderr:\n${diff.stderr}`).toBeTruthy(); const sql = readFileSync(path.join(migrationsDir, written as string), "utf8"); - // The negative-space regression: pre-fix, exit code 0 and this file would - // exist, but silently missing the REVOKE statement (only the CREATE FUNCTION - // survives). Anchor the match to the function's own REVOKE statement — up to - // its terminating `;` — so this cannot pass on an unrelated PUBLIC mention - // elsewhere in the file. + // Anchored to the function's own REVOKE statement, up to its terminating `;`, so this + // cannot pass on an unrelated PUBLIC mention elsewhere in the file. expect(sql).toMatch( /CREATE(?:\s+OR\s+REPLACE)?\s+FUNCTION\s+"?public"?\s*\.\s*"?probe_fn"?\s*\(\)/i, ); diff --git a/apps/cli/src/commands/db/diff/diff.errors.ts b/apps/cli/src/commands/db/diff/diff.errors.ts index 6aacb1ad31..cce6436b19 100644 --- a/apps/cli/src/commands/db/diff/diff.errors.ts +++ b/apps/cli/src/commands/db/diff/diff.errors.ts @@ -76,9 +76,8 @@ export class DbDiffDbNotRunningError extends Data.TaggedError("DbDiffDbNotRunnin readonly suggestion?: string; }> { // Must stay character-identical to `LocalDbRunningError`'s classification - // (`legacy-db-bootstrap`'s equivalent local-db-not-running check) — the two are - // deliberately duplicated for this command's own `AssertSupabaseDbIsRunning` - // parity target, not shared, so keep them in sync by hand. + // (`legacy-db-bootstrap`'s equivalent local-db-not-running check). The two are kept in sync by + // hand rather than shared, since they serve separate parity targets. get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { return this.daemonDown === true ? { ...actionability.dockerNotRunning, fingerprint_suffix: "docker_not_running" } @@ -87,17 +86,13 @@ export class DbDiffDbNotRunningError extends Data.TaggedError("DbDiffDbNotRunnin } /** - * Classic "assertNever" exhaustiveness helper: with every literal of - * `DbDiffPgAdminError["reason"]` handled by its own `case` below, `reason` - * narrows to `never` by the time it reaches this call — so a FUTURE reason added - * to the union without a matching `case` is a compile error here (its residual - * type inside `default:` would no longer be `never`), not a silently-absorbed - * classification. The parameter is intentionally unused at runtime: the drift - * guard (`error-actionability-coverage.unit.test.ts`) evaluates every getter - * against a field-less probe (`Object.create(prototype)`, no constructor args), - * so `this.reason` is genuinely runtime-`undefined` there, bypassing the type - * system entirely — this must still degrade to a valid declaration rather than - * `undefined`/a crash, so it returns the SAME fallback as the "differ" case. + * Exhaustiveness helper: with every literal of `DbDiffPgAdminError["reason"]` handled by its own + * `case` below, `reason` narrows to `never` here — so a new reason added to the union without a + * matching `case` is a compile error, not a silently-absorbed classification. + * + * The drift guard (`error-actionability-coverage.unit.test.ts`) evaluates every getter against a + * field-less probe, so `this.reason` is genuinely `undefined` at runtime here; this must still + * return a valid declaration rather than crash, so it falls back to the "differ" case's value. */ function pgAdminUnreachableReason(_reason: never): CliErrorActionabilityDeclaration { return actionability.dbFinding; diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index 5c8c35de28..4ca78558f0 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -80,11 +80,10 @@ import { diffSchemaPgAdmin } from "./pgadmin-diff.ts"; const warnDiff = `WARNING: The diff tool is not foolproof, so you may need to manually rearrange and modify the generated migration. Run ${aqua("supabase db reset")} to verify that the new migration does not generate errors.`; -// `--use-pg-schema` delegates to the bundled Go binary's in-process -// `stripe/pg-schema-diff` library, which has no TS/container equivalent (see -// SIDE_EFFECTS.md). The flag is deprecated in favor of the pg-delta engine. -// This warning is additive to (and prints before) the delegated child's own -// "experimental" warning, which it still prints unchanged. +// `--use-pg-schema` delegates to the bundled Go binary's in-process `stripe/pg-schema-diff` +// library, which has no TS/container equivalent (see SIDE_EFFECTS.md); the flag is deprecated in +// favor of the pg-delta engine. This warning prints before the delegated child's own unchanged +// "experimental" warning. const warnPgSchemaDeprecated = `${yellow("WARNING:")} "--use-pg-schema" is deprecated. Use the pg-delta engine ([experimental.pgdelta] enabled = true / --use-pg-delta) or the default migra engine instead.`; const declarativeBaselineAdvisory = (declarativePath: string | null) => ({ @@ -102,24 +101,16 @@ const declarativeBaselineNote = (displayPath: string) => `Note: db diff -f uses supabase/migrations as its baseline. Declarative schema files in ${displayPath} are not part of that baseline. If migrations are empty or outdated, the generated migration may include existing declarative objects. -f names the migration; it does not filter objects.\n`; /** - * Rebuilds the `db diff` argv for the `--use-pg-schema` delegate path — the CLI's - * sole remaining Go delegation on this command (the in-process - * `stripe/pg-schema-diff` library has no TS/container equivalent; `--use-pgadmin` - * is native). Flags stay flags (the Go-proxy channel-parity rule). The explicit - * `--from`/`--to` and engine mutex are already handled before this runs, and the - * mutex guarantees `--use-migra`/`--use-pgadmin`/`--use-pg-delta` are all unset - * whenever this is reached, so it just forwards `--use-pg-schema` plus the - * target / schema / file flags the user passed. + * Rebuilds the `db diff` argv for the `--use-pg-schema` delegate path — the CLI's sole remaining + * Go delegation on this command, since the in-process `stripe/pg-schema-diff` library has no + * TS/container equivalent. The explicit `--from`/`--to` and engine mutex are already handled + * before this runs, so it just forwards `--use-pg-schema` plus the target/schema/file flags. */ const rebuildPgSchemaDelegateArgs = (flags: DbDiffFlags): Array<string> => { const args = ["db", "diff", "--use-pg-schema"]; const pushTarget = (name: string, value: Option.Option<boolean>) => { - // Target flags (linked/local) are *selectors*: Go's ParseDatabaseConfig keys - // off `flag.Changed` before the value (`internal/utils/flags/db_url.go`), so a - // Changed-but-false flag still selects that target. Forward whenever `Some` - // (emitting `--flag=false` for `Some(false)`) so the child's `flag.Changed` - // matches the parent's `Option.isSome`; otherwise the child falls through to a - // different default target than the one the native path resolved. + // The child binary treats an explicitly passed `--flag=false` as selecting that target, so + // forward every explicitly set flag, not just the true ones. if (Option.isSome(value)) args.push(value.value ? `--${name}` : `--${name}=false`); }; if (Option.isSome(flags.dbUrl)) args.push("--db-url", flags.dbUrl.value); @@ -127,8 +118,8 @@ const rebuildPgSchemaDelegateArgs = (flags: DbDiffFlags): Array<string> => { pushTarget("local", flags.local); if (Option.isSome(flags.file)) args.push("--file", flags.file.value); if (Option.isSome(flags.output)) args.push("--output", flags.output.value); - // Re-encode each parsed schema as a CSV field so the Go child's pflag StringSlice - // CSV parse doesn't re-split a comma-containing schema (e.g. `"tenant,one"`). + // Re-encoded as a CSV field so the child's pflag CSV parser doesn't re-split a + // comma-containing schema (e.g. "tenant,one"). for (const s of flags.schema) args.push("--schema", schemaToCsvField(s)); return args; }; @@ -151,9 +142,9 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { let linkedRefForCache: string | undefined; yield* Effect.gen(function* () { - // The engine flags (`use-migra use-pgadmin use-pg-schema use-pg-delta`) and the - // target flags (`db-url linked local`) are each mutually exclusive groups; - // "set" means the flag was explicitly passed (`Option.isSome`). + // The engine flags (`use-migra use-pgadmin use-pg-schema use-pg-delta`) and the target flags + // (`db-url linked local`) are each mutually exclusive groups; "set" means the flag was + // explicitly passed (`Option.isSome`). const engineSet: Array<string> = []; if (Option.isSome(flags.useMigra)) engineSet.push("use-migra"); if (Option.isSome(flags.usePgAdmin)) engineSet.push("use-pgadmin"); @@ -178,11 +169,9 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { ); } - // Config is read lazily per path, not unconditionally up front: reading the base - // config before the ref is known would validate fields a `[remotes.<ref>]` block - // overrides (db.major_version, deno_version, …), which would fail a linked diff - // that should succeed. The delegate paths forward to the Go child (which loads - // config itself), so they read nothing here. + // Config is read lazily per path, not unconditionally up front: reading the base config + // before the ref is known would validate fields a `[remotes.<ref>]` block overrides, which + // would fail a linked diff that should succeed. The delegate paths load config themselves. // Explicit `--from`/`--to` mode: both required, always pg-delta. An empty value // (a shell var expanding to `""`) counts as unset — `--from "" --to ""` falls @@ -199,18 +188,11 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { }), ); } - // `--project-ref` never implies `--linked` and must not be silently - // discarded — see push.handler.ts's identical guard for the full TS-only - // rationale. Two exceptions in explicit mode: (1) `--from linked` / - // `--to linked` resolves a linked ref (via `resolveRef`'s "linked" case - // below) without any `--linked`/target flag at all, so the guard must - // NOT fire there — only when NEITHER side is the literal ref "linked" - // (e.g. plain `--project-ref X`, or `--from local --to migrations - // --project-ref X`, where the flag genuinely goes unused). (2) a changed - // `--linked` (even `--linked=false`) genuinely consumes `--project-ref` - // via the preflight below, whose `preflightConnType` keys off - // `Option.isSome(flags.linked)` regardless of the boolean's value — so - // the guard must not fire whenever `--linked` was explicitly set. + // `--project-ref` never implies `--linked` and must not be silently discarded (see + // push.handler.ts's identical guard). Two exceptions in explicit mode: `--from`/`--to + // linked` resolves a linked ref without any `--linked`/target flag, so the guard must not + // fire when either side is the literal ref "linked"; and a changed `--linked` (even + // `--linked=false`) genuinely consumes `--project-ref` via the preflight below. if ( Option.isSome(flags.projectRef) && Option.isNone(flags.linked) && @@ -224,20 +206,18 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { }), ); } - // `mergedLinkedRef` tracks the linked ref resolved so far (preflight or - // cascade) so the config read below + a later `migrations` catalog export - // merge the matching `[remotes.<ref>]` override. Undefined until a linked ref - // resolves, so a `migrations` ref resolved before any linked ref uses base. + // `mergedLinkedRef` tracks the linked ref resolved so far (preflight or cascade) so the + // config read below and a later `migrations` catalog export merge the matching + // `[remotes.<ref>]` override. Undefined until a linked ref resolves, so a `migrations` ref + // resolved before any linked ref uses base. let mergedLinkedRef: string | undefined; - // The FIRST migrations endpoint resolved wins: the engine provisions a single - // migrations shadow/catalog, and resolution-order (like `mergedLinkedRef` above) - // says only refs resolved before that point should influence it. + // The first migrations endpoint resolved wins: the engine provisions a single migrations + // shadow/catalog, so only refs resolved before that point should influence it. let migrationsToml: DbTomlValues | undefined; - // The preflight target resolve below validates a changed target flag - // (`--db-url bad` fails parsing) and is STATEFUL: a changed `--linked` - // resolves the project ref and merges `[remotes.<ref>]`, so the explicit - // `local`/`migrations` refs and `pgDeltaFormatOptions()` below see that - // override. `--local`/`--db-url` load base config (no merge). + // The preflight target resolve below validates a changed target flag and is stateful: a + // changed `--linked` resolves the project ref and merges `[remotes.<ref>]`, so the + // explicit `local`/`migrations` refs below see that override. `--local`/`--db-url` load + // base config (no merge). if (Option.isSome(flags.dbUrl) || Option.isSome(flags.linked) || Option.isSome(flags.local)) { const preflightConnType: DbConnType = Option.isSome(flags.dbUrl) ? "db-url" @@ -309,22 +289,19 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { } satisfies PgDeltaDatabaseEndpoint; } case "migrations": - // Preserve resolution order: the migrations shadow/catalog config must - // reflect only refs resolved before THIS endpoint, same contract as the - // `projectRef` spread below. + // Preserve resolution order: the migrations shadow/catalog config must reflect + // only refs resolved before this endpoint. migrationsToml ??= cfg; return { kind: "migrations", - // Preserve resolution order: only refs resolved before this endpoint - // influence the migrations shadow/catalog. ...(mergedLinkedRef !== undefined ? { projectRef: mergedLinkedRef } : {}), } satisfies PgDeltaEndpoint; case "url": return { kind: "database", ref, - // The next engine parses arbitrary explicit URLs itself. They are - // remote by default, matching Go's TLS-safe connection path. + // The next engine parses arbitrary explicit URLs itself; they connect as remote + // by default, so TLS is used. connectOptions: { isLocal: false, dnsResolver }, } satisfies PgDeltaDatabaseEndpoint; default: @@ -388,24 +365,15 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { return; } - // `--use-pg-schema` delegates to the bundled Go binary's in-process - // pg-schema-diff library, which has no TS/container equivalent. It is an - // explicit engine selection that does not depend on config, so it - // short-circuits before the target resolve. Disable the child's telemetry so - // the single `cli_command_executed` event comes from this TS command's - // instrumentation. `--use-pgadmin` does not short-circuit here: it needs the - // same config validation, `[remotes.<ref>]` merge print, and linked temp-role - // mint as the other native engines, so it resolves the target via the native - // pgadmin branch further down, which reuses this function's own target resolve. + // `--use-pg-schema` is an explicit engine selection that doesn't depend on config, so it + // short-circuits before the target resolve (disabling the child's telemetry so only this + // command's instrumentation fires). `--use-pgadmin` doesn't short-circuit: it needs the same + // config validation and target resolve as the other native engines, further down. const usePgAdmin = Option.getOrElse(flags.usePgAdmin, () => false); const usePgSchema = Option.getOrElse(flags.usePgSchema, () => false); - // The pg-schema engine delegates to the bundled Go binary, whose `db diff` - // never registered `--project-ref` — `rebuildPgSchemaDelegateArgs` cannot - // forward it, so the flag would be silently dropped and the child would diff - // the workdir's own linked ref: the exact wrong-project hazard the guards - // below exist to prevent. Fail up front instead. (`--use-pgadmin` is native - // as of CLI-1968 and honors `--project-ref` through this function's own - // target resolve, like every other native engine.) + // The pg-schema engine delegates to the bundled Go binary, whose `db diff` never registered + // `--project-ref`, so forwarding it would silently drop the flag and diff the workdir's own + // linked ref instead. Fail up front rather than risk the wrong project. if (usePgSchema && Option.isSome(flags.projectRef)) { return yield* Effect.fail( new DbDiffTargetFlagsError({ @@ -414,17 +382,15 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { ); } if (usePgSchema) { - // TS-only deprecation notice, printed before delegating (in both text and - // machine output modes — diagnostics stay stderr-only). The delegated Go - // `db diff --use-pg-schema` still prints its own experimental warning - // itself; this is additive, not a replacement, so don't drop it. + // TS-only deprecation notice, printed before delegating (diagnostics stay stderr-only in + // every mode). The delegated Go `db diff --use-pg-schema` still prints its own + // experimental warning; this is additive, not a replacement, so don't drop it. yield* output.raw(`${warnPgSchemaDeprecated}\n`, "stderr"); const env = { SUPABASE_TELEMETRY_DISABLED: "1" }; - // In machine-output mode the child's stdout is captured and re-emitted as a - // structured envelope, so scripted callers get valid JSON instead of the Go - // child's raw SQL on stdout (stdout is payload-only in machine mode). - // The delegated child owns any `--file` write, so the written migration path - // isn't introspectable here (reported as `file: null`). + // In machine-output mode the child's stdout is captured and re-emitted as a structured + // envelope, so scripted callers get valid JSON instead of the raw SQL. The delegated + // child owns any `--file` write, so the written path isn't introspectable here (`file: + // null`). if (output.format !== "text") { const captured = yield* proxy.execCapture(rebuildPgSchemaDelegateArgs(flags), { env, @@ -449,11 +415,8 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { ? "linked" : "local"; - // `--project-ref` never implies `--linked` and must not be silently - // discarded on a non-linked target — see push.handler.ts's identical guard - // for the full TS-only rationale. (Explicit `--from`/`--to` mode has its own - // earlier guard, with the `--from/--to linked` exception; this native path - // never reaches here when explicit mode ran.) + // `--project-ref` never implies `--linked` and must not be silently discarded on a + // non-linked target (see push.handler.ts's identical guard; explicit mode has its own). if (Option.isSome(flags.projectRef) && connType !== "linked") { return yield* Effect.fail( new DbDiffTargetFlagsError({ @@ -463,27 +426,16 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { ); } - // Go's `ParseDatabaseConfig` resolves the linked ref via the hard `LoadProjectRef`, THEN - // reads the `[remotes.<ref>]`-merged config (`LoadConfig`, which prints "Loading config - // override" unconditionally the moment a remote matches — `pkg/config/config.go:605`) — - // and only AFTER that calls `NewDbConfigWithPassword`, which does the actual connection - // work (TCP probe / temp-role mint over the Management API, `flags/db_url.go:87-97`). - // Pre-load the ref and read config here, before `resolver.resolve()` below, so the - // override print (and the merged-config validation) happen in that same order. - // Previously this read — and its print — ran AFTER `resolve()`, so a `resolve()` failure - // (bad password, unreachable host, network-ban lookup, …) left the user never knowing - // which `[remotes.*]` block had matched (review: PRRT_kwDOErm0O86XHvYl, pull.handler.ts's - // identical fix). The default `db diff` target is local/db-url, which never merges a - // remote block, so only the linked path pre-resolves a ref. + // The ref is resolved and config read here, before `resolver.resolve()` below, so the + // "Loading config override" print and merged-config validation happen before the actual + // connection work (TCP probe / temp-role mint). Only the linked path pre-resolves a ref; the + // default local/db-url target never merges a remote block. let linkedRef: string | undefined; if (connType === "linked") { const projectRefResolver = yield* ProjectRefResolver; linkedRef = yield* projectRefResolver.loadProjectRef(flags.projectRef); - // Cache the ref the moment it's known, not after `cfg`/`localInputs` below (both - // fallible) resolve: the project cache should still be written even when a LATER - // step (config validation, connection, the diff itself) fails, so this is set - // right after the ref resolves rather than only after - // `cfg`/`localInputs`/`resolver.resolve()` all succeed. + // Cached the moment the ref is known, not after `cfg`/`localInputs` below (both + // fallible) resolve, so the project cache is still written even if a later step fails. linkedRefForCache = linkedRef; } const cfg = yield* readDbToml(fs, path, cliSettings.workdir, linkedRef); @@ -498,17 +450,11 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const runtimeInfo = yield* RuntimeInfo; const networkIdFlag = yield* NetworkIdFlag; - // Built BEFORE `resolver.resolve()` below, not just before the "Creating shadow - // database..." banner: this call performs a SECOND config load (distinct from `cfg` - // above) and its own validation (e.g. enabled API TLS's cert/key files, read here — - // `cfg` above only tracks their dotted keys for remote-override gating, it never reads - // the files), which can print a warning (e.g. deprecated `[inbucket]`) or fail - // outright. All config loading (including any warnings) should happen once, before a - // linked target's temp-role mint over the Management API — otherwise a config broken - // only in a field this build reads would surface after that network side effect - // instead of before it. Only the actual Docker-image resolution below - // (`resolvePostgresImage`, lazy until this point) is the provisioning work the - // banner itself announces. + // Built before `resolver.resolve()` below, not just before the "Creating shadow + // database..." banner: this performs a second config load (distinct from `cfg` above) with + // its own validation, which can print a warning or fail outright. All config loading should + // happen before a linked target's temp-role mint over the Management API, so a config error + // surfaces before that network side effect, not after. const localInputs = yield* buildLocalDbContainerInputs( spawner, cliSettings.workdir, @@ -519,9 +465,8 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { // enabled-for-setup flags) reflects the matching `[remotes.<ref>]` override too, same // as `cfg` above (`readDbToml(..., linkedRef)`). connType === "linked" ? linkedRef : undefined, - // `cfg`'s OWN remote-override-key tracking (same matched block) — so a remote-set - // bootstrap field (e.g. `db.major_version`) isn't re-overridden by a conflicting - // `SUPABASE_*` env var when deriving the shadow's container spec. + // `cfg`'s own remote-override-key tracking (same matched block), so a remote-set + // bootstrap field isn't re-overridden by a conflicting `SUPABASE_*` env var. cfg.remoteOverrideKeys, ); @@ -538,11 +483,9 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { if (linkedRef !== undefined) linkedRefForCache = linkedRef; const targetUrl = toPostgresURL(resolved.conn); const ctx: PgDeltaContext = { - // `SUPABASE_PROJECT_ID` env override wins, then config.toml's `project_id`, then - // the workdir basename fallback, with the matched `[remotes.<ref>]` block's own - // `project_id` (`cfg.projectId`, already gated on `remoteOverrideKeys` by - // `readDbToml`) suppressing the raw env argument on the linked path — see - // that helper's own doc comment. + // `SUPABASE_PROJECT_ID` env override wins, then config.toml's `project_id`, then the + // workdir basename fallback; the matched `[remotes.<ref>]` block's own `project_id` + // suppresses the raw env argument on the linked path — see `readDbToml`'s doc comment. projectId: resolvePgDeltaProjectId(cliSettings.projectId, cfg, cliSettings.workdir), cwd: cliSettings.workdir, denoVersion: cfg.denoVersion, @@ -568,19 +511,15 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { yield* output.raw(schemaPathsTransitionWarning, "stderr"); } - // pgAdmin's own text-mode status lines go to STDOUT, not stderr — unlike the migra/ - // pg-delta path's diagnostics below, which always go to stderr. In machine output - // modes (json/stream-json) these are diagnostics, not payload, so they redirect to - // STDERR instead of being dropped (the repo's stdout-payload-only invariant), - // matching the sibling migra/pg-delta banner below, which keeps its own banner on - // stderr in every mode. + // pgAdmin's own text-mode status lines go to stdout, not stderr, unlike the migra/pg-delta + // path's diagnostics below. In machine output modes these are diagnostics, not payload, so + // they redirect to stderr instead of being dropped, matching the sibling banner below. const emitStatus = (line: string) => output.raw(`${line}\n`, output.format === "text" ? "stdout" : "stderr"); - // Shared by both branches below (pgAdmin's `shadowBase` and the migra/pg-delta - // `shadowInput`'s own spread) — resolving the image is the actual provisioning work each - // branch's own "Creating shadow database..." banner announces, so every call site still - // emits its banner FIRST and only then invokes this. + // Shared by both branches below — resolving the image is the actual provisioning work each + // branch's own "Creating shadow database..." banner announces, so every call site emits its + // banner first and only then invokes this. const resolveShadowRunInput = Effect.fnUntraced(function* () { const resolvedShadowImage = yield* localInputs.resolvePostgresImage; return shadowRunInputFromLocalContainerInputs( @@ -598,12 +537,10 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { readonly hazards?: PgDeltaDiffResult["hazards"]; }; if (usePgAdmin) { - // The running-db check runs AFTER the config load + target resolve above, and — - // unlike every other engine on this command — runs for `--linked`/`--db-url` too, - // not just the local target. Uses `ctx.projectId` (already remote-merge-resolved, - // see its own doc comment above), not the raw `cliSettings.projectId` env reader, so - // the check reflects a resolved remote merge rather than an ungated - // `SUPABASE_PROJECT_ID` env var. + // The running-db check runs after the config load + target resolve above, and — unlike + // every other engine on this command — runs for `--linked`/`--db-url` too, not just the + // local target. Uses `ctx.projectId` (already remote-merge-resolved), not the raw + // `cliSettings.projectId` env reader, so it reflects a resolved remote merge. const running = yield* isLocalDbRunning( spawner, fs, @@ -656,11 +593,11 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { }); yield* emitStatus("Diffing local database with current migrations..."); return yield* diffSchemaPgAdmin({ - // `source`/`target` are INVERTED relative to the migra/pg-delta path below: - // `source` is the USER'S db, `target` is the SHADOW. + // `source`/`target` are inverted relative to the migra/pg-delta path below: + // `source` is the user's db, `target` is the shadow. source: targetUrl, - // Deliberately hardcoded, not built via `toPostgresURL`: this ignores - // `SUPABASE_SERVICES_HOSTNAME`/`[db] password` by design — not a bug to fix. + // Hardcoded, not built via `toPostgresURL`: this ignores + // `SUPABASE_SERVICES_HOSTNAME`/`[db] password` by design, not a bug to fix. target: `postgresql://postgres:postgres@127.0.0.1:${shadowBase.shadowPort}/postgres`, schema: flags.schema, projectId: shadowBase.projectId, @@ -679,10 +616,9 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { ...(yield* resolveShadowRunInput()), targetLocal: resolved.isLocal, migrationMode, - // `cfg.schemaPathPatterns`, NOT `localInputs.context.config.db.migrations.schema_paths`: - // the latter is the raw `@supabase/config` field, which never applies - // `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` — `cfg` above (`readDbToml`) already - // resolves that env override. + // `cfg.schemaPathPatterns`, not `localInputs.context.config.db.migrations.schema_paths`: + // the latter is the raw `@supabase/config` field, which never applies the + // `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` env override that `cfg` (`readDbToml`) resolves. schemaPaths: cfg.schemaPathPatterns, pgDelta: cfg.pgDelta, }; @@ -839,10 +775,9 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { if (output.format !== "text") { yield* output.success("Diff complete.", { diff: out, - // `file` keeps the first written path for released consumers that read the - // string field (null when nothing was written); `files` lists EVERY written - // migration path in write order (a pg-delta plan writes one file per unit), - // mirroring pull's `schemaFiles` so machine callers see all of them. + // `file` keeps the first written path for released consumers that read the string field + // (null when nothing was written); `files` lists every written migration path in write + // order (a pg-delta plan writes one file per unit), mirroring pull's `schemaFiles`. file: writtenFiles[0] ?? null, files: writtenFiles, schemas: flags.schema, From e825aef9bfc06888a8c26e728950bf3924de7f40 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 12:41:03 +0100 Subject: [PATCH 18/56] chore(repo): trim comments, shard 16 (1641 -> 725 comment lines) --- .../project-environment.unit.test.ts | 43 +-- .../src/command-internal/project-target.ts | 89 ++--- .../project-target.unit.test.ts | 7 +- .../cli/src/command-internal/prompt-yes-no.ts | 48 +-- apps/cli/src/command-internal/raw-http.ts | 11 +- apps/cli/src/command-internal/ref-patterns.ts | 6 +- .../cli/src/command-internal/resolve-token.ts | 18 +- apps/cli/src/command-internal/rune-width.ts | 17 +- apps/cli/src/command-internal/schema-flags.ts | 38 +- .../schema-flags.unit.test.ts | 10 - apps/cli/src/command-internal/seed-buckets.ts | 193 +++------- apps/cli/src/command-internal/seed-ops.ts | 66 ++-- .../command-internal/seed-ops.unit.test.ts | 39 +- apps/cli/src/command-internal/seed.ts | 49 +-- .../src/command-internal/seed.unit.test.ts | 19 +- .../src/command-internal/service-catalog.ts | 32 +- .../service-version-overrides.ts | 17 +- apps/cli/src/command-internal/size-units.ts | 36 +- .../src/command-internal/sql-files-glob.ts | 354 ++++-------------- .../sql-files-glob.unit.test.ts | 225 ++--------- apps/cli/src/command-internal/sql-split.ts | 85 ++--- .../command-internal/sql-split.unit.test.ts | 7 +- .../command-internal/start-secrets-cleanup.ts | 74 +--- .../start-secrets-cleanup.unit.test.ts | 7 - .../cli/src/command-internal/status-errors.ts | 22 +- .../cli/src/command-internal/status-pretty.ts | 98 ++--- .../status-pretty.unit.test.ts | 40 +- .../cli/src/command-internal/status-values.ts | 161 +++----- .../status-values.unit.test.ts | 31 +- .../command-internal/storage-bucket-config.ts | 43 +-- 30 files changed, 484 insertions(+), 1401 deletions(-) diff --git a/apps/cli/src/command-internal/project-environment.unit.test.ts b/apps/cli/src/command-internal/project-environment.unit.test.ts index bbf4e58097..7da68a4eb4 100644 --- a/apps/cli/src/command-internal/project-environment.unit.test.ts +++ b/apps/cli/src/command-internal/project-environment.unit.test.ts @@ -36,9 +36,7 @@ function fakeProjectEnv( }, values, loadedPaths: [], - // Default every given value to "ambient" unless the caller says otherwise — - // matches how most tests use this helper (representing an already-resolved, - // highest-precedence value) without forcing every call site to spell it out. + // Defaults each value's source to "ambient" so callers don't need to spell it out. sources: Object.fromEntries(Object.keys(values).map((key) => [key, sources[key] ?? "ambient"])), }; } @@ -65,9 +63,6 @@ describe("resolveProjectEnvironmentValues", () => { }); it("lets already-resolved projectEnv.values win over anything discovered locally", () => { - // `projectEnv.values` already reflects loadCliProjectEnvironment's correct - // ambient-wins-over-supabase/.env(.local) result; a redundant root .env - // entry for the same key must never override it. writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); const projectEnv = fakeProjectEnv({ SUPABASE_PROJECT_ID: "ambient-project" }); const merged = resolveProjectEnvironmentValues(projectEnv, root); @@ -117,8 +112,6 @@ describe("resolveProjectEnvironmentValues", () => { }); it("preserves a literal # in an unquoted value with no leading whitespace, matching godotenv", () => { - // godotenv only starts an inline comment at a `#` preceded by whitespace; - // `foo#bar` keeps the `#` verbatim. writeFileSync(root + "/.env", "SUPABASE_AUTH_JWT_SECRET=long#secret\n"); const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("long#secret"); @@ -131,29 +124,19 @@ describe("resolveProjectEnvironmentValues", () => { }); it("strips a trailing comment after a quoted value, matching godotenv", () => { - // godotenv's `extractVarValue` locates the quoted span by scanning forward for the - // closing quote and discards anything after - // it as a comment — the value is `demo`, not the literal `"demo"` a check that - // requires the whole trimmed remainder to end with a quote would produce. writeFileSync(root + "/.env", 'SUPABASE_PROJECT_ID="demo" # local\n'); const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo"); }); it("accepts a colon-separated assignment, matching godotenv's YAML-style key/value form", () => { - // godotenv's `locateKeyName` treats `=` and `:` as interchangeable separators, - // and the repo's other dotenv parser - // (`packages/config/src/project.ts`'s `parseDotEnv`) already accepts both. writeFileSync(root + "/.env", "SUPABASE_PROJECT_ID: colon-project\n"); const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); expect(merged["SUPABASE_PROJECT_ID"]).toBe("colon-project"); }); it("prefers an env-specific file over a same-key value projectEnv.values sourced from a bare .env file", () => { - // `projectEnv.values` has no notion of SUPABASE_ENV-selected filenames, so - // a key it resolved from a plain supabase/.env file is NOT necessarily - // higher Go precedence than a same-named key from `.env.<env>.local` — - // only an "ambient" source outranks the file precedence computed locally. + // Only an "ambient" source outranks the file precedence computed locally. process.env["SUPABASE_ENV"] = "development"; writeFileSync( join(supabaseDir, ".env.development.local"), @@ -189,8 +172,6 @@ describe("resolveProjectEnvironmentValues", () => { }); it("expands an unquoted $VAR reference to an earlier value in the same file", () => { - // godotenv expands unquoted/double-quoted references while loading, - // so a later key can reuse an earlier one. writeFileSync(join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID=$BASE\n"); const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo"); @@ -203,8 +184,6 @@ describe("resolveProjectEnvironmentValues", () => { }); it("does not expand variable references inside single-quoted values", () => { - // godotenv never calls expandVariables for single-quoted values — - // they stay byte-literal. writeFileSync(join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID='$BASE'\n"); const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); expect(merged["SUPABASE_PROJECT_ID"]).toBe("$BASE"); @@ -223,11 +202,6 @@ describe("resolveProjectEnvironmentValues", () => { }); it("preserves a backslash-escaped $VAR reference as a literal, matching godotenv's escape rule", () => { - // godotenv's expandVarRegex captures a leading backslash and strips ONLY - // that backslash, returning the rest of the match verbatim instead of - // doing a lookup — even when - // BASE is defined, `demo\$BASE` must stay `demo$BASE`, not become - // `demodemo`. writeFileSync(join(root, ".env"), "BASE=demo\nSUPABASE_PROJECT_ID=demo\\$BASE\n"); const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo$BASE"); @@ -246,11 +220,8 @@ describe("resolveProjectEnvironmentValues", () => { }); it("preserves a multiline quoted value alongside an unrelated SUPABASE_* key (godotenv parity)", () => { - // godotenv's parser scans the whole buffer with a cursor, not line-by-line, - // so a quoted value spanning physical - // lines — e.g. a pasted PEM private key — doesn't break parsing of the rest - // of the file. A naive line-by-line reader would see the continuation line - // as malformed and abort before SUPABASE_PROJECT_ID is ever read. + // A quoted value spanning physical lines (e.g. a pasted PEM key) must not break + // parsing of the rest of the file. const pem = "-----BEGIN PRIVATE KEY-----\nMIIBogIBAAJ\n-----END PRIVATE KEY-----"; writeFileSync( join(root, ".env"), @@ -261,10 +232,8 @@ describe("resolveProjectEnvironmentValues", () => { }); describe("when no project was found (projectEnv is null)", () => { - // `loadNestedEnv` runs unconditionally before `config.toml` is ever - // opened, so a missing config file must - // not skip dotenv loading — these cover the local fallback that derives - // `<workdir>/supabase`/`workdir` directly instead of giving up. + // A missing config.toml must not skip dotenv loading; these cover the fallback + // that derives `<workdir>/supabase` directly. it("still reads a supabase/-dir dotenv file directly under workdir", () => { writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=fallback-project\n"); diff --git a/apps/cli/src/command-internal/project-target.ts b/apps/cli/src/command-internal/project-target.ts index c94e30021b..5a032a9270 100644 --- a/apps/cli/src/command-internal/project-target.ts +++ b/apps/cli/src/command-internal/project-target.ts @@ -20,11 +20,9 @@ import { } from "../shared/telemetry/error-actionability.ts"; /** - * The resolved comparison/pull target for a command that accepts - * `--project-ref` (currently `config diff`, `config pull`, `config push`, and - * the top-level `pull` orchestrator): a project ref, plus the branch - * name/UUID `--project-ref` carried when it named one — `undefined` for a - * ref-shaped or linked-fallback target. + * The resolved comparison/pull target for a command that accepts `--project-ref` + * (`config diff`, `config pull`, `config push`, `pull`): a project ref, plus the branch + * name/UUID it was given, if any. */ export interface ConfigTarget { readonly ref: string; @@ -33,9 +31,8 @@ export interface ConfigTarget { /** * Builds the four target-resolution failures {@link resolveConfigTarget} can raise. - * One type parameter, not four: every family's builder set is produced by - * {@link configTargetErrorsFor}, whose return type unions the four minted classes, so - * the resolver never has to infer them position-by-position. + * A single type parameter suffices because {@link configTargetErrorsFor} already unions + * the four minted classes into one type. */ export interface ConfigTargetErrors<TError> { /** @@ -60,17 +57,12 @@ export interface ConfigTargetErrors<TError> { type ConfigTargetErrorClass<E> = new (args: { readonly message: string }) => E; /** - * Mints one calling command family's four target-resolution error classes from its - * name prefix (`"ConfigDiff"`, `"ConfigPull"`, `"ConfigPush"`, `"Pull"`). The classes - * stay per-family so `_tag`, telemetry fingerprint, and actionability remain family-owned - * and distinct; only their (identical) definitions live here. + * Mints one calling command family's four target-resolution error classes from its name + * prefix, keeping `_tag`, fingerprint, and actionability family-owned from one definition. * - * The tags are template-interpolated, so `error-tag-stability.unit.test.ts`'s static - * regex scan cannot see them in THIS file — which is why every caller must re-export - * each minted class from its own `*.errors.ts` under the family-prefixed name. That - * test's `collectComputedTagDeclarations` walks `Object.entries(module)` on each of - * those four caller files instead, constructing every exported class to read its real, - * interpolated `_tag` off the instance. + * Every caller must re-export each minted class from its own `*.errors.ts` under the + * family-prefixed name; `error-tag-stability.unit.test.ts`'s static scan only sees the + * template-interpolated tags there. */ export function mintConfigTargetErrors<Prefix extends string>(prefix: Prefix) { class BranchNotFoundError extends Data.TaggedError(`${prefix}BranchNotFoundError`)<{ @@ -105,11 +97,9 @@ export function mintConfigTargetErrors<Prefix extends string>(prefix: Prefix) { } /** - * Wraps a family's four minted classes as the `errors` bundle - * {@link resolveConfigTarget} takes. The message text is identical across every - * family that resolves a branch-shaped `--project-ref`, so it lives here rather than being - * restated in each handler. Four type parameters purely so the declared return type can - * UNION them — the resolver itself then needs only one. + * Wraps a family's four minted classes as the `errors` bundle {@link resolveConfigTarget} + * takes. The message text is identical across every family, so it lives here instead of + * being restated per handler. */ export function configTargetErrorsFor< TNotLinked, @@ -138,33 +128,19 @@ export function configTargetErrorsFor< } /** - * What {@link resolveConfigTarget} needs to know about a branch-lookup failure to - * decide whether it was a 404. Every family's `mapResolveError` comes from - * `mapHttpError`, whose failure union has exactly ONE arm carrying an HTTP status - * (its `statusError` class, `{ status, body, message }`); the transport, request-validation, - * and request-body arms carry none, and neither do the parent-ref resolver's failures. - * Modelling `status` as optional is what lets that whole union satisfy this bound, and turns - * the 404 test below into an ordinary typed field read instead of a `Predicate` duck-type - * probe. `_tag` is required only so this is not a weak type. + * What {@link resolveConfigTarget} needs to know about a branch-lookup failure to decide + * whether it was a 404. `status` is optional because only one arm of the `mapHttpError` + * failure union carries an HTTP status; every other producer has none. `_tag` is required + * so the interface isn't a TypeScript weak type. */ export interface ConfigTargetResolveFailure { readonly _tag: string; readonly status?: number | undefined; } -/** - * Reclassifies a status-shaped branch-lookup failure carrying a 404 as - * `notFoundError`; every other status/network failure re-fails with its - * original identity unchanged. A standalone generic function (rather than a - * refinement passed to `Effect.catchIf`) so its own return type — a union of - * two different `Effect` instantiations — is inferred directly from this - * function's body instead of backward through `Effect.catch`'s inference. - * - * Deliberately NOT baked into `mapResolveError`: `config pull` shares ONE mapper across two - * call sites (the branch lookup AND the `/v2/projects/{ref}/config` read — see - * `pull.errors.ts`'s `ConfigPullReadNetworkError` doc comment), so folding the 404 rule - * into the mapper would misreport a 404 from the unrelated config read as "branch not found". - */ +// Not folded into `mapResolveError`: `config pull` shares one mapper across the branch +// lookup and the config read, so treating every 404 as "branch not found" here would +// misclassify a config-read 404. function reclassifyBranchNotFoundError<E extends ConfigTargetResolveFailure, C>( cause: E, notFoundError: C, @@ -173,23 +149,11 @@ function reclassifyBranchNotFoundError<E extends ConfigTargetResolveFailure, C>( } /** - * Resolves `--project-ref` to a {@link ConfigTarget}. `--project-ref` - * accepts a project ref, or the name (or UUID) of a branch of the linked - * project — `link`'s settled vocabulary (CLI-2167). A ref-shaped value - * (exactly 20 lowercase letters) is always treated as a project ref. - * - * A UUID target resolves through `GET /v1/branches/{id}` directly, which - * needs no parent ref at all, so it keeps the fully lazy parent resolution - * below — the parent-scoped resolver is never evaluated for it, which is - * exactly what lets it work in an unlinked directory. + * Resolves `--project-ref` to a {@link ConfigTarget}: a project ref, or the name/UUID of a + * branch on the linked project. A ref-shaped value (20 lowercase letters) is always a project ref. * - * A NAME target, by contrast, needs the parent project ref to search under, - * so it is resolved eagerly, BEFORE any spinner starts — mirroring `link` - * (link.handler.ts:198-213): an unlinked directory (or a corrupt/stale linked - * ref) must fail immediately with a link-grade error naming the value the - * user passed, rather than falling through to `resolver.resolve`'s - * interactive project picker rendering under a live "Resolving branch..." - * spinner. + * A UUID branch needs no parent ref, so it works unlinked; a name branch resolves the parent + * ref eagerly, before any spinner starts, so an unlinked or stale link fails immediately. */ export function resolveConfigTarget<TError, EResolve extends ConfigTargetResolveFailure>( requested: Option.Option<string>, @@ -234,9 +198,8 @@ export function resolveConfigTarget<TError, EResolve extends ConfigTargetResolve ); yield* resolving?.clear() ?? Effect.void; - // The resolved branch might not have a project ref yet (still - // provisioning) — never let an empty/placeholder ref reach the - // config-read call (mirrors link.handler.ts:248-256's guard). + // The resolved branch might not have a project ref yet (still provisioning); don't + // let an empty ref reach the config-read call. if (!BRANCH_PROJECT_REF_PATTERN.test(ref)) { return yield* Effect.fail(errors.branchNotReady(target)); } diff --git a/apps/cli/src/command-internal/project-target.unit.test.ts b/apps/cli/src/command-internal/project-target.unit.test.ts index 7aba4a8104..74db5949b4 100644 --- a/apps/cli/src/command-internal/project-target.unit.test.ts +++ b/apps/cli/src/command-internal/project-target.unit.test.ts @@ -1,9 +1,6 @@ /** - * Unit tests for project-target.ts's `mintConfigTargetErrors` factory and - * `configTargetErrorsFor` builder — independent of any family's own - * `*.errors.ts` file, so this test keeps verifying the minting mechanism even - * if diff/pull/push's error files change shape entirely (see - * `error-actionability-coverage.unit.test.ts`'s runtime/AST-scan split). + * Unit tests for the `mintConfigTargetErrors` factory and `configTargetErrorsFor` + * builder, independent of any family's own `*.errors.ts` file. */ import { describe, expect, it } from "vitest"; diff --git a/apps/cli/src/command-internal/prompt-yes-no.ts b/apps/cli/src/command-internal/prompt-yes-no.ts index 8f21b32447..b8a6c975f7 100644 --- a/apps/cli/src/command-internal/prompt-yes-no.ts +++ b/apps/cli/src/command-internal/prompt-yes-no.ts @@ -4,13 +4,11 @@ import { Output } from "../shared/output/output.service.ts"; import { Stdin } from "../shared/runtime/stdin.service.ts"; import { Tty } from "../shared/runtime/tty.service.ts"; -/** Go's non-TTY `Console.ReadLine` timeout (`internal/utils/console.go:36`). */ const NON_TTY_TIMEOUT_MILLIS = 100; /** - * Port of Go's `parseYesNo` (`apps/cli-go/internal/utils/console.go:84-93`): - * case-insensitive and trimmed. `y`/`yes` → `true`, `n`/`no` → `false`, - * anything else → `undefined` (caller falls back to the default). + * Parses a yes/no answer, case-insensitively and trimmed: `y`/`yes` → `true`, `n`/`no` → + * `false`, anything else → `undefined` (caller falls back to the default). */ export const parseYesNo = (input: string): boolean | undefined => { const s = input.trim().toLowerCase(); @@ -24,32 +22,11 @@ export const parseYesNo = (input: string): boolean | undefined => { }; /** - * Confirm-or-default prompt mirroring Go's `console.PromptYesNo` - * (`apps/cli-go/internal/utils/console.go:64-82`) — the single Go-faithful - * confirmation helper for every Go-parity prompt (CLI-1974). It lives in - * `command-internal/` (not `command-internal/`) because Go-parity confirmations also - * fire from shell-agnostic shared code (`shared/functions/deploy.ts` prune, - * `shared/init/project-init.ts` IDE settings): - * - when `yes` is set, echoes `<label> [Y/n|y/N] y` and returns true even on a - * TTY (Go auto-confirms with the affirmative echo, `console.go:70-72`); - * - when `interactive` is false (Go callers that force `console.IsTTY = false`, - * e.g. `buckets.Run(ctx, "", false, fsys)` during `db reset`), behaves like a - * non-TTY stdin: label + one bounded line scan, honoring a parsed answer; - * - `json`/`stream-json` output never prompts and uses the default silently; - * - a real TTY otherwise prompts with the given default via clack; - * - on a non-TTY stdin, Go does **not** short-circuit to the default: it prints - * the label, scans the next piped line, echoes it, and honors a parsed answer - * (`console.go:74-82,96-102`). Only empty/unparseable input falls back to the - * default. The shared `Stdin` reader supplies the piped line (one per prompt). - * - * Callers resolve `yes` via `resolveYes` so it honors both `--yes` and - * `SUPABASE_YES`, matching Go's `viper.GetBool("YES")` (root.go:318-320,334). - * - * NOTE: the migration family's `migrationConfirm` - * (`commands/migration/migration.prompt.ts`) intentionally diverges on - * the TS-only machine modes (it prompts regardless of `output.format`) and on a - * real TTY (raw stdin read instead of clack) — see its doc comment before - * consolidating the two (CLI-1974 review). + * Confirm-or-default prompt shared by command handlers and shell-agnostic code alike. + * `yes` echoes an affirmative answer and returns `true` immediately; non-text output uses + * the default silently; a real interactive TTY prompts via clack; otherwise (including + * `interactive: false`) it reads one line via the shared `Stdin` reader, falling back to + * the default only when the line is empty or unparseable. */ export const promptYesNo = Effect.fnUntraced(function* ( output: typeof Output.Service, @@ -67,15 +44,10 @@ export const promptYesNo = Effect.fnUntraced(function* ( return defaultValue; } const tty = yield* Tty; - // `interactive === false` mirrors Go callers that force `console.IsTTY = false` - // (e.g. `buckets.Run(ctx, "", false, fsys)` during `db reset`): Go does NOT - // silently take the default — `PromptYesNo` still prints the label, scans one - // line with the 100ms timeout, echoes it, and honors a parsed answer - // (`console.go:64-102`). So route it through the same non-TTY read path. + // `interactive: false` still prints the label and reads one line instead of silently + // returning the default — it uses the same non-TTY read path below. if (!interactive || !tty.stdinIsTty) { - // Go's `PromptText` prints the label, then `ReadLine` scans one line and (on a - // non-TTY) echoes it to stderr (`console.go:96-102`). A parsed piped answer - // wins; an empty/exhausted scan or an unparseable line uses the default. + // A parsed piped answer wins; an empty or unparseable line falls back to the default. yield* output.raw(`${label} [${choices}] `, "stderr"); const stdin = yield* Stdin; const line = yield* stdin.readLine(NON_TTY_TIMEOUT_MILLIS); diff --git a/apps/cli/src/command-internal/raw-http.ts b/apps/cli/src/command-internal/raw-http.ts index ed59bb0709..0e4c08bf19 100644 --- a/apps/cli/src/command-internal/raw-http.ts +++ b/apps/cli/src/command-internal/raw-http.ts @@ -2,14 +2,11 @@ import { Option, type Redacted } from "effect"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; /** - * Applies the Management API auth + identification headers to a raw - * `HttpClientRequest`: an `Authorization: Bearer` header when a token is - * present, and the CLI `User-Agent`. + * Applies the Management API auth + identification headers to a raw `HttpClientRequest`: + * an `Authorization: Bearer` header when a token is present, and the CLI `User-Agent`. * - * Shared by the commands that bypass the typed Management API client and - * issue raw HTTP (e.g. `postgres-config` arbitrary key/value updates, - * `config push` cost-matrix fetch) so the bearer/User-Agent wiring lives in one - * place instead of being copy-pasted per command. + * Shared by commands that bypass the typed Management API client and issue raw HTTP + * (e.g. `postgres-config`, `config push`'s cost-matrix fetch). */ export function requestWithAuth( request: HttpClientRequest.HttpClientRequest, diff --git a/apps/cli/src/command-internal/ref-patterns.ts b/apps/cli/src/command-internal/ref-patterns.ts index d7a15b3513..b0e238c540 100644 --- a/apps/cli/src/command-internal/ref-patterns.ts +++ b/apps/cli/src/command-internal/ref-patterns.ts @@ -1,8 +1,6 @@ /** - * Service-free ref/branch identifier patterns, shared by the resolver - * machinery and pure formatters alike — a formatter documented as "no - * Effect, no services" must not pull a service-bearing module into its - * dependency graph just to reach a regex. + * Service-free ref/branch identifier patterns. A formatter documented as "no Effect, no + * services" must not pull in a service-bearing module just to reach a regex. */ /** diff --git a/apps/cli/src/command-internal/resolve-token.ts b/apps/cli/src/command-internal/resolve-token.ts index 3f6820a5e6..62021921b5 100644 --- a/apps/cli/src/command-internal/resolve-token.ts +++ b/apps/cli/src/command-internal/resolve-token.ts @@ -4,20 +4,12 @@ import { CommandCredentials } from "../auth/command-credentials.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; /** - * Resolves the Management API access token, preferring an env-set value on - * `CommandSettings.accessToken` over the keyring / file-backed credentials - * service. + * Resolves the Management API access token: `CommandSettings.accessToken` (env-set) wins + * over the keyring/file-backed credentials service, and a validation failure + * (`InvalidAccessTokenError`) resolves to `None` rather than failing. * - * Returns `None` if no token is available. Callers that POST/PUT via the raw - * `HttpClient.HttpClient` use this to build an `Authorization: Bearer` header — - * typed-API-client callers don't need this because the API layer reads the - * token at layer-construction time. Shared between `sso add`, `sso update`, - * and any future raw-HTTP handlers that need the same fallback order. - * - * `Effect.catch` on the credentials lookup absorbs the validation error - * (`InvalidAccessTokenError`) into `None`. Handlers that need a token - * present should treat a `None` result as a hard failure; the typed API - * client's auth pipeline surfaces token-validation errors itself. + * For raw-HTTP callers building their own `Authorization: Bearer` header; typed-API-client + * callers don't need this since the API layer reads the token at layer-construction time. */ export const resolveAccessToken: Effect.Effect< Option.Option<Redacted.Redacted<string>>, diff --git a/apps/cli/src/command-internal/rune-width.ts b/apps/cli/src/command-internal/rune-width.ts index 7f64f7697d..110dbb7fb8 100644 --- a/apps/cli/src/command-internal/rune-width.ts +++ b/apps/cli/src/command-internal/rune-width.ts @@ -1,15 +1,10 @@ /** - * Terminal display width, matching `mattn/go-runewidth` with - * `EastAsianWidth=false` — the default in a modern terminal, which is how - * `olekukonko/tablewriter` measures cells (`db query`'s table/CSV writer). East Asian - * Wide/Fullwidth code points count as 2 columns, zero-width / combining marks as 0, - * and everything else (including East-Asian *Ambiguous*, which is narrow by default) - * as 1. Counting JS code points (`Array.from(s).length`) instead would under-measure - * CJK/emoji cells and misalign the table borders versus Go. - * - * The range tables cover the assigned Unicode East Asian Wide/Fullwidth blocks and the - * common combining/zero-width ranges; unassigned/exotic code points fall through to - * width 1, matching runewidth's default. + * Terminal display width with `EastAsianWidth=false` (the modern-terminal default): East + * Asian Wide/Fullwidth code points count as 2 columns, zero-width/combining marks as 0, and + * everything else (including ambiguous-width code points) as 1. Counting JS code points + * (`Array.from(s).length`) would under-measure CJK/emoji cells and misalign table borders. + * Range tables cover the assigned Wide/Fullwidth and zero-width blocks; unassigned code + * points fall back to width 1. */ // Sorted, non-overlapping [lo, hi] inclusive code-point ranges. diff --git a/apps/cli/src/command-internal/schema-flags.ts b/apps/cli/src/command-internal/schema-flags.ts index d0a40bd264..9aa44377ce 100644 --- a/apps/cli/src/command-internal/schema-flags.ts +++ b/apps/cli/src/command-internal/schema-flags.ts @@ -1,16 +1,11 @@ /** - * Normalizes a repeated `--schema` flag into the flat list Go produces. + * Normalizes a repeated `--schema` flag into a flat list, matching the CSV-per-occurrence + * parsing every `--schema`-accepting delegated Go subprocess expects (shared logic lives in + * `string-slice-flag.ts`). Also provides `schemaToCsvField`, the CSV re-encoder used when + * forwarding `--schema` back to that subprocess. * - * Go defines `--schema` as a Cobra `StringSliceVarP` on both `gen types` - * (`apps/cli-go/cmd/gen.go:155`) and `db lint` (`apps/cli-go/cmd/db.go:506`). - * The CSV-per-occurrence parsing itself lives in `string-slice-flag.ts` - * (shared with every other Go `StringSliceVar` flag ported to the - * CLI); this module re-exports it under the `--schema`-specific names and - * adds `schemaToCsvField`, the CSV re-encoder used when forwarding - * `--schema` back to a delegated Go subprocess. - * - * Shared by `gen types`, `db lint`, `db dump`, `db pull`, `db diff`, and - * `db schema {generate,sync}`. + * Shared by `gen types`, `db lint`, `db dump`, `db pull`, `db diff`, and `db schema + * {generate,sync}`. */ import { parseStringSliceFlag, StringSliceFlagParseError } from "./string-slice-flag.ts"; @@ -18,12 +13,9 @@ export { StringSliceFlagParseError as SchemaFlagParseError }; export const parseSchemaFlags = parseStringSliceFlag; -/** - * Whether a CSV field must be quoted. Mirrors Go's `encoding/csv` - * `Writer.fieldNeedsQuotes`: never quote the empty string; always quote `\.`; - * quote when the field contains `,`, `"`, `\r`, or `\n`; otherwise quote when the - * first rune is whitespace. - */ +// Whether a CSV field must be quoted (matches the `encoding/csv` writer a delegated Go +// subprocess re-parses): never quote the empty string; always quote `\.`; quote when the +// field contains `,`, `"`, `\r`, or `\n`; otherwise quote when the first rune is whitespace. function fieldNeedsQuotes(field: string): boolean { if (field === "") return false; if (field === "\\.") return true; @@ -32,13 +24,11 @@ function fieldNeedsQuotes(field: string): boolean { } /** - * Serializes a SINGLE parsed schema value back into one CSV field — the inverse of - * `readAsCSVStrict` for one element. A schema parsed from `--schema '"tenant,one"'` - * is the single value `tenant,one`; forwarding it raw to the Go binary would let - * pflag's `StringSlice` CSV-parse it a SECOND time and split it into two schemas. - * Re-encoding (mirroring Go's `csv.Writer`) keeps it one field so the delegated - * child sees exactly the schema set the native path would. Used when rebuilding - * `--schema` argv for the Go-delegated `db diff` / `db pull` paths. + * Serializes a single parsed schema value back into one CSV field — the inverse of + * `readAsCSVStrict` for one element. A schema parsed from `--schema '"tenant,one"'` is the + * single value `tenant,one`; forwarding it raw to a delegated Go subprocess would let pflag's + * `StringSlice` re-parse it as CSV and split it into two schemas, so this re-encodes it to + * keep it one field when rebuilding argv for `db diff`/`db pull`. */ export function schemaToCsvField(value: string): string { if (!fieldNeedsQuotes(value)) return value; diff --git a/apps/cli/src/command-internal/schema-flags.unit.test.ts b/apps/cli/src/command-internal/schema-flags.unit.test.ts index 7fbab50bec..54d19e95f4 100644 --- a/apps/cli/src/command-internal/schema-flags.unit.test.ts +++ b/apps/cli/src/command-internal/schema-flags.unit.test.ts @@ -7,7 +7,6 @@ describe("parseSchemaFlags (pflag StringSlice CSV parity)", () => { }); it("keeps a quoted value with embedded comma as a single element", () => { - // pflag TestSSWithComma: `"tenant,one"` → one element "tenant,one" expect(parseSchemaFlags(['"tenant,one"'])).toEqual(["tenant,one"]); }); @@ -28,7 +27,6 @@ describe("parseSchemaFlags (pflag StringSlice CSV parity)", () => { }); it("unescapes doubled double-quote inside quoted field", () => { - // Go csv: `"a""b"` → field is `a"b` expect(parseSchemaFlags(['"a""b"'])).toEqual(['a"b']); }); @@ -37,32 +35,25 @@ describe("parseSchemaFlags (pflag StringSlice CSV parity)", () => { }); it("preserves whitespace (Go does not trim)", () => { - // Go csv passes raw field values; pflag does not trim expect(parseSchemaFlags([" public , private "])).toEqual([" public ", " private "]); }); - // --- malformed inputs: must THROW --- - it("throws on an unterminated quoted field", () => { - // `"tenant` — opening quote but no closing quote expect(() => parseSchemaFlags(['"tenant'])).toThrow(SchemaFlagParseError); expect(() => parseSchemaFlags(['"tenant'])).toThrow(/extraneous or missing " in quoted-field/); }); it("throws on extra bytes after a closing quote", () => { - // `"a"b` — closing quote followed by a non-comma character expect(() => parseSchemaFlags(['"a"b'])).toThrow(SchemaFlagParseError); expect(() => parseSchemaFlags(['"a"b'])).toThrow(/extraneous or missing " in quoted-field/); }); it("throws on a bare quote inside an unquoted field", () => { - // `a"b` — bare " in a field that did not start with a quote expect(() => parseSchemaFlags(['a"b'])).toThrow(SchemaFlagParseError); expect(() => parseSchemaFlags(['a"b'])).toThrow(/bare " in non-quoted-field/); }); it("throws on the first malformed value in a multi-value list", () => { - // The valid "public" comes before the malformed one; the error is still thrown expect(() => parseSchemaFlags(["public", '"broken'])).toThrow(SchemaFlagParseError); }); }); @@ -89,7 +80,6 @@ describe("schemaToCsvField (inverse — re-encode one value as a CSV field)", () }); it("round-trips through the parser for awkward values", () => { - // parse(encode(x)) === [x] for the cases a delegated child would otherwise split. for (const value of ["public", "tenant,one", 'a"b', " leading", "a,b,c", ""]) { expect(parseSchemaFlags([schemaToCsvField(value)])).toEqual(value === "" ? [] : [value]); } diff --git a/apps/cli/src/command-internal/seed-buckets.ts b/apps/cli/src/command-internal/seed-buckets.ts index c135a9cc3c..efff202b12 100644 --- a/apps/cli/src/command-internal/seed-buckets.ts +++ b/apps/cli/src/command-internal/seed-buckets.ts @@ -34,26 +34,16 @@ import { bucketObjectKey } from "../commands/seed/buckets/buckets.upload.ts"; const CONFIG_PATH = "supabase/config.toml"; const UPLOAD_CONCURRENCY = 5; -/** - * Well-known OS metadata files (macOS Finder, Windows Explorer) that must - * never be uploaded as seeded objects — see CLI-1950. Go has no equivalent - * skip; this is an intentional TS-only improvement over Go's current (also - * buggy) behavior. - */ +// OS metadata files (macOS Finder, Windows Explorer) that must never be uploaded as seeded +// objects. const osJunkFileNames = new Set([".DS_Store", "Thumbs.db", "desktop.ini"]); -/** - * Mirrors `ValidateBucketName` regex. - * Used to validate `[storage.buckets]` names before any Storage API call, matching - * Go's config-load-time check. Vector and analytics names are - * NOT validated here — Go only validates `[storage.buckets]`. - */ +// Validates `[storage.buckets]` names before any Storage API call; vector and analytics +// bucket names are not validated here. const BUCKET_NAME_PATTERN = /^(?:[0-9A-Za-z_]|!|-|\.|\*|'|\(|\)| |&|\$|@|=|;|:|\+|,|\?)*$/; -/** - * Verbatim Go regex literal — used in the error message so it - * is byte-identical to Go's output. Do NOT derive from `BUCKET_NAME_PATTERN.source`. - */ +// Kept as a literal string (not derived from `BUCKET_NAME_PATTERN.source`) to keep the error +// message's regex text stable. const BUCKET_NAME_PATTERN_SOURCE = "^(\\w|!|-|\\.|\\*|'|\\(|\\)| |&|\\$|@|=|;|:|\\+|,|\\?)*$"; const validateBucketName = Effect.fnUntraced(function* (name: string) { @@ -96,76 +86,44 @@ function emptySummary(): SeedSummary { }; } -/** - * Embedded-default project config, decoded from an empty object — the same - * `decodeUnknownSync(CliConfigSchema)({})` the loader uses internally - * (`packages/config/src/io.ts:54-56`). `seed buckets` never aborts on a - * missing `config.toml`: it reads the package-global `utils.Config`, initialized - * to embedded defaults, and `config.Load` no-ops on a missing file. So "no - * config file" behaves like the embedded-default config. - */ +// Embedded-default config, decoded from an empty object — the same decoding the loader +// uses internally. A missing `config.toml` behaves like this default rather than aborting. const decodeDefaultCliConfig = Schema.decodeUnknownSync(CliConfigSchema); /** - * Core of `seed buckets`: load config (merging `[remotes.<ref>]` for a non-empty - * `projectRef`), validate bucket config, then upsert/seed buckets + objects against - * the Storage service gateway. Hoisted to `command-internal/` so both the `seed - * buckets` command and `db reset --local` can reuse the exact local-seed path Go - * invokes via `buckets.Run(ctx, "", false, fsys)`. - * - * `emitSummary` controls whether the machine-readable summary is written to stdout: - * the `seed buckets` command emits it; `db reset` does NOT (it emits its own - * result), matching Go where reset's `buckets.Run` prints nothing to stdout. - * - * `interactive` controls whether overwrite/prune confirmations may prompt. `db - * reset` reuses this core with `interactive: false` so it never blocks on input — - * Go forces it via `buckets.Run(ctx, "", false, fsys)`. Defaults to `true`. + * Core of `seed buckets`, shared by the `seed buckets` command and `db reset --local`: loads + * config (merging `[remotes.<ref>]` for a non-empty `projectRef`), validates bucket config, + * then upserts buckets + objects against the Storage service gateway. * - * The caller owns project-ref resolution, the linked-project cache write, and the - * telemetry flush (Go's PersistentPostRun) — this core does none of those. + * `emitSummary` gates the stdout summary; `interactive` (default `true`) gates overwrite/prune + * prompts. The caller owns project-ref resolution, cache writes, and telemetry. */ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { readonly projectRef: string; readonly emitSummary: boolean; readonly interactive?: boolean; /** - * Pre-resolved auto-confirm value. `db reset` resolves `yes` with the nested project - * `.env` loaded (`loadNestedEnv` runs before `buckets.Run`), so pass it through here — - * the internal fallback below only loads whatever THIS command's own project would - * supply. When omitted (the standalone `seed buckets` command), fall back to - * `resolveYesWithProjectEnv`, loading the project env ourselves — `seed buckets` - * defaults to `--local` (`seedFlags.Bool("local", true, ...)`), - * and root's `ParseDatabaseConfig` calls `LoadConfig` — loading the project `.env` files — - * before `buckets.Run`'s overwrite/prune prompts, so a `SUPABASE_YES` - * set only in `supabase/.env` must auto-confirm here too. + * Pre-resolved auto-confirm value. `db reset` passes its own value (resolved with the + * nested project `.env` already loaded) since the internal fallback below only loads + * whatever the standalone `seed buckets` command's own project would supply. When omitted, + * falls back to `resolveYesWithProjectEnv`, loading the project env itself. */ readonly yes?: boolean; /** - * Skips this function's own `loadCliConfig` reload in favor of a config - * the caller already resolved (and may have folded env overrides into — - * see `start.handler.ts`'s `effectiveLocalStorageConfig`). `buckets.Run` - * never reloads config itself: it reads the single process-wide `utils.Config` - * populated once by `Config.Load()` at CLI startup, so any `SUPABASE_*` - * override already in effect for the rest of that process (e.g. an - * env-overridden `api.port`/`api.tls.enabled` that actually brought Kong up - * differently) is automatically visible here too. `start` is a long-running - * process that resolves its own config/env once up front and must reuse - * that SAME resolution for bucket seeding to match — an independent reload - * from disk would silently drop any override that exists only in the - * shell/dotenv, not literally in config.toml. Only `start` passes this; - * the standalone `seed buckets` command's own single load already IS the - * one-shot `Config.Load()` for that process, so it keeps reloading below. + * Skips this function's own `loadCliConfig` reload in favor of a config the caller + * already resolved (see `start.handler.ts`'s `effectiveLocalStorageConfig`). Only `start` + * passes this: it resolves config/env once up front, and a fresh reload here would + * silently drop any override that exists only in the shell/dotenv, not in `config.toml`. */ readonly resolvedConfig?: { readonly config: CliConfig; readonly document: Record<string, unknown> | undefined; }; /** - * Already-resolved nested project dotenv map, when the caller's own config - * resolution walked it (`db reset`'s context, `start`) — same passthrough - * idea as `resolvedConfig` above. When omitted (the standalone command), - * loaded once below and shared by the `SUPABASE_YES` fallback and the - * storage credentials `SUPABASE_API_*` fold. + * Already-resolved nested project dotenv map, when the caller's own config resolution + * walked it (`db reset`, `start`) — same passthrough idea as `resolvedConfig`. When + * omitted, loaded once below and shared by the `SUPABASE_YES` fallback and the storage + * credentials `SUPABASE_API_*` fold. */ readonly projectEnvValues?: Readonly<Record<string, string>>; }) { @@ -180,10 +138,8 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { const { projectRef, emitSummary } = opts; const interactive = opts.interactive ?? true; - // Load config.toml, passing projectRef so `[remotes.*]` overrides are merged for - // --linked. A parse failure aborts before any network call. Skipped entirely - // when the caller already supplied `resolvedConfig` — see that option's doc - // comment above. + // Loads config.toml, merging `[remotes.*]` overrides for `--linked`; skipped when the + // caller already supplied `resolvedConfig`. const loadOptions: InternalLoadCliConfigOptions = projectRef !== "" ? { projectRef, goViperCompat: true, search: shouldSearchAncestors(cliSettings) } @@ -200,22 +156,14 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { }), ), ); - // A missing config file is NOT an early exit: Go uses embedded defaults and - // still gates the no-op on `len(projectRef) == 0`. So local + no-config falls - // into the no-op short-circuit; `--linked` + no-config falls through to the - // remote path so auth/project/API failures surface. `resolvedConfig` (when - // given) always wins over a `null` `loaded` — see that option's doc comment. - // The standalone `seed buckets` command now rejects an explicit-but-project-less - // workdir in its own handler (`buckets.handler.ts`, - // `requireExplicitWorkdirProject`) before ever reaching this function, so - // this fallback is reached only for a DEFAULTED workdir, or a `resolvedConfig` - // caller (`start`/`db reset`, which never load config here at all). + // A missing config file behaves as embedded defaults, not an early exit: local + no-config + // falls into the no-op short-circuit below, while `--linked` + no-config still falls + // through to the remote path so auth/project/API failures surface. const config = opts.resolvedConfig?.config ?? (loaded === null ? decodeDefaultCliConfig({}) : loaded.config); const document = opts.resolvedConfig?.document ?? (loaded === null ? undefined : loaded.document); - // Go prints this from inside config load whenever a - // `[remotes.*]` block matched the linked ref. stderr in all output modes. + // Printed whenever a `[remotes.*]` block matched the linked ref; stderr in all output modes. if (loaded !== null && loaded.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); } @@ -225,12 +173,8 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { const vectorBucketNames = Object.keys(config.storage.vector.buckets); const hasVectorBuckets = vectorBucketNames.length > 0; - // Config-load-time validations run BEFORE the no-op short-circuit: Go decodes the - // whole config (storage.FileSizeLimit, bucket sizes) and runs ValidateBucketName - // during config.Load — before `buckets.Run` can take its no-op path — so an - // invalid value fails even when there's nothing to seed. - // - // Bucket names (`ValidateBucketName`). + // Config-load-time validations run before the no-op short-circuit, so an invalid value + // fails even when there's nothing to seed. for (const name of bucketNames) { yield* validateBucketName(name); } @@ -249,12 +193,9 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { // Short-circuit: nothing to seed (ref present → never short-circuits). if (projectRef === "" && bucketNames.length === 0 && !hasVectorBuckets) { - // The `SUPABASE_API_*`/`SUPABASE_AUTH_*` override decode belongs to config - // load, which runs before the no-op path — a malformed override, invalid - // `api.port`, short or undecryptable auth secret, or broken TLS cert/key - // pairing fails even with nothing to seed, same as the bucket-name/size - // validations above. Validate-only: the seeding path re-resolves the same - // values through `resolveStorageCredentials`. + // Config validation (SUPABASE_API_*/SUPABASE_AUTH_* overrides, TLS cert/key pairing) + // still runs here even with nothing to seed; it's validate-only — the seeding path + // re-resolves these values through `resolveStorageCredentials`. yield* validateLocalStorageConfig(config, projectEnvValues); if (emitSummary && output.format !== "text") { yield* output.success("", { ...emptySummary() }); @@ -269,10 +210,9 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { projectEnvValues, }); - // All gateway operations run with an explicit non-DoH fetch (CA-trusting for - // local + https, plain `globalThis.fetch` otherwise). The api-keys lookup inside - // `resolveStorageCredentials` runs BEFORE this scope, so it still honors - // `--dns-resolver https`, matching `tenant.GetApiKeys`. + // Gateway operations use an explicit non-DoH fetch (CA-trusting for local + https, plain + // `globalThis.fetch` otherwise); the api-keys lookup in `resolveStorageCredentials` runs + // before this scope, so it still honors `--dns-resolver https`. const gatewayOps = Effect.gen(function* () { const gateway = yield* makeStorageGateway({ baseUrl: credentials.baseUrl, @@ -313,7 +253,7 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { // Upload objects for each bucket with a configured objects_path. yield* uploadObjects(fs, path, output, gateway, cliSettings.workdir, bucketsConfig, summary); - // Machine-readable summary (Go has none; text mode emits nothing extra). + // Machine-readable summary; text mode emits nothing extra. if (emitSummary && output.format !== "text") { yield* output.success("", { ...summary }); } @@ -336,9 +276,7 @@ type BucketsConfig = Readonly< > >; -// Parse a `file_size_limit` string to bytes, mapping a parse failure to a -// config-load error (Go rejects an invalid `sizeInBytes` during `config.Load`, -// before NewStorageAPI). +// Parses a `file_size_limit` string to bytes, mapping a parse failure to a config-load error. const parseFileSizeLimitOrFail = (value: string) => Effect.try({ try: () => parseFileSizeLimit(value), @@ -362,9 +300,7 @@ const computeBucketProps = ( }), }); -// Port of `pkg/storage/batch.go:UpsertBuckets`. `propsByName` is precomputed and -// size-validated before this runs (Go parses sizes at config-load, before any -// Storage call). +// `propsByName` is precomputed and size-validated before this runs. const upsertBuckets = Effect.fnUntraced(function* ( output: typeof Output.Service, yes: boolean, @@ -401,7 +337,6 @@ const upsertBuckets = Effect.fnUntraced(function* ( } }); -// Port of `pkg/storage/vector.go:UpsertVectorBuckets`. const upsertVectorBuckets = Effect.fnUntraced(function* ( output: typeof Output.Service, yes: boolean, @@ -442,7 +377,6 @@ const upsertVectorBuckets = Effect.fnUntraced(function* ( } }); -// Port of `pkg/storage/analytics.go:UpsertAnalyticsBuckets`. const upsertAnalyticsBuckets = Effect.fnUntraced(function* ( output: typeof Output.Service, yes: boolean, @@ -484,9 +418,8 @@ const upsertAnalyticsBuckets = Effect.fnUntraced(function* ( }); /** - * Vector graceful-skip: on `FeatureNotEnabled` / - * local-unavailable errors, print the matching WARNING and continue (object - * upload still runs). Any other error propagates. + * On `FeatureNotEnabled` or local-unavailable errors, prints the matching warning and + * continues (object upload still runs); any other error propagates. */ const handleVectorError = Effect.fnUntraced(function* ( output: typeof Output.Service, @@ -512,7 +445,6 @@ const handleVectorError = Effect.fnUntraced(function* ( return yield* Effect.fail(error); }); -// Port of `pkg/storage/batch.go:UpsertObjects` (+ object walk in objects.go). const uploadObjects = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, path: Path.Path, @@ -527,10 +459,9 @@ const uploadObjects = Effect.fnUntraced(function* ( if (objectsPath.length === 0) { continue; } - // Go resolves a relative bucket objects_path against SupabaseDirPath at - // config-resolve time; absolute paths are - // left untouched. `displayRoot` (workdir-relative) drives the `Uploading:` - // stderr and the destination key so both stay byte-identical to Go. + // A relative `objects_path` resolves against the `supabase/` dir; absolute paths are + // left untouched. `displayRoot` (workdir-relative) drives the `Uploading:` stderr line + // and the destination key. const displayRoot = path.isAbsolute(objectsPath) ? objectsPath : path.join("supabase", objectsPath); @@ -544,12 +475,9 @@ const uploadObjects = Effect.fnUntraced(function* ( Effect.gen(function* () { const dstPath = bucketObjectKey(name, displayRoot, file.displayPath); yield* output.raw(`Uploading: ${file.displayPath} => ${dstPath}\n`, "stderr"); - // Content-type is byte-driven: Go sniffs the first 512 bytes with - // http.DetectContentType, refining only a generic text/plain by - // extension. + // Content type is sniffed from the first 512 bytes, refining only a generic + // text/plain by file extension. const sniff = yield* readSniffBytes(fs, file.absPath); - // Go's seed upload always sets Cache-Control max-age=3600 and x-upsert - // (Overwrite) true (`pkg/storage/batch.go`). yield* gateway.uploadObject(dstPath, file.absPath, { contentType: contentTypeForUpload(sniff, file.absPath), cacheControl: "max-age=3600", @@ -563,16 +491,12 @@ const uploadObjects = Effect.fnUntraced(function* ( }); /** - * Collect uploadable files under `absRoot`, lexically ordered, mirroring Go's - * `fs.WalkDir` + `isUploadableEntry`. + * Collects uploadable files under `absRoot`, lexically ordered. * - * Parity details: - * - The **root** is resolved with a following stat (`fs.Stat`), so a - * symlinked `objects_path` is followed; a missing/dangling root fails. - * - **Nested** entries use no-follow detection: real directories are descended; - * symlinks are NOT descended — `isUploadableEntry` OPENS the symlink - * target then stats the handle, uploading only a regular file and skipping - * dangling symlinks / symlinks-to-directories / unreadable targets. + * The root is resolved with a following stat, so a symlinked `objects_path` is followed; a + * missing/dangling root fails. Nested entries use no-follow detection: symlinks are opened + * and their target stat'd, uploading only a regular file and skipping dangling symlinks, + * symlinks-to-directories, and unreadable targets. */ const collectFiles = ( fs: FileSystem.FileSystem, @@ -614,16 +538,15 @@ const collectDir = ( yield* output.raw(`Skipping OS metadata file: ${displayChild}\n`, "stderr"); continue; } - // `readLink` succeeds only on a symlink — our no-follow detector (Effect's - // `stat` follows symlinks and has no `lstat`). + // `readLink` succeeds only on a symlink; Effect's `stat` always follows symlinks and + // has no `lstat`. const isSymlink = yield* fs.readLink(absChild).pipe( Effect.as(true), Effect.catch(() => Effect.succeed(false)), ); if (isSymlink) { - // `isUploadableEntry` OPENS the target then stats the - // handle; it uploads only a regular file. `stat` alone would queue an - // unreadable target and abort later at upload, so mirror that: open + stat. + // Opens the target then stats the handle, so an unreadable target is caught now + // rather than queued and failing later at upload. const targetType = yield* Effect.scoped( Effect.gen(function* () { const handle = yield* fs.open(absChild, { flag: "r" }); diff --git a/apps/cli/src/command-internal/seed-ops.ts b/apps/cli/src/command-internal/seed-ops.ts index 06907ecace..d865d86608 100644 --- a/apps/cli/src/command-internal/seed-ops.ts +++ b/apps/cli/src/command-internal/seed-ops.ts @@ -9,19 +9,17 @@ import { createSeedTable } from "./migration-history.ts"; import { sqlFilesGlob } from "./sql-files-glob.ts"; import { splitAndTrim } from "./sql-split.ts"; -/** - * Seed-history DML, verbatim from Go's `pkg/migration/history.go`. The schema/table - * DDL (with a transaction-scoped lock timeout) lives in `createSeedTable`. - */ +// Seed-history DML; the schema/table DDL (with a transaction-scoped lock timeout) lives in +// `createSeedTable`. const UPSERT_SEED_FILE = "INSERT INTO supabase_migrations.seed_files(path, hash) VALUES($1, $2) ON CONFLICT (path) DO UPDATE SET hash = EXCLUDED.hash"; const SELECT_SEED_TABLE = "SELECT path, hash FROM supabase_migrations.seed_files"; /** A local seed file resolved from `[db.seed].sql_paths`, with its content hash. */ export interface SeedFile { - /** Workdir-relative, forward-slashed path (Go's `filepath.ToSlash`). */ + /** Workdir-relative, forward-slashed path. */ readonly path: string; - /** Lowercase hex SHA-256 of the file content (Go's `NewSeedFile`). */ + /** Lowercase hex SHA-256 of the file content. */ readonly hash: string; /** True when the remote `seed_files` row has a different hash (re-hash only). */ readonly dirty: boolean; @@ -47,17 +45,11 @@ const isUndefinedTable = (error: DbExecError): boolean => !/column .* does not exist/iu.test(error.message); /** - * Resolves the pending seed files for `db push --include-seed`. Mirrors Go's - * `GetPendingSeeds` (`pkg/migration/seed.go:34-63`): glob the configured paths via - * the shared {@link sqlFilesGlob} traversal (also used by `[db.migrations]. - * schema_paths`, `migration-apply.ts`, and by `seed.ts`'s own - * `resolveSeedFiles` for the `migration down`/`start` seed step), warn — don't fail — - * on empty patterns, read the remote `seed_files` hashes, and emit each local file - * that is new (`dirty=false`) or hash-changed (`dirty=true`); files whose hash - * already matches are skipped. Per-pattern warnings are joined with Go's `errors.Join` - * newline semantics and surfaced unconditionally (`seed.go:36-38`) — unlike the - * schema-files apply path (see `applySchemaFiles`), which only surfaces a - * warning when it is the ONLY outcome. + * Resolves the pending seed files for `db push --include-seed`: globs the configured + * patterns via {@link sqlFilesGlob}, warns (without failing) on empty patterns, reads the + * remote `seed_files` hashes, and emits each local file that is new (`dirty=false`) or + * hash-changed (`dirty=true`), skipping files whose hash already matches. Unlike + * `applySchemaFiles`, per-pattern warnings are always surfaced, not only when that's the sole outcome. */ export const getPendingSeeds = Effect.fnUntraced(function* ( session: DbSession, @@ -76,15 +68,14 @@ export const getPendingSeeds = Effect.fnUntraced(function* ( const applied = yield* readRemoteSeeds(session); for (const file of files) { - // Go's `NewSeedFile` hashes the raw file stream (`io.Copy`, `pkg/migration/file.go:184`), - // so hash the bytes — not a UTF-8-decoded string, which replaces invalid sequences and - // would drift from the Go-recorded `seed_files` hash for a non-UTF-8 seed (SQL_ASCII dump - // / binary COPY payload), spuriously re-running it across a Go ↔ native switch. + // Hashes the raw bytes, not a UTF-8-decoded string: decoding would replace invalid + // sequences in a non-UTF-8 seed (SQL_ASCII dump / binary COPY payload) and silently + // change its hash. const content = yield* fs.readFile(path.isAbsolute(file) ? file : path.join(workdir, file)); const hash = createHash("sha256").update(content).digest("hex"); const appliedHash = applied.get(file); if (appliedHash !== undefined) { - if (appliedHash === hash) continue; // Already applied, unchanged. + if (appliedHash === hash) continue; pending.push({ path: file, hash, dirty: true }); continue; } @@ -94,11 +85,9 @@ export const getPendingSeeds = Effect.fnUntraced(function* ( }); /** - * Applies pending seed files. Mirrors Go's `SeedData` + `ExecBatchWithCache` - * (`pkg/migration/seed.go:65-83`, `file.go:198-217`): create the `seed_files` - * table, then per file emit the dirty/clean status line and, in one transaction, - * run the file's statements (skipped when dirty — only the hash is refreshed) - * followed by the `seed_files` hash upsert. + * Applies pending seed files: creates the `seed_files` table, then per file emits the + * dirty/clean status line and, in one transaction, runs the file's statements (skipped when + * dirty — only the hash is refreshed) followed by the `seed_files` hash upsert. */ export const seedData = <E>( session: DbSession, @@ -111,11 +100,8 @@ export const seedData = <E>( Effect.gen(function* () { const output = yield* Output; if (seeds.length === 0) return; - // Go's `CreateSeedTable` (history.go:54-64) runs `SET lock_timeout = '4s'` + - // schema/table DDL in one implicit transaction, so a conflicting schema/table lock - // fails promptly but the timeout reverts on COMMIT and never leaks into the seed - // SQL run below. `createSeedTable` reproduces that with BEGIN + SET LOCAL + - // DDL + COMMIT (creating the schema first so a seed-only run doesn't fail). + // `createSeedTable` runs BEGIN + SET LOCAL lock_timeout + schema/table DDL + COMMIT, so + // a conflicting lock fails promptly but the timeout never leaks into the seed SQL below. yield* createSeedTable(session); for (const seed of seeds) { yield* output.raw( @@ -124,14 +110,10 @@ export const seedData = <E>( : `Seeding data from ${seed.path}...\n`, "stderr", ); - // Go's `ExecBatchWithCache` parses the file (read + `SplitAndTrim`) - // UNCONDITIONALLY before the dirty check (`file.go:198-211`), so a dirty seed - // that is unreadable or contains malformed SQL still fails and leaves the - // previous hash — only the queueing of statements is gated on `Dirty`. Parsing - // includes the same `SUPABASE_SCANNER_BUFFER_SIZE` enforcement every other - // `parseFile` caller gets (`checkScannerBufferSize`'s own doc comment) — Go's - // `SeedFile.ExecBatchWithCache` runs through the identical `parseFile`, so an - // oversized seed statement must fail here too, not execute silently. + // The file is read and parsed unconditionally, before the dirty check, so an + // unreadable or malformed dirty seed still fails (leaving the previous hash) — only + // the queueing of statements is gated on `dirty`. This also applies the same + // `SUPABASE_SCANNER_BUFFER_SIZE` enforcement as any other `parseFile` caller. const content = yield* fs.readFileString( path.isAbsolute(seed.path) ? seed.path : path.join(workdir, seed.path), ); @@ -142,8 +124,8 @@ export const seedData = <E>( const body = Effect.gen(function* () { for (const statement of statements) { yield* session.exec(statement); - // A top-level role revert drops a stepped-down session to the login - // role; restore `postgres` right away (supabase/cli#6236). + // A top-level role revert drops a stepped-down session to the login role; restore + // `postgres` immediately so later statements run with the expected privileges. if (session.restoreRoleSql !== undefined && revertsToLoginRole(statement)) { yield* session.exec(session.restoreRoleSql); } diff --git a/apps/cli/src/command-internal/seed-ops.unit.test.ts b/apps/cli/src/command-internal/seed-ops.unit.test.ts index 078afb327c..85c91b5e5e 100644 --- a/apps/cli/src/command-internal/seed-ops.unit.test.ts +++ b/apps/cli/src/command-internal/seed-ops.unit.test.ts @@ -34,12 +34,9 @@ function fakeSeedSession(opts: { restoreRoleSql?: string } = {}) { return { session, calls }; } -// Glob matching itself is `pathMatch` (`../../../shared/path-match.ts`), -// a faithful port of Go's `path.Match` already covered by -// `path-match.unit.test.ts` (including the `^`-only negation / `!`-is-literal -// rule this file used to duplicate — and get wrong — in a local `matchPattern`). -// This exercises that the seed pipeline's own glob resolution (`getPendingSeeds`) -// actually uses it end to end, per `config.Glob.Files` → `fs.Glob` → `path.Match`. +// Exercises that `getPendingSeeds`'s glob resolution actually uses `pathMatch` +// (`../../../shared/path-match.ts`) end to end; pattern-matching semantics are covered by +// `path-match.unit.test.ts`. describe("getPendingSeeds (glob character classes)", () => { it.effect( "treats a leading `!` in a bracket class as literal, not negation (Go path.Match parity)", @@ -51,9 +48,7 @@ describe("getPendingSeeds (glob character classes)", () => { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - // `[!a]` is a positive class of the literal members `!` and `a` — only a - // leading `^` negates. So this pattern matches `a.sql`, not `b.sql` (the old - // shell-style bug negated on `!` too, and would have matched `b.sql` instead). + // `[!a]` is a positive class of `!` and `a`; only a leading `^` negates. const pending = yield* getPendingSeeds(session, fs, path, ["[!a].sql"], dir); expect(pending.map((seed) => seed.path)).toEqual(["a.sql"]); rmSync(dir, { recursive: true, force: true }); @@ -67,9 +62,8 @@ describe("getPendingSeeds (glob character classes)", () => { it.effect( "warns Go's bad-pattern message for an unterminated bracket class, not a bogus no-match", () => { - // An unclosed `[` is malformed per Go's `path.Match` grammar (`ErrBadPattern`), which - // `fs.Glob` reports as `failed to glob files: syntax error in pattern` — not the - // generic `no files matched pattern` a same-shaped but well-formed glob would get. + // An unclosed `[` is malformed, so `fs.Glob` reports a syntax error, not a bogus + // "no files matched" for a well-formed-but-empty pattern. const dir = mkdtempSync(join(tmpdir(), "seed-glob-")); const { session } = fakeSeedSession(); const out = mockOutput({ format: "text" }); @@ -107,9 +101,6 @@ const runSeed = ( describe("seedData (dirty parse)", () => { it.effect("fails on an unreadable dirty seed instead of refreshing its hash", () => { - // `ExecBatchWithCache` reads + parses the file UNCONDITIONALLY before the - // dirty check, so a dirty seed pointing at a missing file must fail (and leave - // the previous hash) rather than silently upserting the new hash. const dir = mkdtempSync(join(tmpdir(), "seed-")); const { session, calls } = fakeSeedSession(); return runSeed(session, dir, [{ path: "missing.sql", hash: "newhash", dirty: true }]).pipe( @@ -117,10 +108,6 @@ describe("seedData (dirty parse)", () => { Effect.tap((exit) => Effect.sync(() => { expect(Exit.isFailure(exit)).toBe(true); - // The hash upsert is a `query`; the only execs that ran are the - // schema/table creation (whose DDL also mentions `seed_files`), so assert - // no parameterized `query` ran rather than substring-matching the table name - // (the read-only provisioning probe is param-free). expect(calls.some((c) => c.kind === "query" && c.params !== undefined)).toBe(false); rmSync(dir, { recursive: true, force: true }); }), @@ -131,13 +118,9 @@ describe("seedData (dirty parse)", () => { it.effect( "rejects an oversized seed statement when SUPABASE_SCANNER_BUFFER_SIZE is configured (Go SeedFile.ExecBatchWithCache parity)", () => { - // Go's SeedFile.ExecBatchWithCache parses through the same parseFile every - // other file type does, so an oversized statement must abort the seed run — - // same as migration-apply.unit.test.ts's equivalent case for migrations. const dir = mkdtempSync(join(tmpdir(), "seed-scanner-")); - // Raw text must exceed the 4096-byte floor Go's bufio.Scanner starts at - // regardless of the configured limit (see migration-apply.unit.test.ts's - // equivalent case for the exact same 4096-byte floor). + // The seed text must exceed the 4096-byte scanner floor regardless of the configured + // limit (see migration-apply.unit.test.ts's equivalent case). writeFileSync(join(dir, "big.sql"), `select '${"x".repeat(5000)}';`); const { session, calls } = fakeSeedSession(); const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; @@ -164,10 +147,7 @@ describe("seedData (dirty parse)", () => { return runSeed(session, dir, [{ path: "data.sql", hash: "newhash", dirty: true }]).pipe( Effect.tap(() => Effect.sync(() => { - // Go's CreateSeedTable scopes the lock timeout to the DDL transaction - // (BEGIN + SET LOCAL + COMMIT) so it never leaks into the seed SQL below. expect(calls.some((c) => c.sql === "SET LOCAL lock_timeout = '4s'")).toBe(true); - // Statements are NOT executed for a dirty seed, but the hash IS upserted. expect(calls.some((c) => c.sql.includes("insert into t"))).toBe(false); expect( calls.some( @@ -181,8 +161,6 @@ describe("seedData (dirty parse)", () => { }); it.effect("re-asserts the stepped-down role before the seed_files upsert", () => { - // A seed's own `reset role` reverts a stepped-down session to the login role, - // which used to fail the CLI's hash upsert with 42501 (supabase/cli#6236). const dir = mkdtempSync(join(tmpdir(), "seed-")); writeFileSync(join(dir, "data.sql"), "set role r;\ninsert into t values (1);\nreset role;"); const { session, calls } = fakeSeedSession({ restoreRoleSql: "SET SESSION ROLE postgres" }); @@ -212,7 +190,6 @@ describe("seedData (dirty parse)", () => { Effect.sync(() => { const sqls = calls.map((c) => c.sql); const resetAt = sqls.indexOf("reset role"); - // Injected immediately, so the following insert runs as postgres again. expect(sqls[resetAt + 1]).toBe("SET SESSION ROLE postgres"); expect(sqls.indexOf("insert into t values (1)")).toBeGreaterThan(resetAt + 1); rmSync(dir, { recursive: true, force: true }); diff --git a/apps/cli/src/command-internal/seed.ts b/apps/cli/src/command-internal/seed.ts index 11364e6efc..191313c707 100644 --- a/apps/cli/src/command-internal/seed.ts +++ b/apps/cli/src/command-internal/seed.ts @@ -14,7 +14,7 @@ import { createSeedTable, readSeedTable, UPSERT_SEED_FILE } from "./migration-hi import { sqlFilesGlob } from "./sql-files-glob.ts"; import { splitAndTrim } from "./sql-split.ts"; -/** Applying a seed file failed (`SeedData` / `ExecBatchWithCache` errors). */ +/** Applying a seed file failed. */ export class MigrationSeedError extends Data.TaggedError("MigrationSeedError")<{ readonly message: string; }> { @@ -29,26 +29,18 @@ export interface SeedConfig { readonly sqlPaths: ReadonlyArray<string>; } -// Only metadata is kept during the pending scan — the decoded statements are NOT -// retained. `SeedFile` holds just -// `{Path, Hash, Dirty}` and re-parses each file individually inside the apply loop -// ("Parse each file individually to reduce memory usage"), so a -// large/many-file seed set never has every file's statements in memory at once. +// Only metadata is kept during the pending scan; each file's statements are re-parsed +// individually in the apply loop, so a large/many-file seed set never holds every file's +// statements in memory at once. interface PendingSeed { readonly path: string; readonly hash: string; readonly dirty: boolean; } -/** - * Resolves `[db.seed].sql_paths` to existing files, porting `config.Glob.SQLFiles` - * (via the shared {@link sqlFilesGlob} traversal — - * also used by `getPendingSeeds` (`seed-ops.ts`) for the same field on - * the `db push`/`db reset` path, and by `applySchemaFiles` (`migration-apply.ts`) - * for `[db.migrations].schema_paths`). `GetPendingSeeds` prints a single unconditional - * `WARN: <joined>` line for any glob problem — unlike the schema-files - * apply path, which only warns when NO pattern matched anything at all. - */ +// Resolves `[db.seed].sql_paths` to existing files via the shared {@link sqlFilesGlob} +// traversal, printing a single `WARN: <joined>` line for any glob problem — unlike the +// schema-files apply path, which only warns when no pattern matched anything at all. const resolveSeedFiles = ( fs: FileSystem.FileSystem, path: Path.Path, @@ -63,10 +55,8 @@ const resolveSeedFiles = ( }); /** - * Applies pending seed files, port of `applySeedFiles` + `GetPendingSeeds` + - * `SeedData`: - * gated on `db.seed.enabled`; a new seed runs its statements + records its hash; - * a changed seed only updates the recorded hash ("dirty" → skip statements); + * Applies pending seed files, gated on `db.seed.enabled`: a new seed runs its statements + * and records its hash; a changed seed only updates the recorded hash (skipping statements); * an unchanged seed is skipped entirely. */ export const applySeedFiles = ( @@ -101,10 +91,8 @@ export const applySeedFiles = ( ); const hash = createHash("sha256").update(content).digest("hex"); const previous = applied.get(relativePath); - if (previous === hash) continue; // unchanged → skip entirely - // Keep only metadata; the statements are read + split per-file in the apply loop - // below (each file is hashed up front via io.Copy in `NewSeedFile` but does not - // retain its contents). + if (previous === hash) continue; + // Keep only metadata; statements are read and split per-file in the apply loop below. pending.push({ path: relativePath, hash, @@ -130,9 +118,8 @@ export const applySeedFiles = ( "stderr", ); // Read + split this seed's statements here (not up front) so only one file's - // statements are in memory at a time, matching `ExecBatchWithCache` → - // `parseFile` inside the apply loop. A dirty seed only - // updates its recorded hash, so Go never re-reads it — skip the read. + // statements are in memory at a time. A dirty seed only updates its recorded hash, + // so its content is never read. let statements: ReadonlyArray<string> = []; if (!seed.dirty) { const content = new TextDecoder().decode( @@ -145,9 +132,8 @@ export const applySeedFiles = ( ), ), ); - // `SeedFile.ExecBatchWithCache` parses through the same `parseFile` every - // other caller does, so it enforces `SUPABASE_SCANNER_BUFFER_SIZE` here too — - // see `checkScannerBufferSize`'s own doc comment. + // Enforces the same `SUPABASE_SCANNER_BUFFER_SIZE` limit as any other `parseFile` + // caller — see `checkScannerBufferSize`'s own doc comment. yield* checkScannerBufferSize(content, (message) => new MigrationSeedError({ message })); statements = splitAndTrim(content); } @@ -156,8 +142,9 @@ export const applySeedFiles = ( if (!seed.dirty) { for (const statement of statements) { yield* session.exec(statement); - // A top-level role revert drops a stepped-down session to the login - // role; restore `postgres` right away (supabase/cli#6236). + // A top-level role revert drops a stepped-down session to the login role; + // restore `postgres` immediately so later statements run with the expected + // privileges. if (session.restoreRoleSql !== undefined && revertsToLoginRole(statement)) { yield* session.exec(session.restoreRoleSql); } diff --git a/apps/cli/src/command-internal/seed.unit.test.ts b/apps/cli/src/command-internal/seed.unit.test.ts index 4475543043..2247f26e56 100644 --- a/apps/cli/src/command-internal/seed.unit.test.ts +++ b/apps/cli/src/command-internal/seed.unit.test.ts @@ -40,8 +40,6 @@ const run = ( describe("applySeedFiles seed glob", () => { it.effect("treats a backslash escape as a glob metacharacter (matches the real file)", () => { - // `io/fs.hasMeta` counts `\` (escape), so `seed\.sql` globs via path.Match - // and matches the literal `seed.sql` — not a file named `seed\.sql`. const dir = mkdtempSync(join(tmpdir(), "seed-")); writeFileSync(join(dir, "seed.sql"), "insert into t values (1);"); const { session, queries } = fakeSession(); @@ -49,7 +47,6 @@ describe("applySeedFiles seed glob", () => { return run(session, dir, ["seed\\.sql"], out).pipe( Effect.tap(() => Effect.sync(() => { - // The seed file was found and recorded under its clean path. const upsert = queries.find((q) => q.sql.includes("INSERT INTO supabase_migrations.seed_files"), ); @@ -62,8 +59,6 @@ describe("applySeedFiles seed glob", () => { }); it.effect("warns (no match) when a backslash-escaped pattern's literal file is absent", () => { - // `missing\.sql` escapes to the literal `missing.sql`; with no such file it matches - // nothing and Go emits a single `no files matched pattern` warning. const dir = mkdtempSync(join(tmpdir(), "seed-")); const { session, queries } = fakeSession(); const out = mockOutput(); @@ -83,10 +78,6 @@ describe("applySeedFiles seed glob", () => { it.effect( "expands a matched directory to its sorted, regular .sql files (Go's Glob.SQLFiles)", () => { - // `GetPendingSeeds` calls `locals.SQLFiles(fsys)` — the SAME `Glob.SQLFiles` method - // `db.migrations.schema_paths` resolves through — which expands a directory match to its - // recursively-walked, sorted `.sql` files rather than treating the directory itself as a - // seed file. const dir = mkdtempSync(join(tmpdir(), "seed-")); mkdirSync(join(dir, "seeds")); writeFileSync(join(dir, "seeds", "b.sql"), "insert into t values (2);"); @@ -117,13 +108,9 @@ describe("applySeedFiles scanner buffer size", () => { it.effect( "rejects an oversized seed statement when SUPABASE_SCANNER_BUFFER_SIZE is configured (Go SeedFile.ExecBatchWithCache parity)", () => { - // Ports the same `parseFile` every migration/globals/schema-file caller goes - // through (see `checkScannerBufferSize`'s doc comment), so an oversized - // statement must abort here too, not execute silently. const dir = mkdtempSync(join(tmpdir(), "seed-scanner-")); - // Raw text must exceed the 4096-byte floor Go's bufio.Scanner starts at - // regardless of the configured limit (see migration-apply.unit.test.ts's - // equivalent case for the exact same 4096-byte floor). + // The seed text must exceed the 4096-byte scanner floor regardless of the configured + // limit (see migration-apply.unit.test.ts's equivalent case). writeFileSync(join(dir, "big.sql"), `insert into t values ('${"x".repeat(5000)}');`); const { session, queries } = fakeSession(); const out = mockOutput(); @@ -174,8 +161,6 @@ describe("applySeedFiles stepped-down session", () => { const upsertAt = calls.findIndex((sql) => sql.includes("INSERT INTO supabase_migrations.seed_files"), ); - // Injected immediately after the reset, so the following insert (and - // everything else in the file) runs as postgres again. expect(calls[resetAt + 1]).toBe("SET SESSION ROLE postgres"); expect(upsertAt).toBeGreaterThan(resetAt); expect(out.stderrText).not.toContain("WARN:"); diff --git a/apps/cli/src/command-internal/service-catalog.ts b/apps/cli/src/command-internal/service-catalog.ts index 318a8a3f8f..e0cc0d7ce1 100644 --- a/apps/cli/src/command-internal/service-catalog.ts +++ b/apps/cli/src/command-internal/service-catalog.ts @@ -1,37 +1,19 @@ /** - * Per-service identity catalog for the local dev stack — the single source of - * truth `start` and `status` both read so they can never drift on container - * name suffixes or `--exclude` keys. + * Per-service identity catalog for the local dev stack — the single source of truth `start` + * and `status` share, so container name suffixes and `--exclude` keys never drift between them. * - * `containerSuffix` matches the suffix strings `serviceContainerIds`/ - * `localDbContainerId` feed into `serviceContainerName` (`docker-ids.ts`) - * — cross-check by exact string, not by array position, since that function's - * array order (Go's `utils.GetDockerIds()`, `apps/cli-go/internal/utils/config.go:82-98`) - * differs from this catalog's order. - * - * `excludeKey` matches `utils.ShortContainerImageName(image)` for each of - * `config.Images.Services()` (`apps/cli-go/pkg/config/constants.go:60-76`) — the - * value Go's `--exclude` flag matches against. Go's own `ExcludableContainers()` - * (formerly `apps/cli-go/internal/start/start.go:1297-1303`) was deleted along - * with the rest of `internal/start` as unreachable (CLI-1966; last present at - * commit a253ccba2) — the equivalent logic now lives inline in - * `apps/cli-go/cmd/start.go`'s `excludableContainers()`. Postgres has no - * `excludeKey` — it is never excludable in Go. - * - * Array order matches Go's actual container start sequence (formerly - * `apps/cli-go/internal/start/start.go`'s "Start <service>" comments, lines - * 293-1267, deleted in CLI-1966; last present at commit a253ccba2): Postgres, - * then each service's `if` block in file order. `startOrder` (1-14) mirrors - * that same order as an explicit field. + * `containerSuffix` matches a suffix string exactly (cross-check by string, not array + * position). Postgres has no `excludeKey` (never excludable). `startOrder` (1-14) matches the + * container start sequence. */ export interface ServiceCatalogEntry { /** Stable service key, independent of both the container suffix and the exclude key. */ readonly service: string; /** Matches a `serviceContainerIds`/`localDbContainerId` suffix string exactly. */ readonly containerSuffix: string; - /** `--exclude` key (`utils.ShortContainerImageName`). Absent for Postgres — never excludable. */ + /** `--exclude` key. Absent for Postgres — never excludable. */ readonly excludeKey?: string; - /** 1-14, matching Go's real container start sequence in the now-deleted `internal/start/start.go` (CLI-1966; last present at commit a253ccba2). */ + /** 1-14, matching the container start sequence. */ readonly startOrder: number; } diff --git a/apps/cli/src/command-internal/service-version-overrides.ts b/apps/cli/src/command-internal/service-version-overrides.ts index 3c71647e1f..551cd65596 100644 --- a/apps/cli/src/command-internal/service-version-overrides.ts +++ b/apps/cli/src/command-internal/service-version-overrides.ts @@ -3,12 +3,10 @@ import type { LocalServiceVersionOverrides } from "../shared/services/services.s import { tempPaths } from "./temp-paths.ts"; /** - * `supabase/.temp/{gotrue,rest,storage,realtime,studio,pgmeta,logflare,pooler}-version` - * pin files — written by `supabase link` for a linked/bootstrap project, read - * by Go's `Config.Load` to rewrite `c.Auth.Image`/`c.Api.Image`/etc. before any - * command that pulls or starts these services (`apps/cli-go/pkg/config/ - * config.go:827-863`). `gotrue`/`postgrest` are additionally gated on - * `majorVersion > 14`, mirroring Go's same condition there. + * `supabase/.temp/{gotrue,rest,storage,realtime,studio,pgmeta,logflare,pooler}-version` pin + * files, written by `supabase link` for a linked/bootstrap project and read before any + * command that pulls or starts these services to override their image tag. `gotrue`/ + * `postgrest` are additionally gated on `majorVersion > 14`. */ const VERSION_FILES = [ ["auth", "gotrue-version", (majorVersion: number | undefined) => (majorVersion ?? 17) > 14], @@ -28,10 +26,9 @@ const VERSION_FILES = [ >; /** - * Reads every linked-service version pin present under `<workdir>/supabase/ - * .temp/`, returning only the services whose pin file exists and is - * non-blank. Any read error (including not-exist) resolves to "" for that - * file, matching Go's `err == nil && len(version) > 0` gate. + * Reads every linked-service version pin present under `<workdir>/supabase/.temp/`, + * returning only the services whose pin file exists and is non-blank. Any read error + * (including not-exist) resolves to "" for that file. */ export const readServiceVersionOverrides = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, diff --git a/apps/cli/src/command-internal/size-units.ts b/apps/cli/src/command-internal/size-units.ts index c70faa682e..e8af1e3feb 100644 --- a/apps/cli/src/command-internal/size-units.ts +++ b/apps/cli/src/command-internal/size-units.ts @@ -1,11 +1,7 @@ /** - * Ports of `github.com/docker/go-units` used by Go's `sizeInBytes` - * (`pkg/config/config.go`). `file_size_limit` config values are parsed with - * `RAMInBytes` before being sent to service APIs. - * - * Shared across the CLI: `config push` (storage/auth/api/db diffing) - * and `seed buckets` (which converts each `[storage.buckets.*].file_size_limit` - * string to the int64 byte count Go sends in the create/update bucket body). + * Parses `file_size_limit` config values using `docker/go-units`' `RAMInBytes` grammar + * (1024-based, case-insensitive, optional trailing `b`) before sending them to service APIs. + * Shared by `config push` (storage/auth/api/db diffing) and `seed buckets`. * * @see github.com/docker/go-units@v0.5.0/size.go */ @@ -21,9 +17,8 @@ const BINARY_MAP: Readonly<Record<string, number>> = { const DIGIT_OR_DOT_OR_SPACE = "0123456789. "; /** - * Port of `units.RAMInBytes` — parses a human-readable RAM size (1024-based, - * case-insensitive, optional trailing `b`) into bytes. Throws on an unparseable - * string (Go returns an error that aborts config load). + * Parses a human-readable RAM size (1024-based, case-insensitive, optional trailing `b`) + * into bytes. Throws on an unparseable string. */ export function ramInBytes(sizeStr: string): number { let sep = -1; @@ -42,17 +37,11 @@ export function ramInBytes(sizeStr: string): number { num = sizeStr.slice(0, sep); sfx = sizeStr.slice(sep + 1); } - // Go's `RAMInBytes` (docker/go-units v0.5.0) hands the WHOLE numeric part to - // `strconv.ParseFloat`, which rejects a string that isn't a complete float. - // JS `Number.parseFloat` instead silently parses a valid prefix (`1.2.3` → 1.2, - // `1 2` → 1), so validate the numeric part against Go's float grammar first: - // optional sign, a leading OR trailing dot, optional exponent, and single - // underscores BETWEEN digits (Go 1.13+ literal rule — no leading/trailing/ - // doubled `_`, none adjacent to `.`/sign). The digit group `\d(?:_?\d)*` - // enforces the underscore placement. This accepts Go-valid forms (`.5`, `1.`, - // `1e6`, `+5`, `1_000`) and rejects the prefix hazards (`1.2.3`, `1 2`, - // leading-space, `0x10`, `_1`, `1_`). A negative value is rejected post-parse - // below (matching Go's `size < 0` check); `1e309`→Infinity by the isFinite check. + // JS `Number.parseFloat` silently parses a valid numeric prefix (`1.2.3` → 1.2, `1 2` → 1), + // so validate the whole numeric part against a strict float grammar first: optional sign, a + // leading or trailing dot, optional exponent, and single underscores between digits only (no + // leading/trailing/doubled `_`, none adjacent to `.`/sign). Accepts `.5`, `1.`, `1e6`, `+5`, + // `1_000`; rejects `1.2.3`, `1 2`, leading space, `0x10`, `_1`, `1_`. if ( !/^[+-]?(?:\d(?:_?\d)*(?:\.(?:\d(?:_?\d)*)?)?|\.\d(?:_?\d)*)([eE][+-]?\d(?:_?\d)*)?$/.test(num) ) { @@ -61,9 +50,8 @@ export function ramInBytes(sizeStr: string): number { // Strip the (already-validated, between-digits) underscores before parsing: // JS `Number.parseFloat("1_000")` stops at the underscore (→1), unlike Go. const size = Number.parseFloat(num.replace(/_/g, "")); - // Reject NaN and ±Infinity: Go's `strconv.ParseFloat` returns a range error - // for an overflowing numeral like `1e309` (which JS parses to Infinity), so it - // must fail config load rather than flow through as `null` in the request body. + // Reject NaN and ±Infinity: an overflowing numeral like `1e309` parses to Infinity in JS, + // but must fail here rather than flow through as `null` in the request body. if (!Number.isFinite(size)) { throw new Error(`invalid size: '${sizeStr}'`); } diff --git a/apps/cli/src/command-internal/sql-files-glob.ts b/apps/cli/src/command-internal/sql-files-glob.ts index 30796d601e..a17001fd88 100644 --- a/apps/cli/src/command-internal/sql-files-glob.ts +++ b/apps/cli/src/command-internal/sql-files-glob.ts @@ -5,32 +5,15 @@ import { BAD_PATTERN_MESSAGE, pathMatch } from "./path-match.ts"; const META_CHARS = /[*?[\\]/u; -// `config.hasGlobMeta` — a DIFFERENT, -// narrower set than `META_CHARS` above (which mirrors `io/fs.hasMeta`'s `path.Match` -// escape handling and includes `\`). Only used to gate `WithSkipEmptyGlobs()` below. +// Narrower than `META_CHARS` above (excludes `\`); only used to gate `skipEmptyGlobs` below. const GLOB_META_CHARS = /[*?[]/u; -// `filepath.ToSlash` replaces `os.PathSeparator` with `/` — a no-op on the -// non-Windows platforms this shell mostly runs on, since their separator already IS -// `/`. Gating on `win32` (rather than converting unconditionally) matters: on -// non-Windows a `\` in a pattern is never a path separator, only a `path.Match` -// escape (`foo\.sql`, `seed\*.sql`), and unconditionally slashing it would corrupt -// that escape — see `pathMatch`'s escape handling below. +// Only Windows uses `\` as a path separator; everywhere else it's a `pathMatch` escape +// character (`foo\.sql`), and converting it unconditionally would corrupt that escape. const toSlash = (p: string): string => (process.platform === "win32" ? p.replaceAll("\\", "/") : p); -// `sort.Strings` (used by both `Glob.SQLFiles` and -// `walkMatchedDir`) orders strings by their raw UTF-8 BYTES — -// Go strings are just byte slices, so `strings.Compare` never decodes runes. JS's -// default `Array.prototype.sort()` instead compares UTF-16 CODE UNITS, which -// disagrees with UTF-8 byte order for any character outside the Basic Multilingual -// Plane: a supplementary-plane code point (`U+10000`+, a UTF-16 surrogate PAIR -// starting `0xD800`-`0xDBFF`) always UTF-8-encodes to 4 bytes leading `0xF0`-`0xF4`, -// while every 3-byte-encoded BMP character (`U+0800`-`U+FFFF`, UTF-8 lead byte -// `0xE0`-`0xEF`) is numerically SMALLER as a lead byte but can have a LARGER lone -// UTF-16 code unit than the surrogate pair's lead unit — so the two orderings can -// disagree. Verified empirically: sorting a 4-byte emoji filename against a -// 3-byte fullwidth-exclamation filename, `sort.Strings` places the fullwidth -// exclamation FIRST, while JS's default `.sort()` places the emoji first. +// Byte-wise UTF-8 order, not JS's UTF-16 code-unit order; the two differ outside the BMP, and +// established sort output depends on byte-wise order. const UTF8_ENCODER = new TextEncoder(); const utf8Compare = (a: string, b: string): number => { const bytesA = UTF8_ENCODER.encode(a); @@ -43,76 +26,21 @@ const utf8Compare = (a: string, b: string): number => { return bytesA.length - bytesB.length; }; -// Joins a matched directory (or glob-split directory prefix) with a child/entry name, -// delegating to the injected `Path.Path` service for `path.Join`-equivalent -// cleaning. Two Go call sites build a path exactly this way, and both need the same -// cleaning: -// -// - Direct glob-match construction (`globOne`, below): the real runtime glob path — -// `config.Glob.SQLFiles`'s `fs.Glob(fsys, pattern)` call resolves to `afero.IOFS.Glob` -// (it implements `fs.GlobFS`), which -// delegates to `afero.Glob`. Its -// `glob()` helper appends each match as `filepath.Join(dir, n)`, -// so a glob whose directory portion has a cleanable segment -// (`/tmp/./schemas/*.sql`, `/tmp/x/../schemas/*.sql`, a doubled `/tmp/schemas//*.sql`) -// still records the CLEANED path, not a raw concatenation. Verified empirically: a -// scratch `afero.Glob` probe against all three shapes above returns -// `.../tmp/schemas/a.sql` in every case. -// - Walked-child construction (`walkSqlFiles`, below): `fs.WalkDir` builds -// each child path via `path.Join(dirname, name)` (`io/fs/walk.go`). -// -// `path.Join`/`filepath.Join` both run Clean on the joined result — collapsing doubled -// slashes, dropping a bare `.` root, and lexically resolving `.`/`..` segments anywhere -// else in the path. Node's `path.join` (via this module's injected `Path.Path` service, -// backed by `node:path`) runs the same POSIX lexical-cleaning algorithm and was verified -// empirically to match byte-for-byte across every case either call site can hit — -// dot-root, trailing slash, embedded `.`/`..`, and doubled slashes: -// -// Go: path.Join(".", "foo.sql") = "foo.sql" -// filepath.Join("/tmp/schemas/", "a.sql") = "/tmp/schemas/a.sql" -// filepath.Join("/tmp/./schemas", "a.sql") = "/tmp/schemas/a.sql" -// filepath.Join("/tmp/x/../schemas", "a.sql") = "/tmp/schemas/a.sql" -// path.Join("..", "foo.sql") = "../foo.sql" -// Node: path.join(".", "foo.sql") = "foo.sql" -// path.join("/tmp/schemas/", "a.sql") = "/tmp/schemas/a.sql" -// path.join("/tmp/./schemas", "a.sql") = "/tmp/schemas/a.sql" -// path.join("/tmp/x/../schemas", "a.sql") = "/tmp/schemas/a.sql" -// path.join("..", "foo.sql") = "../foo.sql" -// -// For seeds, the resulting path becomes the `supabase_migrations.seed_files.path` hash -// key, so any of these cleaning differences would make a TS-resolved path fail to match -// an already-recorded Go-CLI key and re-run/re-record the seed; for schema files it -// changes what path is suggested on an apply failure. +// Delegates to the injected `Path.Path` service (backed by `node:path`) for a cleaned join, +// matching how both the glob-match and walked-child paths are built: a `.`/`..`/doubled-slash +// segment in the directory portion must resolve to the same clean path either way. For seeds, +// the result becomes the `supabase_migrations.seed_files.path` hash key, so a cleaning +// mismatch would cause a spurious re-run or mis-record. const joinRelChild = (path: Path.Path, rel: string, name: string): string => path.join(rel, name); /** * Splits a forward-slashed path into its directory prefix and final element. * - * A bare root prefix is kept as `"/"`, never chopped to `""`. This mirrors - * the real runtime glob path — `config.Glob.SQLFiles`'s `fs.Glob` call - * resolves to `afero.IOFS.Glob` (it implements `fs.GlobFS`), which delegates - * to `afero.Glob`/`match.go`'s `filepath.Split` followed by a switch on - * `dir` that leaves a bare `filepath.Separator` alone — every OTHER trailing - * separator is chopped, but the root one is deliberately preserved. Verified - * empirically: with cwd elsewhere, a pattern rooted at - * `/`, with a metacharacter in the FIRST component after the root slash - * (e.g. `tmp` + wildcard + `probe-dir` + wildcard + `.sql`), still resolves - * that first component against the filesystem ROOT, not cwd. Collapsing this - * to `dir: ""` would make `globOne` below treat such an absolute root-level - * pattern as relative to the workdir instead of the filesystem root. - * - * On Windows, a bare drive-root prefix (`"C:/"`) needs the exact same - * preservation, for the same reason but a different mechanism: Go's - * `filepath.Split` treats `"C:"` as the volume name (`volumeNameLen`, - * `internal/filepathlite/path_windows.go`) and always keeps the following - * separator attached to `dir` — verified against the stdlib source directly, since - * there is no Windows machine available to run the compiled stdlib on: - * `Split("C:/*.sql")` returns `dir: "C:/"`, not `dir: "C:"`. Chopping the - * separator here would matter downstream: Node's `path.isAbsolute("C:")` is - * `false` (a bare drive letter is a *drive-relative* path in Windows - * semantics, not absolute), so `globOne`'s `resolve()` would wrongly `join` - * it under the workdir instead of resolving the real drive root, while - * `path.isAbsolute("C:/")` is `true`. + * A bare root prefix stays `"/"`, never chopped to `""` — collapsing it would make + * `globOne` treat an absolute root-level pattern as relative to the workdir instead of the + * filesystem root. On Windows, a bare drive-root prefix (`"C:/"`) needs the same treatment: + * chopping it to `"C:"` would make `path.isAbsolute` report `false` (a bare drive letter is + * drive-relative, not absolute), so `globOne` would wrongly resolve it under the workdir. */ const splitPath = (p: string): { readonly dir: string; readonly file: string } => { const slash = p.lastIndexOf("/"); @@ -124,7 +52,7 @@ const splitPath = (p: string): { readonly dir: string; readonly file: string } = return { dir: p.slice(0, slash), file: p.slice(slash + 1) }; }; -/** Faithful port of `fs.Glob` for one pattern, rooted at `workdir`. */ +/** Resolves one glob pattern to matching paths, rooted at `workdir`. */ const globOne = ( fs: FileSystem.FileSystem, path: Path.Path, @@ -132,30 +60,20 @@ const globOne = ( pattern: string, ): Effect.Effect<ReadonlyArray<string>, never> => Effect.gen(function* () { - // `fs.Glob`/`afero.Glob` resolve a literal (no-metacharacter) pattern via - // `Lstat`, which errors on an empty path — so `""` always yields no matches. An - // unguarded `path.join(workdir, "")` resolves to `workdir` itself, which would - // wrongly report the workdir as a match for an empty `schema_paths`/`sql_paths` - // entry (e.g. `schema_paths = [""]`). + // An empty pattern must yield no matches: `path.join(workdir, "")` resolves to `workdir` + // itself, which would wrongly report the workdir as a match for an empty + // `schema_paths`/`sql_paths` entry. if (pattern.length === 0) { return []; } // Absolute patterns resolve against the filesystem root; relative ones are // rooted at the workdir. const resolve = (p: string): string => (path.isAbsolute(p) ? p : path.join(workdir, p)); - // No metacharacters: `fs.Glob`/`afero.Glob` fast path probes - // via `Lstat` (`OsFs.LstatIfPossible` → `os.Lstat`, verified empirically against - // `afero@v1.15.0`), which does NOT follow a symlink — so a literal pattern naming a - // BROKEN symlink still Lstat-succeeds (the link itself exists) and is reported as a - // match; the follow-up `fs.Stat(fsys, fp)` in `sqlFilesGlob` below (which DOES - // follow it) is what fails, with `failed to stat matched file: ...`. `fs.exists` here - // is Effect's `access`-based check (Node's `fs.access`), which follows the symlink - // like a normal `Stat` and would wrongly report "no files matched pattern" instead — - // verified empirically: `fs.access` on a broken symlink resolves ENOENT while - // `fs.lstat` on the same path succeeds. Probe for the entry itself the same - // no-follow way `walkSqlFiles` below already does for a walked child: `readLink` - // succeeds only for a symlink (broken or not), so treat that as an Lstat success - // before falling back to the normal existence check for everything else. + // A literal pattern must match a broken symlink too (the link itself exists, even if its + // target doesn't): `fs.exists` alone follows the symlink and would wrongly report no + // match, so probe with `readLink` first (no-follow) and only fall back to `fs.exists` for + // everything else. The later `fs.stat` in `sqlFilesGlob` is what fails on the dangling + // target. if (!META_CHARS.test(pattern)) { const resolved = resolve(pattern); const isSymlink = yield* fs.readLink(resolved).pipe( @@ -178,23 +96,9 @@ const globOne = ( .pipe(Effect.orElseSucceed((): ReadonlyArray<string> => [])); for (const name of names) { if (pathMatch(file, name).matched) { - // `joinRelChild` (above) is the same Path-service clean-join the walked-child - // path below uses — see its comment for why a raw `${d}/${name}` concatenation - // isn't enough here (`afero.Glob` cleans via `filepath.Join(dir, n)`, so - // `d === "/"`, `d === ""`, and a `dir` containing `.`/`..`/doubled-slash - // segments must all clean the same way as the walked-child case does). - // - // Deliberately NOT `toSlash`'d here, on Windows: `config.Glob.SQLFiles` - // sorts the RAW backslash-joined matches from `fs.Glob`/`afero.Glob` (built via - // `filepath.Join`) and only converts each surviving match - // to forward slash AFTER that sort (`fp := filepath.ToSlash(item)`). - // This function's own caller (`sqlFilesGlob`) already - // sorts the array `globOne` returns and slashes each item only after — slashing - // here too would sort forward-slash-joined strings instead of the raw - // backslash-joined ones, which can disagree: comparing `a\x.sql` vs `a0\x.sql` - // byte-for-byte puts `a0\x.sql` first (`\` is `0x5C`, greater than `0`'s - // `0x30`), while `a/x.sql` vs `a0/x.sql` puts `a/x.sql` first (`/` is `0x2F`, - // less than `0x30`) — a different order for the same two matches. + // Not slashed here: the caller sorts these raw (possibly backslash-joined) matches + // before converting to forward slash, and slashing here first would change the + // byte-order sort result on Windows (`\` is `0x5C`, `/` is `0x2F`). result.push(joinRelChild(path, d, name)); } } @@ -203,19 +107,12 @@ const globOne = ( }); /** - * Recursively collects the regular `.sql` files under a matched directory, porting - * `walkMatchedDir` with the `SQLFiles` include filter (`entry.Type().IsRegular() && - * filepath.Ext(path) == ".sql"`). - * Paths are workdir-relative (matching the glob output), forward-slashed, and sorted - * for deterministic application. + * Recursively collects the regular `.sql` files under a matched directory. Paths are + * workdir-relative, forward-slashed, and sorted for deterministic application. * - * A `ReadDir` failure anywhere in the tree (e.g. a permissions error on a nested - * directory) fails the WHOLE walk with `failed to walk matched directory: <cause>`, - * discarding every file collected so far — `fs.WalkDir` callback returns that - * `err` unchanged, which stops the traversal immediately and makes `walkMatchedDir` - * return `(nil, err)` rather than the partial list (verified - * empirically: an unreadable matched directory makes `Glob.SQLFiles` return a - * `failed to walk matched directory: ...` error with zero files, not an empty match). + * A `readDirectory` failure anywhere in the tree fails the whole walk with `failed to walk + * matched directory: <cause>`, discarding every file collected so far rather than returning + * a partial list. */ const walkSqlFiles = ( fs: FileSystem.FileSystem, @@ -228,13 +125,8 @@ const walkSqlFiles = ( const walk = (rel: string): Effect.Effect<void, string> => Effect.gen(function* () { const absDir = path.isAbsolute(rel) ? rel : path.join(workdir, rel); - // `fs.WalkDir` runs against `afero.OsFs` with the process cwd already the - // workdir, so a `ReadDir` failure — on the - // matched root `dir` itself, or on any nested directory the walk descends into — - // embeds the workdir-relative `rel` in its error text, never an absolute path. - // This module never `process.chdir`s, so the real read needs `absDir`, but the - // wrapped warning must still report `rel` (same substitution pattern as the - // matched-file stat failure below and `applySchemaFiles`'s read errors). + // The real read needs `absDir`, but the wrapped error must report the workdir-relative + // `rel` (same substitution pattern used below and by `applySchemaFiles`'s read errors). const names = yield* fs .readDirectory(absDir) .pipe( @@ -243,28 +135,14 @@ const walkSqlFiles = ( `failed to walk matched directory: ${relativizeErrorMessage(errorMessage(error), absDir, rel)}`, ), ); - // `fs.WalkDir` visits directory entries in lexical byte order — its own - // `ReadDir` (`os.ReadDir`/`afero.OsFs`) contract guarantees results "sorted by - // filename" before `walkDir` ever iterates them. This FileSystem service's - // `readDirectory` makes no such promise (raw OS enumeration order), so when a - // directory contains MULTIPLE problematic children (e.g. two unreadable - // subdirectories), which one's failure short-circuits this loop — and therefore - // which single error message this walk fails with, or which `WARN:`/fatal text a - // caller (the experimental schema branch, or seed-path globbing) surfaces — - // would otherwise depend on filesystem enumeration order instead of matching - // the established deterministic choice (review CLI-1958). Sort with the same UTF-8 - // byte-order comparator `globOne`/the top-level match list already use above. + // `readDirectory` gives raw OS enumeration order, not a sorted one, so sort explicitly + // to keep error ordering and output deterministic across multiple problematic children. for (const name of [...names].sort(utf8Compare)) { const childRel = joinRelChild(path, rel, name); const childAbs = path.isAbsolute(childRel) ? childRel : path.join(workdir, childRel); - // `fs.WalkDir` types each child from the parent's `ReadDir` entry - // (`os.ReadDir`'s Lstat-based `DirEntry`) and never re-`Stat`s through it — - // so a symlinked file or subdirectory found below the matched root is - // neither included nor recursed into, regardless of what it points to - // (`io/fs/walk.go`: only the matched root itself, resolved once by - // the caller before reaching this walk, may be a symlink). `readLink` - // succeeds only for symlinks, so use it as the no-follow probe in place of - // the `Lstat` this FileSystem service doesn't expose. + // A symlinked file or subdirectory below the matched root is never included or + // recursed into, regardless of its target; only the matched root itself may be a + // symlink. `readLink` succeeds only for a symlink, used here as the no-follow probe. const isSymlink = yield* fs.readLink(childAbs).pipe( Effect.map(() => true), Effect.orElseSucceed(() => false), @@ -274,50 +152,19 @@ const walkSqlFiles = ( } const statResult = yield* fs.stat(childAbs).pipe(Effect.result); if (Result.isFailure(statResult)) { - // TOCTOU: this child existed a moment ago in `names` (this directory's - // `readDirectory` snapshot) but is gone by the time we stat it here — e.g. removed - // by a concurrent process. Go never hits this window for the child's TYPE: `fs.WalkDir` - // decides file-vs-directory from the SAME `DirEntry` its parent `ReadDir` already - // returned and never re-`Stat`s a child, so a `.sql` file that disappears here - // stays in Go's declared file list, and only the later, real file-open fails - // loudly. Verified empirically: a scratch `filepath.WalkDir` probe that deletes a - // sibling `.sql` file between `ReadDir` and that file's own visit still reports - // it `IsRegular` from the cached entry, keeps it in `declared`, and the - // subsequent `os.Open` on it fails with "no such file or directory" — never a - // silent drop. Losing the stat here must not silently drop the file and let the - // walk "succeed" having applied nothing — best-effort include it, matching Go's - // outcome, and let the real downstream read surface the failure. + // TOCTOU: this child existed in the `readDirectory` snapshot but vanished before + // the stat above (e.g. a concurrent removal). Losing it here must not silently + // drop the file and let the walk appear to succeed with nothing applied — include + // it optimistically and let the real downstream read surface the failure. if (childRel.endsWith(".sql")) { collected.push(toSlash(childRel)); continue; } - // TOCTOU, directory variant: the vanished entry could equally have been a - // SUBDIRECTORY of the matched tree, not a harmless non-`.sql` file — and Go's - // outcome for those two cases is NOT the same. For a directory `DirEntry`, - // `fs.WalkDir` unconditionally attempts a second `ReadDir` to recurse into it - // (`io/fs/walk.go`'s `walkDir`); when the child has since been removed, that - // second `ReadDir` fails, and `walkMatchedDir`'s callback propagates the error - // unchanged (`if err != nil { return err }`) — `fs.WalkDir` - // returns it, and `walkMatchedDir` wraps it as `failed to walk matched - // directory: <cause>`, discarding every file already collected. - // Verified empirically: a scratch `fs.WalkDir` probe that - // removes a nested subdirectory between the parent's `ReadDir` and the - // subdirectory's own `ReadDir` reproduces exactly this — zero files, error - // `failed to walk matched directory: open .../nested: no such file or - // directory` — never a silent skip, unlike the vanished-`.sql`-file case above - // (round 6). This FileSystem service can't recover the lost entry's type after - // the fact — `DirEntry` type came for free from the same `ReadDir` syscall - // as the listing, whereas this port's `stat` is a second, separate syscall, so a - // raced disappearance here always loses the type along with the entry. Treat any - // non-`.sql` disappearance as a potential directory and fail the whole walk the - // same way an unreadable still-present directory does (the `readDirectory` - // failure above) — matching Go's fail-loud design intent (never silently apply a - // partial schema/seed set) rather than risk silently dropping an entire nested - // subtree of schema files. This `stat` stands in for the second `ReadDir` Go - // itself would issue on the vanished directory, so its display path needs the - // same absolute-to-relative substitution as the `readDirectory` failure above — - // `childAbs` is what the real (stand-in) syscall needed, `childRel` is what Go's - // own `ReadDir` error would embed. + // TOCTOU, directory variant: unlike the `.sql`-file case above, a vanished + // non-`.sql` entry could have been a subdirectory, and its type can't be recovered + // after the fact (a directory entry's type comes from the same syscall as the + // listing; this `stat` is a separate one). Treat it as a potential directory and + // fail the whole walk — never silently apply a partial schema/seed set. return yield* Effect.fail( `failed to walk matched directory: ${relativizeErrorMessage(errorMessage(statResult.failure), childAbs, childRel)}`, ); @@ -339,64 +186,41 @@ interface SqlFilesGlobResult { /** Workdir-relative, forward-slashed matches, deduplicated in first-seen order across patterns. */ readonly files: ReadonlyArray<string>; /** - * Per-pattern/per-match problems (`no files matched pattern: …` / `failed to glob - * files: …` / `failed to walk matched directory: …`), in pattern order. Never fatal - * by itself — callers decide when a warning matters - * (e.g. the seed path always surfaces it; the schema-files apply path only surfaces - * it when NO pattern matched anything at all). + * Per-pattern/per-match problems (`no files matched pattern: …` / `failed to glob files: + * …` / `failed to walk matched directory: …`), in pattern order. Never fatal by itself — + * e.g. the seed path always surfaces it, while the schema-files apply path only surfaces + * it when no pattern matched anything at all. */ readonly warnings: ReadonlyArray<string>; } /** - * Mirrors `GlobOption`s. Neither - * caller in this shared module needs them today (`applySchemaFiles`'s - * `[db.migrations].schema_paths`, and the two `[db.seed].sql_paths` callers — - * `getPendingSeeds` and `seed.ts`'s `resolveSeedFiles` — all pass none, - * matching the established zero-option call sites). Added for `db diff`'s declarative path, - * which calls `Glob.files` `WithSkipEmptyGlobs()` + `WithErrorOnAllSkippedGlobs()`. + * Glob options used only by `db diff`'s declarative path; every other caller (schema-files + * apply, both seed-path callers) passes none. */ export interface SqlFilesGlobOptions { /** - * `WithSkipEmptyGlobs()`: a pattern that contains a glob metacharacter - * (`config.hasGlobMeta` — `*`, `?`, `[`; NOT `\`, unlike `META_CHARS` above) and - * matches nothing is silently skipped — no "no files matched pattern" warning — - * unless `errorOnAllSkipped` retroactively un-skips it (below). A LITERAL pattern - * (no glob metacharacter) that doesn't exist always warns, regardless of this flag. + * A pattern containing a glob metacharacter (`*`, `?`, `[`; not `\`) that matches nothing + * is silently skipped — no "no files matched pattern" warning — unless + * `errorOnAllSkipped` retroactively un-skips it. A literal pattern that doesn't exist + * always warns, regardless of this flag. */ readonly skipEmptyGlobs?: boolean; /** - * `WithErrorOnAllSkippedGlobs()`: only meaningful together with - * `skipEmptyGlobs`. If the overall result ends up empty AND at least one pattern - * was silently skipped, every skipped pattern's silence is retroactively turned - * back into a "no files matched pattern" warning — so a `skipEmptyGlobs` caller - * can still detect the "nothing matched anything" case. + * Only meaningful with `skipEmptyGlobs`: if the overall result ends up empty and at + * least one pattern was silently skipped, every skipped pattern's silence is turned + * back into a "no files matched pattern" warning. */ readonly errorOnAllSkipped?: boolean; } /** - * Resolves SQL-file glob patterns to existing files, porting `config.Glob.SQLFiles` - * over `fs.Glob`. Shared by - * `[db.seed].sql_paths` (via `getPendingSeeds`, `seed-ops.ts`, and - * `seed.ts`'s `resolveSeedFiles`) and `[db.migrations].schema_paths` (via - * `applySchemaFiles`, `migration-apply.ts`) — all three call sites - * resolve through the exact same `Glob` type and `SQLFiles` method, so the traversal - * logic lives here once. - * - * Each pattern is matched independently: matches are sorted per-pattern (`sort.Strings` - * byte order), but the overall result preserves cross-pattern - * DECLARATION order (no global re-sort), with first-seen dedup. A directory match is - * expanded to its regular `.sql` files, recursively, sorted by full path - * (`walkMatchedDir`); a non-directory match is kept verbatim regardless of extension. A - * pattern that matches nothing, or is malformed (`path.ErrBadPattern`, e.g. an - * unterminated `[` class), contributes a warning but does not stop the loop — mirroring - * `fs.Glob`'s up-front `Match(pattern, "")` validation (`io/fs/glob.go`). + * Resolves SQL-file glob patterns to existing files, shared by `[db.seed].sql_paths` and + * `[db.migrations].schema_paths` so the traversal logic lives in one place. * - * Patterns are assumed already resolved to Go's config-load form (a relative entry - * `supabase/`-joined, an absolute entry verbatim) — callers resolve that once at - * config-read time (`resolveSeedSqlPath`), matching `config.resolve` step, - * which runs once at config load, before any glob. + * Each pattern is matched and sorted independently (byte order), keeping cross-pattern + * declaration order with first-seen dedup. A directory match expands recursively to its + * regular `.sql` files; an empty or malformed pattern warns without stopping the loop. */ export const sqlFilesGlob = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, @@ -413,17 +237,12 @@ export const sqlFilesGlob = Effect.fnUntraced(function* ( const skipped: Array<string> = []; for (const rawPattern of patterns) { - // `filepath.ToSlash(pattern)` is passed only as an ARGUMENT - // to `fs.Glob` — the loop's own `pattern` variable (Go's range variable) is never - // reassigned, so every later reference to it in THIS iteration (`hasGlobMeta`, the - // skipped-pattern list, and the "no files matched pattern" warning, all below) - // still reports the ORIGINAL, un-slashed pattern. On Windows, an absolute pattern - // with backslashes (`C:\schemas\*.sql`) must therefore warn with that raw backslash - // form, even though matching itself runs against the slashed form. + // Warnings and the skipped-pattern list report the original `rawPattern`, not the + // slashed form used for matching — on Windows, a backslash pattern (`C:\schemas\*.sql`) + // must warn with that raw form. const pattern = toSlash(rawPattern); - // `fs.Glob` validates the whole pattern up front (`Match(pattern, "")`); a - // malformed glob is reported as `failed to glob files: <ErrBadPattern>` and - // contributes no matches, rather than the misleading "no files matched" below. + // A malformed glob is reported as `failed to glob files: ...` and contributes no + // matches, rather than the misleading "no files matched" below. if (pathMatch(pattern, "").badPattern) { warnings.push(`failed to glob files: ${BAD_PATTERN_MESSAGE}`); continue; @@ -439,23 +258,13 @@ export const sqlFilesGlob = Effect.fnUntraced(function* ( } for (const match of [...matches].sort(utf8Compare)) { const fp = toSlash(match); - // A directory match is expanded to its regular `.sql` files recursively - // (`walkMatchedDir`, sorted); a file match is kept verbatim. - // Go: `if err != nil { allErrors = append(allErrors, errors.Errorf("failed to - // stat matched file: %w", err)); continue }` — a match - // that disappears (or is a broken symlink) between the glob and this stat - // becomes a warning and is skipped, same as a walk failure below. Falling back - // to treating it as a regular file (the previous behaviour here) would instead - // hand a nonexistent path to the caller's later read, turning a warned-but- - // otherwise-successful reset into a hard apply error. + // A directory match expands recursively to its regular `.sql` files; a file match is + // kept verbatim. A match that disappears (or is a broken symlink) between the glob and + // this stat becomes a warning and is skipped, rather than reaching the caller's later + // read as a hard apply error. // - // `fs.Stat(fsys, fp)` runs with its process cwd already - // the workdir, so `fp` IS the exact string the - // real syscall sees and the resulting error's path is that workdir-relative - // `fp`. This module never `process.chdir`s, so the real stat needs an absolute - // path here — but the wrapped message must still report the relative `fp`, not - // the absolute path used to make the syscall work. Same substitution pattern as - // `applySchemaFiles`'s read-error display path (`migration-apply.ts`). + // The real stat needs an absolute path, but the wrapped message must report the + // relative `fp` (same substitution pattern as `applySchemaFiles`'s read errors). const absoluteFp = path.isAbsolute(fp) ? fp : path.join(workdir, fp); const statResult = yield* fs.stat(absoluteFp).pipe(Effect.result); if (Result.isFailure(statResult)) { @@ -465,10 +274,8 @@ export const sqlFilesGlob = Effect.fnUntraced(function* ( } const matchType = statResult.success.type; if (matchType === "Directory") { - // Go: `if err != nil { allErrors = append(allErrors, err); continue }` — a walk - // failure on this match becomes a warning (never a hard Effect failure, like - // every other per-match/per-pattern problem here) and the loop moves on to the - // next match, exactly like a malformed pattern or a "no files matched" miss. + // A walk failure on this match becomes a warning, like every other per-match/ + // per-pattern problem here, rather than a hard Effect failure — the loop continues. const walked = yield* walkSqlFiles(fs, path, workdir, fp).pipe(Effect.result); if (Result.isFailure(walked)) { warnings.push(walked.failure); @@ -489,8 +296,7 @@ export const sqlFilesGlob = Effect.fnUntraced(function* ( } } - // Go: `if opts.errorOnAllSkipped && len(result) == 0 && len(skipped) > 0` — only - // escalate silently-skipped patterns back into warnings when NOTHING matched at all. + // Only escalate silently-skipped patterns back into warnings when nothing matched at all. if (errorOnAllSkipped && files.length === 0 && skipped.length > 0) { for (const pattern of skipped) { warnings.push(`no files matched pattern: ${pattern}`); diff --git a/apps/cli/src/command-internal/sql-files-glob.unit.test.ts b/apps/cli/src/command-internal/sql-files-glob.unit.test.ts index cbcfbf2b15..695331aaac 100644 --- a/apps/cli/src/command-internal/sql-files-glob.unit.test.ts +++ b/apps/cli/src/command-internal/sql-files-glob.unit.test.ts @@ -18,9 +18,6 @@ describe("sqlFilesGlob", () => { it.effect( "treats an empty pattern as no match, not the workdir itself (Go fs.Glob parity)", () => { - // `fs.Glob`/`afero.Glob` resolve a no-metacharacter pattern via `Lstat`, which - // errors on an empty path — an empty `schema_paths`/`sql_paths` entry (e.g. - // `schema_paths = [""]`) always yields no matches, never the workdir itself. const dir = mkdtempSync(join(tmpdir(), "sql-glob-empty-")); return run([""], dir).pipe( Effect.tap((result) => @@ -37,10 +34,6 @@ describe("sqlFilesGlob", () => { it.effect( "does not follow a symlinked .sql file below a matched directory (Go WalkDir parity)", () => { - // `Glob.SQLFiles` expands a matched directory with `fs.WalkDir`, which types - // each child from its parent's `ReadDir` entry (`os.ReadDir`'s Lstat-based - // `DirEntry`) and never re-`Stat`s through it — so a symlinked `.sql` file is - // never included, regardless of what it points to. const dir = mkdtempSync(join(tmpdir(), "sql-glob-symlink-file-")); const schemasDir = join(dir, "schemas"); mkdirSync(schemasDir); @@ -87,18 +80,6 @@ describe("sqlFilesGlob", () => { it.effect( "surfaces a stat failure on a matched file as a warning instead of treating it as a regular file (Go parity)", () => { - // `if info, err := fs.Stat(fsys, fp); err != nil { allErrors = append(allErrors, - // errors.Errorf("failed to stat matched file: %w", err)); continue }` — - // a match that disappears (or is a broken symlink) between the glob and this stat - // becomes a warning and is skipped entirely, never silently treated as a regular file. - // - // `fsys` here is always `afero.NewOsFs()` with the process cwd already the - // workdir, so `fs.Stat(fsys, fp)`'s embedded path - // in the resulting error is the workdir-RELATIVE `fp` (verified directly against - // `os.Stat`/`afero.OsFs.Stat`, which pass the name through to `os.Stat` unchanged). - // This module never `process.chdir`s, so the real stat needs an absolute path — but - // the warning must still report the relative form, not that absolute (temp-dir) - // path, or it would leak a local filesystem path Go never would. const dir = mkdtempSync(join(tmpdir(), "sql-glob-stat-fail-")); const schemasDir = join(dir, "schemas"); mkdirSync(schemasDir); @@ -122,15 +103,6 @@ describe("sqlFilesGlob", () => { it.effect( "surfaces a stat failure for a LITERAL (no-metacharacter) pattern naming a broken symlink, instead of reporting no match (Go afero.Glob Lstat parity)", () => { - // `fs.Glob`/`afero.Glob` no-metacharacter fast path probes - // via `Lstat` (`OsFs.LstatIfPossible` → `os.Lstat`), which does NOT follow a - // symlink — so a LITERAL pattern naming a broken symlink still Lstat-succeeds (the - // link itself exists) and is reported as a match; the follow-up `fs.Stat` above is - // what then fails with `failed to stat matched file: ...`, exactly like the - // wildcard-pattern case the previous test covers. Verified empirically - // (`afero.Glob`/`fs.Stat` scratch probe): a literal broken-symlink - // pattern always Globs to a match and always fails the follow-up Stat — never - // "no files matched pattern". const dir = mkdtempSync(join(tmpdir(), "sql-glob-literal-symlink-")); const schemasDir = join(dir, "schemas"); mkdirSync(schemasDir); @@ -152,15 +124,8 @@ describe("sqlFilesGlob", () => { it.effect( "keeps a bare root ('/') as the directory when a glob pattern's meta character is in the first path component (Go afero.Glob parity)", () => { - // Go's real runtime glob path — `config.Glob.SQLFiles`'s `fs.Glob` call resolves to - // `afero.IOFS.Glob` (it implements `fs.GlobFS`), which delegates to `afero.Glob` - // (`match.go`): `filepath.Split` followed by a switch that leaves a bare - // `filepath.Separator` alone — every OTHER trailing separator is chopped, but the - // root one is deliberately preserved. Verified empirically: - // with cwd elsewhere, a pattern rooted at "/" with a metacharacter in the first - // component after the root slash still resolves against the filesystem ROOT, not - // cwd. A canary file placed in the WORKDIR (never the real "/") proves this native - // port does not fall back to treating the root component as workdir-relative. + // The canary file lives in the workdir (never the real "/"), proving this doesn't + // fall back to treating the root component as workdir-relative. const dir = mkdtempSync(join(tmpdir(), "sql-glob-abs-root-")); writeFileSync(join(dir, "__legacy_sql_glob_canary__.sql"), "select 1;"); return run(["/*__legacy_sql_glob_canary__*.sql"], dir).pipe( @@ -180,10 +145,7 @@ describe("sqlFilesGlob", () => { it.effect( "recurses through a root-anchored directory component without falling back to the workdir (Go afero.Glob parity)", () => { - // Same bug as above, but for a two-level pattern (`/foo*/*.sql`) — the recursive - // call that resolves the "foo*" directory component must also treat "/" as the - // real filesystem root, not "" (which `globOne` maps to the workdir). The workdir - // here contains a subdirectory that WOULD match "foo*" if (and only if) the + // The workdir here contains a subdirectory that would match "foo*" only if the // recursive call incorrectly fell back to reading the workdir instead of "/". const dir = mkdtempSync(join(tmpdir(), "sql-glob-abs-root-nested-")); const canaryDir = join(dir, "__legacy_sql_glob_root_canary_dir__"); @@ -206,18 +168,8 @@ describe("sqlFilesGlob", () => { it.effect( "preserves a Windows drive root ('C:/') as the directory when splitting a glob pattern (Go filepath.Split parity)", () => { - // `filepath.Split` treats `"C:"` as the volume name on Windows - // (`volumeNameLen`, `internal/filepathlite/path_windows.go`) and always - // keeps the following separator attached to `dir` — so `Split("C:/*.sql")` - // returns `dir: "C:/"`, not `dir: "C:"` (verified directly against that - // stdlib source; there is no Windows machine available to run the - // compiled binary on). Losing the trailing slash matters: Node's - // `path.isAbsolute("C:")` is `false` (a bare drive letter is - // *drive-relative*, not absolute, in Windows semantics), so `globOne`'s - // `resolve()` would wrongly `join` it under the workdir instead of - // resolving the real drive root. Force win32 path semantics - // (`BunPath.layerWin32`) and this module's own `process.platform` gate - // so the test exercises the same branch a real Windows install takes. + // Forces win32 path semantics (`BunPath.layerWin32` + `process.platform`) so the test + // exercises the same branch a real Windows install takes. const originalPlatform = process.platform; Object.defineProperty(process, "platform", { value: "win32" }); const dir = mkdtempSync(join(tmpdir(), "sql-glob-drive-root-")); @@ -225,9 +177,8 @@ describe("sqlFilesGlob", () => { return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - // A "C:/" drive root doesn't exist on this (non-Windows) test host, so - // fake just the two calls that must resolve against it, reusing a real - // file's stat info to avoid hand-rolling a `File.Info`. + // A "C:/" drive root doesn't exist on this test host, so fake just the two calls that + // must resolve against it, reusing a real file's stat info. const realFileInfo = yield* fs.stat(join(dir, "a.sql")); const driveRootFs: FileSystem.FileSystem = { ...fs, @@ -257,14 +208,7 @@ describe("sqlFilesGlob", () => { it.effect( "reports the raw backslash pattern in a 'no files matched' warning on Windows, not the slashed form used for matching (Go filepath.ToSlash parity)", () => { - // Go passes `filepath.ToSlash(pattern)` only as an ARGUMENT to `fs.Glob` — - // the loop's own `pattern` variable (Go's range variable) is - // never reassigned, so the "no files matched pattern: %s" warning - // still reports the ORIGINAL backslash form. An absolute Windows pattern with - // backslashes that matches nothing must therefore warn with that backslash form, - // not the slashed one used internally to glob. Force win32 path semantics - // (`BunPath.layerWin32`) and this module's own `process.platform` gate, same as - // the drive-root test above. + // Forces win32 path semantics, same as the drive-root test above. const originalPlatform = process.platform; Object.defineProperty(process, "platform", { value: "win32" }); const dir = mkdtempSync(join(tmpdir(), "sql-glob-win-warn-")); @@ -293,18 +237,9 @@ describe("sqlFilesGlob", () => { it.effect( "sorts raw backslash-joined Windows matches BEFORE slashing, not after (Go afero.Glob/filepath.ToSlash ordering parity)", () => { - // `config.Glob.SQLFiles` calls `fs.Glob`, which resolves - // to `afero.Glob`'s `glob()` helper — it builds each match with `filepath.Join(dir, - // n)` (OS-separator-joined, backslash on Windows) and NEVER slashes it. Only back in - // `SQLFiles`, AFTER `sort.Strings(matches)` sorts those raw backslash matches, does - // each surviving item get `filepath.ToSlash`'d. For a pattern like `a*/x.sql` - // matching both `a\x.sql` and `a0\x.sql`, sorting the RAW backslash strings byte-for - // -byte puts `a0\x.sql` first (`\` is `0x5C`, greater than `0`'s `0x30`) — but - // sorting the SLASHED strings instead would put `a/x.sql` first (`/` is `0x2F`, less - // than `0x30`), a different order for the same two matches. `globOne` must therefore - // push the raw joined match (slashing only happens in the caller's post-sort loop), - // matching Go's real order exactly. Fully faked filesystem (no real Windows host - // available): `readDirectory`/`stat` return canned results keyed by the exact + // For `a\x.sql` vs `a0\x.sql`, sorting the raw backslash bytes puts `a0\x.sql` first + // (`\` is `0x5C` > `0`'s `0x30`), while sorting the slashed form would put `a/x.sql` + // first instead — a fully faked filesystem supplies canned results keyed by the exact // backslash-joined paths `BunPath.layerWin32`'s `path.join` computes. const originalPlatform = process.platform; Object.defineProperty(process, "platform", { value: "win32" }); @@ -350,14 +285,6 @@ describe("sqlFilesGlob", () => { it.effect( "normalizes a doubled slash when the matched directory itself has a trailing slash (Go path.Join parity)", () => { - // `fs.WalkDir` builds each child path via `path.Join(dirname, name)` - // (`io/fs/walk.go`), and `path.Join` runs `path.Clean` on the result, collapsing a - // doubled `/`. A literal (no-metacharacter) `schema_paths`/`sql_paths` entry like - // `"schemas/"` resolves via `fs.Glob`'s fast path to the pattern VERBATIM, trailing - // slash and all — so the walk over its children must not produce `schemas//a.sql`. - // Verified empirically: a scratch probe calling - // `config.Glob{"<dir>/"}.SQLFiles(...)` on a real trailing-slash directory returns - // the single-slash path, not a doubled one. const dir = mkdtempSync(join(tmpdir(), "sql-glob-trailing-slash-")); const schemasDir = join(dir, "schemas"); mkdirSync(schemasDir); @@ -377,17 +304,6 @@ describe("sqlFilesGlob", () => { it.effect( "drops the './' prefix when the matched directory cleans to '.' (Go path.Join parity)", () => { - // `fs.WalkDir` builds each child path via `path.Join(dirname, name)`, and - // `path.Join` runs `path.Clean`, which drops a bare `.` root entirely rather than - // joining it as a prefix. A matched directory can clean to exactly `.` — e.g. - // `[db.migrations].schema_paths = [".."]`/`[db.seed].sql_paths = [".."]`, which - // `baseConfig.resolve`'s own `path.Join(builder.SupabaseDirPath, pattern)` collapses - // to `.` — so the walk over its children - // must record `a.sql`, not `./a.sql`. This matters beyond cosmetics: for seeds, the - // walked path becomes the `supabase_migrations.seed_files.path` hash key, so a - // `./`-prefixed path would never match an already-recorded Go-CLI key. Verified - // empirically: `path.Join(".", "foo.sql")` and a real `fs.WalkDir` rooted at `.` both - // drop the `./` prefix entirely. const dir = mkdtempSync(join(tmpdir(), "sql-glob-dot-root-")); writeFileSync(join(dir, "a.sql"), "select 1;"); const nestedDir = join(dir, "nested"); @@ -408,13 +324,6 @@ describe("sqlFilesGlob", () => { it.effect( "cleans a '..'-segment matched directory when walking its children (Go path.Join parity)", () => { - // `fs.WalkDir` builds each child path via `path.Join(dirname, name)`, and - // `path.Join` runs `path.Clean`, which lexically resolves an embedded `..` segment — - // not just a bare `.` root or a trailing slash. A matched directory can contain a - // `..` anywhere, e.g. `[db.migrations].schema_paths = ["nested/../schemas"]`, and the - // walk over its children must record `schemas/a.sql`, not `nested/../schemas/a.sql`. - // Verified empirically: `path.Join("/tmp/x/../schemas", "a.sql")` and Node's - // `path.join("/tmp/x/../schemas", "a.sql")` both clean to `/tmp/schemas/a.sql`. const dir = mkdtempSync(join(tmpdir(), "sql-glob-dotdot-segment-")); const schemasDir = join(dir, "schemas"); mkdirSync(schemasDir); @@ -434,15 +343,8 @@ describe("sqlFilesGlob", () => { it.effect( "cleans a direct glob match whose directory portion has a '.' segment (Go afero.Glob parity)", () => { - // Distinct from the walked-child cleaning above: this pattern's glob metacharacter - // (`*`) is in the FINAL component, so `globOne` matches `a.sql` directly against - // the directory entries of `schemas/.` — it never goes through - // `walkSqlFiles`. Go's real runtime glob path resolves through - // `afero.IOFS.Glob` -> `afero.Glob`'s `glob()` helper, which appends each match as - // `filepath.Join(dir, n)` — so the recorded match is the CLEANED - // `schemas/a.sql`, not a raw `schemas/./a.sql` concatenation. Verified empirically: - // a scratch `afero.Glob(fs, ".../tmp/./schemas/*.sql")` probe against a real - // filesystem returns the cleaned path. + // Distinct from the walked-child cleaning tests above: the glob metacharacter is in + // the final component, so this matches directly via `globOne`, never `walkSqlFiles`. const dir = mkdtempSync(join(tmpdir(), "sql-glob-direct-dot-segment-")); const schemasDir = join(dir, "schemas"); mkdirSync(schemasDir); @@ -462,14 +364,8 @@ describe("sqlFilesGlob", () => { it.effect( "cleans a direct glob match whose directory portion has a '..' segment (Go afero.Glob parity)", () => { - // Same distinction as above (a direct match via `globOne`, not a walked directory - // expansion), but for an embedded `..` rather than a `.` segment — e.g. an absolute - // `schema_paths`/`sql_paths` entry like `/tmp/x/../schemas/*.sql`. `filepath.Join` - // lexically resolves `..` the same way it drops a bare `.` root, so Go still records - // the cleaned `schemas/a.sql`, not `nested/../schemas/a.sql`. For seed files, that - // recorded path is the `supabase_migrations.seed_files.path` hash key, so leaving it - // uncleaned would make a TS-resolved match fail to line up with an already-recorded - // Go-CLI key and re-run/re-record the seed. + // Same distinction as above (a direct match via `globOne`), but for an embedded `..` + // segment instead of `.`. const dir = mkdtempSync(join(tmpdir(), "sql-glob-direct-dotdot-segment-")); const schemasDir = join(dir, "schemas"); mkdirSync(schemasDir); @@ -489,12 +385,8 @@ describe("sqlFilesGlob", () => { it.effect( "normalizes a doubled slash for a direct glob match under a trailing-slash directory component (Go afero.Glob parity)", () => { - // Same distinction again: `splitPath` on `"schemas//*.sql"` yields a `dir` of - // `"schemas/"` (a single trailing slash survives the split), so the old raw - // `` `${d}/${name}` `` concatenation inserted a SECOND slash on top of it - // (`"schemas//a.sql"`). `filepath.Join`/`path.join` collapse doubled slashes - // regardless of where they came from, so the recorded match must be the - // single-slash `schemas/a.sql`. + // `splitPath` on `"schemas//*.sql"` yields `dir: "schemas/"` (the trailing slash + // survives the split), so the join must still collapse the doubled slash. const dir = mkdtempSync(join(tmpdir(), "sql-glob-direct-doubled-slash-")); const schemasDir = join(dir, "schemas"); mkdirSync(schemasDir); @@ -514,15 +406,8 @@ describe("sqlFilesGlob", () => { it.effect( "still includes a '.sql' child whose stat fails after it's already listed (Go WalkDir parity)", () => { - // `fs.WalkDir` types each child from the parent's `ReadDir`-returned `DirEntry` - // and never re-`Stat`s through it, so a `.sql` file that disappears between `ReadDir` - // and its own visit stays in Go's declared file list — only the later, real file-open - // fails. Simulate the stat failure directly (mocking a real race is flaky) by pointing - // the matched directory at one that lists a child but whose child path is unreadable: - // a broken symlink target used as a bare filename via a `readLink` failure isn't - // enough here (that's the earlier symlink test), so exercise the `fs.stat` failure - // path itself by removing the file the instant after `readDirectory` returns it, via - // a `FileSystem` layer that deletes on first `stat` call for that path. + // Simulates the race by deleting the file the instant after `readDirectory` returns + // it, via a `FileSystem` layer that removes on the first `stat` call for that path. const dir = mkdtempSync(join(tmpdir(), "sql-glob-stat-race-")); const schemasDir = join(dir, "schemas"); mkdirSync(schemasDir); @@ -555,25 +440,6 @@ describe("sqlFilesGlob", () => { it.effect( "fails the whole walk when a non-'.sql' child whose stat fails could have been a subdirectory (Go WalkDir parity)", () => { - // Round 6 (test above) established that a `.sql` FILE child racing away between - // `readDirectory` and this port's own `stat` call is best-effort included, matching - // Go's cached-`DirEntry` behaviour for regular files. But `fs.WalkDir` does NOT - // treat every vanished child the same way: for a DIRECTORY `DirEntry`, it unconditionally - // attempts a second `ReadDir` to recurse into it; when that child is gone, the second - // `ReadDir` fails, and `walkMatchedDir`'s callback propagates the error unchanged - // (`if err != nil { return err }`) — `fs.WalkDir` returns it, and - // `walkMatchedDir` wraps it as `failed to walk matched directory: <cause>`, discarding - // every file already collected. Verified empirically with a - // scratch `fs.WalkDir` probe against the real `walkMatchedDir`: removing a - // nested subdirectory between the parent's `ReadDir` and the subdirectory's own `ReadDir` - // reproduces exactly this — zero files, `failed to walk matched directory: open... - // /nested: no such file or directory` — never a silent skip. This port's `stat` call - // is a second, separate syscall from the parent's `readDirectory` (unlike Go, which gets - // the child's type for free from the SAME syscall as the listing), so a raced - // disappearance here always loses the type along with the entry — a non-`.sql` name - // could equally have been the now-missing subdirectory, and must fail the same way an - // unreadable still-present directory does, not silently vanish along with the files it - // may have held. const dir = mkdtempSync(join(tmpdir(), "sql-glob-dir-race-")); const schemasDir = join(dir, "schemas"); mkdirSync(schemasDir); @@ -601,10 +467,6 @@ describe("sqlFilesGlob", () => { expect(result.files).toEqual([]); expect(result.warnings).toHaveLength(1); expect(result.warnings[0]).toMatch(/^failed to walk matched directory: /); - // This `stat` failure stands in for the second `ReadDir` Go's own `fs.WalkDir` - // would issue on the vanished directory — whose error, like every other Go - // filesystem error here, embeds the workdir-relative path, not this port's - // absolute stand-in syscall path. expect(result.warnings[0]).toContain("schemas/nested"); expect(result.warnings[0]).not.toContain(dir); rmSync(dir, { recursive: true, force: true }); @@ -637,17 +499,6 @@ describe("sqlFilesGlob", () => { it.effect.skipIf(isRoot)( "surfaces a directory-read failure during walk as a warning instead of an empty match (Go WalkDir parity)", () => { - // `fs.WalkDir` returns the `ReadDir` error from its walkFn unchanged, which - // stops the walk immediately; `walkMatchedDir` then wraps it as `failed to walk - // matched directory: ...` and discards every file already found — never an empty - // (successful) match. Verified empirically: an unreadable - // matched directory makes `Glob.SQLFiles` return that error with zero files. - // - // `fsys` here is always `afero.OsFs` with the process cwd already the workdir, - // so the `ReadDir` error's embedded path is the - // workdir-relative matched directory (`schemas`), never an absolute one. This - // module never `process.chdir`s, so the real read needs an absolute path, but the - // warning must still report the relative form. const dir = mkdtempSync(join(tmpdir(), "sql-glob-walk-fail-")); const schemasDir = join(dir, "schemas"); mkdirSync(schemasDir); @@ -676,13 +527,6 @@ describe("sqlFilesGlob", () => { it.effect.skipIf(isRoot)( "surfaces a NESTED directory-read failure during walk with a workdir-relative path, not the matched root's (Go WalkDir parity)", () => { - // Same leak as the matched-root-directory case above, but for a failure during the - // RECURSIVE walk of an already-descended subdirectory — a distinct code path inside - // `fs.WalkDir` callback (it recurses via the SAME `ReadDir` call the matched - // root used, `io/fs/walk.go`), and this port's `walk()` closure recurses the same - // way. The matched root ("schemas") itself is readable; only "schemas/nested" is - // not, so the warning must report "schemas/nested", never the workdir's absolute - // temp-dir path. const dir = mkdtempSync(join(tmpdir(), "sql-glob-walk-fail-nested-")); const schemasDir = join(dir, "schemas"); const nestedDir = join(schemasDir, "nested"); @@ -713,10 +557,6 @@ describe("sqlFilesGlob", () => { it.effect.skipIf(isRoot)( "keeps files from a sibling pattern when only one matched directory fails to walk", () => { - // Go: `if err != nil { allErrors = append(allErrors, err); continue }` — a walk - // failure on one match doesn't stop the loop over the REST of the matches/patterns; - // whether it's ultimately fatal is the caller's decision (`applySchemaFiles`'s - // `len(declared) == 0` gate), not this function's. const dir = mkdtempSync(join(tmpdir(), "sql-glob-walk-fail-partial-")); const goodDir = join(dir, "good"); const badDir = join(dir, "bad"); @@ -746,17 +586,8 @@ describe("sqlFilesGlob", () => { it.effect.skipIf(isRoot)( "picks the lexically-first failing subdirectory as the fatal error, matching Go's fs.WalkDir sorted-visit order (review CLI-1958)", () => { - // `fs.WalkDir` visits directory entries in lexical byte order — its - // `ReadDir` (`os.ReadDir`/`afero.OsFs`) contract guarantees results "sorted by - // filename" before `walkDir` ever iterates them, so when a matched directory - // has MULTIPLE unreadable subdirectories, Go deterministically fails on the - // FIRST one lexically ("aaa" before "bbb") and never even attempts the second. - // This module's own `readDirectory` makes no such ordering promise, so this - // test provides a fake `FileSystem` whose `readDirectory` deliberately returns - // "schemas"'s children in REVERSE order ("bbb" before "aaa") — the opposite of - // Go's guaranteed order — to prove the walk sorts them back (`utf8Compare`) - // before iterating, rather than trusting raw (here: adversarial) enumeration - // order. + // The fake `readDirectory` deliberately returns "schemas"'s children in reverse + // order, to prove the walk sorts them back (`utf8Compare`) before iterating. const dir = mkdtempSync(join(tmpdir(), "sql-glob-walk-order-")); const schemasDir = join(dir, "schemas"); const aaaDir = join(schemasDir, "aaa"); @@ -780,8 +611,6 @@ describe("sqlFilesGlob", () => { const result = yield* sqlFilesGlob(reorderedFs, path, ["schemas"], dir); expect(result.files).toEqual([]); expect(result.warnings).toHaveLength(1); - // Go descends into "aaa" first (lexical order), fails reading it, and stops — - // "bbb" is never even attempted. expect(result.warnings[0]).toContain("schemas/aaa"); expect(result.warnings[0]).not.toContain("schemas/bbb"); }).pipe( @@ -800,15 +629,9 @@ describe("sqlFilesGlob", () => { it.effect( "sorts direct wildcard matches by UTF-8 byte order, not UTF-16 code units (Go sort.Strings parity)", () => { - // `sort.Strings` (`Glob.SQLFiles`) orders the raw UTF-8 bytes - // of each match. A supplementary-plane character (here, an emoji — 4-byte UTF-8, - // lead byte 0xF0) always sorts AFTER a 3-byte-encoded BMP character (here, a - // fullwidth exclamation mark — lead byte 0xEF) in Go, because 0xF0 > 0xEF. JS's - // default `Array.prototype.sort()` instead compares UTF-16 code units, under which - // the emoji's surrogate-pair lead unit (0xD83D) sorts BEFORE the fullwidth - // exclamation mark's code unit (0xFF01) — the opposite order. Verified empirically - // against a real Go `sort.Strings` call: it places the fullwidth-exclamation file - // first. + // The emoji (4-byte UTF-8, lead byte 0xF0) sorts after the fullwidth exclamation mark + // (3-byte UTF-8, lead byte 0xEF) in byte order, but before it in UTF-16 code-unit + // order (0xD83D surrogate lead vs 0xFF01) — these two characters expose the difference. const dir = mkdtempSync(join(tmpdir(), "sql-glob-utf8-sort-")); const schemasDir = join(dir, "schemas"); mkdirSync(schemasDir); diff --git a/apps/cli/src/command-internal/sql-split.ts b/apps/cli/src/command-internal/sql-split.ts index 572ab7768c..0722d09c2b 100644 --- a/apps/cli/src/command-internal/sql-split.ts +++ b/apps/cli/src/command-internal/sql-split.ts @@ -1,14 +1,10 @@ /** - * PostgreSQL statement splitter, ported 1:1 from `pkg/parser` - * (`token.go` + `state.go`). A finite-state machine tracks string literals - * (`'…'`, `"…"`), line/block comments, dollar-quoted bodies (`$tag$…$tag$`), - * backslash escapes, and `BEGIN ATOMIC … END` / parenthesised bodies, so a `;` - * inside any of those is not mistaken for a statement separator. This matters - * for declarative diffs, which contain `CREATE FUNCTION` bodies full of `;`. + * PostgreSQL statement splitter. A finite-state machine tracks string literals, comments, + * dollar-quoted bodies (`$tag$…$tag$`), backslash escapes, and `BEGIN ATOMIC … END`/ + * parenthesised bodies, so a `;` inside any of those isn't mistaken for a statement + * separator — this matters for declarative diffs, whose `CREATE FUNCTION` bodies are full of `;`. * - * Operates on Unicode code points (JS strings) rather than raw bytes; the ASCII - * delimiters are described as slash-star, star-slash, semicolon, quotes, and - * dollar signs; suffix comparison is identical to Go's byte-window logic. + * Operates on Unicode code points (JS strings), not raw bytes. */ interface State { @@ -19,10 +15,8 @@ interface State { const BEGIN_ATOMIC = "ATOMIC"; const END_ATOMIC = "END"; -// `\p{Nd}` (decimal digits only), not `\p{N}` (all Unicode numbers): Go's -// `unicode.IsDigit` — what `isIdentifierRune`/`TagState.next` port — is an alias for -// category `Nd` alone, so it rejects `No`/`Nl` runes like superscript-2 (`²`) that -// `\p{N}` would wrongly accept as a valid identifier/dollar-tag character. +// `\p{Nd}` (decimal digits only), not `\p{N}` (all Unicode numbers): `\p{N}` would wrongly +// accept `No`/`Nl` runes like superscript-2 (`²`) as a valid identifier/dollar-tag character. const isIdentifierRune = (rune: string): boolean => /[\p{L}\p{Nd}_$]/u.test(rune); function isBeginAtomic(data: string): boolean { @@ -149,12 +143,9 @@ class AtomicState implements State { } /** - * One raw token from {@link splitRaw}. `terminated` is `false` only for a - * trailing statement emitted at EOF with no closing delimiter (the - * `acc.length > 0` fallback below) — every other token was emitted because the - * FSM itself found a boundary (a bare `;` in `ReadyState`, or `AtomicState` - * closing). Only ever `false` on the LAST element `splitRaw` returns, since - * that fallback fires at most once, after the main loop. + * One raw token from {@link splitRaw}. `terminated` is `false` only for a trailing + * statement emitted at EOF with no closing delimiter — the FSM found a boundary for every + * other token. Only ever `false` on the last element `splitRaw` returns. */ interface RawToken { readonly text: string; @@ -183,8 +174,8 @@ function splitRaw(sql: string): RawToken[] { } /** - * Splits `sql` into raw statements (comments/whitespace preserved), then applies - * the optional transforms to each. Mirrors `parser.Split`. + * Splits `sql` into raw statements (comments/whitespace preserved), then applies the + * optional transforms to each. */ export function splitSql( sql: string, @@ -199,57 +190,36 @@ export function splitSql( return statements; } -/** `parser.SplitAndTrim`'s per-token transform: trim trailing `;` then surrounding whitespace. */ +/** Per-token transform: trim trailing `;` then surrounding whitespace. */ const trimStatement = (token: string): string => token.replace(/;+$/u, "").trim(); -/** Mirrors `parser.SplitAndTrim`: trim trailing `;` then surrounding whitespace. */ +/** Trims trailing `;` then surrounding whitespace from each statement. */ export function splitAndTrim(sql: string): string[] { return splitSql(sql, trimStatement); } -/** One statement, paired with both its RAW and trimmed forms. */ +/** One statement, paired with both its raw and trimmed forms. */ export interface SplitSqlToken { /** The exact text `splitSql(sql)` (no transforms) would emit for this statement. */ readonly raw: string; /** `trimStatement(raw)` — what `splitAndTrim` emits, including when empty. */ readonly trimmed: string; /** - * `false` only for a trailing statement with no closing delimiter, emitted at - * real EOF (`splitRaw`'s `acc.length > 0` fallback) — see {@link RawToken}. - * `checkScannerBufferSize` (`migration-apply.ts`) needs this to decide - * `>` vs `>=` against the effective buffer limit: `bufio.Scanner` can - * only apply its too-long check (`len(s.buf) >= s.maxTokenSize`) once it has - * given up looking for a delimiter and still needs more data — for a - * delimiter-terminated token the delimiter is found (and the token emitted) - * in the SAME `Scan()` call that fills the buffer to capacity, before that - * check is ever reached, so a token exactly AT the limit still succeeds. An - * unterminated trailing token has no delimiter to find: once the buffer - * fills to the effective limit without one, the too-long check fires - * immediately — there's never a chance to attempt the extra `Read()` that would - * reveal real EOF and let the split function emit the trailing token - * instead. Verified empirically against `pkg/parser.Split`: a - * single terminated statement of exactly `maxbuf` bytes always succeeds, - * while an unterminated one of exactly `maxbuf` bytes always fails with - * `bufio.ErrTooLong` (one byte under still succeeds; one byte over always - * fails either way). + * `false` only for a trailing statement with no closing delimiter, emitted at real EOF — + * see {@link RawToken}. `checkScannerBufferSize` needs this to decide `>` vs `>=` against + * the effective buffer limit: a delimiter-terminated token exactly at the limit still + * succeeds (the delimiter is found before the too-long check is reached), while an + * unterminated one exactly at the limit always fails. */ readonly terminated: boolean; } /** - * Same FSM traversal as {@link splitAndTrim}, but pairs each statement's RAW - * (pre-trim) text with its trimmed form instead of discarding the raw text once - * emitted. `bufio.Scanner`-based `parser.Split` - * enforces `SUPABASE_SCANNER_BUFFER_SIZE` against the untransformed - * `scanner.Text()` — the RAW form — and its `bufio.ErrTooLong` message reports - * that same raw text for the LAST successfully scanned statement, so a caller - * replicating that check (`migration-apply.ts`'s `execMigrationBatch`) - * needs both forms, not just the trimmed one `splitAndTrim` returns. + * Same FSM traversal as {@link splitAndTrim}, but pairs each statement's raw (pre-trim) text + * with its trimmed form, since `SUPABASE_SCANNER_BUFFER_SIZE` enforcement needs the raw form. * - * Unlike `splitSql`/`splitAndTrim`, this does NOT drop a statement - * whose trimmed form is empty — callers that replicate `len(stats)` counter - * (which only increments for a non-empty trimmed statement) need to see every raw - * token, including the ones `splitAndTrim` itself would filter out. + * Unlike `splitSql`/`splitAndTrim`, this does not drop a statement whose trimmed form is + * empty, so callers counting only non-empty trimmed statements still see every raw token. */ export function splitSqlTokens(sql: string): ReadonlyArray<SplitSqlToken> { return splitRaw(sql).map(({ text: raw, terminated }) => ({ @@ -259,13 +229,12 @@ export function splitSqlTokens(sql: string): ReadonlyArray<SplitSqlToken> { })); } -// `(?i)drop\s+` — `dropStatementPattern`. +// Case-insensitive: matches "drop" followed by whitespace. const DROP_STATEMENT_PATTERN = /drop\s+/i; /** - * Extracts DROP statements from a schema diff for the safety warning shown by - * `db diff` / `db pull` / declarative `sync`. Mirrors `findDropStatements`: - * split the SQL into statements, then keep those matching `(?i)drop\s+`. + * Extracts DROP statements from a schema diff for the safety warning shown by `db diff`, + * `db pull`, and declarative `sync`. */ export function findDropStatements(sql: string): ReadonlyArray<string> { return splitAndTrim(sql).filter((statement) => DROP_STATEMENT_PATTERN.test(statement)); diff --git a/apps/cli/src/command-internal/sql-split.unit.test.ts b/apps/cli/src/command-internal/sql-split.unit.test.ts index b330aac2f5..6db8a583ca 100644 --- a/apps/cli/src/command-internal/sql-split.unit.test.ts +++ b/apps/cli/src/command-internal/sql-split.unit.test.ts @@ -33,9 +33,8 @@ describe("splitAndTrim", () => { }); it("treats a non-decimal Unicode digit as an invalid dollar-tag character, like Go's unicode.IsDigit", () => { - // Go's TagState.Next gates on unicode.IsDigit (category Nd only), which is false - // for superscript-2 (U+00B2, category No) — the tag "a²" is therefore invalid, - // Go falls back out of the tag and the embedded `;` becomes a real boundary. + // "a²" (U+00B2, category No) is not a valid dollar-tag character, so the tag falls back + // and the embedded `;` becomes a real boundary. const sql = "CREATE FUNCTION f() AS $a²$foo; bar$a²$ LANGUAGE sql;"; expect(splitAndTrim(sql)).toEqual(["CREATE FUNCTION f() AS $a²$foo", "bar$a²$ LANGUAGE sql"]); }); @@ -82,8 +81,6 @@ describe("findDropStatements", () => { }); it("does not split a function body on its inner ; (no spurious statements)", () => { - // The dollar-quoted `;` must not create extra statements; this benign - // function (no DROP) stays whole and is therefore not flagged. const sql = "CREATE FUNCTION f() AS $$ BEGIN RETURN 1; END; $$ LANGUAGE plpgsql;\nDROP TABLE real;"; expect(findDropStatements(sql)).toEqual(["DROP TABLE real"]); diff --git a/apps/cli/src/command-internal/start-secrets-cleanup.ts b/apps/cli/src/command-internal/start-secrets-cleanup.ts index bf768f41d3..4bc78693a5 100644 --- a/apps/cli/src/command-internal/start-secrets-cleanup.ts +++ b/apps/cli/src/command-internal/start-secrets-cleanup.ts @@ -6,72 +6,20 @@ import { Effect } from "effect"; import type { ContainerIdName } from "./docker-lifecycle.ts"; /** - * Best-effort removal of per-container staged-secret directories for every - * container in `containers` — plaintext secret/env material some `start` - * services stage on host disk that otherwise survives indefinitely, since - * neither `stop` nor a failed-start rollback previously touched - * `<workdir>/supabase/.temp/start-secrets/`. There is no Go behavior to match - * here — Go never stages secrets on host disk in the first place (it injects - * them into `container.Config.Cmd`/`Entrypoint` directly via the Docker - * Engine API) — this is a TS-port-only hygiene fix. + * Best-effort removal of per-container staged-secret directories: plaintext secret/env + * material some `start` services stage on host disk. Used by `start`'s rollback and `stop`. + * Only Edge Runtime still stages anything here; an unstaged directory is a harmless no-op. * - * As of supabase/cli#6022, Kong/Postgres/Supavisor's own `secretFiles` no - * longer stage anything under this tree — `container-lifecycle.ts`'s - * `createContainer` now streams them straight into the created - * container instead (see `copyStartSecretFilesIntoContainer`'s doc - * comment), so a bind mount's host-side path never has to be resolved by a - * remote Docker daemon. This module remains load-bearing for Edge Runtime's - * OWN, still-host-persisted staging under the exact same tree - * (`shared/functions/serve.ts`'s `startEdgeRuntimeContainer`, whose env-file - * and bind-mounted multiline-env-script artifacts stay on host disk; only its - * bootstrap template is `docker cp`-streamed in) — this function has no way - * to distinguish which - * producer staged a given container's directory, nor does it need to: a - * directory that was never staged in the first place is a harmless no-op (see - * below). + * Each container's directory is resolved under its own workdir label, not the caller's + * `fallbackWorkdir` (used only when a container's label is empty) — otherwise a + * `--project-id`/`--all` teardown could orphan a different project's secrets. * - * Hoisted here (`command-internal/`) per `apps/cli/CLAUDE.md`'s "Hoist Before - * You Duplicate" rule: both `start`'s own rollback (`command-internal/db-bootstrap/rollback.ts`) and - * `stop` (`stop.handler.ts`) need this same cleanup. + * `containers` must be exactly what Docker reported for the just-completed teardown, never + * reconstructed or a pre-teardown snapshot — a container that failed to be removed must keep + * its secrets, and this also avoids ever deleting the whole `start-secrets/` parent. * - * Each container's own directory is resolved as `<workdir>/supabase/.temp/ - * start-secrets/<name>`, where `workdir` is that container's own - * `CLI_WORKDIR_LABEL` value (see that constant's doc comment) — NOT - * necessarily `fallbackWorkdir` (the caller's own `CommandSettings.workdir`). - * A caller tearing down containers by an explicit `--project-id`/`--all` - * filter may be tearing down a DIFFERENT project's containers than the one - * its own cwd/`--workdir` points at, so using the caller's workdir - * unconditionally would look in the wrong directory and silently orphan that - * project's staged secret files. `fallbackWorkdir` is used only for a - * container whose own label is empty — created before this label existed - * (or by a Go binary, which never sets it). - * - * `containers` MUST be exactly the containers Docker itself reported as - * matching the same label filter the caller just tore down, and only once - * that teardown is CONFIRMED complete (`dockerRemoveAll`'s - * `onContainersRemoved` hook) — never independently reconstructed/guessed, - * and never a pre-teardown snapshot, since a container that a later stage - * failed to actually remove must keep its secrets. This also avoids a - * blanket delete of the whole `start-secrets/` parent directory, which would - * be unsafe if a workdir's project id ever changed across `start` runs - * without an intervening `stop`: that parent could then hold subdirectories - * for more than one project id, some possibly still backing a live - * `restartPolicy: "unless-stopped"` container a narrower `stop - * --project-id`/rollback isn't tearing down. - * - * Never fails: a directory that was never staged (every service besides Edge - * Runtime) is a harmless no-op, and a real deletion error is not worth - * failing `stop`/rollback over. - * - * `container.name` is a `docker ps` field value read back off whatever containers matched - * the caller's label filter (`listContainerIdsAndNames`) — external metadata, not - * something this function generated itself, so it cannot be trusted as a bare path segment - * without a defence-in-depth check. Resolve the candidate and require it to be a direct - * child of the staging root before deleting it — same defence-in-depth shape as - * `bootstrap.templates.ts`'s identical guard against a GitHub-supplied path escaping its - * target directory. This also covers the degenerate case where `container.name` ends up - * empty (would otherwise resolve to the staging root itself and wipe every project's - * secrets). + * Never fails. `container.name` is external `docker ps` metadata, so the resolved candidate + * must be a direct child of the staging root before deletion — this also covers an empty name. */ export function cleanupStartSecrets( containers: ReadonlyArray<ContainerIdName>, 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 80a9d209db..1c09a2c982 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 @@ -79,10 +79,6 @@ describe("cleanupStartSecrets", () => { it.effect( "refuses to delete outside the staging root when container.name contains path-traversal segments", () => { - // `container.name` is a `docker ps` field value read back off whatever containers - // matched the caller's project-label filter — external metadata, not something this - // process generated. A crafted name containing `..` segments must never be able to walk - // `rm -rf` outside `start-secrets/` and onto an unrelated host directory. const workdir = mkdtempSync(join(tmpdir(), "start-secrets-cleanup-")); const canary = join(workdir, "important"); return Effect.gen(function* () { @@ -101,9 +97,6 @@ describe("cleanupStartSecrets", () => { ); it.effect("refuses to delete the whole staging root when container.name is empty", () => { - // Degenerate case: an empty `name` would otherwise resolve to the staging root itself - // (`<workdir>/supabase/.temp/start-secrets`) and wipe every project's staged secrets in - // one call, not just this one container's. const workdir = mkdtempSync(join(tmpdir(), "start-secrets-cleanup-")); const stagingRoot = join(workdir, "supabase", ".temp", "start-secrets"); const otherProjectSecretDir = join(stagingRoot, "supabase_kong_other"); diff --git a/apps/cli/src/command-internal/status-errors.ts b/apps/cli/src/command-internal/status-errors.ts index 0accf0ac35..3da9741a59 100644 --- a/apps/cli/src/command-internal/status-errors.ts +++ b/apps/cli/src/command-internal/status-errors.ts @@ -6,11 +6,8 @@ import { } from "../shared/telemetry/error-actionability.ts"; /** - * An explicit `--workdir`/`SUPABASE_WORKDIR` path doesn't exist or isn't a - * directory. Mirrors `ChangeWorkDir`, which unconditionally `os.Chdir(workdir)`s in `PersistentPreRunE` — - * before `status`'s own `PreRunE` - * (override-name parsing) or `RunE`, so a bad explicit workdir must fail here - * first, before config load or any Docker access. + * An explicit `--workdir`/`SUPABASE_WORKDIR` path doesn't exist or isn't a directory. Checked + * before config load or any Docker access. */ export class StatusWorkdirError extends Data.TaggedError("StatusWorkdirError")<{ readonly message: string; @@ -29,7 +26,7 @@ export class StatusConfigLoadError extends Data.TaggedError("StatusConfigLoadErr } } -/** A `--override-name KEY=VALUE` entry did not parse, mirroring `env.EnvironToEnvSet`. */ +/** A `--override-name KEY=VALUE` entry did not parse. */ export class StatusOverrideParseError extends Data.TaggedError("StatusOverrideParseError")<{ readonly message: string; }> { @@ -39,12 +36,9 @@ export class StatusOverrideParseError extends Data.TaggedError("StatusOverridePa } /** - * Inspecting the db container failed for a reason other than "not found" — - * except `assertContainerHealthy` never special-cases a missing - * container (see `status.handler.ts`'s step-5 comment): an absent container - * is just another non-zero inspect exit, so the dominant real trigger of this - * error is "the local stack was never started", same fix as - * {@link StatusDbNotRunningError}. + * Inspecting the db container failed for a reason other than "not found" — but since an + * absent container is just another non-zero inspect exit, the dominant real trigger is that + * the local stack was never started, same as {@link StatusDbNotRunningError}. */ export class StatusDbInspectError extends Data.TaggedError("StatusDbInspectError")<{ readonly message: string; @@ -82,8 +76,8 @@ export class StatusListError extends Data.TaggedError("StatusListError")<{ } /** - * `config.toml` resolved to a value `Config.Validate` would reject before status - * ever renders — e.g. an `auth.jwt_secret` shorter than 16 characters. + * `config.toml` resolved to a value validation would reject before status ever renders — + * e.g. an `auth.jwt_secret` shorter than 16 characters. */ export class StatusInvalidConfigError extends Data.TaggedError("StatusInvalidConfigError")<{ readonly message: string; diff --git a/apps/cli/src/command-internal/status-pretty.ts b/apps/cli/src/command-internal/status-pretty.ts index d12a0a402f..d6255023d0 100644 --- a/apps/cli/src/command-internal/status-pretty.ts +++ b/apps/cli/src/command-internal/status-pretty.ts @@ -2,30 +2,12 @@ import { aqua, bold, green, yellow } from "./colors.ts"; import type { StatusOutputNames } from "./status-values.ts"; /** - * Port of `PrettyPrint` / `OutputGroup.printTable`, - * reproducing - * `tablewriter.NewTable` with `tw.StyleRounded` byte-for-byte for the fixed - * 5-group, 2-column layout `status` needs. This is not a general tablewriter - * port — column sizing, wrapping, and merge behavior are only implemented to the - * extent this command's rounded box needs them. + * Renders `status`'s fixed 5-group, 2-column layout as rounded-border tables — column 0 caps + * at 16 display columns and word-wraps (no fixed label reaches that width today), and this is + * distinct from `output/glamour-table.ts`'s single-ASCII-table renderer used elsewhere. * - * Column 0 (the label column) is capped at 16 display columns - * (`ColMaxWidths.PerColumn[0] = 16`); a label wider than that - * word-wraps across multiple lines, leaving column 1 blank on the continuation - * lines (verified against a real `tablewriter@v1.1.4` render — see the port - * plan). None of the fixed labels below reach 17 characters today, so this is - * defensive parity rather than an observed case. - * - * This does not reuse `output/glamour-table.ts` — that helper - * byte-matches `glamour.RenderTable(..., AsciiStyle)`, a single ASCII table - * with a different border style used by other commands. `status`'s Go source - * renders with `tablewriter`/`tw.StyleRounded` into 5 separate grouped, colored, - * Unicode-rounded-box tables, which is a different rendering contract entirely. - * - * Every color call below styles text written to **stdout** (via `output.raw` - * with no stream argument in `status.handler.ts`), so each one explicitly passes - * `process.stdout` to `colors.ts`'s helpers — they default to - * `process.stderr`, which would check the wrong stream's TTY status here. + * Every color call below explicitly passes `process.stdout`, since `colors.ts` defaults to + * `process.stderr` and would check the wrong stream's TTY status. */ type OutputKind = "text" | "link" | "key"; @@ -44,10 +26,8 @@ interface OutputGroup { const COLUMN_0_MAX_WIDTH = 16; /** - * Builds the 5 fixed groups `PrettyPrint` declares, - * looking up each label's value by output KEY from the resolved value map — - * `--override-name` remaps the KEY but never the group layout, matching Go - * (`values[names.StudioURL]`, not a hardcoded default name). + * Builds the 5 fixed display groups, looking up each label's value by its resolved output + * key — `--override-name` remaps the key but never the group layout. */ function buildGroups( values: Readonly<Record<string, string>>, @@ -96,18 +76,16 @@ function buildGroups( } /** - * Display width, matching `go-runewidth`'s treatment closely enough for this - * command's inputs: URLs/keys/labels are always plain ASCII, so every rune is - * width 1. The only non-ASCII runes ever rendered are the 5 fixed group-title - * emoji, whose exact rendered widths are hardcoded in {@link HEADER_DISPLAY_WIDTH} - * below rather than computed generically (avoids taking a full Unicode - * East-Asian-Width dependency for a 5-value constant table). + * Display width for this command's inputs: URLs/keys/labels are always plain ASCII, so every + * rune is width 1. The 5 fixed group-title emoji are the only non-ASCII runes ever rendered, + * and their widths are hardcoded in {@link HEADER_DISPLAY_WIDTH} instead of computed + * generically. */ function displayWidth(text: string): number { return [...text].length; } -/** Go-rendered display width of each fixed group title (see `status.pretty.unit.test.ts`). */ +/** Rendered display width of each fixed group title (see `status.pretty.unit.test.ts`). */ const HEADER_DISPLAY_WIDTH: Readonly<Record<string, number>> = { "🔧 Development Tools": 20, "🌐 APIs": 7, @@ -117,19 +95,17 @@ const HEADER_DISPLAY_WIDTH: Readonly<Record<string, number>> = { }; /** - * Exported only for direct unit coverage of the fallback branch (a group title - * outside the 5-entry {@link HEADER_DISPLAY_WIDTH} table) — every call site in - * this file only ever passes one of those 5 fixed titles. + * Exported only for direct unit coverage of the fallback branch — every call site in this + * file passes one of the 5 fixed titles in {@link HEADER_DISPLAY_WIDTH}. */ export function statusHeaderWidth(name: string): number { return HEADER_DISPLAY_WIDTH[name] ?? displayWidth(name); } /** - * Greedy word-wrap to `width` columns, mirroring tablewriter's column wrapping. - * Exported only for direct unit coverage of the >16-char defensive-wrap branch - * (see the file-level doc comment) — none of this command's real labels reach - * that width today, so `renderStatusPretty` never exercises it end to end. + * Greedy word-wrap to `width` columns. Exported only for direct unit coverage of the + * >16-char defensive-wrap branch — no real label reaches that width today, so + * `renderStatusPretty` never exercises it end to end. */ export function wrapStatusLabel(text: string, width: number): ReadonlyArray<string> { if (displayWidth(text) <= width) return [text]; @@ -149,11 +125,7 @@ export function wrapStatusLabel(text: string, width: number): ReadonlyArray<stri return lines.length > 0 ? lines : [text]; } -/** - * Value coloring, mirroring the `switch row.Type` in `printTable`: - * `Link` → Aqua, `Key` → Yellow, `Text` → unstyled (the - * switch has no `Text` case, so `value` keeps its raw pre-switch assignment). - */ +/** Value coloring: `link` → aqua, `key` → yellow, `text` → unstyled. */ function colorValue(kind: OutputKind, value: string): string { switch (kind) { case "link": @@ -172,14 +144,11 @@ interface ColumnLayout { } /** - * Computes the padded column widths and total inner (header) width for a group, - * mirroring tablewriter's column-sizing pass: each column is sized from its - * widest content cell (col 0 capped at 16), then both columns widen evenly - * (col 0 taking the larger half of an odd remainder) if the header text is - * wider than the data-driven layout. Exported only for direct unit coverage of - * the header-widens-the-table branch — none of this command's 5 fixed group - * titles are wider than their data today, so `renderStatusPretty` never - * exercises it end to end (see the file-level doc comment). + * Computes the padded column widths and total inner (header) width for a group: each column + * is sized from its widest content cell (col 0 capped at 16), then both columns widen evenly + * if the header text is wider than the data-driven layout. Exported only for direct unit + * coverage of that header-widens-the-table branch — no real group title is wider than its + * data today. */ export function statusColumnLayout( headerWidthValue: number, @@ -208,9 +177,9 @@ function renderGroupTable(group: OutputGroup): string | undefined { const rows = group.items.filter((item) => item.value.length > 0); if (rows.length === 0) return undefined; - // Column 0 wraps at 16; column 1 is never capped (Go only sets PerColumn[0]). - // Kept as plain text here — color is applied only after padding, below, so an - // ANSI escape is never counted toward the padded display width. + // Column 0 wraps at 16; column 1 is never capped. Kept as plain text here — color is + // applied only after padding, below, so an ANSI escape is never counted toward the padded + // display width. const wrappedRows = rows.map((row) => ({ lines: wrapStatusLabel(row.label, COLUMN_0_MAX_WIDTH), kind: row.kind, @@ -229,9 +198,8 @@ function renderGroupTable(group: OutputGroup): string | undefined { // must never be counted toward the padded display width. const pad = (text: string, width: number) => text + " ".repeat(Math.max(0, width - displayWidth(text))); - // The header uses `headerWidth` (the hardcoded emoji-aware width table) rather - // than `displayWidth`, so its padding lines up with the border math above, - // which sized `targetInner` off the same `headerWidth` call. + // The header uses `statusHeaderWidth` (the hardcoded emoji-aware width table) rather than + // `displayWidth`, so its padding matches the border math above, sized off the same call. const padHeader = (text: string, width: number) => text + " ".repeat(Math.max(0, width - statusHeaderWidth(text))); @@ -241,8 +209,8 @@ function renderGroupTable(group: OutputGroup): string | undefined { lines.push(`├${"─".repeat(col0Padded)}┬${"─".repeat(col1Padded)}┤`); for (const row of wrappedRows) { row.lines.forEach((line, index) => { - // Only the first wrapped line carries the value; Go's continuation lines - // (from a >16-char label wrapping) leave column 1 blank. + // Only the first wrapped line carries the value; continuation lines (from a >16-char + // label wrapping) leave column 1 blank. const labelCell = green(pad(line, col0Width), process.stdout); const paddedValue = pad(index === 0 ? row.value : "", col1Width); const valueCell = index === 0 ? colorValue(row.kind, paddedValue) : paddedValue; @@ -254,10 +222,8 @@ function renderGroupTable(group: OutputGroup): string | undefined { } /** - * Port of `PrettyPrint`: renders the 5 fixed groups - * as rounded-border tables, skipping empty rows and empty groups, with a blank - * line after every group (rendered or not — Go's loop always - * `fmt.Fprintln(w)`s after a nil-error `printTable`, even when nothing rendered). + * Renders the 5 fixed groups as rounded-border tables, skipping empty rows and empty + * groups, with a blank line after every group whether or not it rendered. */ export function renderStatusPretty( values: Readonly<Record<string, string>>, diff --git a/apps/cli/src/command-internal/status-pretty.unit.test.ts b/apps/cli/src/command-internal/status-pretty.unit.test.ts index f26d2141a8..c98a8cb76b 100644 --- a/apps/cli/src/command-internal/status-pretty.unit.test.ts +++ b/apps/cli/src/command-internal/status-pretty.unit.test.ts @@ -9,9 +9,7 @@ import { } from "./status-pretty.ts"; import type { StatusOutputNames } from "./status-values.ts"; -// Default (un-overridden) output names, matching `status-values.ts`'s -// `resolveOutputNames` with an empty override map — the KEYs the pretty -// renderer looks values up by. +// Default (un-overridden) output names — the keys the pretty renderer looks values up by. const NAMES: StatusOutputNames = { apiUrl: "API_URL", restUrl: "REST_URL", @@ -47,9 +45,6 @@ const FULL_VALUES: Record<string, string> = { }; describe("renderStatusPretty", () => { - // Byte-for-byte parity with a real `tablewriter@v1.1.4` + `tw.StyleRounded` - // render of `PrettyPrint` group layout (verified by running the actual - // vendored Go module against this exact value set — see the port plan). it("matches the Go rounded-table fixture for a fully running stack", () => { const out = stripAnsi(renderStatusPretty(FULL_VALUES, NAMES)); @@ -98,9 +93,7 @@ describe("renderStatusPretty", () => { expect(out).toBe(expected); }); - // Byte-for-byte parity with a real render of a single-row group (Database), - // confirming the header-vs-single-short-row column sizing. All other groups - // are empty in this fixture, so only the Database box should appear. + // All other groups are empty in this fixture, so only the Database box should appear. it("matches the Go rounded-table fixture for a single-row group", () => { const out = stripAnsi( renderStatusPretty({ DB_URL: FULL_VALUES.DB_URL ?? "" }, { ...NAMES, dbUrl: "DB_URL" }), @@ -118,8 +111,7 @@ describe("renderStatusPretty", () => { expect(out).toBe(["", "", expectedTable, "", "", ""].join("\n")); }); - // All other groups are empty in this fixture, so only the APIs box appears - // (only Project URL, the rest of the group's rows are excluded/disabled). + // All other groups are empty in this fixture; only Project URL is present in APIs. it("matches the Go rounded-table fixture for a partial APIs group", () => { const out = stripAnsi(renderStatusPretty({ API_URL: "http://127.0.0.1:54321" }, NAMES)); @@ -135,8 +127,6 @@ describe("renderStatusPretty", () => { }); it("skips a row whose value is missing from the value map", () => { - // Only Studio present; Mailpit/MCP absent from the map entirely (excluded - // or disabled upstream in `status-values.ts`) — same as an empty string. const out = stripAnsi(renderStatusPretty({ STUDIO_URL: "http://127.0.0.1:54323" }, NAMES)); expect(out).toContain("Studio"); @@ -161,14 +151,9 @@ describe("renderStatusPretty", () => { expect(out).toBe(["", "", "", "", ""].join("\n")); }); - // `renderStatusPretty` is a pure lookup: it renders whatever `values` - // are reachable through `names`' keys, with no opinion on how the caller - // derived either. This is NOT asserting that `--override-name` reaches - // pretty-mode output in production — `status.handler.ts` deliberately always - // calls this function with un-overridden names (matching `PrettyPrint`, - // which unmarshals a fresh empty `EnvSet{}` rather than the CLI's overridden - // `CustomName`). This test only proves the renderer's KEY-based lookup itself - // works correctly for an arbitrary names/values pairing. + // This is not asserting that `--override-name` reaches pretty-mode output in production — + // `status.handler.ts` always calls this function with un-overridden names. It only proves + // the renderer's key-based lookup works for an arbitrary names/values pairing. it("resolves values through whatever KEY the names parameter specifies", () => { const overriddenNames: StatusOutputNames = { ...NAMES, @@ -181,10 +166,8 @@ describe("renderStatusPretty", () => { }); }); -// None of `status`'s 18 fixed field labels or 5 fixed group titles are wide -// enough to exercise these two branches through the public -// `renderStatusPretty` API today (see the file-level doc comment on -// `status-pretty.ts`) — covered directly here as defensive Go-parity logic. +// No fixed label or group title is wide enough to exercise these branches through the +// public `renderStatusPretty` API today — covered directly here instead. describe("wrapStatusLabel", () => { it("returns the text unwrapped when it fits within the width", () => { expect(wrapStatusLabel("Edge Functions", 16)).toEqual(["Edge Functions"]); @@ -215,9 +198,6 @@ describe("wrapStatusLabel", () => { }); it("returns the input unchanged for a whitespace-only label wider than the column", () => { - // Every "word" from splitting on spaces is itself empty, so `current` never - // accumulates anything to flush after the loop — the `lines` array stays - // empty and the function falls back to the original text. expect(wrapStatusLabel(" ", 2)).toEqual([" "]); }); }); @@ -229,8 +209,8 @@ describe("statusColumnLayout", () => { }); it("widens both columns evenly when the header is wider than the data", () => { - // Base data-driven layout: col0="a"(1+2=3), col1="b"(1+2=3), dataInner=3+1+3=7. - // A 10-char header needs innerWidth=12, so 5 extra columns split 3/2. + // col0="a" (1+2=3), col1="b" (1+2=3), dataInner=7; a 10-char header needs innerWidth=12, + // so 5 extra columns split 3/2. const layout = statusColumnLayout(10, ["a"], ["b"]); expect(layout.targetInner).toBe(12); expect(layout.col0Padded).toBe(6); diff --git a/apps/cli/src/command-internal/status-values.ts b/apps/cli/src/command-internal/status-values.ts index a413560138..fa3ad6a9f0 100644 --- a/apps/cli/src/command-internal/status-values.ts +++ b/apps/cli/src/command-internal/status-values.ts @@ -9,13 +9,9 @@ import { } from "./local-config-values.ts"; /** - * Port of `status.CustomName` + `toValues()`. - * Each field's Go `env:"..."` tag carries two things: the dotted key - * `--override-name <key>=<name>` matches against (`fieldKey` below), and the - * default output env-var name (`defaultName`). `deprecated` fields (`inbucket`, - * `jwt_secret`, `anon_key`, `service_role_key`) are still emitted — Go's - * `deprecated` tag only affects a startup warning it never wires up for `status` - * (only `env.Unmarshal` reads the tag, and it does not warn), so no divergence here. + * A status output field: the dotted key `--override-name <key>=<name>` matches against + * (`fieldKey`), and its default output env-var name (`defaultName`). Deprecated fields + * (`inbucket`, `jwt_secret`, `anon_key`, `service_role_key`) are still emitted. */ export interface StatusField { readonly fieldKey: string; @@ -62,7 +58,7 @@ const STORAGE_S3_REGION: StatusField = { defaultName: "S3_PROTOCOL_REGION", }; -/** All 18 fields, in `CustomName` struct declaration order. */ +/** All 18 fields, in declaration order. */ export const STATUS_FIELDS: ReadonlyArray<StatusField> = [ API_URL, REST_URL, @@ -103,9 +99,8 @@ export interface StatusOutputNames { } /** - * Resolves each field's output KEY, applying `--override-name <fieldKey>=<name>` - * remaps over the Go default names. `overrides` maps `fieldKey` (e.g. `"api.url"`) - * to the replacement output name, mirroring `env.Unmarshal`'s `default=` override. + * Resolves each field's output key, applying `--override-name <fieldKey>=<name>` remaps over + * the default names. `overrides` maps `fieldKey` (e.g. `"api.url"`) to the replacement name. */ function resolveOutputNames(overrides: ReadonlyMap<string, string>): StatusOutputNames { const nameFor = (field: StatusField) => overrides.get(field.fieldKey) ?? field.defaultName; @@ -127,11 +122,7 @@ function resolveOutputNames(overrides: ReadonlyMap<string, string>): StatusOutpu }; } -/** - * Container ids `toValues()` gates each group on, taken from - * `serviceContainerIds`'s alias order (`kong`, `auth`, `inbucket`, ..., - * `edge_runtime`, ...) — see `docker-ids.ts`. - */ +/** Container ids each status group gates on, taken from `serviceContainerIds` (`docker-ids.ts`). */ export interface StatusContainerIds { readonly kong: string; readonly auth: string; @@ -142,9 +133,8 @@ export interface StatusContainerIds { readonly edgeRuntime: string; } -// Positional indices into `serviceContainerIds`'s fixed 13-element -// array (`docker-ids.ts`'s `GetDockerIds()` order), named so a caller -// never has to destructure the array positionally. +// Positional indices into `serviceContainerIds`'s fixed array, named so a caller never has +// to destructure it positionally. const CONTAINER_INDEX = { kong: 0, auth: 1, @@ -156,11 +146,8 @@ const CONTAINER_INDEX = { } as const; /** - * Derives {@link StatusContainerIds} from `serviceContainerIds`'s - * flat array for a given project id. The array's length and order are a fixed - * Go-parity contract (13 elements, `GetDockerIds()` order), so every named - * index here is guaranteed present — this only exists to give the handler a - * named-field view instead of positional array destructuring. + * Derives {@link StatusContainerIds} from `serviceContainerIds`'s flat array for a given + * project id, giving the handler a named-field view instead of positional destructuring. */ export function statusContainerIds(projectId: string): StatusContainerIds { const ids = serviceContainerIds(projectId); @@ -177,25 +164,18 @@ export function statusContainerIds(projectId: string): StatusContainerIds { } /** - * Port of `utils.ShortContainerImageName`: - * extracts the repo name between the (first) `/` and the (last) `:`, falling back to - * the full string when the image ref doesn't match (no slash, or no tag). + * Extracts the repo name between the (first) `/` and the (last) `:`, falling back to the + * full string when the image ref doesn't match (no slash, or no tag). */ export function shortContainerImageName(imageName: string): string { const match = /\/(.*):/.exec(imageName); return match?.[1] ?? imageName; } -// Default image short names `--exclude` also matches against, -// one per gated service. Sourced from the same -// embedded Dockerfile manifest Go parses (`dockerfileServiceImageRaw`), so a version bump -// there is picked up automatically. Pinned-version substitution -// (`db-image.ts`'s `replaceImageTag`) only ever rewrites the portion after the -// first `:`, which `shortContainerImageName` discards — so these are invariant to -// version pinning and no `.temp/<service>-version` file needs to be read here. -// They read the RAW manifest so `SUPABASE_USE_SLIM_IMAGES` cannot shift them: -// these names are the established `--exclude`/status-key contract (`gotrue`, -// `storage-api`), while slim refs would report `supabase/cli/auth` etc. +// Default image short names `--exclude` also matches against, one per gated service. +// Invariant to version pinning (`db-image.ts`'s `replaceImageTag` only rewrites the tag, +// which `shortContainerImageName` discards) and to `SUPABASE_USE_SLIM_IMAGES` (read from the +// raw manifest, since the established `--exclude` contract uses non-slim names). const KONG_IMAGE_NAME = shortContainerImageName(dockerfileServiceImageRaw("kong")); const POSTGREST_IMAGE_NAME = shortContainerImageName(dockerfileServiceImageRaw("postgrest")); const STUDIO_IMAGE_NAME = shortContainerImageName(dockerfileServiceImageRaw("studio")); @@ -211,12 +191,9 @@ export interface StatusValuesResult { } /** - * Everything `toValues()` needs that does NOT depend on `--override-name` — - * i.e. every field except the output KEY remapping. Resolving this once and - * reusing it for both the env/json/toml/yaml values (real overrides) and the - * pretty-table values (always recomputed with an empty override map) - * avoids re-reading `auth.signing_keys_path` and - * re-signing the anon/service_role JWTs a second time per invocation. + * Everything needed to compute status output except `--override-name` remapping. Resolving + * this once and reusing it for both the real values and the pretty-table values avoids + * re-reading `auth.signing_keys_path` and re-signing the anon/service_role JWTs twice. */ export interface StatusState { readonly config: CliConfig; @@ -232,15 +209,11 @@ export interface StatusState { } /** - * The config-load/`Validate`-equivalent half of {@link StatusState} — - * everything that can THROW, and none of it depends on `excluded`/ - * `containerIds`. Split out so `status.handler.ts` can resolve and validate - * this before any Docker call, matching `flags.LoadConfig` (config load - * + `Validate`) running entirely before `assertContainerHealthy`/ - * container listing — a bad - * `auth.jwt_secret` or malformed `SUPABASE_*_PORT`/`SUPABASE_*_ENABLED` - * override must fail here, not be masked by a Docker/DB error when the local - * stack happens to be unavailable. + * The validating half of {@link StatusState}: everything that can throw, and none of it + * depends on `excluded`/`containerIds`. Split out so `status.handler.ts` can resolve and + * validate this before any Docker call — a bad `auth.jwt_secret` or malformed + * `SUPABASE_*_PORT`/`SUPABASE_*_ENABLED` override must fail here, not be masked by a + * Docker/DB error when the local stack happens to be unavailable. */ export interface StatusLocalState { readonly config: CliConfig; @@ -255,31 +228,19 @@ export interface StatusLocalState { } /** - * Port of the throwing, non-Docker-dependent half of - * `(*CustomName).toValues(exclude...)`: - * resolves local config values (URLs, keys — can throw, see - * {@link resolveLocalConfigValues}) and the per-service `.enabled` gates, - * with NO reference to `excluded`/`containerIds` — see {@link gateStatusState} - * for the Docker-dependent, non-throwing half this composes with (in - * `status.handler.ts`, or via {@link statusValues} for callers that - * don't need to run validation before Docker calls). - * - * Each `.enabled` gate is read through {@link envOverrideBool}, not the - * raw decoded `config.<section>.enabled`, because `status.toValues()` - * reads `utils.Config.*.Enabled` — a package-level struct - * that already has any `SUPABASE_<SECTION>_ENABLED` env/dotenv - * override applied — generically, - * not just for `auth.enabled`. Skipping this would mean a stack started - * with e.g. `SUPABASE_API_ENABLED=true` over a `false` TOML value has Kong/ - * PostgREST running while native `status` omits them entirely. + * Resolves local config values (URLs, keys — can throw, see {@link resolveLocalConfigValues}) + * and the per-service `.enabled` gates, with no reference to `excluded`/`containerIds` — see + * {@link gateStatusState} for the Docker-dependent half this composes with. Each `.enabled` + * gate is read through {@link envOverrideBool}, not the raw decoded `config.<section>.enabled`, + * so an env-overridden stack's running services match what `status` reports. * * @throws {InvalidJwtSecretError} when `auth.jwt_secret` is set but too short. - * @throws {InvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv - * override doesn't parse as a valid port. - * @throws {InvalidBoolEnvOverrideError} when a `SUPABASE_*_ENABLED` env/dotenv - * override doesn't parse as a valid bool. - * @throws when `auth.signing_keys_path` is set but the file is missing, malformed, - * or its first key is unsupported — see {@link generateAsymmetricGoJwt}. + * @throws {InvalidPortEnvOverrideError} when a `SUPABASE_*_PORT` env/dotenv override doesn't + * parse as a valid port. + * @throws {InvalidBoolEnvOverrideError} when a `SUPABASE_*_ENABLED` env/dotenv override + * doesn't parse as a valid bool. + * @throws when `auth.signing_keys_path` is set but the file is missing, malformed, or its + * first key is unsupported — see {@link generateAsymmetricGoJwt}. */ export function resolveStatusLocalState( config: CliConfig, @@ -289,19 +250,12 @@ export function resolveStatusLocalState( /** `LoadedCliConfig.document` — see {@link resolveLocalConfigValues}'s doc comment. */ document?: Readonly<Record<string, unknown>>, /** - * An already-resolved {@link resolveLocalConfigValues} result to reuse - * instead of re-deriving one. Callers that resolved `local` earlier in the - * SAME process (e.g. `start`'s success-path status print, after the values - * it already resolved were used to build every container spec) must pass it - * here rather than let this function call - * {@link resolveLocalConfigValues} again — a second call re-mints a - * time-dependent asymmetric JWT (`auth.signing_keys_path` + - * {@link generateAsymmetricGoJwt}'s `exp` claim) with a DIFFERENT - * signature than the one baked into the already-running containers. The - * reference implementation never has this problem: `c.Auth.generateAPIKeys()` runs exactly once per - * process (`Config.Validate()`), mutating the - * config in place, so every later read — including its own status print — - * sees the same value. + * An already-resolved {@link resolveLocalConfigValues} result to reuse instead of + * re-deriving one. Callers that resolved `local` earlier in the same process (e.g. + * `start`'s success-path status print) must pass it here: a second call re-mints a + * time-dependent asymmetric JWT (`auth.signing_keys_path` + {@link generateAsymmetricGoJwt}'s + * `exp` claim) with a different signature than the one baked into the already-running + * containers. */ precomputedLocal?: LocalConfigValues, ): StatusLocalState { @@ -366,15 +320,11 @@ export function resolveStatusLocalState( } /** - * The Docker-dependent, non-throwing half of `toValues()`: applies - * `excluded` (matching each gated service by its container id - * (`statusContainerIds`) OR its default Docker image short name - * (`shortContainerImageName` above) — the 6 relevant Go config fields - * (`Api.KongImage`, `Api.Image`, `Studio.Image`, `Auth.Image`, `Inbucket.Image`, - * `Storage.Image`, `EdgeRuntime.Image`) all carry `toml:"-"`, so they're never - * user-overridable and the default image is always the one to check) on top of - * an already-resolved {@link StatusLocalState}. Pure: every throwing - * concern already ran in {@link resolveStatusLocalState}. + * The Docker-dependent, non-throwing half of status resolution: applies `excluded`, matching + * each gated service by its container id ({@link statusContainerIds}) or its default Docker + * image short name ({@link shortContainerImageName}) — service images are never + * user-overridable, so the default is always the one to check. Pure: every throwing concern + * already ran in {@link resolveStatusLocalState}. */ export function gateStatusState( localState: StatusLocalState, @@ -418,9 +368,8 @@ export function gateStatusState( } /** - * Applies `--override-name` remapping to an already-resolved {@link StatusState}. - * Pure and non-throwing — every failure mode of `toValues()` lives in - * {@link resolveStatusLocalState}, which runs once per `status` invocation. + * Applies `--override-name` remapping to an already-resolved {@link StatusState}. Pure and + * non-throwing — every failure mode lives in {@link resolveStatusLocalState}. */ export function statusValuesFromState( state: StatusState, @@ -474,14 +423,10 @@ export function statusValuesFromState( } /** - * Convenience wrapper combining {@link resolveStatusLocalState} + - * {@link gateStatusState} + {@link statusValuesFromState} in one - * call — used directly by tests that only need a single override map. - * `status.handler.ts` calls the three separately instead, so it can resolve + - * validate `localState` before any Docker call (see - * {@link resolveStatusLocalState}'s doc comment), and reuse the gated - * `state` for both the real and pretty-mode (empty-override) value maps - * without recomputing `local`. + * Convenience wrapper combining {@link resolveStatusLocalState}, {@link gateStatusState}, and + * {@link statusValuesFromState} in one call — used by tests needing only a single override + * map. `status.handler.ts` calls the three separately so it can validate before any Docker + * call and reuse the gated state for both the real and pretty-mode value maps. */ export function statusValues( config: CliConfig, diff --git a/apps/cli/src/command-internal/status-values.unit.test.ts b/apps/cli/src/command-internal/status-values.unit.test.ts index cc841ded79..87e9167744 100644 --- a/apps/cli/src/command-internal/status-values.unit.test.ts +++ b/apps/cli/src/command-internal/status-values.unit.test.ts @@ -175,8 +175,8 @@ describe("statusValues", () => { }); it("omits FUNCTIONS_URL when the edge-runtime image short name is excluded", () => { - // The image repo name (`supabase/edge-runtime`) differs from the Dockerfile's - // build alias (`edgeruntime`) — the short name Go matches against is the former. + // The image repo name (`supabase/edge-runtime`) differs from the Dockerfile build + // alias (`edgeruntime`); the short name matched against is the former. const { values } = statusValues( baseConfig(), CONTAINER_IDS, @@ -390,8 +390,8 @@ describe("statusValues", () => { }); it("omits storage S3 fields when the storage-api image short name is excluded", () => { - // The image repo name (`supabase/storage-api`) differs from the Dockerfile's - // build alias (`storage`) — the short name Go matches against is the former. + // The image repo name (`supabase/storage-api`) differs from the Dockerfile build + // alias (`storage`); the short name matched against is the former. const { values } = statusValues( baseConfig(), CONTAINER_IDS, @@ -412,12 +412,8 @@ describe("statusValues", () => { }); describe("SUPABASE_*_ENABLED env overrides", () => { - // `status.toValues()` reads `utils.Config.*.Enabled` - // AFTER Viper's `SetEnvPrefix("SUPABASE")` + `AutomaticEnv()` binding - // has already applied any - // `SUPABASE_<SECTION>_ENABLED` override — generically, not just for auth. - // `resolveStatusLocalState` must read the same post-override value - // for every gate, not the raw decoded `config.<section>.enabled`. + // `resolveStatusLocalState` must read the post-env-override value for every `.enabled` + // gate, not the raw decoded `config.<section>.enabled`. it("includes API_URL/REST_URL when SUPABASE_API_ENABLED overrides a disabled api.enabled", () => { const config = baseConfig({ api: { enabled: false } }); @@ -466,10 +462,6 @@ describe("statusValues", () => { }); it("includes the 5 auth fields when SUPABASE_AUTH_ENABLED overrides a disabled auth.enabled", () => { - // Reproduces the exact scenario a Go-started stack can hit: TOML says - // auth is disabled, but the running stack was actually started with - // SUPABASE_AUTH_ENABLED=true from the shell/dotenv, so Auth is up and - // status must still print its credentials. const config = baseConfig({ auth: { enabled: false } }); const { values } = statusValues( config, @@ -664,9 +656,7 @@ describe("statusValues", () => { }); it("combines stopped-service exclusions with --exclude flag exclusions", () => { - // Both `stopped` (from the health-check diff) and `--exclude` (user flag) - // funnel into the same `excluded` array in the handler; the pure function - // only sees the merged list. + // Both `stopped` and `--exclude` funnel into the same `excluded` array in the handler. const excluded = [CONTAINER_IDS.storage, CONTAINER_IDS.studio]; const { values } = statusValues( baseConfig(), @@ -682,10 +672,9 @@ describe("statusValues", () => { }); }); -// `--exclude` short names are the established contract, so they must stay on the -// docker.io repo names even when the stack itself runs slim `ghcr.io/supabase/cli` -// images. Re-imports the module so the flag is in effect while its -// image-name constants are built. +// `--exclude` short names must stay on the docker.io repo names even when the stack runs +// slim images. Re-imports the module so the env var is in effect while its image-name +// constants are built. describe("--exclude image short names under SUPABASE_USE_SLIM_IMAGES", () => { afterEach(() => { vi.unstubAllEnvs(); diff --git a/apps/cli/src/command-internal/storage-bucket-config.ts b/apps/cli/src/command-internal/storage-bucket-config.ts index ab5b4778dd..5f5932635a 100644 --- a/apps/cli/src/command-internal/storage-bucket-config.ts +++ b/apps/cli/src/command-internal/storage-bucket-config.ts @@ -2,22 +2,16 @@ import { ramInBytes } from "./size-units.ts"; import type { UpsertBucketProps } from "./storage-gateway.ts"; /** - * Pure helpers that turn a `[storage.buckets.*]` config entry into the - * create/update bucket props the Storage gateway sends. Shared by `seed buckets` - * (which seeds every configured bucket) and `storage cp` (which auto-creates a - * bucket on a `Bucket not found` upload, reading the same config). Kept free of - * Effect/services so the - * established rules (size parsing, storage-level inheritance, `public` tri-state) - * stay unit-testable. + * Pure helpers that turn a `[storage.buckets.*]` config entry into the create/update bucket + * props the Storage gateway sends. Shared by `seed buckets` and `storage cp` (which + * auto-creates a bucket on a `Bucket not found` upload). Kept free of Effect/services so the + * size-parsing, storage-level-inheritance, and `public` tri-state rules stay unit-testable. */ /** - * Parse a `file_size_limit` config string (e.g. `"50MiB"`) to the int64 byte - * count sent in the create/update bucket body. `@supabase/config` keeps the - * field as the raw - * human-readable string, so the conversion normally done at config-load happens - * here. Throws on an unparseable value (aborts config load), which the caller - * maps to a config-load error. + * Parses a `file_size_limit` config string (e.g. `"50MiB"`) to the byte count sent in the + * create/update bucket body. Throws on an unparseable value; the caller maps that to a + * config-load error. */ export function parseFileSizeLimit(sizeStr: string): number { return ramInBytes(sizeStr); @@ -28,12 +22,10 @@ function isRecord(value: unknown): value is Record<string, unknown> { } /** - * Whether the bucket's TOML entry explicitly declares `key`. Go reads `public` - * into a `*bool` and `file_size_limit` into a pointer, so an absent key is - * omitted (not the decoded schema default), and that "omitted" signal drives the - * `public` tri-state and the storage-level `file_size_limit` inheritance. - * `@supabase/config` loses it (decodes to the schema default), so recover - * presence from the raw (post-`env()`) document. + * Whether the bucket's TOML entry explicitly declares `key`. The decoded config loses this + * signal (an absent key decodes to the schema default), but it drives the `public` tri-state + * and the storage-level `file_size_limit` inheritance, so recover presence from the raw + * (post-`env()`) document instead. */ export function bucketHasKey( document: Record<string, unknown> | undefined, @@ -56,15 +48,12 @@ interface BucketConfigEntry { } /** - * Resolve a bucket's create/update props, mirroring `config.resolve()` - * + the `sizeInBytes` decode at config-load: - * - an omitted or zero `file_size_limit` inherits the (already-parsed) - * storage-level limit; - * - `public` is the explicit value only when the TOML declares it, else - * `undefined` (`*bool` nil → omitted from the request body). + * Resolves a bucket's create/update props: an omitted or zero `file_size_limit` inherits the + * (already-parsed) storage-level limit; `public` is the explicit value only when the TOML + * declares it, else `undefined` (omitted from the request body). * - * Throws on an unparseable bucket `file_size_limit` (the caller maps it to a - * config-load error). `storageFileSizeLimitBytes` must already be parsed. + * Throws on an unparseable bucket `file_size_limit`, mapped by the caller to a config-load + * error. `storageFileSizeLimitBytes` must already be parsed. */ export function resolveBucketProps(opts: { readonly document: Record<string, unknown> | undefined; From 251f5aa699ac21326a94834128ace119a4a74934 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 12:41:52 +0100 Subject: [PATCH 19/56] chore(repo): trim comments, shard 24 (1196 -> 525 comment lines) --- apps/cli/src/commands/db/shared/go-string.ts | 18 +- .../db/shared/migra.deno-templates.ts | 12 +- .../shared/migra.deno-templates.unit.test.ts | 4 - .../src/commands/db/shared/migra.errors.ts | 20 +- apps/cli/src/commands/db/shared/migra.ts | 72 ++-- .../db/shared/pgdelta-engine.next.layer.ts | 7 +- .../db/shared/pgdelta-migrations.write.ts | 29 +- .../db/shared/pgdelta-next-adapter.layer.ts | 12 +- .../db/shared/pgdelta-next-diagnostics.ts | 16 +- .../db/shared/pgdelta-next-shadow.layer.ts | 21 +- .../pgdelta-next-shadow.layer.unit.test.ts | 9 +- .../db/shared/pgdelta-next-shadow.plan.ts | 62 +--- .../db/shared/pgdelta-next-shadow.service.ts | 10 +- .../src/commands/db/shared/pgdelta.errors.ts | 20 +- .../shared/pgdelta.seam.integration.test.ts | 8 +- .../commands/db/shared/pgdelta.seam.layer.ts | 32 +- .../src/commands/db/shared/pgdelta.write.ts | 74 ++--- .../src/commands/db/shared/pooler-fallback.ts | 45 +-- .../src/commands/db/shared/shadow-source.ts | 278 ++++------------ .../db/shared/shadow-source.unit.test.ts | 94 +----- .../src/commands/db/start/start.handler.ts | 24 +- .../db/start/start.integration.test.ts | 311 +++--------------- .../cli/src/commands/db/start/start.layers.ts | 22 +- apps/cli/src/commands/db/test/test.command.ts | 15 +- .../commands/db/test/test.integration.test.ts | 65 +--- .../commands/domains/delete/delete.handler.ts | 11 +- 26 files changed, 309 insertions(+), 982 deletions(-) diff --git a/apps/cli/src/commands/db/shared/go-string.ts b/apps/cli/src/commands/db/shared/go-string.ts index ac1c99f096..adfd8de329 100644 --- a/apps/cli/src/commands/db/shared/go-string.ts +++ b/apps/cli/src/commands/db/shared/go-string.ts @@ -1,19 +1,7 @@ /** - * Go string-primitive helpers shared across the `db` command family. Currently - * just `strings.TrimSpace`/`bytes.TrimSpace` — hoisted here (per the repo's - * "hoist before you duplicate" rule, AGENTS.md) once a second `db`-family caller - * needed the exact same primitive: the since-removed legacy pg-delta apply - * module (CLI-1956, apply error-detail trimming) and `pgadmin-diff.ts` - * (CLI-1968, `diff_ddl` trimming) each carried their own private, verbatim copy - * before this move. - */ - -/** - * Go's `strings.TrimSpace`/`bytes.TrimSpace` trim exactly the Unicode - * `White_Space` set — which, unlike JS's `String.prototype.trim`, does NOT - * include U+FEFF (BOM/ZWNBSP). A BOM-prefixed payload must therefore fail to - * parse (or render un-trimmed) here exactly like it does in Go, and - * BOM-adjacent fields must render it, not eat it. + * Trims exactly the Unicode `White_Space` set, matching Go's + * `strings.TrimSpace`/`bytes.TrimSpace`. Unlike JS's `String.prototype.trim`, + * it does not strip U+FEFF (BOM/ZWNBSP), so a BOM-prefixed value is left untouched. */ export const trimGoSpace = (value: string): string => value.replace(/^\p{White_Space}+|\p{White_Space}+$/gu, ""); diff --git a/apps/cli/src/commands/db/shared/migra.deno-templates.ts b/apps/cli/src/commands/db/shared/migra.deno-templates.ts index 2539e0764b..45fe4234cd 100644 --- a/apps/cli/src/commands/db/shared/migra.deno-templates.ts +++ b/apps/cli/src/commands/db/shared/migra.deno-templates.ts @@ -1,12 +1,6 @@ -// Verbatim copies of the Go migra Deno + bash templates. These embed the -// scripts byte-for-byte; `migra.deno-templates.unit.test.ts` asserts -// equality against the Go sources in `apps/cli-go/internal/db/diff/templates/`. -// Do not hand-edit — regenerate from Go. -// -// Rollback engine for `db diff --use-migra` and `db pull --diff-engine migra`. -// The `.ts` template runs inside Edge Runtime (`@pgkit/migra` + -// `@pgkit/client`); the `.sh` template is the OOM bash fallback executed in the -// `supabase/migra` Docker image. +// Embedded templates for the migra rollback path (`db diff --use-migra`, +// `db pull --diff-engine migra`). `migra.deno-templates.unit.test.ts` pins their +// exact content — edit through that test, not by hand. /** `templates/migra.ts` — diffs SOURCE→TARGET via @pgkit/migra inside Edge Runtime. */ export const migraDiffScript = diff --git a/apps/cli/src/commands/db/shared/migra.deno-templates.unit.test.ts b/apps/cli/src/commands/db/shared/migra.deno-templates.unit.test.ts index 2a20cb2026..12fdcab2bf 100644 --- a/apps/cli/src/commands/db/shared/migra.deno-templates.unit.test.ts +++ b/apps/cli/src/commands/db/shared/migra.deno-templates.unit.test.ts @@ -7,8 +7,6 @@ import { EDGE_RUNTIME_SCRIPT_ERROR_SENTINEL } from "../../../command-internal/ed import { migraDiffScript, migraDiffShellScript } from "./migra.deno-templates.ts"; import { listSchemasSql } from "./migra.ts"; -// Resolve the Go template sources relative to this file so the byte-equality -// assertion fails loudly if the embedded copies drift from upstream. const goDiffTemplatesDir = fileURLToPath( new URL("../../../../../cli-go/internal/db/diff/templates/", import.meta.url), ); @@ -26,8 +24,6 @@ describe("embedded migra templates", () => { }); describe("embedded user-schema queries", () => { - // An unscoped pg_depend anti-join hid user schemas whose oid collided with a - // row in another catalog (supabase/cli#6375). it.each([ ["listSchemasSql", listSchemasSql], ["dropObjectsSql", dropObjectsSql], diff --git a/apps/cli/src/commands/db/shared/migra.errors.ts b/apps/cli/src/commands/db/shared/migra.errors.ts index 9c3b586868..bc667600c3 100644 --- a/apps/cli/src/commands/db/shared/migra.errors.ts +++ b/apps/cli/src/commands/db/shared/migra.errors.ts @@ -7,19 +7,15 @@ import { } from "../../../shared/telemetry/error-actionability.ts"; /** - * The migra diff failed (edge-runtime run, or the OOM bash fallback in the - * `supabase/migra` Docker image). Byte-matches Go's - * `"error diffing schema: %w:\n%s"` wrapping in `DiffSchemaMigra` / - * `DiffSchemaMigraBash` (`apps/cli-go/internal/db/diff/migra.go`). + * The migra diff failed, either in the edge-runtime run or the OOM bash fallback in the + * `supabase/migra` Docker image. */ export class MigraDiffError extends Data.TaggedError("MigraDiffError")<{ readonly message: string; /** - * Threaded from a wrapped `DockerRunError` in the OOM bash fallback so a - * docker-boundary failure (docker daemon down or registry pull) does not - * misclassify as a user-SQL (`dbFinding`) failure. `daemon` maps to - * docker-not-running, `pull` to an external network problem. `undefined` for - * genuine diff/script failures, which keep the user-SQL classification. + * Set from a wrapped `DockerRunError` in the OOM bash fallback so a docker-boundary failure + * isn't misclassified as user-SQL: `"daemon"` maps to docker-not-running, `"pull"` to an + * external network problem. */ readonly docker?: "daemon" | "pull"; }> { @@ -35,10 +31,8 @@ export class MigraDiffError extends Data.TaggedError("MigraDiffError")<{ } /** - * Loading the target's user-defined schemas for the migra bash fallback failed. - * Byte-matches Go's `migration.ListUserSchemas` → `"failed to list schemas: %w"` - * (`apps/cli-go/pkg/migration/drop.go:46`); reached only on the OOM fallback path - * when no `--schema` is given. + * Loading the target's user-defined schemas for the migra bash fallback failed. Reached only + * on the OOM fallback path when no `--schema` is given. */ export class MigraSchemaLoadError extends Data.TaggedError("MigraSchemaLoadError")<{ readonly message: string; diff --git a/apps/cli/src/commands/db/shared/migra.ts b/apps/cli/src/commands/db/shared/migra.ts index 2676d18d2c..541ea24bc0 100644 --- a/apps/cli/src/commands/db/shared/migra.ts +++ b/apps/cli/src/commands/db/shared/migra.ts @@ -17,18 +17,15 @@ import { MigraDiffError, MigraSchemaLoadError } from "./migra.errors.ts"; import { edgeRuntimeId, type PgDeltaContext } from "../../../command-internal/pgdelta.ts"; /** - * The migra Docker image, parsed by Go from its embedded Dockerfile - * (`apps/cli-go/pkg/config/templates/Dockerfile:19` → `config.Images.Migra`). - * Used only by the OOM bash fallback (`DiffSchemaMigraBash`); the common - * edge-runtime path runs `@pgkit/migra` instead. + * The migra Docker image, used only by the OOM bash fallback; the common edge-runtime path + * runs `@pgkit/migra` instead. */ const MIGRA_IMAGE = "supabase/migra:3.0.1663481299"; /** - * Schemas excluded from a no-`--schema` migra diff. Verbatim from Go's - * `managedSchemas` (`apps/cli-go/internal/db/diff/migra.go:26-56`): local-dev, - * extension-owned, deprecated-extension, and Supabase-managed schemas. Passed as - * `EXCLUDED_SCHEMAS` to the edge-runtime template. + * Schemas excluded from a no-`--schema` migra diff: local-dev, extension-owned, + * deprecated-extension, and Supabase-managed schemas. Passed as `EXCLUDED_SCHEMAS` to the + * edge-runtime template. */ const MIGRA_MANAGED_SCHEMAS: ReadonlyArray<string> = [ // Local development @@ -62,11 +59,7 @@ const MIGRA_MANAGED_SCHEMAS: ReadonlyArray<string> = [ "supabase_migrations", ]; -/** - * LIKE patterns excluded by `ListUserSchemas` when resolving the migra bash - * fallback's schema list. Verbatim from Go's `migration.ManagedSchemas` - * (`apps/cli-go/pkg/migration/drop.go:19-31`). - */ +/** LIKE patterns excluded when resolving the migra bash fallback's schema list. */ const LIST_SCHEMAS_EXCLUDE: ReadonlyArray<string> = [ "information\\_schema", "pg\\_%", @@ -82,12 +75,9 @@ const LIST_SCHEMAS_EXCLUDE: ReadonlyArray<string> = [ ]; /** - * Lists user-defined schemas, excluding extension-created ones via a - * `pg_depend` anti-join scoped by `classid` to `pg_namespace` rows (an oid - * collision with another catalog must not hide a schema — supabase/cli#6375), - * Supabase-managed names via the `$1` LIKE patterns, and schemas owned by - * `supabase_admin`. Shared by the migra bash fallback and `db lint` - * (`lint.lint-sql.ts`). + * Lists user-defined schemas, excluding extension-created ones (the `pg_depend` anti-join is + * scoped to `pg_namespace` via `classid` so an oid collision in another catalog can't hide a + * schema — see supabase/cli#6375), Supabase-managed names, and schemas owned by `supabase_admin`. */ export const listSchemasSql = `-- List user defined schemas, excluding -- Extension created schemas @@ -100,12 +90,10 @@ where pd.deptype is null and pn.nspowner::regrole::text != 'supabase_admin' order by pn.nspname`; -/** Mirrors Go's `types.IsSSLDebugEnabled` (`internal/gen/types/types.go:201`). */ function isSslDebugEnabled(): boolean { return (process.env["SUPABASE_SSL_DEBUG"] ?? "").toLowerCase() === "true"; } -/** Mirrors Go's `shouldFallbackToLegacyMigra` (`internal/db/diff/migra.go:155`). */ function shouldFallbackToBashMigra(message: string): boolean { return ( message.includes("Fatal JavaScript out of memory") || @@ -125,8 +113,7 @@ const buildMigraEnv = Effect.fnUntraced(function* (params: { TARGET: params.target, }; if (isSslDebugEnabled()) env["SUPABASE_SSL_DEBUG"] = "true"; - // Go's GetRootCA: probe the target for TLS; if it speaks TLS, inject the - // embedded CA bundle as SSL_CA (`internal/gen/types/types.go:124-148`). + // Probe the target for TLS; if it speaks TLS, inject the embedded CA bundle as SSL_CA. const requireSsl = yield* probe.requireSsl(params.target); if (requireSsl) env["SSL_CA"] = PG_DELTA_CA_BUNDLE; if (params.schema.length > 0) { @@ -138,10 +125,8 @@ const buildMigraEnv = Effect.fnUntraced(function* (params: { }); /** - * Loads the target's user-defined schemas for the bash fallback (the bash - * migra.sh iterates over an explicit schema list and cannot diff in exclude - * mode). Mirrors Go's `loadSchema` → `migration.ListUserSchemas` - * (`internal/db/diff/migra.go:99` / `pkg/migration/drop.go:40`). + * Loads the target's user-defined schemas for the bash fallback: migra.sh iterates over an + * explicit schema list and cannot diff in exclude mode. */ const loadTargetUserSchemas = Effect.fnUntraced(function* ( target: string, @@ -180,10 +165,9 @@ const loadTargetUserSchemas = Effect.fnUntraced(function* ( }); /** - * The OOM bash fallback: run migra in the `supabase/migra` Docker image over the - * host network. Mirrors Go's `DiffSchemaMigraBash` - * (`internal/db/diff/migra.go:60`): when no `--schema` is given the included - * schemas are loaded from the target, then passed as positional args to migra.sh. + * The OOM bash fallback: runs migra in the `supabase/migra` Docker image over the host + * network. When no `--schema` is given, the included schemas are loaded from the target and + * passed as positional args to migra.sh. */ const diffMigraBash = Effect.fnUntraced(function* (params: { readonly source: string; @@ -200,14 +184,11 @@ const diffMigraBash = Effect.fnUntraced(function* (params: { : yield* loadTargetUserSchemas(params.target, params.connectOptions); const env: Record<string, string> = { SOURCE: params.source, TARGET: params.target }; if (isSslDebugEnabled()) env["SUPABASE_SSL_DEBUG"] = "true"; - // Passing the script as a string means command-line args must be set manually - // via `set --` so migra.sh's `"$@"` loop sees the schema list (Go's `args`). + // The script runs as a string, so command-line args must be set manually via `set --` + // for migra.sh's `"$@"` loop to see the schema list. const args = `set -- ${schema.join(" ")};`; - // Go's bash fallback (`DiffSchemaMigraBash`) routes through `DockerStart` - // (`internal/utils/docker.go:266-271`), which appends the Linux - // `host.docker.internal:host-gateway` mapping and overrides host networking with - // `--network-id` when set. Mirror that here so the fallback reaches the database - // on custom-network / `host.docker.internal` setups, matching the primary path. + // Add the Linux `host.docker.internal:host-gateway` mapping, and use a named network when + // `--network-id` is set, so this fallback reaches the database like the primary path does. const networkId = Option.getOrUndefined(networkIdFlag); const network = networkId !== undefined && networkId.length > 0 @@ -230,9 +211,8 @@ const diffMigraBash = Effect.fnUntraced(function* (params: { (cause) => new MigraDiffError({ message: `error diffing schema: ${cause.message}`, - // Thread the docker discriminant so a daemon-down / registry-pull - // failure at the docker boundary is not misclassified as user SQL, - // mirroring the edge-runtime-script fix. + // Distinguish a daemon-down / registry-pull failure at the docker boundary from + // a genuine user-SQL diff failure. docker: cause.reason === "spawn" || cause.daemonDown ? "daemon" : "pull", }), ), @@ -248,13 +228,9 @@ const diffMigraBash = Effect.fnUntraced(function* (params: { }); /** - * Diffs SOURCE → TARGET with migra via the edge-runtime template - * (`@pgkit/migra` + `@pgkit/client`), falling back to the `supabase/migra` - * Docker image when the edge-runtime worker runs out of memory. Mirrors Go's - * `DiffSchemaMigra` (`internal/db/diff/migra.go:109`). `source`/`target` are - * live Postgres URLs (the shadow source and the diff target). Symmetric with - * `diffPgDelta`: a free function over a `PgDeltaContext`, not a - * service. + * Diffs SOURCE → TARGET with migra via the edge-runtime template, falling back to the + * `supabase/migra` Docker image when the edge-runtime worker runs out of memory. + * `source`/`target` are live Postgres URLs (the shadow source and the diff target). */ export const diffMigra = Effect.fnUntraced(function* ( ctx: PgDeltaContext, diff --git a/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.ts b/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.ts index 2be3525cf1..58bcc00c36 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-engine.next.layer.ts @@ -117,10 +117,9 @@ export function parsePgDeltaNextEndpoint( return yield* Effect.fail( new PgDeltaEngineError({ message: "failed to parse Postgres connection string for pg-delta", - // `redactConnectionString`, not a local `:password@` regex: the input - // reaching here is by definition unparseable, and a hand-typed password - // containing `/`, `@`, or `:` defeats a naive single-character-class match - // (CWE-209). The shared redactor over-redacts instead of leaking. + // The input is by definition unparseable, so a naive `:password@` regex could miss a + // hand-typed password containing `/`, `@`, or `:` (CWE-209); `redactConnectionString` + // over-redacts instead of risking a leak. cause: redactConnectionString(endpoint.ref), }), ); diff --git a/apps/cli/src/commands/db/shared/pgdelta-migrations.write.ts b/apps/cli/src/commands/db/shared/pgdelta-migrations.write.ts index cbf1f9adb4..a99ff28f09 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-migrations.write.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-migrations.write.ts @@ -32,33 +32,16 @@ export class PgDeltaMigrationWriteError extends Data.TaggedError("PgDeltaMigrati /** * Bounds the base-timestamp bump retry so a directory already full of same-second - * migrations can't spin forever. Mirrors Go's `maxVersionCollisionAttempts`. + * migrations can't spin forever. */ const MAX_VERSION_COLLISION_ATTEMPTS = 60; /** - * Port of Go's `WritePgDeltaMigrations` (`apps/cli-go/internal/db/diff/pgdelta_migrations.go`). - * - * Writes one ordered migration file per plan unit. A single-unit plan (the common - * case) keeps the exact `<ts>_<name>.sql` filename; multi-unit plans append the - * unit name and give each file a strictly increasing timestamp (real time - * arithmetic on the base millis, never string increment) so their execution order - * and migration-history order stay stable. - * - * Before writing anything the FULL set of generated versions is collision-checked - * against the migrations directory: if any version is already used, the base is - * advanced by one second and every version recomputed, so the set stays strictly - * ascending AND unique against pre-existing migrations regardless of their names. - * The base only ever moves forward — never backdated below the caller's wall clock, - * since backdating could sort a new file before pre-existing migrations. The - * resulting ≤N−1s future-dating is inherent to second-granularity versions and - * acceptable once uniqueness is enforced. - * - * Each file is written with the exclusive `"wx"` flag so a race between the - * collision check and the write can still never silently overwrite an existing - * migration. If any open/write fails mid-loop, every file already written by THIS - * invocation is best-effort removed before the error surfaces (a removal failure - * never masks the original error). + * Writes one migration file per plan unit, giving multi-unit plans strictly increasing + * timestamps so execution and history order stay stable. The full version set is + * collision-checked against the migrations directory up front, advancing the base timestamp + * forward only (never backdated) until unique. Each file opens with the exclusive `"wx"` + * flag against overwrite races; a failed write removes every file this call already wrote. */ export const writePgDeltaMigrations = ( fs: FileSystem.FileSystem, diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-adapter.layer.ts b/apps/cli/src/commands/db/shared/pgdelta-next-adapter.layer.ts index 3f6b2bd9b5..77a6d25ba1 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-adapter.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-adapter.layer.ts @@ -262,15 +262,9 @@ function normalizePgDeltaNextDiagnostics<Subject>( } /** - * Turns `planSchemaFiles`' skipped statements into coverage diagnostics so they - * travel the ONE diagnostic report path every consumer already renders and - * enforces — warned by default, blocking under `--strict-coverage`. Built here, - * where `skipped` originates, so no consumer has to remember to look at the - * separate `skipped` field (nothing did, and the statements vanished silently). - * The per-diagnostic message deliberately carries the raw statement verbatim: - * it is the user's own declarative file content (already on their machine), and - * a redacted message would leave `--strict-coverage`/debug failures - * unactionable. The default aggregate warning still names files only. + * Routes `planSchemaFiles`' skipped statements through the shared diagnostic-report path, + * warned by default and blocking under `--strict-coverage`, instead of an unread `skipped` + * field; the raw statement is kept verbatim since it's already the user's own file content. */ function skippedStatementDiagnostics( skipped: readonly { readonly file: string; readonly stmt: string }[], diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-diagnostics.ts b/apps/cli/src/commands/db/shared/pgdelta-next-diagnostics.ts index 51f48e8715..d0448a96bf 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-diagnostics.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-diagnostics.ts @@ -24,13 +24,9 @@ const operationAction: Record<PgDeltaNextOperation, string> = { }; /** - * Declarative statements pg-delta's loader could not model, e.g. `CREATE ROLE` - * (`planSchemaFiles`' `skipped` list). Synthesized by the next adapter rather than - * emitted by pg-delta itself, so it is absent from the library's - * `STRICT_COVERAGE_CODES` — {@link isCoverageDiagnostic} layers it on top so a - * skipped statement warns by default and fails under `--strict-coverage`, exactly - * like an unmodeled object kind. Silently dropping these would let a statement the - * user wrote in a declarative file vanish from the plan with no signal at all. + * Code for declarative statements pg-delta's loader couldn't model. Synthesized here + * rather than emitted by pg-delta, so {@link isCoverageDiagnostic} layers it onto + * `STRICT_COVERAGE_CODES` to warn by default and fail under `--strict-coverage`. */ export const PG_DELTA_NEXT_SKIPPED_STATEMENT_CODE = "skipped_statement"; @@ -108,9 +104,9 @@ function pgDeltaNextUnmodeledKindsMessage( } /** - * Aggregate line for skipped declarative statements, shaped like the unmodeled-kind - * summary above: it names the files, never the statement SQL (the per-diagnostic - * detail carries that, and only renders under `--debug`/`--strict-coverage`). + * Aggregate line for skipped declarative statements: names only the files, never the + * statement SQL. The per-diagnostic detail carries that, rendered only under + * `--debug`/`--strict-coverage`. */ function pgDeltaNextSkippedStatementsMessage( operation: PgDeltaNextOperation, diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts index 1b1d1d7574..ca49141a92 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.ts @@ -98,9 +98,9 @@ interface ProvisionedDeclarativeShadow { } /** - * Bypass pg-delta's same-database guard when both shadows share one snapshot - * key and the declarative side was restored from that tar. A cold-exported - * migrations handle is still that tar's lineage. + * Bypass pg-delta's same-database guard when both shadows share one snapshot key and the + * declarative side was restored from that tar — a cold-exported migrations handle is still + * that tar's lineage. */ export function allowSameDatabaseIdentityForPlanShadows(opts: { readonly declarativeRestoredFromPgDataSnapshot: boolean; @@ -120,11 +120,7 @@ const setupRunInput = (input: NativeShadowInput, handle: ShadowAcquiredHandle) = setup: input.base.setup, }); -/** - * Scoped, native TypeScript shadow orchestration for pg-delta next. The command - * workflows and this specialized two-shadow planner share the same bootstrap - * primitives; no Go command or shadow handoff protocol is involved. - */ +/** Scoped, native TypeScript shadow orchestration for pg-delta next. */ export const pgDeltaNextShadowLayer = Layer.effect( PgDeltaNextShadow, Effect.gen(function* () { @@ -307,11 +303,10 @@ export const pgDeltaNextShadowLayer = Layer.effect( ? cache : { ...cache, precomputedKeyInputs: peek.keyInputs }; const strategy = resolvePlanShadowStrategy(migrationsPeek, declarativePeek); - // Peeked inputs are reused only where the acquire follows the peek immediately: the - // migrations acquire always does, the declarative one only under `parallel`. Delayed - // declarative acquires (handoff / sequential) re-resolve so a mid-run `roles.sql` - // edit cannot publish a baseline under a stale key. Identity still uses the acquired - // handles' snapshot keys, so a delayed re-resolve cannot lie about lineage. + // Peeked inputs are reused only when acquire immediately follows peek: always for + // migrations, only under `parallel` for declarative. Delayed declarative acquires + // re-resolve so a mid-run `roles.sql` edit can't publish under a stale key — identity + // still comes from the acquired handles' snapshot keys, so this can't lie about lineage. const migrationsOpts = withPeek(cacheOpts(opts, "config"), migrationsPeek); const declarativeOpts = strategy === "parallel" diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.unit.test.ts b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.unit.test.ts index 7517d1b351..df59e236f6 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.unit.test.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.layer.unit.test.ts @@ -5,32 +5,25 @@ import { allowSameDatabaseIdentityForPlanShadows } from "./pgdelta-next-shadow.l describe("allowSameDatabaseIdentityForPlanShadows", () => { it.each([ { - // The baseline handoff on a cold cache: the migrations shadow exported the tar this very - // run and the declarative shadow warm-restored that same key — an exact physical clone, - // so the guard has to be bypassed even though the migrations handle is not a restore. scenario: "the declarative shadow restored the tar the migrations shadow just exported", restored: true, sameKey: true, expected: true, }, { - // Both sides warm off the same tar on a later run — same lineage, same conclusion. scenario: "both shadows warm-restored the same key", restored: true, sameKey: true, expected: true, }, { - // A freshly initdb'd declarative shadow always carries a brand-new identity, so the guard - // stays armed no matter what the migrations side did. scenario: "the declarative shadow was cold-provisioned", restored: false, sameKey: true, expected: false, }, { - // Restored from a DIFFERENT key's tar: a different originating cluster, own identity. This - // also covers an absent key on either side (uncached/bypassed/uncachable acquisitions), + // Also covers an absent key on either side (uncached/bypassed/uncachable acquisitions), // which the caller folds into `sameSnapshotKey: false`. scenario: "the shadows carry different or absent snapshot keys", restored: true, diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.plan.ts b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.plan.ts index 9038c4053a..ccd0bd78e7 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.plan.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.plan.ts @@ -1,24 +1,7 @@ /** - * Orchestration for pg-delta next's two plan shadows (migrations + declarative) — the strategy - * choice, the concurrency runner, and the output buffering that keeps the user-visible - * transcript free of cross-fiber interleaving. Extracted from - * `pgdelta-next-shadow.layer.ts` so the branch logic, the baseline-handoff signal, and - * the flush ordering are unit-testable with plain fakes instead of a full Docker/runtime layer - * graph. - * - * The three strategies, chosen from a {@link peekShadowBaseline} of each shadow: - * - * - `parallel` — both snapshots are published: both provisions warm-restore concurrently. A warm - * provision skips the platform baseline entirely, so the declarative fiber prints nothing and - * the migrations fiber's `Applying migration ...` lines stream live and in order. - * - `baseline-handoff` — both are cold with the same cache key (webhooks agree): the baseline is - * paid exactly once. The migrations shadow cold-provisions; its snapshot export runs at the - * baseline seam (after platform setup, before migration replay) and signals the declarative - * fiber, which then warm-restores from the just-published tar concurrently with the migration - * replay. - * - `sequential` — everything else (different keys, mixed warm/cold, `--no-cache`, cache env off, - * PG<=14/OrioleDB): no baseline can be shared, so run migrations then declarative exactly as - * the pre-parallel code did. + * Orchestration for pg-delta next's two plan shadows: the concurrency strategy chosen from each + * shadow's cache peek ({@link resolvePlanShadowStrategy}), the runner for it + * ({@link runPlanShadowProvisions}), and output buffering ({@link bufferedShadowOutput}). */ import { Deferred, Effect } from "effect"; @@ -29,10 +12,9 @@ import type { ShadowBaselinePeek } from "../../../command-internal/db-bootstrap/ export type PlanShadowStrategy = "parallel" | "baseline-handoff" | "sequential"; /** - * Pure strategy choice from the two peeks. Equal-key implies equal warm/cold state (one key = - * one tar), so `cold`+`cold`+equal-keys is the only shareable-baseline shape; a mixed warm/cold - * pair always means different keys, where nothing can be shared and sequential keeps the cold - * side's baseline prints off the migration replay's live stream. + * Pure strategy choice from the two peeks: equal-key implies equal warm/cold state, so only + * `cold`+`cold` with equal keys shares a baseline. A mixed warm/cold pair always means + * different keys, so sequential keeps the cold side's baseline prints off the live stream. */ export function resolvePlanShadowStrategy( migrations: ShadowBaselinePeek, @@ -50,14 +32,10 @@ export function resolvePlanShadowStrategy( } /** - * Runs the two provisions under the chosen strategy. - * - * `provisionMigrations` receives an `onBaselineSeam` effect it must arrange to run once its - * baseline seam passes (the snapshot-export point, before migration replay) — the layer wires it - * into the acquired handle's `snapshotBaseline` via `Effect.ensuring`, and fires it immediately - * when the acquired handle will never run a snapshot (a warm or uncached acquire). The runner - * additionally `Effect.ensuring`s the signal onto the whole migrations provision as a liveness - * backstop, so the declarative waiter can never deadlock. + * Runs the two provisions under the chosen strategy. `provisionMigrations` must fire + * `onBaselineSeam` once its snapshot-export point passes (immediately if the acquired handle + * never snapshots); the runner also `Effect.ensuring`s that signal onto the whole migrations + * provision as a liveness backstop, so the declarative waiter can never deadlock. */ export const runPlanShadowProvisions = <M, D, EM, ED, RM, RD>(opts: { readonly strategy: PlanShadowStrategy; @@ -94,23 +72,19 @@ export interface BufferedShadowOutput { /** The wrapped service to provide to the fiber whose writes must not interleave. */ readonly output: typeof Output.Service; /** - * Replays every buffered write to the real output, in order. Run it after the live fiber has - * finished (`Effect.ensuring` on the join, not on the buffered fiber — the buffered fiber can - * finish first). Idempotent; writes arriving after a flush pass straight through live so late - * teardown warnings are never lost. + * Replays every buffered write to the real output, in order; idempotent, and writes after a + * flush pass straight through live so late teardown warnings are never lost. Run this after + * the live fiber joins, not after the buffered fiber (which can finish first). */ readonly flush: Effect.Effect<void>; } /** - * An {@link Output} decorator that buffers `raw`/`rawBytes` (the only channels the shadow - * provisioning paths write to) and delegates everything else live. This is the hard guarantee - * that a concurrently provisioned shadow can never land a line between two of the live fiber's - * lines — in normal mode the buffer stays empty (a warm restore prints nothing), so this exists - * for the anomaly paths: cache warnings and cold-fallback baseline prints. - * - * Deliberately not covering writes that bypass `Output` entirely (`SUPABASE_SHADOW_DEBUG` timing - * lines and failure-path container-log dumps write straight to `process.stderr`). + * An {@link Output} decorator that buffers `raw`/`rawBytes` and delegates everything else live, + * so a concurrently provisioned shadow's writes can never land mid-line in the other fiber's + * live transcript. The buffer stays empty in the common case (a warm restore prints nothing) and + * only holds anomaly-path output (cache warnings, cold-fallback baseline prints); it does not + * cover writes that bypass `Output`, such as `SUPABASE_SHADOW_DEBUG` timing lines. */ export function bufferedShadowOutput(real: typeof Output.Service): BufferedShadowOutput { type BufferedWrite = diff --git a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.service.ts b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.service.ts index 6582b2d37c..2a713f5562 100644 --- a/apps/cli/src/commands/db/shared/pgdelta-next-shadow.service.ts +++ b/apps/cli/src/commands/db/shared/pgdelta-next-shadow.service.ts @@ -38,13 +38,9 @@ interface PgDeltaNextShadowShape { opts: PgDeltaNextShadowInput, ) => Effect.Effect<PgDeltaNextMigrationsShadow, DeclarativeShadowDbError, Scope.Scope>; /** - * Provisions the independent migrated and declarative shadows needed by a - * declarative plan. Concurrency is strategy-driven (see - * `pgdelta-next-shadow.plan.ts`): warm snapshots restore in parallel, - * a shared cold baseline is built once and handed off, and everything else - * runs sequentially — with the concurrent shapes buffering the declarative - * side's output so progress lines never interleave. Both shadows are removed - * when the current Effect scope closes. + * Provisions the independent migrated and declarative shadows needed by a declarative plan. + * Concurrency is strategy-driven (see `pgdelta-next-shadow.plan.ts`). Both shadows are + * removed when the current Effect scope closes. */ readonly provisionPlan: ( opts: PgDeltaNextShadowInput, diff --git a/apps/cli/src/commands/db/shared/pgdelta.errors.ts b/apps/cli/src/commands/db/shared/pgdelta.errors.ts index 2b5b547039..e090b1b88f 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.errors.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.errors.ts @@ -6,18 +6,15 @@ import { ErrorActionabilityId, } from "../../../shared/telemetry/error-actionability.ts"; -/** - * Setting up / connecting to / migrating the throwaway shadow database failed. - * Wraps the errors from `CreateShadowDatabase` / `ConnectShadowDatabase` / - * `SetupShadowDatabase` / `MigrateShadowDatabase` - * (`apps/cli-go/internal/db/diff/diff.go`). - */ +/** Setting up, connecting to, or migrating the throwaway shadow database failed. */ export class DeclarativeShadowDbError extends Data.TaggedError("DeclarativeShadowDbError")<{ readonly message: string; readonly docker?: "daemon"; - /** Recovery hint carried over from the underlying failure (e.g. a health-check - * timeout's exact image-removal command), so the seam surfaces the same - * actionable suggestion `db start` itself would render. */ + /** + * Recovery hint carried over from the underlying failure (e.g. a health-check timeout's + * exact image-removal command), so the seam surfaces the same actionable suggestion + * `db start` itself would render. + */ readonly suggestion?: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -28,10 +25,7 @@ export class DeclarativeShadowDbError extends Data.TaggedError("DeclarativeShado } /** - * Materializing the declarative export on disk failed. Byte-matches Go's - * `WriteDeclarativeSchemas` errors (`declarative.go:239`): - * `"failed to clean declarative schema directory: " + err` and - * `"unsafe declarative export path: " + path`. Shared by `db schema declarative + * Materializing the declarative export on disk failed. Shared by `db schema declarative * generate`/`sync` and `db pull --declarative`. */ export class DeclarativeWriteError extends Data.TaggedError("DeclarativeWriteError")<{ diff --git a/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts b/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts index e8cb0f617c..12e4736252 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.seam.integration.test.ts @@ -33,11 +33,9 @@ import { declarativeSeamLayer } from "./pgdelta.seam.layer.ts"; import { DeclarativeSeam } from "./pgdelta.seam.service.ts"; /** - * Integration coverage for the fully-native `declarativeSeamLayer` (CLI-1970) — - * `generate`/`sync`'s own integration tests stub `DeclarativeSeam` entirely - * (per its own service doc comment), so this file is the only place the real - * local-database bring-up composition gets exercised end-to-end, with a fake - * `DbConnection`/`DockerRun`. + * Integration coverage for the fully-native `declarativeSeamLayer`: `generate`/`sync`'s own + * tests stub `DeclarativeSeam` entirely, so this file is the only place the real local-database + * bring-up composition is exercised end-to-end, with a fake `DbConnection`/`DockerRun`. */ const alwaysReadyHttpClientLayer = Layer.succeed( diff --git a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts index 0c30f776d8..c2c75b0741 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.seam.layer.ts @@ -19,10 +19,9 @@ const shadowDockerCause = (stderr: string): { readonly docker: "daemon" } | Reco /** * Whether an underlying failure signals the Docker daemon is unreachable, across every tagged - * error class this seam composes over — `ShadowDbError.reason === "docker_daemon"`, - * `ImagePrepullError.reason === "docker_daemon"`, `LocalDbRunningError.daemonDown`, - * and every `*.docker === "daemon"` field. Checked structurally rather than per-tag so a - * new error class in the union doesn't silently drop its own daemon signal. + * error class this seam composes over. Checked structurally (`reason`/`docker`/`daemonDown` + * fields) rather than per-tag, so a new error class in the union can't silently drop its own + * daemon signal. */ function hasDaemonSignal(cause: { readonly message: string; @@ -39,10 +38,9 @@ function hasDaemonSignal(cause: { } /** - * Maps any failure from the native local-database bring-up stack (shadow create/setup, health - * checks, config loading) into the seam's own {@link DeclarativeShadowDbError}, carrying - * the underlying message. Every component error class in that stack declares `message: string`, - * so this accepts the whole union structurally rather than enumerating each tag. + * Maps any failure from the native local-database bring-up stack into the seam's own + * {@link DeclarativeShadowDbError}. Every component error class declares `message: string`, so + * this accepts the whole union structurally rather than enumerating each tag. */ export const toShadowDbError = (cause: { readonly message: string; @@ -59,8 +57,7 @@ export const toShadowDbError = (cause: { /** * Real `DeclarativeSeam`: fully native. `ensureLocalDatabaseStarted` shares the same - * `startLocalDatabase` bring-up `db start` uses; `ensureLocalPostgresImageCurrent` was - * already native (CLI-1956) and is unchanged here. + * `startLocalDatabase` bring-up `db start` uses. */ export const declarativeSeamLayer = Layer.effect( DeclarativeSeam, @@ -69,13 +66,9 @@ export const declarativeSeamLayer = Layer.effect( const spawner = yield* ChildProcessSpawner; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - // Captures every OTHER service `startLocalDatabase` needs internally (Output, - // RuntimeInfo, HttpClient, DbConnection, DockerRun, NetworkIdFlag, the - // `--experimental`/CliArgs global-flag machinery, …) into a plain `Context` so each closure - // below can `Effect.provideContext` it and satisfy `DeclarativeSeamShape`'s - // `Effect<T, E>` (no leftover requirements) without hand-enumerating every transitive - // dependency — mirrors `command-platform-api-factory.layer.ts`'s identical - // capture-and-provide shape. + // Captures every service `startLocalDatabase` needs into a plain `Context`, so each + // closure below can `Effect.provideContext` it and satisfy `DeclarativeSeamShape` without + // hand-enumerating every transitive dependency. const context = yield* Effect.context<StartLocalDatabaseDeps>(); return DeclarativeSeam.of({ @@ -93,9 +86,8 @@ export const declarativeSeamLayer = Layer.effect( new DeclarativeShadowDbError({ message: cause.message, ...(cause.daemonDown === true ? { docker: "daemon" as const } : {}), - // Same propagation as the start-failure catch below: the inspect error's - // Docker-install recovery text (Go's `utils.CmdSuggestion`) must survive the - // seam, or the normalizer falls back to its generic debug hint. + // The inspect error's Docker-install recovery text must survive the seam, or + // the normalizer falls back to its generic debug hint. ...(cause.suggestion !== undefined ? { suggestion: cause.suggestion } : {}), }), ), diff --git a/apps/cli/src/commands/db/shared/pgdelta.write.ts b/apps/cli/src/commands/db/shared/pgdelta.write.ts index 696d46b11e..1acd1fa86e 100644 --- a/apps/cli/src/commands/db/shared/pgdelta.write.ts +++ b/apps/cli/src/commands/db/shared/pgdelta.write.ts @@ -74,21 +74,11 @@ const readManagedDeclarativeSqlFiles = Effect.fnUntraced(function* ( }); /** - * Materializes pg-delta declarative export output under the declarative dir - * using the export manifest's ownership and file classification: only stale - * files owned by the previous export are removed, unchanged files are not - * rewritten, unmanaged files are preserved, and the reserved root `_custom/` - * tree is never read as managed output or deleted. Returns which unmanaged - * files that preservation kept, so the caller can warn (see - * {@link warnPreservedUnmanagedDeclarativeFiles}). - * - * Go also updates `[db.migrations] schema_paths` afterwards, but only when - * pg-delta is *disabled* in config (`if utils.IsPgDeltaEnabled() { return nil }`). - * `db schema declarative generate/sync` force-enable pg-delta, so that branch is - * unreachable for them; `db pull --declarative` does NOT force-enable it, so the - * pull caller invokes `updateDeclarativeSchemaPathsConfig` (below) when - * config pg-delta is disabled. Keeping the config edit at the caller leaves this - * writer a pure file-materializer shared unchanged by generate/sync. + * Materializes pg-delta declarative export output under the declarative dir: only stale files + * owned by the previous export are removed, unchanged files are untouched, unmanaged files are + * preserved, and `_custom/` is never read as managed output or deleted. Returns which unmanaged + * files were preserved (see {@link warnPreservedUnmanagedDeclarativeFiles}). Never touches + * `[db.migrations] schema_paths`; `db pull --declarative` handles that separately. */ export const writeDeclarativeSchemas = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, @@ -140,12 +130,10 @@ export const writeDeclarativeSchemas = Effect.fnUntraced(function* ( ? { previouslyOwned: new Set(previousManifest.files) } : {}), }); - // With no manifest there is no ownership record, so nothing can be classified as - // stale: every pre-existing file the export does not itself replace survives. The - // typical producer of such a directory is the OLD legacy full-wipe exporter, whose - // files still feed future plans (`LoadPgDeltaSqlFiles` walks the whole tree), - // so the result is a silent partial merge behind an "overwrite existing files" - // prompt. Report them and let the handler say so out loud. + // With no manifest there is no ownership record, so nothing can be classified as stale: + // every pre-existing file the export doesn't itself replace survives, producing a silent + // partial merge behind an "overwrite existing files" prompt. Report them so the handler + // can say so out loud. const proposedNames = new Set(proposed.map((file) => file.name)); const preservedUnmanagedFiles = previousManifest?.files === undefined @@ -207,22 +195,18 @@ export const writeDeclarativeSchemas = Effect.fnUntraced(function* ( }); /** - * Go's `declarative.Generate` / `pull.go`'s written-to line, printed by all three - * declarative write paths (`generate`, `pull --declarative`, `sync`'s bootstrap). - * Every caller passes the relative `GetDeclarativeDir()` value, never the resolved - * absolute dir (`generate` and `sync` route through this helper; `pull` still - * inlines the same template) — this pins the shared message text in one place. + * The written-to line, printed by all three declarative write paths (`generate`, + * `pull --declarative`, `sync`'s bootstrap). Every caller passes the relative declarative + * dir, never the resolved absolute path, so this pins the shared message text in one place. */ export const declarativeSchemaWrittenLine = (dir: string): string => `Declarative schema written to ${bold(dir)}\n`; /** - * The manifest-less-merge warning text. The next writer only prunes files an - * existing `.pgdelta-export.json` claimed, so a directory produced by the old - * legacy full-wipe exporter (no manifest) keeps every file the new export does not - * itself replace — even though the prompt the user just answered said existing - * files may be deleted. Name the survivors and give the one instruction that - * actually produces a clean tree. + * The manifest-less-merge warning text: without an export manifest, nothing can be classified + * as stale, so a pre-existing declarative directory keeps every file the new export doesn't + * itself replace — even though the "overwrite existing files" prompt implied a clean rewrite. + * Names the survivors and gives the one instruction that produces a clean tree. */ const preservedUnmanagedDeclarativeFilesWarning = ( dir: string, @@ -253,22 +237,14 @@ export const warnPreservedUnmanagedDeclarativeFiles = Effect.fnUntraced(function ); }); -// Go's `schemaPathsPattern` (`internal/db/declarative/declarative.go:59`): -// `(?s)\nschema_paths = \[(.*?)\]\n`. The `(?s)` (dotall) maps to `[\s\S]`, and -// the capture group is unused (Go uses `ReplaceAllLiteral`). +// `(?s)` (dotall) is equivalent to `[\s\S]`; the capture group is unused. const SCHEMA_PATHS_PATTERN = /\nschema_paths = \[[\s\S]*?\]\n/g; /** - * Ports Go's `updateDeclarativeSchemaPathsConfig` (`declarative.go:276-304`): a - * raw-text replace-or-append of `[db.migrations] schema_paths` in - * `supabase/config.toml`, pointing it at the `supabase/`-relative declarative dir. - * This is a literal byte-edit (NOT a TOML re-serialize), so it preserves comments - * and formatting exactly like Go — reproduce the regex and the literal block - * rather than "doing the right TOML thing". - * - * `resolvedDeclarativeDir` is the resolved declarative dir (Go's - * `GetDeclarativeDir()`, e.g. `supabase/schemas`); the leading `supabase/` is - * trimmed for the written value (Go's `strings.TrimPrefix`). + * A raw-text replace-or-append of `[db.migrations] schema_paths` in `supabase/config.toml`, + * pointing it at `resolvedDeclarativeDir` with its leading `supabase/` trimmed. This is a + * literal byte-edit, not a TOML re-serialize, so the rest of the file's comments and + * formatting are preserved exactly. */ export const updateDeclarativeSchemaPathsConfig = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, @@ -280,13 +256,11 @@ export const updateDeclarativeSchemaPathsConfig = Effect.fnUntraced(function* ( const relative = normalized.startsWith("supabase/") ? normalized.slice("supabase/".length) : normalized; - // Go's literal replacement block (`declarative.go:278-284`): leading newline, - // two-space indent, trailing comma inside the array, trailing newline. const block = `\nschema_paths = [\n "${relative}",\n]\n`; const configPath = path.join(workdir, "supabase", "config.toml"); const existing = yield* fs.readFileString(configPath).pipe( Effect.catchTag("PlatformError", (error) => - // Go tolerates a missing config (`os.ErrNotExist`); other read errors abort. + // A missing config file is tolerated; other read errors abort. error.reason._tag === "NotFound" ? Effect.succeed("") : Effect.fail( @@ -296,8 +270,8 @@ export const updateDeclarativeSchemaPathsConfig = Effect.fnUntraced(function* ( ), ), ); - // Use a replacer function so `$` in the path/value is never interpreted as a - // replacement pattern (Go's `ReplaceAllLiteral` semantics). + // A replacer function, not a string, so a literal "$" in the path/value can't be + // misread as a replacement pattern. const replaced = existing.replace(SCHEMA_PATHS_PATTERN, () => block); const next = replaced.includes(block) ? replaced : `${existing}\n[db.migrations]${block}`; yield* fs diff --git a/apps/cli/src/commands/db/shared/pooler-fallback.ts b/apps/cli/src/commands/db/shared/pooler-fallback.ts index 00fb8668f7..69381230a6 100644 --- a/apps/cli/src/commands/db/shared/pooler-fallback.ts +++ b/apps/cli/src/commands/db/shared/pooler-fallback.ts @@ -18,14 +18,11 @@ interface PoolerFallbackResult { } /** - * Go's `PoolerFallbackConfig` host gate (`internal/db/dump/pooler_fallback.go:82-96`): - * a dump/diff is only rerouted through the pooler when it was a `--linked` run against - * a *direct* Supabase DB host (`db.<ref>.<projectHost>`, never local/pooler). - * `ProjectRefFromDirectDbHost` already excludes local, and `PoolerFallbackEligible == - * linked` makes local impossible — the `!isLocal` check is belt-and-braces. Shared by - * the result-based dump/pull retry ({@link runWithPoolerFallback}) and the - * error-based diff retry in `db pull`; each ANDs in its own IPv6 classification of the - * relevant stderr/error. + * A dump/diff is only rerouted through the pooler when it's a `--linked` run against a + * direct Supabase DB host (`db.<ref>.<projectHost>`, never local/pooler); the `!isLocal` + * check is belt-and-braces since a direct-host match already implies non-local. Shared by + * the result-based dump/pull retry ({@link runWithPoolerFallback}) and the error-based diff + * retry in `db pull`, each ANDing in its own IPv6 classification of the relevant error. */ export const isDirectLinkedHost = (params: { readonly connType: DbConnType; @@ -39,25 +36,10 @@ export const isDirectLinkedHost = (params: { /** * Container-level IPv6 → IPv4-pooler retry shared by `db dump` and `db pull`'s initial - * remote-schema dump — the single port of Go's `RunWithPoolerFallback` - * (`internal/db/dump/pooler_fallback.go:31-66`). Runs the first attempt's `result` - * through the host gate + IPv6 classification; when eligible and a pooler connection - * resolves, emits Go's warning and retries once via `runWithConn`, returning the - * retry's result. Otherwise returns the original `result` unchanged, so the caller's - * failure classification reads the correct stderr in both cases (Go returns the retry - * error on a failed retry, the original error on no fallback). - * - * The one load-bearing per-command difference is `reprintOnRetry`: Go prints the - * "Dumping ..." line *inside* `db dump`'s run closure, so it re-prints on the retry - * (`dump.go:39-45`); `db pull` prints it once *before* `RunWithPoolerFallback` - * (`pull.go:146`), so it does not. Callers pass the re-print effect (`db dump`) or - * `Effect.void` (`db pull`). - * - * Two Go behaviours are intentionally *not* reproduced here (pre-existing TS gaps, - * unchanged by this hoist): the `resetOutput` truncation between attempts (each caller - * (re)truncates its own file per attempt / streams to stdout) and the - * `SuggestIPv6Pooler` URL enrichment on the no-fallback hint (callers fall back to the - * generic `ipv6Suggestion`). + * remote-schema dump. Runs the first attempt's `result` through the host gate and IPv6 + * classification; when eligible and a pooler connection resolves, emits the fallback + * warning and retries once via `runWithConn`. Otherwise returns the original `result` + * unchanged, so the caller's failure classification always reads the correct stderr. */ export const runWithPoolerFallback = Effect.fnUntraced(function* <E, RRun>(params: { /** The first attempt's result; returned unchanged when no fallback fires. */ @@ -69,12 +51,9 @@ export const runWithPoolerFallback = Effect.fnUntraced(function* <E, RRun>(param /** `cliSettings.projectHost` — the direct-DB-host suffix (`supabase.co`/`.red`). */ readonly projectHost: string; /** - * Resolves the IPv4 pooler connection, already error-neutralised to `None` (the - * caller pipes `resolver.resolvePoolerFallback(...)` through `orElseSucceed(None)`) — - * Go treats any fallback-resolution error as "no fallback" and surfaces the original - * dump failure. A **thunk**: Go only resolves the pooler (creating a temp role) once - * the error is eligible, so this is invoked only after the gate passes, never on the - * happy path. + * Resolves the IPv4 pooler connection, already error-neutralized to `None` (a resolution + * failure means "no fallback"). A thunk, since resolving the pooler creates a temp role — + * it only runs once the eligibility gate passes, never on the happy path. */ readonly resolvePooler: () => Effect.Effect<Option.Option<PgConnInput>>; /** Re-runs the dump against a connection (`db dump`/`db pull` each adapt their runner). */ diff --git a/apps/cli/src/commands/db/shared/shadow-source.ts b/apps/cli/src/commands/db/shared/shadow-source.ts index 20a3ffcdf1..bb8f8c3a9e 100644 --- a/apps/cli/src/commands/db/shared/shadow-source.ts +++ b/apps/cli/src/commands/db/shared/shadow-source.ts @@ -1,11 +1,9 @@ /** - * The composed shadow-provisioning shape `db diff`/`db pull` actually call: - * {@link prepareShadowSource} builds on `shared/db-bootstrap/shadow-database.ts`'s - * lower-level primitives (create → health-wait → platform baseline → migrations replay) and - * adds the migra `--target-local` declarative-schema branch, which applies declarative files - * to a second database on the same shadow container instead of diffing the user's local DB - * directly. Schema selection deliberately plays no part in shadow provisioning — the `--schema` - * flag only scopes the diff itself, never what the shadow contains. + * The composed shadow-provisioning shape `db diff`/`db pull` call: {@link prepareShadowSource} + * builds on `shared/db-bootstrap/shadow-database.ts`'s primitives and adds the migra + * `--target-local` declarative-schema branch, which applies declarative files to a second + * database on the same shadow container instead of diffing the user's local DB directly. + * The `--schema` flag only scopes the diff itself, never what the shadow contains. */ import { Effect, Result, type FileSystem, type Path } from "effect"; @@ -49,16 +47,12 @@ type Spawner = ChildProcessSpawner["Service"]; export type { ShadowSourceResult }; -// `shadowRunInputFromLocalContainerInputs` used to be re-exported here (promoted to -// `shared/db-bootstrap/shadow-database.ts` when `migration squash` became this builder's -// third consumer, CLI-1969). Callers import it from there directly — no re-export shim needed. - export interface PrepareShadowSourceInput<E> extends ShadowSetupInput<E> { - /** Go's `utils.IsLocalDatabase(config)` — the only target-derived input the shadow prep needs. */ + /** The only target-derived input the shadow prep needs. */ readonly targetLocal: boolean; /** Selects the shadow baseline and whether a local target may use the migra declarative override. */ readonly migrationMode?: "legacy" | "pgdelta-next"; - /** `db.migrations.schema_paths`, RAW (unresolved) — Go's `Config.Db.Migrations.SchemaPaths` pre-`config.go:976-979`-resolution form. */ + /** `db.migrations.schema_paths`, unresolved (raw config value). */ readonly schemaPaths: ReadonlyArray<string>; readonly pgDelta: PgDeltaTomlConfig; } @@ -72,35 +66,11 @@ export type PrepareShadowSourceError = | ImagePrepullError; /** - * Port of Go's `PrepareShadowSource` (`apps/cli-go/internal/db/diff/shadow.go:37-91`): - * readiness-wait against an already-`createShadowDatabase`-created shadow (a direct - * connect probe, `waitForShadowReady` — NOT the Docker-health gate the long-running `db` - * container uses, which the shadow's own 10s-interval healthcheck cannot satisfy until well - * after Postgres is connectable; see that function's own doc comment) -> - * `MigrateShadowDatabase` (platform baseline + local migrations + the `contrib_regression` - * template database) -> build the diff-source config -> for legacy local targets, the - * declarative-schema override branch. Pg-delta next always compares that migrations shadow - * directly to the live target. - * - * Deliberately does NOT call `createShadowDatabase` (`shadow-database.ts`) itself, and - * no longer wraps its own body in `Effect.onError` cleanup — the caller does both, structuring - * this function as the `use` phase of an `Effect.acquireUseRelease` whose `acquire` is - * `createShadowDatabase` and whose `release` is `removeShadowDatabase` (see - * `diff.handler.ts`/`pull.handler.ts`'s call sites). An earlier shape passed THIS WHOLE - * function (create -> health-wait -> migrate -> declarative-apply) as `acquire` instead — - * matching Go's `ok`-sentinel + `defer` pattern for the "remove on any failure after - * creation" case, but Effect's `acquireUseRelease` runs `acquire` inside an - * `uninterruptibleMask` with no `restore` (`uninterruptibleMask(restore => - * flatMap(acquire, a => onExitPrimitive(restore(use(a)), ...)))`), so passing all of this - * function as `acquire` made the ENTIRE health-wait/migration-replay/declarative-apply - * sequence uninterruptible too — a SIGINT during any of it (each of which can run for - * seconds to minutes) was silently swallowed until the whole sequence finished on its own, - * unlike Go, which threads one cancellable `ctx` through every one of these calls. Moving - * creation out to the (brief, Docker-API-bound) `acquire` and keeping this sequence as the - * `use` phase restores that parity: a SIGINT here now interrupts immediately, same as Go's - * ctx cancellation, while `removeShadowDatabase` still runs as the `release` finalizer - * regardless of how `use` exits — success, a typed failure, or an interrupt (review: - * PRRT_kwDOErm0O86XMrID). + * The `use` phase of an `Effect.acquireUseRelease` whose `acquire` creates the shadow and + * `release` removes it (see the `diff`/`pull` handlers). Health-wait, migration replay, and + * the declarative-schema override for legacy local targets all run here, not in `acquire` — + * `acquireUseRelease` runs `acquire` under an uninterruptible mask, so nesting them there + * would swallow a SIGINT until the whole sequence finished. */ export const prepareShadowSource = <E>( spawner: Spawner, @@ -127,11 +97,10 @@ export const prepareShadowSource = <E>( image: input.image, }); - // `handle` doubles as the baseline state: on a warm shadow-cache hit the cluster it carries - // already holds the platform baseline (so only the template database + user migrations run), - // and on a cache-enabled cold provision it carries the snapshot step that runs between the - // two — see `shadow-cache.ts`/`ShadowBaselineState`. An uncached acquire hands over the - // always-cold state, which reproduces today's sequence exactly. + // `handle` doubles as the baseline state: on a warm shadow-cache hit it already holds the + // platform baseline (so only the template database + user migrations run); on a + // cache-enabled cold provision it carries the snapshot step that runs between the two — see + // `shadow-cache.ts`/`ShadowBaselineState`. An uncached acquire is always-cold. const migrateShadow = input.migrationMode === "pgdelta-next" ? migrateNextShadowDatabase : migrateShadowDatabase; yield* migrateShadow( @@ -174,23 +143,16 @@ export const prepareShadowSource = <E>( } satisfies ShadowSourceResult; }); -/** Go's `pkg/config.hasGlobMeta` (`config.go:211-213`) — `*?[` only, NOT `io/fs.hasMeta`'s broader set (which also counts `\`). */ +/** Glob metacharacters recognized here: `*?[` only — not `\`. */ function hasConfigGlobMeta(pattern: string): boolean { return /[*?[]/u.test(pattern); } /** - * Port of Go's `Glob.SQLFiles(fsys, WithSkipEmptyGlobs(), WithErrorOnAllSkippedGlobs())` - * (`apps/cli-go/pkg/config/config.go:119-192`), the exact option combination - * `loadDeclaredSchemas`'s `schema_paths` branch uses. Deliberately separate from - * `migrate-and-seed.ts`'s `resolveSchemaPathFiles` (Go's SAME `Glob.SQLFiles` - * with ZERO options, `applySchemaFiles`) — the two option sets are genuinely different: a - * per-pattern "no files matched" is unconditionally an error here UNLESS the pattern - * contains a glob metacharacter (`skipEmptyGlobs`), in which case it's only converted back - * into an error when EVERY pattern ended up skipped and the combined result is still empty - * (`errorOnAllSkippedGlobs`) — and, unlike `applySchemaFiles`'s caller (which swallows any - * collected errors once `len(declared) > 0`), `loadDeclaredSchemas`'s caller propagates - * ANY error unconditionally, regardless of whether other patterns matched. + * A pattern with no matches is an error, unless it contains a glob metacharacter, in which + * case it's merely skipped — and the skip becomes an error too if every pattern in this call + * was skipped and the combined result is still empty. Any resulting error always propagates, + * unlike `resolveSchemaPathFiles`'s caller, which swallows errors once some files matched. */ function globDeclaredSchemaPaths( fs: FileSystem.FileSystem, @@ -205,62 +167,26 @@ function globDeclaredSchemaPaths( const skipped: Array<string> = []; for (const rawPattern of patterns) { - // Go's `config.go:976-979`: a non-empty, non-absolute `schema_paths` entry is resolved - // under `supabase/` (via `path.Join`, which also cleans the result) at config-load - // time — `resolveSeedSqlPath` already implements the identical resolution Go - // applies to `[db.seed] sql_paths`, the same shape. Go's `Glob.files` then normalizes - // to forward slashes immediately before globbing (`fs.Glob(fsys, - // filepath.ToSlash(pattern))`, `config.go:145`) — an absolute Windows entry such as - // `C:\repo\schema.sql` must become `C:/repo/schema.sql` before `pathMatch`/ - // `globPattern` (which only recognize `/` as a segment separator) ever see it. - // Mirrors `seed-ops.ts`'s identical `toSlash` step for `[db.seed] sql_paths`. - // - // Gated on `path.sep !== "/"`, mirroring BOTH `cleanSchemaPath` below AND - // `globPattern`'s own internal `path.sep === "/" ? pattern : ...` normalization - // (`glob.ts:68`) — `filepath.ToSlash` is a byte-for-byte no-op on POSIX (only - // Windows's `filepath.Separator` is `\`), so converting unconditionally here previously - // fed `globPattern` an already-slashed pattern on POSIX too, silently discarding - // any `\` a caller wrote as a `path.Match` escape. Verified empirically with a scratch - // `path.Match` probe on darwin: `path.Match("foo\\*.sql", "foo*.sql")` (Go's real, - // unconverted-on-POSIX behavior) is `true` — a literal `\*` escapes the metacharacter, - // matching a file literally named `foo*.sql` — while this file's OLD unconditional - // `.replaceAll("\\", "/")` turned the same pattern into `foo/*.sql`, which instead - // searches a `foo/` subdirectory and never matches the literal `foo*.sql` file Go finds. - // The same probe also caught a second-order bug: unconditionally rewriting `\[` (a valid - // escaped literal `[`) into `/[` turns it into an unterminated character class, so a - // pattern that is well-formed for Go's `path.Match` was spuriously rejected as malformed - // here. Leaving `\` untouched on POSIX lets `pathMatch`'s own escape handling (used - // by both this and `globPattern`) reproduce Go's semantics directly — no gap in - // that shared module needs fixing first. + // Non-absolute entries resolve under `supabase/` first. Slashes are normalized only + // when `path.sep !== "/"` (Windows): on POSIX, `\` is a literal `pathMatch`/`globPattern` + // escape character, so converting it unconditionally would silently break a pattern like + // `foo\*.sql` (matches the literal file `foo*.sql`) by turning it into `foo/*.sql` (a + // subdirectory glob). const rawResolved = resolveSeedSqlPath(path, rawPattern); - // Go's `Glob.files` (`config.go:145`) only ever ToSlashes the pattern for the internal - // `fs.Glob` CALL itself — `hasGlobMeta`, the `skipped` slice, and both "no files matched - // pattern" error sites all keep using the loop's own `pattern` variable, which is NEVER - // ToSlash'd (`config.go:143-154`). So on Windows, an absolute entry like - // `C:\schemas\*.sql` must glob-match as `C:/schemas/*.sql` but still ERROR/report as - // `C:\schemas\*.sql` — `matchPattern` (slashed) feeds `pathMatch`/`globPattern` - // below; `rawResolved` (untouched) feeds every diagnostic (`skipped`/`problems`) so stderr - // stays byte-compatible with Go's un-ToSlash'd `pattern`. + // `matchPattern` (slash-normalized) feeds the glob/match calls below; diagnostics always + // use `rawResolved` (untouched), so an absolute Windows entry like `C:\schemas\*.sql` + // still reports with its original backslashes even though it matched via forward slashes. const matchPattern = path.sep === "/" ? rawResolved : rawResolved.replaceAll("\\", "/"); if (pathMatch(matchPattern, "").badPattern) { problems.push(`failed to glob files: ${BAD_PATTERN_MESSAGE}`); continue; } - // Go's `io/fs.Glob` never matches an empty pattern: its literal (no-metacharacter) - // branch calls `Stat(fsys, "")`, which fails on a real OS filesystem (there is no file - // whose path is the empty string), so `Glob` returns zero matches — verified empirically - // against the real `config.Glob.SQLFiles` (`apps/cli-go/pkg/config/config.go:119-133`) - // fed pattern `""` against an `afero.NewOsFs()`: it reports `no files matched pattern: `, - // the same as any other non-matching literal pattern. `globPattern`'s own - // literal-pattern branch, however, resolves an empty pattern to the WORKDIR itself - // (`resolveUnderWorkdir(path, workdir, "")` is the workdir, which always exists), - // so without this guard an empty `schema_paths` entry would recurse into and collect - // every `.sql` file in the entire project instead of matching nothing. Short-circuit - // before calling it, rather than fixing `globPattern` itself, since that shared - // helper (`glob.ts`) also backs `[db.seed] sql_paths` (`seed.ts`) and - // `migrate-and-seed.ts`, both out of scope for this PR. - // Go's `sort.Strings(matches)` (`config.go:154`) — byte order, not JS's default UTF-16 - // code-unit order; see `compareUtf8Bytes`'s own doc comment. + // An empty pattern must short-circuit to zero matches: `globPattern`'s literal-pattern + // branch resolves `""` to the workdir itself, so without this guard an empty + // `schema_paths` entry would collect every `.sql` file in the project instead of + // matching nothing. + // Byte order, not JS's default UTF-16 code-unit order; see `compareUtf8Bytes`'s own doc + // comment. const matches = matchPattern.length === 0 ? [] @@ -270,11 +196,9 @@ function globDeclaredSchemaPaths( skipped.push(rawResolved); continue; } - // Go always resolves `SchemaPaths` (`config.go:976-979`) before this error can fire - // (resolution happens at config-load time, ahead of any glob), so the error must show - // the RESOLVED, `supabase/`-prefixed pattern, matching the all-skipped-globs branch - // below — not the raw, caller-supplied one. Still `rawResolved`, not `matchPattern`: - // see this loop's own doc comment above on why Go's error text is never ToSlash'd. + // The error shows the resolved, `supabase/`-prefixed pattern (not the raw + // caller-supplied one), and keeps `rawResolved`'s original separators rather than + // `matchPattern`'s slashes. problems.push(`no files matched pattern: ${rawResolved}`); continue; } @@ -292,28 +216,19 @@ function globDeclaredSchemaPaths( } continue; } - // Go's `walkMatchedDir` (`pkg/config/config.go:194-211`) propagates ANY `fs.WalkDir` - // error (e.g. a permission-denied or I/O-erroring subdirectory) as `failed to walk - // matched directory: <err>` — it does NOT treat an unreadable directory as an empty - // match set, since silently doing so can omit declared schemas and compare a - // local-target diff against the wrong target. `walkSqlFiles` (`glob.ts`) - // also matches Go's byte-sorted, no-follow-symlink walk semantics — see its own doc - // comment. + // A walk error propagates rather than being treated as an empty match set — silently + // ignoring an unreadable subdirectory could omit a declared schema and compare a + // local-target diff against the wrong target. const sqlRelativeResult = yield* walkSqlFiles(fs, absMatch, "").pipe(Effect.result); if (Result.isFailure(sqlRelativeResult)) { problems.push(`failed to walk matched directory: ${sqlRelativeResult.failure.message}`); continue; } for (const relative of sqlRelativeResult.success) { - // `io/fs.WalkDir`'s own path.Join(dir, entry.Name()) (`io/fs/walk.go`'s `walkDir`) - // cleans redundant separators before `walkMatchedDir`'s callback ever records the - // child path — so a `match` that retains a trailing separator (e.g. a directory - // `schema_paths` entry configured as `"supabase/schemas/"`) never reaches Go's dedup - // `set` as a double-slashed key. A raw template join skips that implicit clean and - // can let the same file be recorded twice — once here, once via a literal - // `schema_paths` entry for the file itself — bypassing `seen` and double-applying the - // SQL. `cleanSchemaPath` (below) performs the equivalent slash-segment - // collapsing and is reused here rather than duplicated (review: PRRT_kwDOErm0O86XAlIr). + // `cleanSchemaPath` collapses a trailing separator in `match` (e.g. a directory entry + // configured as `"supabase/schemas/"`), so a file reached both via this walk and via + // its own literal `schema_paths` entry dedupes to the same key instead of + // double-applying. const relativeToWorkdir = cleanSchemaPath(`${match}/${relative}`); if (!seen.has(relativeToWorkdir)) { seen.add(relativeToWorkdir); @@ -334,37 +249,11 @@ function globDeclaredSchemaPaths( } /** - * Port of Go's `afero.Walk` + regular-`.sql`-file filter + `sort.Strings` (the shared tail of - * both `loadDeclaredSchemas`'s pg-delta-declarative-dir and `SchemasDir` branches, - * `apps/cli-go/internal/db/diff/diff.go:65-76,86-96`). `walkSqlFiles` (`glob.ts`) - * also matches Go's byte-sorted, no-follow-symlink walk semantics — see its own doc comment. - * - * The walk ROOT itself is checked for being a symlink here, unlike `globDeclaredSchemaPaths`'s - * directory branch (Go's `fs.WalkDir`, whose own doc comment says "if root itself is a symbolic - * link, its target will be walked" — so a symlinked `schema_paths` match is deliberately followed, - * matching `walkSqlFiles`'s existing never-checks-its-own-root behavior). `afero.Walk` is the - * opposite: its `Walk(fs, root, walkFn)` entry point `Lstat`s the root BEFORE ever calling - * `walkFn`, so a symlinked root is treated as a non-directory and produces zero files silently, - * never descending into the target — verified against `afero`'s own source (`path.go`'s - * `Walk`/`lstatIfPossible`). The PRECEDING `fs.stat`-based existence check in - * `loadDeclaredSchemas` (which follows symlinks, matching Go's `afero.DirExists` — also - * `fs.Stat`-based) can't substitute for this: existence and walkability are different checks in - * Go, and only the latter uses `Lstat`. - * - * Paths are joined with the injected `Path` service (not a literal `/` template) so a symlink-free - * result matches Go's own `filepath.Join`-built path on every platform — on Windows this yields - * native backslashes (Go's `afero.Walk` never calls `filepath.ToSlash` on this branch, unlike - * `walkMatchedDir`'s `schema_paths` branch, which does), and `path.join` normalizes ANY `/` - * `walkSqlFiles`'s own relative-path construction produced internally, not just the outer - * `dirRel`/`relative` join (verified: `path.win32.join("supabase/database", - * "sub/dir/file.sql")` returns `"supabase\\database\\sub\\dir\\file.sql"`, not a mixed-separator - * string) — on POSIX this is a no-op (`path.posix.join` is byte-identical to the old template). - * - * `errorPrefix` lets the two callers preserve Go's own DIFFERENT wrapping messages for the same - * walk failure: the pg-delta declarative-dir branch reports `"failed to walk declarative dir: - * %w"` while the `supabase/schemas` fallback reports `"failed to walk dir: %w"` - * (`apps/cli-go/internal/db/diff/diff.go:65-76,86-96` — same walk, genuinely different prefix - * per source), so stderr still identifies which configured source failed. + * Shared walk tail for both `loadDeclaredSchemas` sources: regular `.sql` files only, + * byte-sorted (see `walkSqlFiles`'s own doc comment). Unlike `globDeclaredSchemaPaths`'s + * directory branch, the walk root itself is checked for being a symlink and treated as + * empty if so — this walk never follows a symlinked root. Paths are joined with the + * injected `Path` service so results use native separators on every platform. */ function walkSqlFilesSorted( fs: FileSystem.FileSystem, @@ -390,10 +279,9 @@ function walkSqlFilesSorted( } /** - * Port of Go's `loadDeclaredSchemas` (`apps/cli-go/internal/db/diff/diff.go:52-101`): a - * three-source priority ladder — `db.migrations.schema_paths` (when non-empty) -> - * pg-delta's declarative dir (when `[experimental.pgdelta] enabled` AND the dir exists) -> - * `supabase/schemas` (when it exists) -> `[]`. Each source is `sort.Strings`-ordered. + * A three-source priority ladder: `db.migrations.schema_paths` (when non-empty), then + * pg-delta's declarative dir (when `[experimental.pgdelta] enabled` and the dir exists), then + * `supabase/schemas` (when it exists), then `[]`. Each source is byte-sorted. */ export function loadDeclaredSchemas( fs: FileSystem.FileSystem, @@ -409,9 +297,8 @@ export function loadDeclaredSchemas( if (pgDelta.enabled) { const declDirRel = resolveDeclarativeDir(path, pgDelta); const declDirAbs = resolveUnderWorkdir(path, workdir, declDirRel); - // Go's `afero.DirExists` (`diff.go:63`) — a path that exists but is a regular file is - // "not a directory" (`err == nil && exists` is false), not an error, so it falls through - // to the `supabase/schemas` source below rather than being walked as a directory. + // A path that exists but is a regular file is treated as "not a directory," not an + // error, so it falls through to the `supabase/schemas` source below. const isDeclDir = yield* fs.stat(declDirAbs).pipe( Effect.map((info) => info.type === "Directory"), Effect.orElseSucceed(() => false), @@ -428,9 +315,8 @@ export function loadDeclaredSchemas( } const schemasDirRel = "supabase/schemas"; const schemasDirAbs = resolveUnderWorkdir(path, workdir, schemasDirRel); - // Same `afero.DirExists` semantics as above (`diff.go:80`): a missing path or a path that - // exists but isn't a directory both resolve to "no declared schemas" (`[]`), not an error — - // only a genuine stat failure (permission denied, I/O error) propagates. + // A missing path or a non-directory path both resolve to "no declared schemas" (`[]`), + // not an error — only a genuine stat failure propagates. const isSchemasDir = yield* fs.stat(schemasDirAbs).pipe( Effect.matchEffect({ onFailure: (cause) => @@ -450,14 +336,11 @@ export function loadDeclaredSchemas( } /** - * Windows-only sibling of {@link cleanSchemaPath}'s segment cleaner: the length of the - * leading "volume" a Windows path can carry, mirroring Go's `volumeNameLen` - * (`internal/filepathlite/path_windows.go`) for the two shapes realistic in a `schema_paths` - * config value — a drive letter (`C:...`, length 2) and a UNC share (`//host/share`, length - * through the second separator, Go's `uncLen`). Deliberately does NOT port Go's `\\.\`/`\\?\`/ - * `\??\` device-path branches (`\\.\C:\...`, Root Local Device paths) — not realistic values - * for this field, and porting them would add meaningful complexity for no reachable parity - * benefit. `path` is already backslash-normalized to `/` by the caller. + * Windows-only sibling of {@link cleanSchemaPath}'s segment cleaner: the length of the leading + * "volume" a Windows path can carry — a drive letter (`C:...`, length 2) or a UNC share + * (`//host/share`, length through the second separator). Device-path forms (`\\.\`, `\\?\`) + * aren't realistic `schema_paths` values, so they're not handled. `path` is already + * backslash-normalized to `/` by the caller. */ function windowsVolumeLen(path: string): number { if (path.length >= 2 && path[1] === ":") return 2; @@ -473,24 +356,11 @@ function windowsVolumeLen(path: string): number { } /** - * Go's `cleanSchemaPath` (`apps/cli-go/internal/db/diff/diff.go:117-119`): - * `filepath.ToSlash(filepath.Clean(path))`. `filepath.Clean`/`ToSlash` only treat `\` as a path - * separator on the Windows build of the Go CLI (`filepath.Separator == '\\'` there) — on every - * POSIX build (darwin/linux, what this TS binary stands in for on those hosts) a backslash is - * just a literal filename character that survives untouched. Verified empirically: - * `filepath.ToSlash(filepath.Clean(\`supabase/foo\bar\`))` compiled for `GOOS=darwin` returns - * `supabase/foo\bar`, not `supabase/foo/bar`. Gate the separator-normalization on the host - * platform so this matches whichever Go build this TS binary is standing in for. - * - * On Windows, `filepath.Clean` never cleans INTO a leading volume (`internal/filepathlite/ - * path_windows.go`'s `volumeNameLen`/`Clean`) — a UNC host+share (or a drive letter) survives - * verbatim, including its doubled leading separator for UNC, through `ToSlash`. Split it off - * with {@link windowsVolumeLen} before the segment-cleanup loop below, which would - * otherwise treat a UNC path's two leading empty segments the same as any other redundant - * separator and collapse `//host/share` down to `/host/share` — verified empirically against - * a standalone extraction of Go's own windows `Clean`/`ToSlash` source, run natively (review: - * PRRT_kwDOErm0O86W2tRk): `filepath.ToSlash(filepath.Clean(\`\\server\share\schemas\`))` - * compiled for `GOOS=windows` returns `//server/share/schemas`, not `/server/share/schemas`. + * Backslash is a path separator only on Windows; on POSIX it's a literal filename character, + * so separator normalization is gated on the host platform. A UNC/drive-letter volume prefix + * is split off with {@link windowsVolumeLen} before the segment-cleanup loop, since that loop + * would otherwise collapse a UNC path's two leading empty segments (`//host/share`) like any + * other redundant separator. */ export function cleanSchemaPath( rawPath: string, @@ -500,9 +370,9 @@ export function cleanSchemaPath( const volumeLen = platform === "win32" ? windowsVolumeLen(normalized) : 0; const volume = normalized.slice(0, volumeLen); const remainder = normalized.slice(volumeLen); - // A bare volume with nothing after it (`\\server\share`, or `C:`) — Go's Clean leaves it - // untouched rather than falling into the segment-cleanup loop below (which would otherwise - // turn "no path left" into a bare "." and lose the volume). + // A bare volume with nothing after it (`\\server\share`, or `C:`) is returned untouched — + // the segment-cleanup loop below would otherwise turn "no path left" into a bare "." and + // lose the volume. if (volumeLen > 0 && remainder === "") return volume; const isAbsolute = remainder.startsWith("/"); const out: Array<string> = []; @@ -521,11 +391,9 @@ export function cleanSchemaPath( } /** - * Port of Go's `migrateBaseDatabase` (`apps/cli-go/internal/db/diff/diff.go:261-274`): prints - * the declarative-schema file list, connects to `config` (the shadow's `contrib_regression` - * override), then seeds `migrations` as globals (Go's `migration.SeedGlobals` — no history - * row, no history table, WITHOUT the migra-engine schema files' own transactional/seed - * distinctions {@link seedGlobals} already reproduces for every other caller of it). + * Prints the declarative-schema file list, connects to the shadow's `contrib_regression` + * override, then seeds the files as globals — no history row or table, unlike the + * transactional/seed distinctions {@link seedGlobals} otherwise applies for the migra engine. */ function migrateBaseDatabase( fs: FileSystem.FileSystem, diff --git a/apps/cli/src/commands/db/shared/shadow-source.unit.test.ts b/apps/cli/src/commands/db/shared/shadow-source.unit.test.ts index 9452484872..b5dd27d3cf 100644 --- a/apps/cli/src/commands/db/shared/shadow-source.unit.test.ts +++ b/apps/cli/src/commands/db/shared/shadow-source.unit.test.ts @@ -25,12 +25,6 @@ function makeWorkdir(): string { const isRoot = typeof process.getuid === "function" && process.getuid() === 0; describe("cleanSchemaPath", () => { - // Go's `filepath.Clean` (windows build) never cleans INTO a leading UNC volume — verified - // empirically against a standalone extraction of Go's own windows `internal/filepathlite` - // Clean/ToSlash/volumeNameLen source, run natively: `filepath.ToSlash(filepath.Clean( - // \`\\server\share\schemas\`))` compiled for `GOOS=windows` returns `//server/share/schemas` - // (review: PRRT_kwDOErm0O86W2tRk) — the doubled leading separator is part of the UNC host+ - // share, not a redundant separator to collapse to one. it("preserves a UNC host+share prefix on win32, matching Go's Clean", () => { expect(cleanSchemaPath("\\\\server\\share\\schemas", "win32")).toBe("//server/share/schemas"); }); @@ -52,9 +46,6 @@ describe("cleanSchemaPath", () => { }); it("does not confuse a UNC path with the distinct root-relative path of the same tail", () => { - // The bug this guards against: collapsing `//server/share/schemas` down to - // `/server/share/schemas` would make a UNC `schema_paths` entry compare equal to an - // unrelated root-relative declarative dir. expect(cleanSchemaPath("\\\\server\\share\\schemas", "win32")).not.toBe( cleanSchemaPath("/server/share/schemas", "win32"), ); @@ -65,8 +56,6 @@ describe("cleanSchemaPath", () => { }); it("does not treat a doubled separator as a UNC volume off win32", () => { - // POSIX has no UNC concept — Go's non-Windows `filepath.Clean` collapses redundant - // separators uniformly, same as this function's pre-existing POSIX behavior. expect(cleanSchemaPath("//server/share/schemas", "darwin")).toBe("/server/share/schemas"); }); }); @@ -173,13 +162,6 @@ describe("loadDeclaredSchemas", () => { it.effect( 'an empty schema_paths entry matches nothing, not the entire project (Go\'s fs.Glob(""))', () => { - // Go's `io/fs.Glob` never matches an empty pattern — its literal-pattern branch calls - // `Stat(fsys, "")`, which fails on a real OS filesystem, so `Glob.SQLFiles` reports - // `no files matched pattern: ` for it (verified empirically against the real - // `config.Glob.SQLFiles` fed `""` over an `afero.NewOsFs()`). Without this guard, - // `globPattern`'s literal-pattern branch resolves `""` to the workdir itself - // (which always exists) and recursively collects every `.sql` file in the project, - // including files well outside any declared schema path. const workdir = makeWorkdir(); mkdirSync(join(workdir, "supabase", "migrations"), { recursive: true }); writeFileSync(join(workdir, "supabase", "migrations", "001_init.sql"), "select 1;\n"); @@ -217,15 +199,6 @@ describe("loadDeclaredSchemas", () => { it.effect( "on POSIX, a backslash in a schema_paths entry is a path.Match escape, not a separator (review: PRRT_kwDOErm0O86W7n90)", () => { - // Go's `filepath.ToSlash` (`fs.Glob(fsys, filepath.ToSlash(pattern))`, - // `pkg/config/config.go:145`) is a byte-for-byte no-op on POSIX — only Windows's - // `filepath.Separator` is `\`. `path.Match` (what `fs.Glob` compiles down to) then - // treats an un-converted `\` as an escape metacharacter: `custom\x.sql` escapes the - // literal `x`, matching a FILE literally named `customx.sql` directly under - // `supabase/`, never the path-separated `supabase/custom/x.sql`. Verified empirically: - // `path.Match("custom\\x.sql", "customx.sql")` is `true` on darwin, while - // `path.Match("custom\\x.sql", "custom/x.sql")` never even reaches that filename (the - // pattern has no `/`, so it only lists `supabase/`, never descends into `custom/`). const workdir = makeWorkdir(); mkdirSync(join(workdir, "supabase"), { recursive: true }); writeFileSync(join(workdir, "supabase", "customx.sql"), "select 1;\n"); @@ -242,12 +215,6 @@ describe("loadDeclaredSchemas", () => { it.effect( "on POSIX, a backslash-escaped glob metacharacter in schema_paths matches the literal filename (review: PRRT_kwDOErm0O86W7n90)", () => { - // The specific case the review thread flagged: `path.Match("foo\\*.sql", "foo*.sql")` - // is `true` on darwin — the escaped `*` is a literal asterisk, matching a file named - // `foo*.sql`, not a glob that searches a `foo/` subdirectory. Before this fix, - // `globDeclaredSchemaPaths` unconditionally rewrote the pattern to `foo/*.sql` - // ahead of globbing, which searches `foo/` instead and would report "no files matched" - // for this exact, valid Go config. const workdir = makeWorkdir(); mkdirSync(join(workdir, "supabase"), { recursive: true }); writeFileSync(join(workdir, "supabase", "foo*.sql"), "select 1;\n"); @@ -264,15 +231,8 @@ describe("loadDeclaredSchemas", () => { it.effect( "dedupes a directory schema_paths entry with a trailing separator against a literal-file entry for the same file (review: PRRT_kwDOErm0O86XAlIr)", () => { - // A RELATIVE trailing-slash entry gets `path.Join`-cleaned away by - // `resolveSeedSqlPath` before it ever reaches the glob, matching Go's own - // `path.Join(builder.SupabaseDirPath, pattern)` resolution — so the bug is only - // reachable via an ABSOLUTE entry, which `resolveSeedSqlPath` returns verbatim - // (Go's `Glob.files` never resolves an absolute entry either). Without the fix, the - // directory branch recorded the walked file as `<abs>/custom//x.sql` (raw template - // concatenation), which never matches the literal entry's `<abs>/custom/x.sql` in - // `seen`, so both were appended to `result` and the declarative apply would run the - // same file's SQL twice. + // Must use absolute paths: a relative trailing-slash entry gets cleaned before reaching + // the glob, so only an absolute entry reaches the code path this test exercises. const workdir = makeWorkdir(); mkdirSync(join(workdir, "supabase", "custom"), { recursive: true }); writeFileSync(join(workdir, "supabase", "custom", "x.sql"), "select 1;\n"); @@ -297,9 +257,6 @@ describe("loadDeclaredSchemas", () => { it.effect( "excludes a symlinked .sql file from a recursively-matched schema_paths directory", () => { - // Go's `entry.Type().IsRegular()` (`config.go:127`) is a no-follow check — a symlink - // is never "regular", so `walkMatchedDir` excludes it even when it resolves to a real - // `.sql` file. const workdir = makeWorkdir(); mkdirSync(join(workdir, "supabase", "custom"), { recursive: true }); writeFileSync(join(workdir, "supabase", "custom", "real.sql"), "select 1;\n"); @@ -335,10 +292,6 @@ describe("loadDeclaredSchemas", () => { it.effect( "does not follow a symlinked subdirectory in a recursively-matched schema_paths directory", () => { - // Go's `fs.WalkDir` (`walkMatchedDir`, `config.go:194-211`) is `Lstat`-based and never - // descends into a symlinked directory (`io/fs.WalkDir` doc: "WalkDir does not follow - // symbolic links found in directories") — a schema dir symlinking OUT of the configured - // schema tree must not leak the linked directory's files into the diff/pull target. const workdir = makeWorkdir(); mkdirSync(join(workdir, "supabase", "custom"), { recursive: true }); writeFileSync(join(workdir, "supabase", "custom", "real.sql"), "select 1;\n"); @@ -379,10 +332,6 @@ describe("loadDeclaredSchemas", () => { it.effect( "falls back to supabase/schemas when the pg-delta declarative path exists but is a regular file", () => { - // Go's `afero.DirExists` (`apps/cli-go/internal/db/diff/diff.go:63`) treats a non-directory - // path as absent, not present-but-unwalkable — a stray `supabase/database` FILE (e.g. left - // over from a previous config) must fall through to `supabase/schemas`, not make - // `walkSqlFilesSorted` try (and fail) to read a file as a directory. const workdir = makeWorkdir(); mkdirSync(join(workdir, "supabase"), { recursive: true }); writeFileSync(join(workdir, "supabase", "database"), "not a directory"); @@ -426,12 +375,6 @@ describe("loadDeclaredSchemas", () => { it.effect( "returns [] (does not follow) when the pg-delta declarative dir itself is a symlink", () => { - // Go's `afero.Walk(fsys, declDir, ...)` Lstat's the ROOT before ever calling `walkFn` - // (`afero`'s own `Walk`/`lstatIfPossible`) — a symlinked root is treated as a - // non-directory and produces zero files, silently, never descending into the target. - // The PRECEDING `afero.DirExists`-equivalent existence check (which follows symlinks, - // matching Go's own `fs.Stat`-based `DirExists`) reports the symlinked dir as present, so - // only the WALK itself (not the existence check) must reject it. const workdir = makeWorkdir(); const realDir = join(workdir, "real-database"); mkdirSync(realDir, { recursive: true }); @@ -476,12 +419,8 @@ describe("loadDeclaredSchemas", () => { it.effect( "sorts declared schema paths by UTF-8 byte order, not JS's default UTF-16 code-unit order", () => { - // A supplementary-plane character (U+1F600, a surrogate pair in UTF-16) alongside a BMP - // private-use character (U+E000) is the textbook case where JS's default `.sort()` - // (UTF-16 code units) disagrees with Go's `sort.Strings` (UTF-8 bytes, which preserves - // codepoint order): JS ranks the surrogate pair first (0xD800 < 0xE000), Go ranks the - // supplementary-plane codepoint last (it's numerically > U+FFFF). Verified empirically - // against `Buffer.compare` on the two filenames' UTF-8 encodings. + // U+1F600 (a UTF-16 surrogate pair) sorts before U+E000 under JS's default order, but + // after it in UTF-8 byte order. const workdir = makeWorkdir(); mkdirSync(join(workdir, "supabase", "schemas"), { recursive: true }); const supplementary = "a\u{1F600}.sql"; @@ -504,10 +443,6 @@ describe("loadDeclaredSchemas", () => { it.effect( "propagates (rather than silently drops) a per-entry stat failure during the pg-delta/schemas walk", () => { - // Both Go walkers (`afero.Walk`, `fs.WalkDir`) pass a per-entry stat/lstat error to their - // callback, which returns it and aborts the whole walk — an entry that can't be statted - // after its parent was listed (permissions, I/O error, a concurrent filesystem change) - // must not be silently omitted, which could build an incomplete declarative target. const workdir = makeWorkdir(); mkdirSync(join(workdir, "supabase", "schemas"), { recursive: true }); writeFileSync(join(workdir, "supabase", "schemas", "a.sql"), "select 1;\n"); @@ -544,12 +479,6 @@ describe("loadDeclaredSchemas", () => { it.effect.skipIf(isRoot)( "fails (rather than silently treating as empty) when a matched schema directory can't be read, and keeps the underlying cause in the message", () => { - // Go's `walkMatchedDir` (`pkg/config/config.go:194-211`) propagates ANY `fs.WalkDir` - // error as `failed to walk matched directory: <err>` — an unreadable directory must - // surface as a failure, not silently contribute zero files (which could compare a - // local-target diff against the wrong target or generate an incomplete migration), and - // the reported message must carry the real underlying error (permission denied, here), - // not just the directory name — otherwise a user can't tell WHY the walk failed. const workdir = makeWorkdir(); const locked = join(workdir, "supabase", "locked"); mkdirSync(locked, { recursive: true }); @@ -575,14 +504,8 @@ describe("loadDeclaredSchemas", () => { it.effect.skipIf(isRoot)( "visits sibling directories in UTF-8 byte order, not JS's default UTF-16 order, so the reported failure matches Go's (review: PRRT_kwDOErm0O86XAlIo)", () => { - // `["dir\u{1F600}", "dir\u{E000}"].sort()` (JS default, UTF-16 code-unit order) puts the - // supplementary-plane name FIRST — its lead surrogate (0xD83D) is less than the - // private-use code unit (0xE000). Byte order (Go's `sort.Strings`/`bytealg.CompareString`, - // what `compareUtf8Bytes` reproduces) disagrees: U+1F600 encodes to a LARGER first - // UTF-8 byte (0xF0) than U+E000 (0xEE), so the private-use name sorts first instead. - // Both subdirectories are unreadable, so whichever the walk visits FIRST is the one whose - // `EACCES` failure aborts the whole walk (Effect.gen never reaches the second entry) — - // its path, not the other one's, must appear in the resulting error. + // Byte order visits `dir\u{E000}` before `dir\u{1F600}` (the opposite of JS's default + // order), so its EACCES failure must be the one that surfaces. const workdir = makeWorkdir(); const matched = join(workdir, "supabase", "custom"); const utf16First = join(matched, "dir\u{1F600}"); @@ -613,11 +536,6 @@ describe("loadDeclaredSchemas", () => { it.effect.skipIf(isRoot)( "reports the pg-delta declarative dir walk failure as 'failed to walk declarative dir', not the generic 'failed to walk dir'", () => { - // Go's `loadDeclaredSchemas` (`apps/cli-go/internal/db/diff/diff.go:52-101`) wraps the - // SAME `afero.Walk` failure with a DIFFERENT prefix per source: the pg-delta declarative - // dir branch reports `failed to walk declarative dir: %w`, while the `supabase/schemas` - // fallback (covered by the sibling test below) reports `failed to walk dir: %w` — both - // walks share `walkSqlFilesSorted`, which must be told which source it's walking. const workdir = makeWorkdir(); const declDir = join(workdir, "supabase", "database"); mkdirSync(declDir, { recursive: true }); diff --git a/apps/cli/src/commands/db/start/start.handler.ts b/apps/cli/src/commands/db/start/start.handler.ts index 94de7a7230..3e6f9b9b35 100644 --- a/apps/cli/src/commands/db/start/start.handler.ts +++ b/apps/cli/src/commands/db/start/start.handler.ts @@ -6,25 +6,11 @@ import { startLocalDatabase } from "../../../command-internal/db-bootstrap/start import type { DbStartFlags } from "./start.command.ts"; /** - * `supabase db start` — start the local Postgres database. - * - * Fully native. The actual bring-up sequence (config load+validate, the already-running - * short-circuit, network/volume/container bring-up, health wait, fresh-volume setup, - * `_current_branch`) is the shared `startLocalDatabase` helper - * (`shared/db-bootstrap/start-local-database.ts`) — also used by the `db schema declarative` - * seam's `ensureLocalDatabaseStarted`, so there is one implementation. This handler only adds - * `db start`'s own output-format-aware terminal message and telemetry flush. - * - * Parity notes: this is `db start`, NOT the top-level `supabase start`. It does NOT print a - * status table and does NOT fire `cli_stack_started` — those belonged to the deleted - * `internal/start/start.go` and are now natively ported in `commands/start/`. There is - * no `Finished` line. Unlike `supabase start`, there is no `--exclude`/`--ignore-health-check` - * here at all (`db start` has neither flag) — a health-check timeout always fails the command - * UNLESS `--from-backup` is set, in which case `startLocalDatabase` itself swallows it (a - * large restore can take longer than the health timeout) and the command still succeeds. - * `--exclude`'s absence also means the fresh-volume one-shot setup jobs (realtime/storage/auth - * migrate) are gated purely on each service's own `enabled` flag, with no `--exclude` filtering - * to layer on top. + * `supabase db start` — start the local Postgres database. Fully native: the bring-up + * sequence is the shared `startLocalDatabase` helper, also used by `db schema declarative`'s + * `ensureLocalDatabaseStarted`. This handler only adds the output-format-aware terminal + * message and telemetry flush. Unlike `supabase start`, it has no status table, no + * `cli_stack_started` event, and no `--exclude`/`--ignore-health-check` flags. */ export const dbStart = Effect.fn("db.start")(function* (flags: DbStartFlags) { const output = yield* Output; diff --git a/apps/cli/src/commands/db/start/start.integration.test.ts b/apps/cli/src/commands/db/start/start.integration.test.ts index 6fe67a9fd6..72089d637b 100644 --- a/apps/cli/src/commands/db/start/start.integration.test.ts +++ b/apps/cli/src/commands/db/start/start.integration.test.ts @@ -52,7 +52,7 @@ type RouteResult = { readonly stderr?: ReadonlyArray<string>; }; -/** Scoped-down port of `start.integration.test.ts`'s own `mockStartContainerCliSpawner` — one container instead of 14. */ +/** A single-container version of `start.integration.test.ts`'s `mockStartContainerCliSpawner`. */ function mockContainerCliSpawner(route: (args: ReadonlyArray<string>) => RouteResult) { const spawned: Array<SpawnRecord> = []; const encoder = new TextEncoder(); @@ -189,10 +189,9 @@ function freshVolumeRoute( } /** - * Makes `isLocalDbRunning`'s pre-bring-up `container inspect` succeed - * unconditionally, simulating an already-up local db — this is the very first - * Docker call the handler makes, so no other `container inspect` call happens on - * this path (the already-running short-circuit returns before `StartDatabase`). + * Makes `isLocalDbRunning`'s pre-bring-up `container inspect` succeed unconditionally, + * simulating an already-up local db — the already-running short-circuit returns before any + * other `container inspect` call happens. */ function alreadyRunningRoute( base: (args: ReadonlyArray<string>) => RouteResult, @@ -204,10 +203,9 @@ function alreadyRunningRoute( } /** - * Makes `isLocalDbRunning`'s pre-bring-up `container inspect` fail for a - * reason other than "no such container" — simulates an unreachable Docker daemon - * during the running-check, which `AssertSupabaseDbIsRunning` propagates instead of - * treating as "not running". + * Makes `isLocalDbRunning`'s pre-bring-up `container inspect` fail for a reason other than + * "no such container" — simulates an unreachable Docker daemon during the running-check, which + * is propagated rather than treated as "not running". */ function runningCheckFailsRoute( base: (args: ReadonlyArray<string>) => RouteResult, @@ -227,7 +225,7 @@ const alwaysReadyHttpClientLayer = Layer.succeed( ), ); -/** Mirrors `start.integration.test.ts`'s own `fakeDbSession` — PG15+ (this suite's default) never calls `exec`/`query` (its schema init is three one-shot `DockerRun` jobs instead). */ +/** PG15+ (this suite's default) never calls `exec`/`query` directly — its schema init is three one-shot `DockerRun` jobs instead. */ function fakeDbSession() { const calls: Array<{ kind: "exec" | "query"; sql: string }> = []; const session: DbSession = { @@ -367,9 +365,6 @@ describe("db start", () => { expect(out.stderrText).toContain("Postgres database is already running."); expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); expect(telemetry.flushed).toBe(true); - // `initCurrentBranch` is inside `startDatabase`, never reached on - // the already-running short-circuit — the already-running check - // returns before `startDatabase` is ever called. expect(existsSync(currentBranchPath(tempRoot.current))).toBe(false); }); }); @@ -428,8 +423,6 @@ describe("db start", () => { return Effect.gen(function* () { yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Initialising schema..."); - // PG <= 14's `initSchema` execs globals.sql + the initial-schema SQL directly over the - // `DbConnection` session — no PG15+ one-shot `docker run --rm` migrate jobs at all. expect(dbSetupJobCalls(child.spawned)).toHaveLength(0); expect(dbSession.calls.length).toBeGreaterThan(0); expect(readFileSync(currentBranchPath(tempRoot.current), "utf8")).toBe("main"); @@ -440,14 +433,8 @@ describe("db start", () => { it.live( "a fresh volume with realtime disabled skips the realtime migrate job AND never attempts JWKS resolution", () => { - // A configured (but unreachable) third-party JWKS issuer would fail - // `resolveLocalJwks` if it were ever called — the PG15 realtime - // job resolves JWKS itself, gated on `Realtime.Enabled`, so a fresh - // volume with realtime disabled must never even attempt it, regardless - // of what it would have resolved to. This is the one place `db - // start`'s own JWKS gating is directly observable - // (`startDatabase`'s `setup.jwks` is a LAZY `Effect`, evaluated - // only when reached). + // globalThis.fetch is stubbed to fail so any JWKS resolution attempt would blow up the + // test. const previousFetch = globalThis.fetch; globalThis.fetch = Object.assign(() => Promise.reject(new Error("ECONNREFUSED")), { preconnect: previousFetch.preconnect, @@ -490,8 +477,8 @@ describe("db start", () => { if (Exit.isFailure(exit)) { expect(JSON.stringify(exit.cause)).toContain("DbConfigLoadError"); } - // The container was already created/started/healthy by the time JWKS resolution runs - // (deep inside the fresh-volume setup step) — the rollback still tears it down. + // The container is already created/healthy by the time JWKS resolution runs, so the + // rollback still tears it down. expect(rollbackWasAttempted(child.spawned)).toBe(true); }).pipe( Effect.ensuring( @@ -512,9 +499,8 @@ describe("db start", () => { expect(out.stderrText).toContain("Starting database from backup...\n"); expect(out.stderrText).not.toContain("Initialising schema..."); expect(dbSetupJobCalls(child.spawned)).toHaveLength(0); - // No schema/globals/vault/roles SQL — the setup pipeline really is skipped. The - // only SQL on this path is the Database Webhooks convergence, which reads the - // migration history and (webhooks disabled, no migration owning pg_net) drops it. + // The only SQL on this path is the Webhooks convergence, which finds no migration + // owning pg_net (webhooks disabled) and drops it. expect(dbSession.calls.some((call) => call.sql.includes(PG_NET_CREATE_FINGERPRINT))).toBe( false, ); @@ -584,9 +570,8 @@ describe("db start", () => { ); } expect(child.spawned.some((s) => s.args[0] === "create")).toBe(false); - // The rollback always removes everything on ANY start-database - // failure, including this guard — `deleteVolumes: false` since the - // volume this guard detected must never be pruned. + // The volume this guard detected must never be pruned, even though the rollback still + // removes everything else on failure. expect(rollbackWasAttempted(child.spawned)).toBe(true); expect(volumePruneWasAttempted(child.spawned)).toBe(false); }); @@ -628,14 +613,9 @@ describe("db start", () => { const exit = yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(rollbackWasAttempted(child.spawned)).toBe(true); - // This run's own volume was confirmed fresh (`freshVolumeRoute`), so - // the rollback prunes it too. A regression that hardcoded - // `rollbackStart`'s `deleteVolumes` to `false` would still pass - // every OTHER assertion in this file, since only the "backup volume - // already exists" test (a non-fresh-volume scenario) currently asserts - // the negative half. + // This run's fresh volume means the rollback prunes it too — the "backup volume already + // exists" test above covers the non-pruning case. expect(volumePruneWasAttempted(child.spawned)).toBe(true); - // The health-check timeout aborts before `SetupLocalDatabase`/`initCurrentBranch` ever run. expect(existsSync(currentBranchPath(tempRoot.current))).toBe(false); }); }); @@ -643,12 +623,8 @@ describe("db start", () => { it.live( "honors a SUPABASE_DEBUG set only in the project .env: the rollback's Pruned reports fire without --debug", () => { - // Every Go debug read on this path went through `viper.GetBool("DEBUG")` under - // `AutomaticEnv` after `loadNestedEnv` had `os.Setenv`'d the project `supabase/.env` into - // the process — so a `SUPABASE_DEBUG` set only there, never in the shell or via `--debug`, - // still gated the rollback's `Pruned …:` stderr reports. Delete any shell `SUPABASE_DEBUG` - // first: shell *presence* (even `false`) suppresses the project value entirely, per - // `viperEnvBoolWithProjectFallback`'s own semantics. + // A shell SUPABASE_DEBUG (even "false") would suppress the project .env value, so clear + // it first. const previous = process.env["SUPABASE_DEBUG"]; delete process.env["SUPABASE_DEBUG"]; const { layer, child } = setup({ @@ -656,9 +632,8 @@ describe("db start", () => { route: freshVolumeRoute(defaultRoute({ neverHealthy: true })), }); writeFileSync(join(tempRoot.current, "supabase", ".env"), "SUPABASE_DEBUG=true\n"); - // `reportPruned` writes straight to the real process stderr (matching Go's unconditional - // `os.Stderr`), never the mocked `Output` service — intercept it like - // `health-check.unit.test.ts`'s own log-dump assertions do. + // This log write goes straight to the real process stderr, never the mocked Output + // service, so intercept it directly. const writes: Array<string> = []; const originalWrite = globalThis.process.stderr.write.bind(globalThis.process.stderr); globalThis.process.stderr.write = ((chunk: string | Uint8Array) => { @@ -690,10 +665,8 @@ describe("db start", () => { route: freshVolumeRoute(defaultRoute({ neverHealthy: true })), }); return Effect.gen(function* () { - // The log dump (`waitForHealthyServices`'s own unconditional behavior on timeout, - // teed straight to the real process stderr, not the mocked `Output` service) still runs — - // exercised by every other health-timeout test via the shared `../../../shared/db-bootstrap/health-check.ts` suite; - // this test only asserts the command-level outcome that's specific to `--from-backup`. + // The stderr log dump on timeout is covered by the shared health-check.ts suite; this + // test only asserts the outcome specific to `--from-backup`. yield* dbStart(flags("/abs/host/backup.sql")).pipe(Effect.provide(layer)); expect(rollbackWasAttempted(child.spawned)).toBe(false); expect(readFileSync(currentBranchPath(tempRoot.current), "utf8")).toBe("main"); @@ -773,10 +746,6 @@ describe("db start", () => { ); it.live("falls back to SUPABASE_NETWORK_ID when --network-id is omitted", () => { - // `network-id` is a persistent flag bound to a `SUPABASE_NETWORK_ID` env - // fallback, read fresh at its own call site — well after the config's - // dotenv pass — so a shell/project-dotenv `SUPABASE_NETWORK_ID` is - // effective when the flag itself is omitted (review: PRRT_kwDOErm0O86VlqIL). process.env["SUPABASE_NETWORK_ID"] = "env-network"; const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); return Effect.gen(function* () { @@ -793,10 +762,6 @@ describe("db start", () => { it.live( "an explicitly empty --network-id falls back to the generated network name, not a literal empty override", () => { - // The gate is on a non-empty resolved value, not merely "the flag was - // passed" — an empty override (e.g. a shell expanding an unset var to - // "") must fall through to the generated `supabase_network_<projectId>` - // name, not produce a literal `--network ""` on the `docker create` call. const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()), networkId: "", @@ -832,10 +797,8 @@ describe("db start", () => { }, ); - // Config loading decodes every duration field unconditionally, for every - // command including `db start` — even though `db start` never starts - // GoTrue itself. Mirrors `commands/start/start.handler.ts`'s own identical - // eager-validation tests. + // Config loading decodes every duration field unconditionally for every command, including + // `db start`, even though it never starts GoTrue itself. it.live.each([ ["auth.email.max_frequency", '[auth.email]\nmax_frequency = "not-a-duration"\n'], ["auth.sms.max_frequency", '[auth.sms]\nmax_frequency = "not-a-duration"\n'], @@ -867,11 +830,8 @@ describe("db start", () => { it.live( "fails with a typed config error on a malformed SUPABASE_AUTH_RATE_LIMIT_EMAIL_SENT override, before any container is created", () => { - // `auth.rate_limit` (plain uints) has no enabled-gated validation — - // its only check is the unconditional type-decode inside config - // loading's single pass, which fails a non-numeric override regardless - // of `auth.enabled` or whether `db start` itself ever reads the field - // (review: PRRT_kwDOErm0O86Vk-e0). + // auth.rate_limit has no enabled-gated validation — it's decoded unconditionally + // regardless of auth.enabled or whether db start reads the field. const { layer, child } = setup({}); writeFileSync( join(tempRoot.current, "supabase", ".env"), @@ -890,11 +850,8 @@ describe("db start", () => { }, ); - // Closes the review-thread gap: config loading decodes the ENTIRE config - // struct unconditionally in a single pass, including every field below, - // regardless of whether `db start` itself ever reads it — mirrors - // `commands/start/start.handler.ts`'s own identical eager-validation tests - // for these same fields (review: PRRT_kwDOErm0O86VlOHQ). + // Config loading decodes the entire config struct unconditionally in one pass, including + // every field below, regardless of whether `db start` itself reads it. it.live.each([ ["edge_runtime.inspector_port", "SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", "not-a-port"], ["edge_runtime.policy", "SUPABASE_EDGE_RUNTIME_POLICY", "not-a-policy"], @@ -927,13 +884,6 @@ describe("db start", () => { }, ); - // Regression test for the exact gap the review thread found: `envOverrideRealtimeIpVersion`/ - // `envOverrideRealtimeMaxHeaderLength` used to be invoked ONLY inside - // `resolveDbBootstrapConfig`, which never runs once `isLocalDbRunning` - // short-circuits — so a malformed override was silently ignored whenever - // Postgres was already running, unlike the established behavior of - // decoding both fields unconditionally before the already-running check - // (review: PRRT_kwDOErm0O86VmHkl). it.live.each([ ["realtime.ip_version", "SUPABASE_REALTIME_IP_VERSION", "IPv5"], ["realtime.max_header_length", "SUPABASE_REALTIME_MAX_HEADER_LENGTH", "not-a-uint"], @@ -955,14 +905,6 @@ describe("db start", () => { }, ); - // Same gap, same shape, whole `db.settings.*` group: `resolveDbSettingsEnvOverrides` - // was only ever invoked building `startDatabase`'s - // `postgresSpec.db.settings`, which never runs once - // `isLocalDbRunning` short-circuits — so a malformed override was - // silently ignored whenever Postgres was already running, unlike the - // established behavior of decoding the entire `db.settings` struct - // unconditionally before the already-running check (review: - // PRRT_kwDOErm0O86Vn3Hw). it.live.each([ ["db.settings.max_connections", "SUPABASE_DB_SETTINGS_MAX_CONNECTIONS", "bogus"], ["db.settings.track_commit_timestamp", "SUPABASE_DB_SETTINGS_TRACK_COMMIT_TIMESTAMP", "bogus"], @@ -984,13 +926,6 @@ describe("db start", () => { }, ); - // Same gap, same shape, different field: `storage.enabled` (a plain bool) - // was only ever resolved inside `resolveDbBootstrapConfig` (gating - // the fresh-volume storage migrate job), which never runs once - // `isLocalDbRunning` short-circuits — so a malformed override was - // silently ignored whenever Postgres was already running, unlike the - // established behavior of decoding it unconditionally before the - // already-running check (review: PRRT_kwDOErm0O86VooCL). it.live( "fails with a typed config error on a malformed SUPABASE_STORAGE_ENABLED override even when Postgres is already running", () => { @@ -1012,15 +947,6 @@ describe("db start", () => { }, ); - // Same gap, same shape, different fields: `edge_runtime.enabled`, - // `db.network_restrictions.enabled`, `studio.enabled`, and - // `local_smtp.enabled` were only ever resolved inside - // `resolveLocalConfigValues` (the not-running branch's own - // config-values resolver, called below), which never runs once - // `isLocalDbRunning` short-circuits — so a malformed override was - // silently ignored whenever Postgres was already running, unlike the - // established behavior of decoding all four unconditionally before the - // already-running check (review: PRRT_kwDOErm0O86Vo7zx). it.live.each([ ["edge_runtime.enabled", "SUPABASE_EDGE_RUNTIME_ENABLED", "not-a-bool"], ["db.network_restrictions.enabled", "SUPABASE_DB_NETWORK_RESTRICTIONS_ENABLED", "not-a-bool"], @@ -1044,15 +970,6 @@ describe("db start", () => { }, ); - // Regression test for the review thread this fix closes: `studio.api_url` - // was resolved (`studioApiUrlForValidation`) but never parsed with - // `goUrlParse` in this eager battery — only - // `resolveLocalConfigValues` (the not-running branch, called well - // after the already-running short-circuit below) ever validated it. - // Validation parses `studio.api_url` immediately after the `studio.port` - // check, still inside the studio-enabled gate — so a malformed - // `SUPABASE_STUDIO_API_URL` would otherwise be silently accepted whenever - // Postgres is already running (review: PRRT_kwDOErm0O86WEBfl). it.live( "fails with a typed config error on a malformed SUPABASE_STUDIO_API_URL override even when Postgres is already running", () => { @@ -1074,14 +991,6 @@ describe("db start", () => { }, ); - // Regression test for the sibling review thread: `local_smtp.port` was resolved - // (`localSmtpPortForValidation`) but the `local_smtp.enabled`-gated zero check never ran in this - // eager battery — only `resolveLocalConfigValues`'s `mailpitEnabled`/`mailpitPort` pair - // (the not-running branch, called well after the already-running short-circuit below) ever - // checked it. Validation rejects `local_smtp.port === 0` ONLY when - // `local_smtp.enabled` — so an enabled `[local_smtp]` section with a zero - // port would otherwise be silently accepted whenever Postgres is already - // running (review: PRRT_kwDOErm0O86WEBfq). it.live( "fails with a typed config error when local_smtp is enabled with a zero port even when Postgres is already running", () => { @@ -1102,13 +1011,6 @@ describe("db start", () => { }, ); - // Same gap, same shape, different field: `auth.jwt_expiry` (a plain uint) - // was only ever resolved as part of `values.authJwtExpiry` - // (`resolveLocalConfigValues`), which this handler calls ONLY in the - // not-running branch — so a malformed override was silently ignored - // whenever Postgres was already running, unlike the established behavior - // of decoding it unconditionally before the already-running check - // (review: PRRT_kwDOErm0O86VmpeG). it.live( "fails with a typed config error on a malformed SUPABASE_AUTH_JWT_EXPIRY override even when Postgres is already running", () => { @@ -1130,13 +1032,6 @@ describe("db start", () => { }, ); - // Same gap, same shape, different field: `api.port` (a plain uint16) was - // only ever resolved as part of `values.apiPort` - // (`resolveLocalConfigValues`), which this handler calls ONLY in the - // not-running branch — so a malformed override was silently ignored - // whenever Postgres was already running, unlike the established behavior - // of decoding it unconditionally before the already-running check - // (review: PRRT_kwDOErm0O86Vnmss). it.live( "fails with a typed config error on a malformed SUPABASE_API_PORT override even when Postgres is already running", () => { @@ -1155,14 +1050,6 @@ describe("db start", () => { }, ); - // Same gap, same shape, remaining root-level `auth.*` scalars: none of - // these is referenced by any auth-enabled gate, so — like `auth.jwt_expiry` - // above — each was only ever resolved as part of - // `resolveLocalConfigValues`, which this handler calls ONLY in the - // not-running branch, and a malformed override was silently ignored - // whenever Postgres was already running, unlike the established behavior - // of decoding all of them unconditionally before the already-running - // check (review: PRRT_kwDOErm0O86VnEV6). it.live.each([ ["auth.enable_signup", "SUPABASE_AUTH_ENABLE_SIGNUP", "not-a-bool"], ["auth.enable_anonymous_sign_ins", "SUPABASE_AUTH_ENABLE_ANONYMOUS_SIGN_INS", "not-a-bool"], @@ -1200,13 +1087,9 @@ describe("db start", () => { it.live( "fails on an invalid auth.passkey.enabled even when auth is disabled, matching Go's Config.Load", () => { - // `auth.passkey`/`auth.webauthn` have no `@supabase/config` schema at - // all — `auth.passkey.enabled` is decoded unconditionally via - // `resolveGotruePasskeyWebauthn`'s raw-document read, so the - // malformed value must live directly in config.toml here since - // `@supabase/config` never sees (or rejects) this unmodeled field — - // there's no schema-level bool coercion to catch it first - // (review: PRRT_kwDOErm0O86VlOHQ). + // auth.passkey has no @supabase/config schema, so the malformed value must live in + // config.toml directly — an env override would never reach the raw-document read that + // decodes it. const { layer, child } = setup({ configContents: 'project_id = "test"\n[auth]\nenabled = false\n[auth.passkey]\nenabled = "bad"\n', @@ -1227,12 +1110,9 @@ describe("db start", () => { it.live( "fails on an invalid auth.external.<custom>.enabled even when auth is disabled, matching Go's Config.Load", () => { - // `auth.external` is a genuine dynamic map keyed by provider name — an - // unmodeled/custom provider name like `custom` is a legitimate config - // shape `@supabase/config`'s schema silently drops at decode time, so - // `resolveAuthExternalProviders`'s raw-document read is the only - // place this malformed value is ever seen — same override-only-throw - // reasoning as the passkey test above (review: PRRT_kwDOErm0O86VlOHQ). + // auth.external is a dynamic provider map; an unmodeled key like "custom" is silently + // dropped by @supabase/config's schema, so the malformed value must live in config.toml + // directly. const { layer, child } = setup({ configContents: 'project_id = "test"\n[auth]\nenabled = false\n[auth.external.custom]\nenabled = "bad"\n', @@ -1253,17 +1133,9 @@ describe("db start", () => { it.live( "fails on a malformed SUPABASE_AUTH_HOOK_SEND_EMAIL_ENABLED override, matching Go's Config.Load", () => { - // `auth.hook.<type>.*` is bound like every other nested field, decoded - // in the same unconditional config-load pass as `auth.external` above. - // `resolveAuthHooks` only applies the env override when the - // `[auth.hook.<type>]` section is present in the raw document - // (`@supabase/config`'s schema always decodes a `{ enabled: false }` - // default regardless of file presence, which would otherwise erase the - // presence signal the env override needs) — so the section must exist - // in config.toml for the override below to be reached at all. `db - // start` never built a GoTrue container, so nothing else in this - // handler called `resolveAuthHooks` before now (review: - // PRRT_kwDOErm0O86WBGSW). + // The [auth.hook.send_email] section must be present for the env override to reach the + // decode — an absent section decodes a schema default that erases the presence signal + // the override needs. const { layer, child } = setup({ configContents: 'project_id = "test"\n[auth.hook.send_email]\nenabled = false\n', }); @@ -1287,14 +1159,8 @@ describe("db start", () => { it.live( "fails on a malformed SUPABASE_AUTH_EMAIL_SMTP_PORT override even when auth is disabled, matching Go's Config.Load", () => { - // `auth.email.smtp.*` is bound like every other nested field once - // `[auth.email.smtp]` is present in config.toml, decoded in the same - // unconditional config-load pass as `auth.hook` above — confirmed - // empirically that this decode failure fires even with `auth.enabled = false`, - // well before the auth-enabled-gated email validation and before the - // already-running check. `db start` never built a GoTrue container, so - // nothing else in this handler called `resolveAuthEmailSmtp` - // before now (review: PRRT_kwDOErm0O86WC8J3). + // [auth.email.smtp] must be present in config.toml for the env override to reach the + // decode. const { layer, child } = setup({ configContents: 'project_id = "test"\n[auth]\nenabled = false\n[auth.email.smtp]\nhost = "smtp.example.com"\n', @@ -1319,11 +1185,6 @@ describe("db start", () => { it.live( "ignores SUPABASE_AUTH_EMAIL_SMTP_PORT when [auth.email.smtp] is absent from config.toml", () => { - // Only keys already bound from the merged config are intercepted — an - // absent `[auth.email.smtp]` section never picks up an env override - // alone (confirmed empirically), so `resolveAuthEmailSmtp`'s own - // presence gate must return `undefined` and the eager check below must - // be a no-op rather than failing on a section the config never mentions. const { layer, child } = setup({ configContents: 'project_id = "test"\n[auth]\nenabled = false\n', route: freshVolumeRoute(defaultRoute()), @@ -1342,15 +1203,8 @@ describe("db start", () => { it.live( "fails on a malformed SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED override, matching Go's Config.Load", () => { - // `storage.image_transformation` is a nil-unless-declared pointer - // field, bound like every other nested pointer field once - // `[storage.image_transformation]` is present in config.toml — the - // same shape as `auth.hook`/`auth.email.smtp` above (confirmed - // empirically: `SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED=bogus` - // fails config loading when the section is present, even though `db - // start` never builds ImgProxy or the Storage container). `db start` - // never resolves this field elsewhere, so nothing else in this - // handler called the eager check before now (review: PRRT_kwDOErm0O86WDkO9). + // [storage.image_transformation] must be present in config.toml for the env override to + // reach the decode. const { layer, child } = setup({ configContents: 'project_id = "test"\n[storage.image_transformation]\nenabled = true\n', }); @@ -1374,11 +1228,6 @@ describe("db start", () => { it.live( "ignores SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED when [storage.image_transformation] is absent from config.toml", () => { - // Only keys already bound from the merged config are intercepted — an - // absent `[storage.image_transformation]` section never picks up an - // env override alone (confirmed empirically), so the eager check must - // gate on section presence and be a no-op rather than failing on a - // section the config never mentions. const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); writeFileSync( join(tempRoot.current, "supabase", ".env"), @@ -1394,13 +1243,8 @@ describe("db start", () => { it.live( "fails on a malformed SUPABASE_DB_SSL_ENFORCEMENT_ENABLED override, matching Go's Config.Load", () => { - // `db.ssl_enforcement` is a nil-unless-declared pointer field, bound - // like every other nested pointer field once `[db.ssl_enforcement]` is - // present in config.toml — the same presence-gated shape as - // `storage.image_transformation` above, not the plain-bool shape of - // `db.network_restrictions.enabled` (never a pointer). `db start` - // never resolves this field elsewhere, so nothing else in this - // handler called the eager check before now (review: PRRT_kwDOErm0O86WE42a). + // [db.ssl_enforcement] must be present in config.toml for the env override to reach the + // decode (a presence-gated pointer field, unlike the plain-bool db.network_restrictions.enabled). const { layer, child } = setup({ configContents: 'project_id = "test"\n[db.ssl_enforcement]\nenabled = true\n', }); @@ -1424,10 +1268,6 @@ describe("db start", () => { it.live( "fails on a malformed SUPABASE_DB_SSL_ENFORCEMENT_ENABLED override even when Postgres is already running", () => { - // Same gap, already-running variant: Codex's exact repro for this finding was an - // already-running project declaring `[db.ssl_enforcement]` — the eager check above already - // covers the non-running path, this proves the running short-circuit doesn't mask it either - // (review: PRRT_kwDOErm0O86WE42a). const { layer, child } = setup({ configContents: 'project_id = "test"\n[db.ssl_enforcement]\nenabled = true\n', running: true, @@ -1452,12 +1292,6 @@ describe("db start", () => { it.live( "ignores SUPABASE_DB_SSL_ENFORCEMENT_ENABLED when [db.ssl_enforcement] is absent from config.toml", () => { - // Only keys already bound from the merged config are intercepted — an - // absent `[db.ssl_enforcement]` section never picks up an env override - // alone (confirmed empirically for the identical - // `storage.image_transformation` shape above), so the eager check must - // gate on section presence and be a no-op rather than failing on a - // section the config never mentions. const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); writeFileSync( join(tempRoot.current, "supabase", ".env"), @@ -1473,17 +1307,8 @@ describe("db start", () => { it.live( "fails with a typed config error when [experimental.webhooks] is present without enabled = true, even when Postgres is already running", () => { - // Experimental validation rejects ANY present `[experimental.webhooks]` - // section whose `enabled` isn't explicitly `true` — this runs - // unconditionally inside config loading, before the already-running - // check. `db start`'s own `checkDbToml` call (D's shared - // db/migration config pipeline, `db-config.toml-read.ts`) - // previously never populated - // `ExperimentalInput.webhooksPresent`/`webhooksEnabled` at all, - // so `validateResolvedConfig`'s existing webhooks check never - // ran for `db start` (or any other D caller) — silently accepted - // regardless of whether Postgres was already running (review: - // PRRT_kwDOErm0O86WE42i). + // Experimental validation rejects any present [experimental.webhooks] section whose + // enabled isn't explicitly true, unconditionally before the already-running check. const { layer, child } = setup({ configContents: 'project_id = "test"\n[experimental.webhooks]\nenabled = false\n', running: true, @@ -1504,9 +1329,6 @@ describe("db start", () => { ); it.live("starts normally when [experimental.webhooks] is absent from config.toml", () => { - // An absent `[experimental.webhooks]` section is never rejected (the - // bool zero-value default is fine) — the new webhooks presence check - // must be a no-op rather than failing on a section the config never mentions. const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); return Effect.gen(function* () { yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer)); @@ -1517,11 +1339,8 @@ describe("db start", () => { it.live( "never mentions api.auto_expose_new_tables on stderr, whatever the flag is set to", () => { - // Auto-exposing new `public` entities is the default and an explicit `false` is a - // supported opt-out, so neither value is worth a line of start output. Each case's - // config.toml is written just before that case's run — `setup()` writes eagerly into - // the shared temp workdir, so writing all three up front would let the last write - // clobber the earlier ones before their runs ever execute. + // Each case's config.toml is written just before its own run — writing all three up + // front would let the last write clobber the earlier ones in the shared temp workdir. const configContentsCases = [ 'project_id = "test"\n[api]\nauto_expose_new_tables = true\n', 'project_id = "test"\n[api]\nauto_expose_new_tables = false\n', @@ -1545,18 +1364,9 @@ describe("db start", () => { it.live( "prints @supabase/config's deprecated-[inbucket]-section WARN only once on a fresh, not-already-running start", () => { - // `loadLocalProjectContext` wraps `@supabase/config`'s `loadCliConfig`, which - // unconditionally `Console.error`s a deprecation WARN for a legacy `[inbucket]` section - // (`packages/config/src/io.ts`'s `normalizeDeprecatedSMTPSections`, pinned to the real - // console — not this file's `Output` service, so it must be observed with a raw - // `console.error` spy, same idiom as `stop`/`status`'s own identical deprecated-provider - // tests). This handler used to load that context TWICE on the not-running path: once - // eagerly here (ahead of the already-running short-circuit), and again inside - // `buildLocalDbContainerInputs`'s own, now-removed, internal - // reload — doubling this WARN for one invocation, unlike the - // established single config-load call. Threading the eagerly-loaded - // context through as `buildLocalDbContainerInputs`'s - // `preloadedContext` fixes this. + // The deprecation WARN is Console.error-pinned to the real console, not this file's + // Output service, so it must be observed with a raw console.error spy, like + // stop/status's identical tests. const { layer } = setup({ configContents: 'project_id = "test"\n[inbucket]\n', route: freshVolumeRoute(defaultRoute()), @@ -1578,11 +1388,6 @@ describe("db start", () => { ); it.live("fails on a malformed auth duration field even when the db is already running", () => { - // Config loading (and therefore this eager duration validation) runs - // before the already-running check — a malformed `auth.*` duration - // field must fail the command even when Postgres is already up, not be - // masked by the already-running short-circuit. Mirrors the sibling - // "undecryptable secret" already-running test above. const { layer, out } = setup({ configContents: 'project_id = "test"\n[auth.email]\nmax_frequency = "not-a-duration"\n', running: true, @@ -1616,9 +1421,6 @@ describe("db start", () => { it.live( "does not warn about SMS when auth is disabled, matching Go's Enabled-gated (s *sms) validate()", () => { - // The sms validation that sources this warning only runs when auth is - // enabled. A disabled-auth project with `enable_signup = true` and no - // provider configured must NOT print the warning (review: PRRT_kwDOErm0O86Vk-e2). const { layer, out } = setup({ configContents: 'project_id = "test"\n[auth]\nenabled = false\n[auth.sms]\nenable_signup = true\n', @@ -1668,8 +1470,6 @@ describe("db start", () => { const exit = yield* dbStart(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(rollbackWasAttempted(child.spawned)).toBe(true); - // Same reasoning as the health-timeout rollback test above — this run's own volume was - // confirmed fresh, so the rollback prunes it too. expect(volumePruneWasAttempted(child.spawned)).toBe(true); }); }); @@ -1690,11 +1490,8 @@ describe("db start", () => { expect(createArgs(child.spawned)).not.toBeUndefined(); const success = out.messages.find((m) => m.type === "success"); expect(success?.data?.["status"]).toBe("started"); - // "Starting database..." (or "...from backup..." on a pre-existing - // volume, as here — see `defaultRoute`) is written to stderr - // unconditionally — no output-format concept gates it, so the - // `--output-format json` run must still see it on stderr (review: - // PRRT_kwDOErm0O86VmHkn). + // Progress text like "Starting database..." goes to stderr unconditionally, even in + // json output mode — only structured payloads on stdout are format-gated. expect(out.stderrText).toContain("Starting database from backup...\n"); }); }); diff --git a/apps/cli/src/commands/db/start/start.layers.ts b/apps/cli/src/commands/db/start/start.layers.ts index 8a350c6f38..efd1e23774 100644 --- a/apps/cli/src/commands/db/start/start.layers.ts +++ b/apps/cli/src/commands/db/start/start.layers.ts @@ -9,23 +9,11 @@ import { dockerRunLayer } from "../../../command-internal/docker-run.layer.ts"; import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; /** - * Runtime layer for `supabase db start`. `CommandSettings`/`ChildProcessSpawner`/ - * `FileSystem`/`Path` are ambient from the root runtime (`shared/cli/run.ts`), matching - * `supabase start`'s own layer composition (`start.command.ts`). - * - * No `DbBootstrapSeam` composition — that hidden `db __db-bootstrap` seam no - * longer exists at all: `isLocalDbRunning` (the already-running check) and - * `startDatabase` (the container bring-up itself) are both native TS, - * hoisted to `command-internal/db-bootstrap/`. `db reset --local` is ALSO fully - * native now, via its own composition over the same primitives (`reset.layers.ts`). - * - * `dockerRunLayer`/`dbConnectionLayer`/`httpClientLayer` back the native - * container bootstrap itself (`start.handler.ts`): the fresh-volume `SetupLocalDatabase`- - * equivalent pipeline runs its PG15+ one-shot migrate jobs through `DockerRun` and its - * schema/globals/API-privileges SQL over a direct `DbConnection` session, and the health - * wait (`waitForHealthyServices`) requires `HttpClient.HttpClient` in its type signature - * even though `db start` never uses the PostgREST/Edge-Runtime gateway probes — same reasoning - * as `start.command.ts`'s own composition of all three. + * Runtime layer for `supabase db start`, matching `supabase start`'s own composition. + * `dockerRunLayer`/`dbConnectionLayer`/`httpClientLayer` back the native container + * bootstrap: migrate jobs run through `DockerRun`, schema SQL over `DbConnection`, and the + * health wait requires `HttpClient.HttpClient` in its signature even though `db start` never + * uses the PostgREST/Edge-Runtime probes. */ const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); const httpClient = httpClientLayer.pipe(Layer.provide(debugLoggerLayer)); diff --git a/apps/cli/src/commands/db/test/test.command.ts b/apps/cli/src/commands/db/test/test.command.ts index 2ccc2a4292..f80ee47b21 100644 --- a/apps/cli/src/commands/db/test/test.command.ts +++ b/apps/cli/src/commands/db/test/test.command.ts @@ -9,17 +9,10 @@ import { import { testDbRuntimeLayer } from "../../../command-internal/test-db.layers.ts"; /** - * `db test` is a hidden alias for `test db` (registered hidden by the - * parent, `../db.command.ts`'s `dbTestCommand.pipe(Command.unlisted)`). - * - * `db test` and `test db` share one implementation, registered as two - * separate commands with identical flags and Short text. The native TS port - * mirrors that: both this file and `../../test/db/db.command.ts` import the - * shared config/handler/runtime-layer from `command-internal/legacy-test-db.*` - * instead of either command owning the implementation directly — - * `commands/<family>/` files may not import another family's - * internals (`code-structure.unit.test.ts`), so the implementation lives - * outside `commands/` entirely. + * `db test` is a hidden alias for `test db`, registered hidden by the parent + * (`../db.command.ts`'s `dbTestCommand.pipe(Command.unlisted)`). Both commands share one + * implementation, hoisted to `command-internal/` since `commands/<family>/` files may not + * import another family's internals (`code-structure.unit.test.ts`). */ export const dbTestCommand = Command.make("test", testDbConfig).pipe( Command.withDescription(TEST_DB_DESCRIPTION), diff --git a/apps/cli/src/commands/db/test/test.integration.test.ts b/apps/cli/src/commands/db/test/test.integration.test.ts index 0dda4fbccf..0121c229a4 100644 --- a/apps/cli/src/commands/db/test/test.integration.test.ts +++ b/apps/cli/src/commands/db/test/test.integration.test.ts @@ -1,37 +1,9 @@ /** - * `db test` is a hidden alias that reuses `test db`'s flag config and - * assembled handler verbatim (see `test.command.ts`). The core pgTAP - * enable/disable + `pg_prove` docker-invocation behavior is already - * exhaustively covered by `../../../shared/test-db.integration.test.ts` (calling the - * same `testDb` this alias ultimately runs), so this file focuses on - * what is actually NEW/alias-specific: - * - * 1. The alias still produces correct behavior end-to-end through - * `runTestDbCommand` (one golden path + one failure path), proving - * the delegation itself is wired correctly. - * 2. The `cli_command_executed` telemetry `command` property records the - * ACTUAL invoked path — `"db test"`, not `"test db"` — which differs - * between the two entry points even though the handler is identical. This - * is proven by dispatching through the REAL exported `dbTestCommand` - * (via `Command.runWith` on a minimal root, mirroring - * `../../../../shared/cli/hidden-flag.unit.test.ts`'s pattern) rather than - * hand-building a runtime layer with a test-supplied `commandPath` — a - * regression in `test.command.ts`'s own `testDbRuntimeLayer(["db", - * "test"])` wiring would otherwise silently corrupt product analytics - * without failing any test. - * 3. The non-text branch of `onRunFailure` (json/stream-json: stderr + exit - * code 1, no Effect failure) — shared code that had no prior coverage - * from either entry point. - * - * Mocking follows the same established patterns already used elsewhere in - * this codebase rather than inventing a new one: the domain-level fakes - * (`DbConfigResolver` / `DbConnection` / `DockerRun`) mirror - * `../../../shared/test-db.integration.test.ts`; the instrumentation-level fakes - * (`Analytics` reading `CurrentAnalyticsContext`, `Stdio.layerTest`, - * `commandRuntimeLayer`) mirror `../../../telemetry/command-telemetry.unit.test.ts`; - * and the telemetry-wiring test's ambient layer (satisfying - * `testDbRuntimeLayer`'s own requirements so it actually builds) mirrors - * `../../../shared/test-db.layers.unit.test.ts`'s `ambientStubs()`. + * `db test` is a hidden alias that reuses `test db`'s flag config and handler verbatim (see + * `test.command.ts`). The core pgTAP behavior is already covered by + * `../../../shared/test-db.integration.test.ts`, so this file only covers alias-specific + * behavior: end-to-end delegation, telemetry recording `"db test"` (not `"test db"`) as the + * invoked path, and the non-text `onRunFailure` branch. */ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; @@ -229,8 +201,6 @@ describe("db test (alias) integration", () => { "create extension if not exists pgtap with schema extensions", "drop extension if exists pgtap", ]); - // docker.run was reached with the expected pg_prove invocation and - // returned exit 0 (no failure surfaced above). expect(docker.lastOpts?.cmd[0]).toBe("pg_prove"); }).pipe(Effect.provide(layer)); }); @@ -238,14 +208,8 @@ describe("db test (alias) integration", () => { it.live( "dispatches through the real `dbTestCommand` and records `db test`, not `test db`, as the telemetry command", () => { - // `--local --linked` together fail INSIDE `testDb` (mutual - // exclusivity) before any DB/docker IO, so this can dispatch through the - // REAL command tree — proving `test.command.ts`'s own - // `testDbRuntimeLayer(["db", "test"])` wiring — without needing a - // real Postgres or Docker. `withCommandTelemetry` still - // captures `cli_command_executed` on the way out (it wraps the whole - // handler in `Effect.exit`), so mutating `test.command.ts` to pass - // `["test", "db"]` instead makes this assertion fail. + // `--local --linked` fail inside `testDb` (mutual exclusivity) before any DB/docker IO, + // so this can dispatch through the real command tree without a real Postgres or Docker. const args = ["db", "test", "--local", "--linked"]; const analytics = mockContextualAnalytics(); const layer = Layer.mergeAll( @@ -259,9 +223,9 @@ describe("db test (alias) integration", () => { CliOutput.layer(textCliOutputFormatter()), Stdio.layerTest({ args: Effect.succeed(args) }), Layer.succeed(CliArgs, { args }), - // `dbCommand` is the whole `db` subtree, so the root's R - // includes every sibling subcommand's global-flag/Go-delegation - // requirements too, even though this test only dispatches `db test`. + // `dbCommand` is the whole `db` subtree, so its R includes every sibling subcommand's + // global-flag/Go-delegation requirements too, even though this test only dispatches + // `db test`. Layer.succeed(AgentFlag, "auto"), Layer.succeed(CreateTicketFlag, false), Layer.succeed(DebugFlag, false), @@ -302,8 +266,8 @@ describe("db test (alias) integration", () => { return Effect.gen(function* () { const exit = yield* Effect.exit(runTestDbCommand(flags())); expect(exit._tag).toBe("Failure"); - // Unlike the json-mode branch below (which hand-writes exit 1), text - // mode must let the failed Effect itself drive the process exit code. + // Text mode lets the failed Effect itself drive the process exit code, unlike the + // json-mode branch below, which hand-writes exit 1. expect(processControl.exitCode).toBeUndefined(); }).pipe(Effect.provide(layer)); }); @@ -313,9 +277,8 @@ describe("db test (alias) integration", () => { () => { const { layer, out, processControl } = setup({ format: "json", exitCode: 1 }); return Effect.gen(function* () { - // Succeeds (no thrown/failed Effect) so a JSON error envelope is - // never appended after the TAP stream — established output - // contract: stderr + exit 1, never corrupting stdout. + // Succeeds (no thrown/failed Effect), so a JSON error envelope is never appended + // after the TAP stream. yield* runTestDbCommand(flags()); expect(out.stderrText).toContain("error running container: exit 1"); expect(processControl.exitCode).toBe(1); diff --git a/apps/cli/src/commands/domains/delete/delete.handler.ts b/apps/cli/src/commands/domains/delete/delete.handler.ts index b179154955..3cd8967534 100644 --- a/apps/cli/src/commands/domains/delete/delete.handler.ts +++ b/apps/cli/src/commands/domains/delete/delete.handler.ts @@ -12,10 +12,9 @@ const mapDeleteError = mapDomainsHttpError("delete"); const DELETE_SUCCESS_MESSAGE = "Deleted custom hostname config successfully."; -// `flags.includeRawOutput` is intentionally unread: `--include-raw-output` -// is a persistent flag on the `domains` group, so it is accepted on `delete` -// too, but ignored (delete has no response body to encode). The flag is -// inert here, asserted by the "ignores --include-raw-output" integration test. +// `flags.includeRawOutput` is unread: `--include-raw-output` is a persistent flag on the +// `domains` group, so it's accepted on `delete` too, but ignored — delete has no response +// body to encode. Asserted by the "ignores --include-raw-output" integration test. export const domainsDelete = Effect.fn("domains.delete")(function* (flags: DomainsDeleteFlags) { const output = yield* Output; const api = yield* CommandPlatformApi; @@ -30,8 +29,8 @@ export const domainsDelete = Effect.fn("domains.delete")(function* (flags: Domai output.format === "text" ? yield* output.task("Deleting custom hostname config...") : undefined; - // Delete returns an empty (void) body; Go ignores `-o` here and only prints - // the success line to stderr. + // Delete returns an empty (void) body, so `-o` has nothing to encode; only the success + // line prints to stderr. yield* api.v1.deleteHostnameConfig({ ref }).pipe( Effect.tapError(() => deleting?.fail() ?? Effect.void), Effect.catch(mapDeleteError), From 06afd75cd93ffb729bcdb16ea61990aeb3e39eec Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 12:44:22 +0100 Subject: [PATCH 20/56] chore(repo): trim comments, shard 25 (1607 -> 758 comment lines) --- .../cli/src/commands/domains/domains.cname.ts | 36 +-- apps/cli/src/commands/domains/domains.emit.ts | 29 +- .../src/commands/domains/domains.errors.ts | 5 +- .../src/commands/domains/domains.format.ts | 22 +- .../encryption/encryption.e2e.test.ts | 5 - .../commands/encryption/encryption.errors.ts | 4 +- .../update-root-key.handler.ts | 19 +- .../update-root-key.integration.test.ts | 9 +- .../experimental/stack/stack.shared.ts | 5 +- .../stack/start/start.e2e.test.ts | 5 +- .../workers/delete/delete.handler.ts | 106 ++----- .../workers/delete/delete.integration.test.ts | 42 --- .../experimental/workers/list/list.handler.ts | 82 +++-- .../workers/list/list.integration.test.ts | 38 +-- .../experimental/workers/logs/logs.handler.ts | 206 +++++-------- .../workers/logs/logs.integration.test.ts | 93 +----- .../experimental/workers/new/new.errors.ts | 8 +- .../experimental/workers/new/new.handler.ts | 144 ++++----- .../workers/new/new.integration.test.ts | 115 +------ .../experimental/workers/push/push.command.ts | 24 +- .../experimental/workers/push/push.handler.ts | 286 ++++++------------ .../workers/push/push.integration.test.ts | 163 +--------- .../workers/status/status.handler.ts | 38 +-- .../workers/status/status.integration.test.ts | 32 +- .../workers/workers-logs.format.ts | 147 +++------ .../workers/workers-logs.format.unit.test.ts | 34 +-- 26 files changed, 424 insertions(+), 1273 deletions(-) diff --git a/apps/cli/src/commands/domains/domains.cname.ts b/apps/cli/src/commands/domains/domains.cname.ts index b6c76dd533..3de30aa66f 100644 --- a/apps/cli/src/commands/domains/domains.cname.ts +++ b/apps/cli/src/commands/domains/domains.cname.ts @@ -12,12 +12,9 @@ function isRecord(value: unknown): value is Record<string, unknown> { } /** - * Internal discriminated failure for the CNAME verification pipeline: - * `transport: true` for resolver failures (fetch error, non-200, timeout), - * `transport: false` for a genuine finding about the user's DNS records - * (no CNAME answer). Consumed exclusively by {@link verifyCname}, which - * folds it into `DomainsCnameError` so telemetry can tell a Cloudflare - * DoH outage apart from a misconfigured record. + * Discriminated CNAME verification failure: `transport: true` for resolver + * failures (fetch error, non-200, timeout), `transport: false` for a genuine + * finding about the user's DNS records (no CNAME answer). */ export interface CnameFailure { readonly transport: boolean; @@ -25,14 +22,9 @@ export interface CnameFailure { } /** - * Extract the first CNAME answer's `data` from a Cloudflare DNS-over-HTTPS JSON - * response. Mirrors `utils.ResolveCNAME` - * (`apps/cli-go/internal/utils/api.go:60-79`): scan `Answer` for the first entry - * with `type === 5` and return its `data`; otherwise fail with the - * established "failed to locate" wording, embedding a capped, readable JSON - * dump of the answers instead of the reference implementation's actual - * (uncapped, `%+v`-on-`[]byte`) dump — see the NOTE at the failure site below - * for why those don't byte-match. + * Extracts the first CNAME answer's `data` from a Cloudflare DNS-over-HTTPS + * JSON response, or fails with a "failed to locate" message embedding a + * capped JSON dump of the answers. */ export function parseFirstCname( payload: unknown, @@ -44,15 +36,7 @@ export function parseFirstCname( return Effect.succeed(answer["data"]); } } - // Cap the embedded answer dump (mirrors the 1024-byte policy in - // `sanitizeErrorBody`) so an oversized DNS response can't flood the - // error envelope. Both the cap and the readable-JSON format are deliberate - // TS divergences: `ResolveCNAME` (`apps/cli-go/internal/utils/api.go:73-78`) - // JSON-marshals the answers to a `[]byte`, then formats that `[]byte` with - // `%+v` — a `%+v`-on-`[]byte` footgun that Go's `fmt` renders as an - // uncapped decimal byte-value array (e.g. `[91 10 32 32 ...]` — `91` is the - // `[` that opens the marshaled JSON array, not the JSON text itself; - // empirically verified by compiling Go). + // Cap the embedded answer dump so an oversized DNS response can't flood the error envelope. const dump = JSON.stringify(answers, null, 4); const capped = dump.length > 1024 ? `${dump.slice(0, 1024)}…` : dump; return Effect.fail({ @@ -61,11 +45,7 @@ export function parseFirstCname( }); } -/** - * Render the `%w`-wrapped cause string for the "failed to resolve" CNAME error. - * Transport / timeout / parse failures all flow through here so the outer - * message stays consistently shaped without leaking object internals. - */ +/** Formats a failure cause as a plain string, without leaking object internals. */ export function formatCnameCause(cause: unknown): string { if (cause instanceof Error) return cause.message; if (isRecord(cause) && typeof cause["message"] === "string") return cause["message"]; diff --git a/apps/cli/src/commands/domains/domains.emit.ts b/apps/cli/src/commands/domains/domains.emit.ts index 97ca898968..74b85c720b 100644 --- a/apps/cli/src/commands/domains/domains.emit.ts +++ b/apps/cli/src/commands/domains/domains.emit.ts @@ -16,10 +16,8 @@ import { import { formatHostnameStatus, type HostnameResponse } from "./domains.format.ts"; /** - * Type shape for `api.UpdateCustomHostnameResponse` - * (`apps/cli-go/pkg/api/types.gen.go`) — every hostname subcommand encodes - * this struct for `-o yaml` / `-o toml`, so keys derive from these field - * names and non-pointer fields are zero-filled (CLI-1975). + * Struct spec for the custom-hostname response, driving `-o yaml`/`-o toml` + * key casing for every hostname subcommand; non-pointer fields are zero-filled. */ const GO_HOSTNAME_RESPONSE = goStruct([ ["custom_hostname", goString], @@ -99,21 +97,16 @@ function terminateHumanStatus(status: string): string { } /** - * Emit a custom-hostname response across all output modes: + * Emits a custom-hostname response across all output modes: * - * - In `pretty`/text mode the human status text goes to **stderr**, and - * nothing goes to stdout. Unlike the reference implementation's - * no-newline `Fprintf` branches, the final human status here is - * newline-terminated so an interactive shell prompt cannot redraw over - * the last line. + * - In `pretty`/text mode the human status goes to **stderr**, + * newline-terminated so a shell prompt cannot redraw over the last line; + * nothing goes to stdout. * - In a structured `-o` mode (`json`/`yaml`/`toml`/`env`) the encoded - * response goes to **stdout** and the human status is **suppressed**, - * keeping stdout/stderr stable for machine consumers. - * - `--include-raw-output` (deprecated) forces `-o` to `json` when it is - * unset or `pretty`. - * - For the TS-native `--output-format json|stream-json` modes (no `-o` - * flag), emit a single structured `success` event and suppress the - * stderr status. + * response goes to **stdout** and the human status is suppressed. + * - `--include-raw-output` (deprecated) forces `-o` to `json` when unset or `pretty`. + * - For `--output-format json|stream-json` (no `-o` flag), emits a single + * structured `success` event and suppresses the stderr status. */ export const emitHostnameResult = Effect.fnUntraced(function* ( response: HostnameResponse, @@ -143,12 +136,10 @@ export const emitHostnameResult = Effect.fnUntraced(function* ( return; } - // goFmt is undefined or "pretty" — defer to the TS --output-format mode. if (output.format === "json" || output.format === "stream-json") { yield* output.success("", normalizeHostnameResponse(response)); return; } - // text mode (Go pretty parity): status to stderr, nothing to stdout. yield* output.raw(terminateHumanStatus(formatHostnameStatus(response)), "stderr"); }); diff --git a/apps/cli/src/commands/domains/domains.errors.ts b/apps/cli/src/commands/domains/domains.errors.ts index e51ef8cb2d..410dd443b9 100644 --- a/apps/cli/src/commands/domains/domains.errors.ts +++ b/apps/cli/src/commands/domains/domains.errors.ts @@ -34,9 +34,8 @@ class DomainsUnexpectedStatusError extends Data.TaggedError("DomainsUnexpectedSt readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // The gated create/get/activate/reverify wrappers currently do not retain - // the entitlement check's boolean on this shared error. Keep 404 on the - // conservative API-status policy until that typed signal is threaded. + // Entitlement-gated wrappers don't retain their check's boolean on this shared + // error, so 404 stays on the conservative API-status policy until that signal is threaded through. return statusCodeActionability(this.status); } } diff --git a/apps/cli/src/commands/domains/domains.format.ts b/apps/cli/src/commands/domains/domains.format.ts index 3865201a41..ecf3a5022f 100644 --- a/apps/cli/src/commands/domains/domains.format.ts +++ b/apps/cli/src/commands/domains/domains.format.ts @@ -28,17 +28,14 @@ function getHostnameStatus(response: HostnameResponse): HostnameStatus | undefin } /** - * Byte-for-byte port of `hostnames.PrintStatus`. Returns the exact string - * written to stderr — mind the trailing-newline difference between - * `Fprintln` (adds `\n`) and `Fprintf` (does not). + * Returns the exact string written to stderr for a hostname status. + * Trailing-newline presence differs by branch and is part of the established output. */ export function formatHostnameStatus(response: HostnameResponse): string { switch (getHostnameStatus(response)) { case "5_services_reconfigured": - // Fprintf — no trailing newline. return `Custom hostname setup completed. Project is now accessible at ${response.custom_hostname}.`; case "4_origin_setup_completed": - // Fprintf raw string literal — no trailing newline. return `Custom hostname configuration complete, and ready for activation. Please ensure that your custom domain is set up as a CNAME record to your Supabase subdomain: @@ -47,7 +44,6 @@ ${response.custom_hostname} CNAME -> ${response.data.result.custom_origin_server case "2_initiated": { const ssl = response.data.result.ssl; if (ssl.status === "initializing") { - // Fprintln — trailing newline. return "Custom hostname setup is being initialized; please request re-verification in a few seconds.\n"; } const validationErrors = ssl.validation_errors; @@ -55,21 +51,16 @@ ${response.custom_hostname} CNAME -> ${response.data.result.custom_origin_server const errorMessages: string[] = []; for (const valError of validationErrors) { if (valError.message.includes("caa_error")) { - // Fprintln — trailing newline; Go returns immediately. return 'CAA mismatch; please remove any existing CAA records on your domain, or add one for "digicert.com"\n'; } errorMessages.push(valError.message); } - // Fprintf with explicit trailing `\n`. return `SSL validation errors: \n\t- ${errorMessages.join("\n\t- ")}\n`; } const validationRecords = ssl.validation_records ?? []; if (validationRecords.length !== 1) { - // Fprintf — no trailing newline. Go formats the ssl struct with `%+v`; - // not byte-reproducible (see formatSslStructDump). return `expected a single SSL verification record, received: ${formatSslStructDump(ssl)}`; } - // Fprintln on the two-line heading, then a tab-indented record (Fprintf, no newline). let out = "Custom hostname verification in-progress; please configure the appropriate DNS entries and request re-verification.\nRequired outstanding validation records:\n"; const rec = validationRecords[0]; @@ -79,20 +70,15 @@ ${response.custom_hostname} CNAME -> ${response.data.result.custom_origin_server return out; } case "1_not_started": - // Fprintln — trailing newline. return "Custom hostname configuration not started.\n"; default: - // No default arm — nothing is written. return ""; } } /** - * Approximates `fmt.Sprintf("%+v", ssl)` for the degenerate - * "validation_records != 1" branch. The reference output embeds a pointer - * address for the `ValidationErrors` field and is therefore not - * byte-reproducible; this dump is deterministic and documented as a - * divergence in SIDE_EFFECTS.md. + * Formats the ssl struct for the "more than one validation record" branch. + * Deterministic but not byte-reproducible; see SIDE_EFFECTS.md. */ export function formatSslStructDump(ssl: HostnameSsl): string { const validationErrors = diff --git a/apps/cli/src/commands/encryption/encryption.e2e.test.ts b/apps/cli/src/commands/encryption/encryption.e2e.test.ts index 2ea7b8595a..e96f8b4860 100644 --- a/apps/cli/src/commands/encryption/encryption.e2e.test.ts +++ b/apps/cli/src/commands/encryption/encryption.e2e.test.ts @@ -5,9 +5,6 @@ const E2E_TIMEOUT_MS = 30_000; const TEST_TOKEN = "sbp_" + "a".repeat(40); describe("supabase encryption", () => { - // Golden-path e2e: validates real subprocess dispatch + ref-resolution error - // wiring for the get path. With an isolated HOME and no --project-ref / - // SUPABASE_PROJECT_ID, the resolver fails before any API call. test( "get-root-key without a resolvable project ref exits non-zero with the not-linked message", { timeout: E2E_TIMEOUT_MS }, @@ -20,8 +17,6 @@ describe("supabase encryption", () => { }, ); - // Validates the piped-stdin read path reaches the resolver in a real - // subprocess — the key is consumed from stdin, then ref resolution fails. test( "update-root-key with piped key but no resolvable ref exits non-zero", { timeout: E2E_TIMEOUT_MS }, diff --git a/apps/cli/src/commands/encryption/encryption.errors.ts b/apps/cli/src/commands/encryption/encryption.errors.ts index fd341b68ac..1b6ad0ba8c 100644 --- a/apps/cli/src/commands/encryption/encryption.errors.ts +++ b/apps/cli/src/commands/encryption/encryption.errors.ts @@ -39,8 +39,8 @@ class EncryptionUnexpectedStatusError extends Data.TaggedError("EncryptionUnexpe } /** - * Build the network/status error mapper for an encryption subcommand. Go uses - * different verbs for the network vs status message of the same subcommand + * Build the network/status error mapper for an encryption subcommand. The + * network and status messages of the same subcommand use different verbs * (get: "retrieve"/"get"; update: "update"/"update"), so the factory takes * both and shares the dispatch + body-truncation policy from `mapHttpError`. */ diff --git a/apps/cli/src/commands/encryption/update-root-key/update-root-key.handler.ts b/apps/cli/src/commands/encryption/update-root-key/update-root-key.handler.ts index a1dcf52c7c..45ed1ba0d5 100644 --- a/apps/cli/src/commands/encryption/update-root-key/update-root-key.handler.ts +++ b/apps/cli/src/commands/encryption/update-root-key/update-root-key.handler.ts @@ -27,15 +27,9 @@ export const encryptionUpdateRootKey = Effect.fn("encryption.update-root-key")(f const ref = yield* resolver.resolve(flags.projectRef); - // Faithful port of `update.Run` + `credentials.PromptMasked(os.Stdin)`. - // The prompt is unconditionally written to stderr, the key is read - // (masked on a TTY, `io.Copy` of all stdin when piped), then a trailing - // newline is printed to stdout (`defer fmt.Println()`) — even when stdin - // is piped. Both read paths trim, matching `strings.TrimSpace(input)`. - // The stderr prompt + stdout newline are reproduced only in text mode; - // json / stream-json reserve stdout for the structured result. On a TTY - // the masked prompt uses clack framing, so the rendered prompt is not - // byte-identical to the reference implementation (see SIDE_EFFECTS.md). + // In text mode, the piped-input path writes the prompt to stderr and echoes a + // trailing newline to stdout after reading; on a TTY the masked prompt uses + // clack framing instead — see SIDE_EFFECTS.md for that divergence. let rootKey: string; if (stdin.isTTY) { rootKey = yield* output.promptPassword("Enter a new root key: "); @@ -45,8 +39,7 @@ export const encryptionUpdateRootKey = Effect.fn("encryption.update-root-key")(f if (output.format === "text") yield* output.raw("\n", "stdout"); } - // Write the linked-project cache and persist the telemetry state file on - // success and failure. + // Runs on both success and failure. yield* Effect.gen(function* () { const updating = output.format === "text" ? yield* output.task("Updating root key...") : undefined; @@ -57,14 +50,10 @@ export const encryptionUpdateRootKey = Effect.fn("encryption.update-root-key")(f yield* updating?.clear() ?? Effect.void; if (output.format !== "text") { - // json / stream-json — emit a structured result. yield* output.success("", { root_key: response.root_key }); return; } - // text — Go: `fmt.Fprintln(os.Stderr, "Finished "+utils.Aqua("supabase - // root-key update")+".")` (`internal/encryption/update/update.go:26`). - // `aqua` renders cyan on a TTY and plain when piped, like lipgloss. yield* output.raw(`Finished ${aqua("supabase root-key update")}.\n`, "stderr"); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts b/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts index df6b1fa462..82366f96c6 100644 --- a/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts +++ b/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts @@ -20,7 +20,6 @@ interface SetupOpts { readonly status?: number; readonly network?: "fail"; readonly projectId?: Option.Option<string>; - // stdin readonly stdinIsTty?: boolean; readonly pipedInput?: string; readonly promptPasswordResponses?: ReadonlyArray<string>; @@ -66,12 +65,9 @@ describe("encryption update-root-key integration", () => { const put = api.requests.find((r) => r.method === "PUT"); expect(put?.url).toContain(`/v1/projects/${VALID_REF}/pgsodium`); expect(put?.body).toEqual({ root_key: "new-key" }); - // Prompt to stderr, trailing newline to stdout (defer Println), - // finished notice to stderr. expect(out.stderrText).toContain("Enter a new root key: "); - // The command path is wrapped in ANSI (aqua) in colour-capable - // environments, so assert on the tokens around it — same convention as - // `db/reset/reset.integration.test.ts`'s aqua'd branch name. + // ANSI wraps the command path in colour-capable environments, so assert on + // the surrounding tokens instead of the full string. expect(out.stderrText).toContain("Finished "); expect(out.stderrText).toContain("supabase root-key update"); expect(out.stdoutText).toBe("\n"); @@ -106,7 +102,6 @@ describe("encryption update-root-key integration", () => { const success = out.messages.find((m) => m.type === "success"); expect(success?.message).toBe(""); expect(success?.data).toEqual({ root_key: "new-key" }); - // json mode reserves stdout for the structured result — no prompt newline. expect(out.stdoutText).toBe(""); expect(out.stderrText).toBe(""); }).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/commands/experimental/stack/stack.shared.ts b/apps/cli/src/commands/experimental/stack/stack.shared.ts index 73503d7d4b..63b4801838 100644 --- a/apps/cli/src/commands/experimental/stack/stack.shared.ts +++ b/apps/cli/src/commands/experimental/stack/stack.shared.ts @@ -36,9 +36,8 @@ export class ExperimentalStackTargetError extends Data.TaggedError("Experimental } /** - * Configuration and targeting are deliberately supplied by the CLI adapter. - * Keeping this boundary independent of command handlers lets the later stack - * commands reuse exactly the same project, name, id, and environment rules. + * Configuration and targeting are supplied by the CLI adapter so later stack + * commands can reuse the same project, name, id, and environment rules. */ interface ExperimentalStackTargetResolverShape { readonly resolve: (input: { diff --git a/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts b/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts index 5097fd1a37..8934240254 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.e2e.test.ts @@ -1,6 +1,5 @@ -// This is a compiled CLI boundary test. It deliberately starts and stops the native stack through -// the built binary, then uses the package's public Promise API to inspect and destroy that exact -// stack. +// Starts and stops a native stack through the compiled CLI binary, then uses the package's +// public Promise API to inspect and destroy that stack. // oxlint-disable-next-line effecttsgo/process-env -- package runtime composition is scoped below. // oxlint-disable-next-line effecttsgo/node-builtin-import -- compiled CLI fixture requires host process/filesystem APIs diff --git a/apps/cli/src/commands/experimental/workers/delete/delete.handler.ts b/apps/cli/src/commands/experimental/workers/delete/delete.handler.ts index 4838e2162a..20f3ac3048 100644 --- a/apps/cli/src/commands/experimental/workers/delete/delete.handler.ts +++ b/apps/cli/src/commands/experimental/workers/delete/delete.handler.ts @@ -31,25 +31,11 @@ import { import type { WorkersDeleteFlags } from "./delete.command.ts"; /** - * `supabase experimental workers delete [name]` — delete the worker; its instances and image - * are torn down asynchronously. Whether it exists is asked of the API, never of - * a local file. - * - * Note what it does *not* remove: the worker's directory and its `config.toml` - * entry stay on disk, so `push <name>` brings it straight back — which is why - * the command says so. - * - * Being irreversible, an interactive session has to type the worker's name back - * to proceed — the same "confirm by typing it" pattern as GitHub's own repo - * deletion, rather than a bare y/n that is too easy to reflexively confirm. - * `--yes`/`SUPABASE_YES` skips it for scripts, resolved through - * `resolveYes` like every other confirming command rather than through a - * local flag that would shadow the root one. It also makes an already-absent - * worker a success: teardown run twice should not fail the second time. - * - * Without a terminal to prompt on there is no third option: `interactive` tracks - * stdout, so merely redirecting output would otherwise delete unattended. This - * refuses instead, and says which flag would have authorised it. + * `supabase experimental workers delete [name]` — deletes the worker via the API + * (never checking local files); an already-absent worker counts as success. The + * worker's directory and `config.toml` entry stay on disk so `push <name>` can + * redeploy it. Interactive runs require typing the name to confirm (`--yes`/ + * `SUPABASE_YES` skips it); without a terminal to prompt on, the command refuses. */ export const workersDelete = Effect.fn("experimental.workers.delete")(function* ( flags: WorkersDeleteFlags, @@ -60,17 +46,13 @@ export const workersDelete = Effect.fn("experimental.workers.delete")(function* const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; const tty = yield* Tty; - // `--yes` OR `SUPABASE_YES`, matching `projects delete` and every other - // command that guards a destructive step behind a prompt. const yes = yield* resolveYes; - // The ref is resolved outside the finalizers because caching it is one of - // them; everything that can fail on its own — loading `config.toml`, - // validating the name, resolving the worker — belongs inside, so those - // failures still flush telemetry. Same shape as `config/push`. + // Resolved here, outside the block below, since caching it is one of that + // block's own finalizers — everything else that can fail belongs inside so + // those failures still flush telemetry. const projectRef = yield* resolver.resolve(flags.projectRef); - // Every retry this command suggests is for a *destructive* re-run, so the ref - // has to survive the copy-paste. + // Retry suggestions repeat a destructive command, so the ref must survive the copy-paste. const refSuffix = workersProjectRefSuffix(flags.projectRef); yield* Effect.gen(function* () { @@ -78,18 +60,14 @@ export const workersDelete = Effect.fn("experimental.workers.delete")(function* const name = yield* validateWorkerName(flags.name); const worker = yield* describeWorkerForReporting(project, name); - // Before the first API call, not at emit time: the emit branch is reached - // *after* the DELETE, so `--yes -o env` deleted the worker and only then - // exited non-zero with no payload — which a script reads as a failed delete. + // Checked before the DELETE call: checking at emit time would let `--yes -o env` + // delete the worker, then exit non-zero with no payload for the caller to read. yield* rejectWorkersEnvOutput(); const fetching = yield* output.task("Fetching worker..."); - // The lookup is a courtesy, not a prerequisite: it supplies the instance - // tally the confirmation quotes and the "already gone" verdict. The API - // grants the read and the delete separately — `edge_functions:read` for - // `GET`, `edge_functions:write` for `DELETE` — so a credential holding only - // the latter could not delete a worker it is entitled to delete. A refused - // read now leaves the worker *unknown* and the delete goes ahead. + // The GET is informational only: read (`edge_functions:read`) and delete + // (`edge_functions:write`) are separate grants, so a 403 here must not block + // a credential that is only entitled to delete. const lookup = yield* getWorker(api, projectRef, name).pipe( Effect.map((found) => ({ readable: true, worker: Option.getOrUndefined(found) })), Effect.catchIf( @@ -103,35 +81,24 @@ export const workersDelete = Effect.fn("experimental.workers.delete")(function* const deployed = lookup.worker; const machineOutput = yield* workersMachineOutputRequested(); - // `--yes` is the scripted path, and `deleteWorker` already treats a DELETE - // 404 as done — "a delete that races another one is still a delete that - // happened". The pre-flight GET contradicted that for teardown: a script run - // twice exited non-zero the second time, for a worker in exactly the state - // it asked for. Interactively the error stays: somebody typed this command - // and wants to hear the worker was not there. + // `--yes` treats a DELETE 404 as success (a delete racing another delete still + // happened), but this pre-flight check fails interactively so a re-run tells + // the user nothing was there instead of silently succeeding. if (lookup.readable && deployed === undefined && !yes) { return yield* Effect.fail( new WorkerNotDeployedError({ detail: `Nothing is deployed for "${name}" in project ${projectRef}.`, - // `status`'s wording, inherited, pointed the wrong way here: somebody - // deleting "api" and hearing "nothing is deployed" does not want to - // deploy it — they want to see what *is* deployed. + // Points at `list`, not `push` — the user wants to see what's deployed, not deploy it. suggestion: `See what is deployed with \`supabase experimental workers list${refSuffix}\`.`, }), ); } if (!yes) { - // `-o json` leaves `output.format` as `text`, so the format check alone - // still let the warning and the prompt run — onto the stdout the user had - // asked to carry a payload. A machine format is as non-interactive as a - // redirected stdout, whichever flag asked for it. - // - // `output.interactive` only tracks *stdout*, so on its own it still let - // `printf 'api\n' | supabase experimental workers delete api` feed the pipe straight - // into the prompt and delete without `--yes`. The confirmation is only - // meaningful from a keyboard, so stdin has to be a terminal too — the same - // pair `projects delete` guards its prompt with. + // Four checks, none alone sufficient: `-o json` leaves `output.format` as + // `text`, so machine mode needs its own check; `output.interactive` only + // tracks stdout, so piped stdin (e.g. `echo api | ... delete api`) also + // needs `tty.stdinIsTty` — a confirmation typed from a pipe isn't real. if (output.format !== "text" || machineOutput || !output.interactive || !tty.stdinIsTty) { return yield* Effect.fail( new WorkerDeleteConfirmationRequiredError({ @@ -141,12 +108,9 @@ export const workersDelete = Effect.fn("experimental.workers.delete")(function* ); } - // The live tally when the API reports one, labelled "declared" when it - // does not. `spec.instances` is the target, which for a worker still - // provisioning differs from what is running — and a destructive prompt is - // the wrong place to overstate. - // Absent when the read was refused: the prompt still asks for the name, - // it just cannot quote a count it was not allowed to see. + // Uses the live instance count when known; otherwise falls back to the + // declared target so a still-provisioning worker doesn't understate what + // gets torn down. Both are absent when the read was refused. const live = deployed?.instances?.live; const declared = deployed?.spec.instances; const terminating = @@ -161,8 +125,7 @@ export const workersDelete = Effect.fn("experimental.workers.delete")(function* `This permanently deletes "${name}" from project ${projectRef}.${terminating}\n`, ); const typed = yield* output.promptText(`Type ${name} to confirm`); - // Trimmed: a trailing space from a paste is not a different answer, and - // making someone re-run a destructive command over one is just friction. + // Trimmed so a pasted trailing space doesn't force a re-run. if (typed.trim() !== name) { return yield* Effect.fail( new WorkerDeleteNotConfirmedError({ @@ -173,9 +136,8 @@ export const workersDelete = Effect.fn("experimental.workers.delete")(function* } } - // Skipped only when the fetch actually said there is nothing there. An - // unreadable worker still gets the DELETE — that request is the one the - // credential is entitled to make, and the API treats a 404 on it as done. + // Skipped only when the GET confirmed nothing exists; an unreadable worker + // still gets the DELETE, since that's the request the credential may hold. if (deployed !== undefined || !lookup.readable) { const deleting = yield* output.task("Deleting worker..."); yield* deleteWorker(api, projectRef, name).pipe(Effect.tapError(() => deleting.fail())); @@ -219,21 +181,17 @@ export const workersDelete = Effect.fn("experimental.workers.delete")(function* `Deleted Worker ${aqua(name, process.stdout)} from project ${projectRef}\n`, ); - // "Deleted" reads more final than it is *when there is something left* — - // so only say so when there is. For an orphan there is nothing local to - // keep, and pointing at `push` would send the user at a command that has - // no source to deploy. + // Says "Kept" only when something remains; an orphaned worker has no + // source or entry left, and pointing at `push` there would be a dead end. const kept = [ ...(keptSource === undefined ? [] : [keptSource]), ...(keptEntry ? ["its supabase/config.toml entry"] : []), ]; if (kept.length > 0) { yield* output.raw(renderWorkerDetails([["Kept", kept.join(", ")]])); - // Only when the source is still there: a retained `config.toml` entry - // alone is not enough to redeploy from, so `push` would fail on the very - // command this line recommends. + // Only when the source remains: a config.toml entry alone can't redeploy, + // so `push` would fail on the very command this recommends. if (keptSource !== undefined) { - // Trailer, like every other "what to run next" line in this shell. yield* emitSuccessTrailer( `Redeploy it with ${aqua(`supabase experimental workers push ${name}${refSuffix}`)}.\n`, ); diff --git a/apps/cli/src/commands/experimental/workers/delete/delete.integration.test.ts b/apps/cli/src/commands/experimental/workers/delete/delete.integration.test.ts index 836b551134..30af31ff71 100644 --- a/apps/cli/src/commands/experimental/workers/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/experimental/workers/delete/delete.integration.test.ts @@ -64,24 +64,15 @@ describe("workers delete", () => { expect(http.routeKeys).toEqual([getRoute, deleteRoute]); expect(out.stdoutText).toContain("permanently deletes"); - // Labelled "declared" because this response carries no live tally. - // `spec.instances` is the target, not what is running. expect(out.stdoutText).toContain("3 declared instances"); expect(out.stdoutText).toContain("Kept"); - // Nothing local is touched — that is what makes `push` a one-command undo. expect(existsSync(join(repo.dir, "supabase", "workers", "api", "index.js"))).toBe(true); expect(readFileSync(join(repo.dir, "supabase", "config.toml"), "utf8")).toBe(CONFIG); - // The redeploy hint is a success trailer, which lands on stderr. expect(out.stderrText).toContain("supabase experimental workers push api"); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The refusal has to precede the DELETE. At emit time `--yes -o env` would - // remove the worker and then exit non-zero with no payload, which a script - // reads as "the delete failed" and may retry. - // Deletion never touches local files, so a malformed local config has no - // business standing between the user and a worker they named explicitly. it.live("deletes a remote worker despite an unparseable local config", () => { const repo = project("project_id = [unclosed\n"); const otherRef = "qrstuvwxyzabcdefghij"; @@ -104,9 +95,6 @@ describe("workers delete", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The API grants `edge_functions:read` for the GET and `edge_functions:write` - // for the DELETE separately, so a credential holding only write could not - // delete a worker it is entitled to delete. it.live("deletes with --yes when the credential may not read the worker", () => { const repo = project(); const { layer, http } = setupWorkers({ @@ -140,13 +128,11 @@ describe("workers delete", () => { yield* workersDelete({ name: "api", projectRef: Option.none() }); expect(out.stdoutText).toContain("permanently deletes"); - // No count is quoted: the read that would have supplied one was refused. expect(out.stdoutText).not.toContain("will be terminated"); expect(http.routeKeys).toEqual([getRoute, deleteRoute]); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // A refusal is not an absence: only a real 404 means there was nothing there. it.live("reports an unreadable worker as deleted, not as nothing to delete", () => { const repo = project(); const { layer, out } = setupWorkers({ @@ -205,9 +191,6 @@ describe("workers delete", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The suggested retry is copy-pasted verbatim and carries `--yes`, so dropping - // an explicit ref points a no-prompt delete at whatever this checkout is - // linked to — a same-named worker in a project the user never named. it.live("keeps an explicit --project-ref in the retry it suggests", () => { const repo = project(); const otherRef = "qrstuvwxyzabcdefghij"; @@ -290,9 +273,6 @@ describe("workers delete", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `printf 'api\n' | supabase experimental workers delete api`: stdout is still a TTY, so - // `output.interactive` stayed true and the prompt read the worker name off the - // pipe — a confirmation the user never typed. it.live("refuses to read the confirmation off a piped stdin", () => { const repo = project(); const { layer, http } = setupWorkers({ @@ -328,8 +308,6 @@ describe("workers delete", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `interactive` follows stdout, so a plain `>` redirect reaches this branch even - // from a live terminal — the case where deleting without asking would be worst. it.live("refuses when stdout is redirected and no --yes was given", () => { const repo = project(); const { layer, http } = setupWorkers({ @@ -379,7 +357,6 @@ describe("workers delete", () => { }).pipe(Effect.flip); expect(error).toBeInstanceOf(WorkerNotDeployedError); - // Not `workers push`: somebody deleting "api" does not want to deploy it. const suggestion = error instanceof WorkerNotDeployedError ? error.suggestion : ""; expect(suggestion).toContain("supabase experimental workers list"); expect(suggestion).not.toContain("workers push"); @@ -387,9 +364,6 @@ describe("workers delete", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `deleteWorker` already treats a DELETE 404 as done; the pre-flight GET used - // to contradict that, so a teardown script run twice failed the second time - // for a worker in exactly the state it asked for. it.live("succeeds under --yes when the worker is already gone", () => { const repo = project(); const { layer, out, http } = setupWorkers({ @@ -401,7 +375,6 @@ describe("workers delete", () => { return Effect.gen(function* () { yield* workersDelete({ name: "api", projectRef: Option.none() }); - // Nothing to delete, so nothing is asked of the API beyond the lookup. expect(http.routeKeys).toEqual([getRoute]); expect(out.stdoutText).toContain("nothing to delete"); expect(out.stdoutText).not.toContain("Deleted Worker"); @@ -487,9 +460,6 @@ describe("workers delete", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `-o json` leaves `output.format` as `text`, so the interactive check alone - // still ran the warning and the prompt — onto the stdout the payload was - // supposed to own. it.live("refuses rather than prompting when -o json asked for the stdout", () => { const repo = project(); const { layer, out, http } = setupWorkers({ @@ -511,9 +481,6 @@ describe("workers delete", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The live tally is what is actually running; `spec.instances` is the target. - // For a worker mid-provision the two differ, and a destructive confirmation is - // the worst place to overstate. it.live("counts the live instances in the confirmation when the API reports them", () => { const repo = project(); const { layer, out } = setupWorkers({ @@ -569,8 +536,6 @@ describe("workers delete", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Scaled to zero: there is a tally, and it says nothing is running. Warning - // about terminated instances there would invent a consequence. it.live("promises no terminations when nothing is running", () => { const repo = project(); const { layer, out } = setupWorkers({ @@ -599,9 +564,6 @@ describe("workers delete", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // An orphan — deployed from another checkout — has no local entry and no local - // directory, so there is nothing that was "kept" and `push` has no source to - // redeploy from. it.live("does not claim to have kept local files it never had", () => { const repo = project('project_id = "demo"\n'); const { layer, out } = setupWorkers({ @@ -647,8 +609,6 @@ describe("workers delete", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // A `config.toml` entry on its own is not something `push` can deploy from, so - // recommending it would send the user at a command that fails. it.live("keeps the config entry but does not advise redeploying without a source", () => { const repo = project(); rmSync(join(repo.dir, "supabase", "workers", "api"), { recursive: true, force: true }); @@ -662,8 +622,6 @@ describe("workers delete", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Deletion never reads the local source, so a `source` that does not resolve - // inside the project must not block removing the remote worker. it.live("deletes the remote worker even when the configured source is unusable", () => { const repo = project('project_id = "demo"\n\n[workers.api]\nsource = "../../elsewhere"\n'); const { layer, out, http } = setupWorkers({ workdir: repo.dir, routes, yes: true }); diff --git a/apps/cli/src/commands/experimental/workers/list/list.handler.ts b/apps/cli/src/commands/experimental/workers/list/list.handler.ts index f09d1cf411..bbf99ead46 100644 --- a/apps/cli/src/commands/experimental/workers/list/list.handler.ts +++ b/apps/cli/src/commands/experimental/workers/list/list.handler.ts @@ -18,25 +18,22 @@ import type { WorkersListFlags } from "./list.command.ts"; /** * `supabase experimental workers list` — every worker in this project, deployed or not. * - * A union of two sources, because either half alone is misleading: the - * project's `[workers.*]` entries (scaffolded, maybe never deployed) and what - * the API reports as deployed (including anything deployed from elsewhere, or - * from a directory since deleted). A worker in the config with nothing deployed - * shows as `not deployed`; a deployed worker with no local entry is called out, - * since pushing it from here would have to guess its runtime. + * Merges two sources that are each misleading alone: the project's + * `[workers.*]` entries (maybe never deployed) and what the API reports as + * deployed (including workers pushed from elsewhere). A configured worker with + * nothing deployed shows as `not deployed`; a deployed worker with no local + * entry is called out, since pushing it from here would have to guess its runtime. * - * The list endpoint deliberately makes no per-worker backend call, so it - * carries no live instance tally — the `INSTANCES` column is the declared - * count from the spec. `status` is where the live tally lives. + * `INSTANCES` is the declared count from the spec — the list endpoint makes no + * per-worker call, so it carries no live tally; `status` has that. */ /** - * No URL column. Every worker's URL is the same 40-odd characters of host and - * prefix with the name on the end, which pushed the table past 130 columns to - * carry one derivable field — `renderGlamourTable` sizes each column to its - * widest cell and never wraps. `workers status` renders it, vertically, for the - * same reason (see `workers.format.ts`), and every machine format still carries - * `url` per worker. + * No URL column: every worker's URL is the same ~40 characters of host and + * prefix with just the name changing, which would push the table past 130 + * columns for one derivable field. `workers status` renders it vertically for + * the same reason (see `workers.format.ts`); every machine format still + * carries `url` per worker. */ const HEADERS = ["NAME", "RUNTIME", "SIZE", "STATE", "INSTANCES"] as const; @@ -62,10 +59,9 @@ function stateLabel(row: WorkerRow): string { } /** - * The API omits `spec.runtime` only for a context-only build, so for a deployed - * worker its absence *is* "dockerfile". For one that has never been deployed - * there is nothing to infer from — `push` would guess from marker files — so say - * unknown rather than assert a runtime it may not have. + * The API omits `spec.runtime` only for a context-only build, so its absence + * on a deployed worker means "dockerfile". An undeployed worker has nothing to + * infer from, so this reports unknown rather than guessing. */ function runtimeLabelFor(row: WorkerRow): string | undefined { if (row.deployed !== undefined) { @@ -106,18 +102,17 @@ export const workersList = Effect.fn("experimental.workers.list")(function* ( const telemetryState = yield* TelemetryState; const settings = yield* CommandSettings; - // The ref is resolved outside the finalizers because caching it is one of - // them; everything that can fail on its own — loading `config.toml`, - // validating the name, resolving the worker — belongs inside, so those - // failures still flush telemetry. Same shape as `config/push`. + // Resolved here, outside the block below, since caching it is one of that + // block's own finalizers — everything else that can fail belongs inside so + // those failures still flush telemetry. const projectRef = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { const project = yield* loadWorkersProject(); - // Up front, like the rest of the family: this payload always carries a - // `workers` array, so `-o env` can never encode it, and finding that out at - // emit time means failing after the fetch has already been paid for. + // Checked up front: this payload always carries a `workers` array, which + // `-o env` can never encode, so failing at emit time would mean paying for + // the fetch first. yield* rejectWorkersEnvOutput(); const fetching = yield* output.task("Fetching workers..."); @@ -128,9 +123,9 @@ export const workersList = Effect.fn("experimental.workers.list")(function* ( const byName = new Map(deployed.map((worker) => [worker.name, worker])); const configuredNames = Object.keys(project.section.workers); - // Three sources: config entries, deployed workers, and directories under the - // workers root. The last are deployable — `discoverWorkerNames` is the - // walk a bare `push` does — so the inventory has to show them. + // Three sources: config entries, deployed workers, and on-disk directories + // under the workers root — the last are deployable via a bare `push`, so + // the inventory must show them too. const discoveredNames = yield* discoverWorkerNames(project); const names = [...new Set([...configuredNames, ...discoveredNames, ...byName.keys()])].sort(); @@ -156,10 +151,9 @@ export const workersList = Effect.fn("experimental.workers.list")(function* ( configured: row.configured, local: row.local, deployed: row.deployed !== undefined, - // Read the same way `runtimeLabel` reads it, so `-o json` and the text - // table cannot disagree: for a deployed worker an absent `spec.runtime` - // *means* dockerfile, and falling back to the local config there - // reported a stale runtime the deployment had moved off. + // Reads the same way `runtimeLabel` does, so `-o json` and the text + // table can't disagree: an absent `spec.runtime` on a deployed worker + // means dockerfile, not a stale local config value. runtime: runtimeLabelFor(row), size: row.deployed?.spec.size, state: stateLabel(row), @@ -168,9 +162,9 @@ export const workersList = Effect.fn("experimental.workers.list")(function* ( })), }; - // `-o` is independent of `--output-format`: it leaves `output.format` as - // `text`, so this has to be checked before the text branch below, not - // inside the structured one. + // `-o` is independent of `--output-format` and leaves `output.format` as + // `text`, so this must be checked before the text branch, not inside the + // structured one. if (yield* emitWorkersMachineOutput(payload)) { return; } @@ -189,18 +183,10 @@ export const workersList = Effect.fn("experimental.workers.list")(function* ( yield* output.raw(renderGlamourTable([...HEADERS], rows.map(toCells))); - // Two different problems, and they need different advice. A worker with a - // local directory but no entry can be pushed — the runtime is the only - // unknown. One with nothing local at all cannot: `deployOneWorker` checks - // the source directory *before* inferring a runtime and fails with - // `WorkerSourceMissingError`, so telling that user about runtime guessing - // points them at the wrong prerequisite. - // - // Both are written the way this shell writes every other heads-up that is - // not a failure: a yellow `WARNING:` prefix, then the consequence on its own - // line (`start`'s Docker-on-Windows notice is the same two-line shape). A - // single long sentence re-flows differently at every terminal width, right - // under a table that lines its columns up. + // A local directory with no config entry can still be pushed (only the + // runtime is unknown); nothing local at all can't — `deployOneWorker` + // requires a source directory before it infers a runtime, so pointing that + // case at runtime guessing would name the wrong problem. const unconfigured = rows .filter((row) => row.deployed !== undefined && !row.configured && row.local) .map((row) => row.name); diff --git a/apps/cli/src/commands/experimental/workers/list/list.integration.test.ts b/apps/cli/src/commands/experimental/workers/list/list.integration.test.ts index 6f6f60aed2..8c30e6831a 100644 --- a/apps/cli/src/commands/experimental/workers/list/list.integration.test.ts +++ b/apps/cli/src/commands/experimental/workers/list/list.integration.test.ts @@ -68,10 +68,7 @@ describe("workers list", () => { const rows = stdout.split("\n").filter((line) => /\|/.test(line) && /api|box|old/.test(line)); expect(rows).toHaveLength(3); - // Sorted by name, so `api`, `box`, then the scaffolded-but-undeployed `old`. expect(rows[0]).toContain("2gb (1 vCPU)"); - // The URL is deliberately not a column: one derivable field pushed the - // table past 130 columns. The machine payload still carries it. expect(stdout).not.toContain("https://"); expect(rows[1]).toContain("sandbox"); expect(rows[2]).toContain("not deployed"); @@ -95,8 +92,6 @@ describe("workers list", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // A local directory with no `[workers.<name>]` entry: pushable, and the - // runtime is the only thing a push would have to work out for itself. it.live("calls out a deployed worker that config.toml does not know about", () => { const created = makeWorkersProject({ "supabase/config.toml": `project_id = "demo"\n`, @@ -124,8 +119,6 @@ describe("workers list", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Two of them, so the advisory has to read as a list rather than as one name - // with a stray verb. it.live("calls out every deployed worker config.toml does not know about", () => { const created = makeWorkersProject({ "supabase/config.toml": `project_id = "demo"\n`, @@ -159,9 +152,6 @@ describe("workers list", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Deletion is asynchronous, so a worker can be listed while it is being torn - // down. Reporting its build state would show `active` for something on its - // way out. it.live("shows a worker being torn down as deleting", () => { const repo = project(`project_id = "demo"\n\n[workers.api]\nruntime = "node"\n`); const { layer, out } = setupWorkers({ @@ -181,9 +171,6 @@ describe("workers list", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Nothing local at all: `deployOneWorker` checks the source directory before - // it ever infers a runtime, so "would have to guess the runtime" named the - // wrong prerequisite for this one. it.live("tells a worker with no local source to restore it, not to expect a guess", () => { const repo = project(`project_id = "demo"\n`); const { layer, out } = setupWorkers({ @@ -283,7 +270,6 @@ describe("workers list", () => { return Effect.gen(function* () { yield* workersList({ projectRef: Option.none() }); - // `-o` payloads own stdout outright: no clack success line may share it. const parsed = JSON.parse(out.stdoutText); expect(parsed.project_ref).toBe(WORKERS_PROJECT_REF); expect(parsed.workers).toHaveLength(2); @@ -373,9 +359,6 @@ describe("workers list", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // A directory under the workers root with no `[workers.<name>]` entry is what - // a bare `push` discovers and deploys, so an inventory that leaves it out can - // say "No workers found" about a worker `push` would happily deploy. it.live("includes a local worker directory that has no config entry", () => { const repo = project('project_id = "demo"\n'); mkdirSync(join(repo.dir, "supabase", "workers", "scaffolded"), { recursive: true }); @@ -390,15 +373,10 @@ describe("workers list", () => { expect(out.stdoutText).toContain("scaffolded"); expect(out.stdoutText).not.toContain("No workers found"); - // Never deployed, so it is not announced as a deployed-but-unconfigured - // orphan either. expect(out.stderrText).not.toContain("scaffolded"); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The API omits `spec.runtime` only for a context-only build, so for a - // deployed worker its absence *is* dockerfile. Falling back to the local - // config there made `-o json` report a runtime the text table contradicted. it.live("reports a deployed dockerfile worker as dockerfile in both renderings", () => { const repo = project('project_id = "demo"\n\n[workers.api]\nruntime = "node"\n'); const { layer, out } = setupWorkers({ @@ -419,9 +397,6 @@ describe("workers list", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // An undeployed worker has no `size`/`instances` and a private one no `url`, - // so a realistic inventory hands the encoder a payload full of holes. Pins - // that they are omitted rather than rendered or thrown on. it.live("encodes TOML for an inventory holding undeployed and private workers", () => { const repo = project(); const { layer, out } = setupWorkers({ @@ -463,15 +438,12 @@ describe("workers list", () => { expect(out.stdoutText).toContain("project_ref:"); expect(out.stdoutText).toContain("name: api"); - // The table would have gone to stdout too, and broken the document. expect(out.stdoutText).not.toContain("NAME"); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `pretty` is the human default; `table` and `csv` are accepted by the global - // flag for `db query`'s benefit, and every resource command is meant to ignore - // them and render text. Falling through to the TOML encoder is the trap the - // payload allowlist closes. + // `table`/`csv` are accepted by the global flag for `db query`'s benefit; every + // other resource command, including this one, renders text for them too. it.live.each(["pretty", "table", "csv"] as const)( "renders text rather than TOML for -o %s", (goOutput) => { @@ -507,10 +479,8 @@ describe("workers list", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // CLI-2285: `loadWorkersProject`'s JSON-capable read must thread the - // ancestor-search predicate — the workdir's own default resolution only - // probes config.toml, so a config.json-only project invoked from a - // subdirectory relied on this second climb to be found at all. + // The default workdir resolution only probes config.toml, so a config.json-only + // project invoked from a subdirectory relies on this second climb to be found. it.live( "discovers a config.json-only project's [workers.*] entry from a subdirectory when --workdir is defaulted", () => { diff --git a/apps/cli/src/commands/experimental/workers/logs/logs.handler.ts b/apps/cli/src/commands/experimental/workers/logs/logs.handler.ts index b9e483c375..74d26d577a 100644 --- a/apps/cli/src/commands/experimental/workers/logs/logs.handler.ts +++ b/apps/cli/src/commands/experimental/workers/logs/logs.handler.ts @@ -40,76 +40,50 @@ import type { WorkersLogsFlags } from "./logs.command.ts"; /** * `supabase experimental workers logs <name>` — what the worker has actually been doing. * - * `status` reports the deployment; this reports the runtime. Between them they - * cover the two questions a deployed worker raises, and neither answers the - * other's. - * - * Unlike the rest of the family this does not talk to `/v2/.../workers` — there is - * no worker-scoped log route — but to the project's unified logs stream. See - * `worker-logs.sql.ts` for the query and why it filters on `log_attributes` - * rather than the `source` column. + * `status` reports the deployment; this reports the runtime. It reads the + * project's unified logs stream rather than a worker-scoped route — see + * `worker-logs.sql.ts` for the query and why it filters on `log_attributes`. */ /** - * How many printed ids the follow loop remembers. - * - * Only lines inside the cursor's grace window can still be re-offered by a later - * poll, so a bound well above one window's worth cannot cause a repeat while - * keeping the set from growing for the lifetime of a long tail. + * How many printed ids the follow loop remembers, bounded well above one grace + * window's worth so it can't cause a repeat while still capping growth for a + * long-running tail. */ const SEEN_ID_LIMIT = 5000; /** - * How many rows one poll asks for per request. - * - * Independent of `--tail`, which bounds only the history a run opens with. - * Sharing them meant `--tail 1 --follow` polled with `limit 1`: the query orders - * newest-first, so a burst came back as its newest row alone and the cursor then - * advanced past the rest, dropping them for good. The default `--tail 100` had - * the same hole above 100 rows in a polling interval. + * How many rows one poll asks for per request, independent of `--tail` (which + * only bounds the history a run opens with). Sharing them meant a small + * `--tail` polled with the same small limit: a burst larger than that limit + * came back as its newest rows alone, and the cursor advanced past the rest, + * dropping them for good. */ const FOLLOW_PAGE_SIZE = 1000; /** - * How many requests one poll may spend draining a burst. - * - * A bound rather than an open loop: the endpoint allows 10 requests a minute, so - * an unbounded drain could spend a whole window's allowance on one poll. - * - * **Rows past the bound are dropped, not deferred.** The drain walks `end` - * backwards, so the pages it did fetch are the *newest* ones; the cursor then - * advances to the newest row printed, past the region it never reached. Only the - * part of that region inside the next window's grace is picked up again. Nothing - * here can fix that — `followWindow` moves the window's floor, not its ceiling, - * so lowering the cursor just re-fetches the same newest pages and never walks - * down to the gap. A burst above {@link FOLLOW_PAGE_SIZE} × this bound in one - * poll interval therefore loses its middle, and says so on stderr. + * How many requests one poll may spend draining a burst — bounded because the + * endpoint allows only 10 requests/minute, so an unbounded drain could spend a + * whole window's allowance on one poll. The drain walks backward from the + * newest row, so rows past this bound are dropped for good, not just delayed; + * the tail reports this on stderr rather than silently losing the gap. */ const FOLLOW_MAX_PAGES = 5; /** - * How long one poll may keep failing before the tail gives up. - * - * Bounded by elapsed time rather than attempts, and spaced, so a 429 or a - * momentary blip is ridden out without spending the rate limit on retries. Same - * reasoning as `awaitWorkerBuild`'s read retry. + * How long one poll may keep failing before the tail gives up. Bounded by + * elapsed time rather than attempts, and spaced, so a 429 or a momentary blip + * is ridden out without spending the rate limit on retries. */ const FOLLOW_READ_RETRY = Schedule.spaced("5 seconds").pipe( Schedule.upTo({ duration: "1 minute" }), ); /** - * Which poll failures are worth spending another request on. - * - * A tail should ride out a 429 or a momentary blip, but 401, 402 and 404 answer - * the same way every time. Retrying those held the error back for a minute and - * spent most of the endpoint's ten-requests-per-minute allowance getting nowhere, - * so the reader waited longer and then hit a rate limit on top of the real cause. - * - * Server-side statuses are retried and client-side ones are not, with the - * exception of 408 and 429, which are the server asking for exactly that. A - * decode failure carries the response's own status, so a malformed 200 body is - * correctly read as terminal: it will not parse any better on a second attempt. + * Which poll failures are worth spending another request on. Server-side + * statuses and 408/429 are retryable (the server is asking for exactly that); + * other client errors answer the same way every time, so retrying just burns + * the rate limit for a minute with no chance of success. */ function isRetryableFollowFailure(error: unknown): boolean { if (error instanceof WorkersApiUnexpectedStatusError) { @@ -118,18 +92,14 @@ function isRetryableFollowFailure(error: unknown): boolean { return ( error instanceof WorkerLogsRateLimitedError || error instanceof WorkersApiNetworkError || - // The endpoint reports a rejected or timed-out query this way, and its own - // suggestion is to retry shortly. + // The endpoint's own suggestion is to retry a rejected or timed-out query shortly. error instanceof WorkerLogsQueryFailedError ); } /** - * Test seams for the follow loop. - * - * Both schedules are parameters for the same reason `awaitWorkerBuild`'s are: the - * real ones are spaced in seconds, and a test exercising the cursor, the dedupe, - * or the retry path should not wait on a wall clock to do it. + * Test seams for the follow loop: the real schedules are spaced in seconds, so + * a test exercising the cursor, dedupe, or retry path shouldn't wait on a wall clock. */ export interface WorkersLogsOptions { readonly pollSchedule?: Schedule.Schedule<unknown>; @@ -163,10 +133,9 @@ export const workersLogs = Effect.fn("experimental.workers.logs")(function* ( const telemetryState = yield* TelemetryState; const processControl = yield* ProcessControl; - // Telemetry wraps the ref resolution as well: an unlinked non-interactive - // checkout fails inside `resolve`, and by then the command has run. Only the - // linked-project cache stays under the ref, since it has nothing to write - // without one. + // Telemetry wraps ref resolution too, since an unlinked non-interactive + // checkout fails inside `resolve` after the command has already run. Only the + // linked-project cache stays under the ref, since it has nothing to write without one. yield* Effect.gen(function* () { const projectRef = yield* resolver.resolve(flags.projectRef); const refSuffix = workersProjectRefSuffix(flags.projectRef); @@ -174,13 +143,12 @@ export const workersLogs = Effect.fn("experimental.workers.logs")(function* ( yield* Effect.gen(function* () { const name = yield* validateWorkerName(flags.name); - // Up front, like the rest of the family: this payload always carries a `logs` - // array, so `-o env` can never encode it, and finding that out at emit time - // means failing after the query has already been paid for. + // Checked up front: this payload always carries a `logs` array, which `-o env` + // can never encode, so failing at emit time would mean paying for the query first. yield* rejectWorkersEnvOutput(); - // Resolved once, before anything branches: `-o` outranks `--output-format`, - // so `output.format` on its own is not what this run renders in. + // Resolved once, before anything branches: `-o` outranks `--output-format`, so + // `output.format` alone isn't what this run renders in. const renderFormat = yield* workersRenderFormat(); // Also up front: a tail has no single terminal payload, so the bounded @@ -205,13 +173,11 @@ export const workersLogs = Effect.fn("experimental.workers.logs")(function* ( const showStream = Option.isNone(flags.kind); /** - * Write a batch of lines out, in whichever form the format calls for. + * Writes a batch of lines out in whichever form the format calls for. * - * `stream-json` emits the existing `log-entry` event per line rather than one - * terminal `result`: a tail has no terminal element, and that variant already - * carries the field set this needs. `stream` is derived from the level so a - * consumer can split diagnostics from ordinary output the way it would for a - * real process; `source` distinguishes the backlog from what arrived after. + * `stream-json` emits a `log-entry` event per line rather than one terminal + * `result`, since a tail has no terminal element; `stream` is derived from + * the level, and `source` distinguishes backlog from newly arrived lines. */ const emitLines = ( batch: ReadonlyArray<WorkerLogEntry>, @@ -229,10 +195,8 @@ export const workersLogs = Effect.fn("experimental.workers.logs")(function* ( timestamp: new Date(entry.timestampMs).toISOString(), service: name, stream: level === "error" || level === "warn" ? "stderr" : "stdout", - // The composed sentence, the same one text mode renders: a - // request line's status and duration and a build's reason live - // in `log_attributes`, and `log-entry` has no field to carry - // them separately. + // Composed the same way text mode renders it: request status/duration + // and a build's reason live in `log_attributes`, with no separate field for them here. line: workerLogText(entry), source: origin, }); @@ -269,18 +233,13 @@ export const workersLogs = Effect.fn("experimental.workers.logs")(function* ( return rows; }); - // Nothing came back, which is two different situations wearing the same face: - // a worker that is not deployed at all, and one that is deployed and quiet. - // Only worth one extra request, and only in this branch. - // - // `--tail 0` makes no history query, so zero rows says nothing either way — - // but a tail still has to know the worker exists, or a typo waits forever on - // logs that can never arrive. A bounded `--tail 0` run prints nothing by - // definition and is left alone. + // Zero rows means either an undeployed worker or a deployed, quiet one — worth + // one extra request to tell apart, and only here: `--tail 0` makes no history + // query, so zero rows there says nothing (and a bounded `--tail 0` run prints + // nothing by definition, so it's left alone). if (entries.length === 0 && (flags.tail > 0 || flags.follow)) { - // Its own task: with `--tail 0` there is no "Fetching logs..." to inherit, - // and clearing that one before this request left text mode silent across - // a call that can take a moment. + // Its own task: `--tail 0` skips "Fetching logs...", and reusing/clearing + // that one before this call left text mode silent while it ran. const checking = yield* output.task("Checking worker..."); const deployed = yield* getWorker(api, projectRef, name).pipe( Effect.tapError(() => checking.fail()), @@ -304,15 +263,13 @@ export const workersLogs = Effect.fn("experimental.workers.logs")(function* ( }; // `-o` asks for a machine-readable stdout, so nothing human may be written to - // it — `output.success` logs to stdout in text mode. Unreachable while - // following, which refuses these formats up front. + // it. Unreachable while following, since that combination is refused above. if (!flags.follow && (yield* emitWorkersMachineOutput(payload))) { return; } - // One structured emission, in the structured branch only, and only for a - // bounded read. A tail has no terminal payload to put here — it emits a - // `log-entry` event per line through `emitLines` instead. + // Only for a bounded (non-follow) read — a tail has no terminal payload; it + // emits a `log-entry` event per line via `emitLines` instead. if (!flags.follow && renderFormat !== "text") { yield* output.success("", payload); return; @@ -342,25 +299,20 @@ export const workersLogs = Effect.fn("experimental.workers.logs")(function* ( return; } - // --- follow --------------------------------------------------------------- - // - // The cursor is the newest timestamp printed, and the set of ids already - // printed. Both live inside this generator rather than being captured while - // the Effect was built: an Effect is a reusable description and may run more - // than once, and shared cursor state across runs would drop lines. + // The cursor is the newest timestamp printed, plus the set of printed ids. Both + // live inside this generator, not a closure built once, since an Effect is a + // reusable description that may run more than once — shared state across runs + // would drop lines. const seenIds = yield* Ref.make(new Set(entries.map((entry) => entry.id))); - // Same reason these live in the generator rather than the closure: an - // Effect is a reusable description, and a notice already "shown" on a - // previous run would stay silent on the next one. + // Same reason: a notice already shown on a previous run would stay silent on the next. const skipNoticeShown = yield* Ref.make(false); const newestSeenMs = yield* Ref.make( entries.length === 0 ? Date.now() : entries[entries.length - 1]!.timestampMs, ); - // `--tail 0` asked for no history, and `followWindow` deliberately reaches - // a grace period behind the cursor so a line relayed late is still caught. - // Both are wanted, and together they let pre-invocation lines through — so - // keep the wide window and filter on when the line was actually written. + // `--tail 0` asked for no history, but `followWindow` still reaches a grace + // period behind the cursor so a late-arriving line is caught; keeping the wide + // window and filtering by actual timestamp lets both cases coexist. const followFloorMs = flags.tail === 0 ? startedAtMs : Number.NEGATIVE_INFINITY; const pollOnce = Effect.gen(function* () { @@ -397,14 +349,10 @@ export const workersLogs = Effect.fn("experimental.workers.logs")(function* ( end = nextEnd; } - // Once per run, not once per poll: a sustained burst would otherwise - // repeat this every interval and bury the lines it is warning about. - // - // Emitted in **every** format, unlike the "Waiting for new logs" notice - // above — same reasoning as `push`'s `reportUnattempted`. That one is - // progress, which a machine consumer did not ask for; this one says the - // stream it is reading has a hole in it, which it cannot infer from the - // events themselves. + // Shown once per run, not once per poll, so a sustained burst doesn't repeat + // this every interval. Emitted in every format (unlike the "Waiting for new + // logs" notice below) since it reports data loss a machine consumer can't + // infer from the events themselves. if (!drained && !(yield* Ref.get(skipNoticeShown))) { yield* Ref.set(skipNoticeShown, true); yield* output.raw( @@ -415,9 +363,8 @@ export const workersLogs = Effect.fn("experimental.workers.logs")(function* ( ); } - // Windows always overlap - the server rounds them to the minute and the - // cursor deliberately lags - so dedupe is what makes the overlap invisible - // rather than a source of repeats. + // Windows always overlap — the server rounds to the minute and the cursor + // lags behind on purpose — so dedupe is what makes the overlap invisible. const printed = yield* Ref.get(seenIds); const fresh = collected .filter((row) => !printed.has(row.id) && row.timestampMs >= followFloorMs) @@ -448,33 +395,24 @@ export const workersLogs = Effect.fn("experimental.workers.logs")(function* ( ); }); - // A 429 or a blip should not end a tail the user is watching; the schedule is - // spaced in seconds, so retrying rides out a transient failure without - // spending the rate limit. Anything definitive surfaces on the first - // attempt — see `isRetryableFollowFailure`. + // A 429 or a blip should not end a tail the user is watching; the spaced + // schedule rides out a transient failure without spending the rate limit. + // Anything definitive surfaces on the first attempt. const poll = pollOnce.pipe( Effect.retry({ schedule: readRetrySchedule, while: isRetryableFollowFailure }), ); // `repeat` runs the body before applying the schedule, so the first poll is - // immediate. That is wanted: it catches anything that landed while the history - // query was in flight, and the rows it repeats are discarded by the id dedupe. - // - // Cost against the endpoint's limit of 10 requests per 60 seconds: a quiet - // tail spends one request per poll, so 6 a minute, plus the opening history - // query and the deployed-worker check. A poll draining a burst spends up to - // `FOLLOW_MAX_PAGES`, so a sustained backlog can reach 30 a minute and will - // be rate limited — `isRetryableFollowFailure` treats the 429 as transient - // and the spaced retry rides it out, which throttles the tail rather than - // ending it. + // immediate and the id dedupe discards anything that repeats. Against the + // endpoint's 10-requests-per-minute limit, a quiet tail spends about 6; a poll + // draining a burst can spend up to `FOLLOW_MAX_PAGES`, reaching 30 and getting + // rate limited, which the retry schedule treats as transient rather than fatal. yield* Effect.raceFirst( poll.pipe(Effect.repeat({ schedule: pollSchedule })), // `setExitCode`, not `exit`: the production `exit` calls `process.exit` - // synchronously, which tears the runtime down from inside this race - // branch — before the linked-project cache is written, before telemetry - // is flushed, and before the instrumentation wrapper emits its post-run - // event. Recording the code lets the race complete normally so the - // finalizers run, and `runCli` exits with it once they have. + // synchronously, tearing the runtime down before the linked-project cache is + // written, telemetry flushed, or the instrumentation wrapper's post-run event + // fires. This lets the race complete normally so those finalizers run. processControl .awaitSignal() .pipe( diff --git a/apps/cli/src/commands/experimental/workers/logs/logs.integration.test.ts b/apps/cli/src/commands/experimental/workers/logs/logs.integration.test.ts index 1226334087..f27d7c9803 100644 --- a/apps/cli/src/commands/experimental/workers/logs/logs.integration.test.ts +++ b/apps/cli/src/commands/experimental/workers/logs/logs.integration.test.ts @@ -55,10 +55,9 @@ function flags(overrides: Record<string, unknown> = {}) { } /** - * Follow options that drive the loop instantly and stop after N polls. - * - * The real schedule is spaced in seconds; `recurs` also gives the tail an end, so - * a test does not have to deliver a signal just to finish. + * Follow options that drive the loop instantly and stop after N polls: the real + * schedule is spaced in seconds, and `recurs` gives the tail an end so a test + * doesn't need to deliver a signal to finish. */ function followFor(polls: number) { return { @@ -72,10 +71,8 @@ function logsResponse(rows: ReadonlyArray<unknown>) { } /** - * The query parameters the handler actually sent. - * - * Read off the recorded request rather than the URL: `HttpClientRequest` keeps - * `urlParams` beside the URL rather than appended to it. + * The query parameters the handler actually sent, read off the recorded + * request rather than the URL: `HttpClientRequest` keeps `urlParams` separate. */ function sentQuery(request: { readonly urlParams: Readonly<Record<string, string>> }) { return request.urlParams; @@ -98,8 +95,7 @@ describe("workers logs", () => { return Effect.gen(function* () { yield* workersLogs(flags()); - // `<time> [app] <message>` — the tag is present because no --kind - // pinned a stream. + // Strips the `<time> [app]` prefix; the tag appears since no --kind pinned a stream. const messages = out.stdoutText .trimEnd() .split("\n") @@ -164,14 +160,10 @@ describe("workers logs", () => { const start = query.iso_timestamp_start; const end = query.iso_timestamp_end; - // A lone bound yields a one-minute window server-side and sending neither - // is an outright error, so both must always be present. expect(start).toBeTruthy(); expect(end).toBeTruthy(); expect(start!.endsWith("Z")).toBe(true); expect(end!.endsWith("Z")).toBe(true); - // Over 24h the server silently clamps to start+24h, returning an older - // slice than the one asked for. expect(Date.parse(end!) - Date.parse(start!)).toBeLessThan(24 * 60 * 60 * 1000); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); @@ -225,8 +217,6 @@ describe("workers logs", () => { }); return Effect.gen(function* () { - // The log contract is additive-only: unknown streams must be ignored, not - // rejected. yield* workersLogs(flags()); expect(out.stdoutText).toContain("from the future"); @@ -282,7 +272,6 @@ describe("workers logs", () => { }); return Effect.gen(function* () { - // `limit 0` would be a 400, so no-history has to mean no query. yield* workersLogs(flags({ tail: 0 })); expect(http.routeKeys).not.toContain(workerLogsRoute()); @@ -372,8 +361,6 @@ describe("workers logs", () => { }); return Effect.gen(function* () { - // The endpoint reports a failed query with a 200, so reading `result` - // first would report success. const error = yield* workersLogs(flags()).pipe(Effect.flip); expect(error).toBeInstanceOf(WorkerLogsQueryFailedError); @@ -512,7 +499,6 @@ describe("workers logs", () => { }; expect(payload.worker_name).toBe("api"); expect(payload.logs[0]?.level).toBe("error"); - // Both timestamp forms, and the raw attributes. expect(payload.logs[0]?.timestamp).toBe(new Date(T1).toISOString()); expect(payload.logs[0]?.timestamp_ms).toBe(T1); const attributes = payload.logs[0]?.attributes as Record<string, string> | undefined; @@ -551,9 +537,6 @@ describe("workers logs", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The command has run by the time the ref fails to resolve, so its post-run - // event still has to be written. Resolution sits above the query, so the - // failing-query test above does not cover this path. it.live("flushes telemetry when the project ref cannot be resolved", () => { const repo = project(); const { layer, telemetry, http } = setupWorkers({ @@ -587,8 +570,6 @@ describe("workers logs", () => { flags({ projectRef: Option.some(WORKERS_PROJECT_REF) }), ).pipe(Effect.flip); - // A copy-pasted suggestion must not silently re-resolve to whatever this - // checkout happens to be linked to. const suggestion = error instanceof WorkerNotDeployedError ? error.suggestion : ""; expect(suggestion).toContain(`--project-ref ${WORKERS_PROJECT_REF}`); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); @@ -614,8 +595,6 @@ describe("workers logs", () => { expect(out.stdoutText).toContain("second"); expect(out.stdoutText).toContain("third"); - // Every request, history and polls alike, must carry both bounds: a lone - // start silently yields a one-minute window and neither is an error. for (const request of http.requests) { const query = sentQuery(request); expect(query.iso_timestamp_start).toBeTruthy(); @@ -634,7 +613,7 @@ describe("workers logs", () => { routes: { [LOGS_ROUTE]: [ logsResponse([workerLogRow({ id: "a", tsMs: T1, message: "only once" })]), - // The cursor lags deliberately, so the same row comes back. + // The cursor lags, which is why the same row appears in both responses. logsResponse([ workerLogRow({ id: "a", tsMs: T1, message: "only once" }), workerLogRow({ id: "b", tsMs: T2, message: "and this" }), @@ -658,7 +637,7 @@ describe("workers logs", () => { routes: { [LOGS_ROUTE]: [ logsResponse([workerLogRow({ id: "a", tsMs: T3, message: "newest first" })]), - // Older than the cursor, which is why the cursor lags at all. + // Older than the cursor — this is what the grace period is for. logsResponse([workerLogRow({ id: "late", tsMs: T1, message: "arrived late" })]), ], }, @@ -676,10 +655,7 @@ describe("workers logs", () => { const { layer, out, http } = setupWorkers({ workdir: repo.dir, routes: { - // A tail-zero follow still asks whether the worker exists: with no history - // query, zero rows would otherwise be indistinguishable from a typo. [GET_WORKER_ROUTE]: { status: 200, body: { data: workerResource({ name: "api" }) } }, - // After the run starts, since a tail-zero follow prints only what arrives. [LOGS_ROUTE]: logsResponse([ workerLogRow({ id: "new", tsMs: Date.now() + 5_000, message: "brand new" }), ]), @@ -690,8 +666,6 @@ describe("workers logs", () => { yield* workersLogs(flags({ follow: true, tail: 0 }), followFor(1)); expect(out.stdoutText).toContain("brand new"); - // No history request; every log request belongs to the poll loop, and none - // may ask for `limit 0`, which the endpoint rejects. const sql = http.requests.map((request) => sentQuery(request).sql).filter(Boolean); expect(sql).not.toHaveLength(0); for (const query of sql) { @@ -718,20 +692,13 @@ describe("workers logs", () => { const entries = out.events.filter((event) => event.type === "log-entry"); expect(entries).toHaveLength(2); - // A tail has no terminal payload, so no single `result` is emitted. expect(out.events.filter((event) => event.type === "result")).toHaveLength(0); - // An error line is routed to stderr so a consumer can split diagnostics. expect(entries[0]).toMatchObject({ stream: "stderr", source: "history" }); expect(entries[1]).toMatchObject({ stream: "stdout", source: "live" }); - // `line` carries the composed sentence, not the raw `event_message`: the - // status and duration live in `log_attributes`, and `log-entry` has no - // field a consumer could recover them from. expect(entries[0]).toMatchObject({ line: "500 GET / 23ms" }); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `-o` outranks `--output-format` when both are set, and `-o pretty` encodes - // nothing — so this pair asks for the text rendering, not for JSON. it.live("renders text when -o pretty overrides --output-format json", () => { const repo = project(); const { layer, out } = setupWorkers({ @@ -749,8 +716,6 @@ describe("workers logs", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Same precedence, on the branch that refuses a tail: `-o pretty` means this - // run has no single-payload format to be incompatible with. it.live("allows --follow when -o pretty overrides --output-format json", () => { const repo = project(); const { layer, out } = setupWorkers({ @@ -781,15 +746,11 @@ describe("workers logs", () => { ); expect(error).toBeInstanceOf(WorkersFollowNotSupportedError); - // Refused before any query is paid for. expect(setup.http.requests).toHaveLength(0); } }).pipe(Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `--tail 0 --follow` is "skip history, print what arrives". The poll window - // still reaches a grace period behind the cursor so late relays are caught, so - // the floor has to be the line's own timestamp rather than a narrower window. it.live("does not replay pre-invocation lines for --tail 0 --follow", () => { const repo = project(); const { layer, out } = setupWorkers({ @@ -813,8 +774,6 @@ describe("workers logs", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // No history query means zero rows proves nothing, so the tail has to ask - // directly — otherwise a typo waits forever on logs that cannot arrive. it.live("still checks the worker exists for --tail 0 --follow", () => { const repo = project(); const { layer, out } = setupWorkers({ @@ -828,16 +787,14 @@ describe("workers logs", () => { ); expect(error).toBeInstanceOf(WorkerNotDeployedError); - // Text mode is not silent across that request. expect(out.progressEvents).toContainEqual( expect.objectContaining({ type: "start", message: "Checking worker..." }), ); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `ts_ms` feeds `new Date(...).toISOString()` while the payload is built, which - // happens for text runs too. Out of Date range that throws `RangeError`, which - // is a defect rather than the typed unreadable-response failure. + // `ts_ms` feeds `new Date(...).toISOString()`, which throws `RangeError` on an + // out-of-range value. it.live("fails typed rather than throwing on an out-of-range timestamp", () => { const repo = project(); const { layer } = setupWorkers({ @@ -854,8 +811,6 @@ describe("workers logs", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // A 404 answers the same way every time. Retrying it held the error back for a - // minute and spent the endpoint's ten-per-minute allowance getting nowhere. it.live("surfaces a definitive poll failure without retrying it", () => { const repo = project(); const { layer, http } = setupWorkers({ @@ -868,16 +823,13 @@ describe("workers logs", () => { return Effect.gen(function* () { yield* workersLogs(flags({ follow: true }), { pollSchedule: Schedule.recurs(0), - // Would retry three times over if the failure were treated as transient. retrySchedule: Schedule.recurs(3), }).pipe(Effect.flip); - // History, then the one poll that failed — no second attempt at it. expect(http.requests).toHaveLength(2); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // A rate limit is the server asking for exactly that, so it still rides out. it.live("retries a rate-limited poll", () => { const repo = project(); const { layer, http } = setupWorkers({ @@ -901,8 +853,6 @@ describe("workers logs", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `--tail` bounds the history a run opens with; reusing it as the poll size - // meant `--tail 1 --follow` asked each poll for a single row. it.live("polls with a page size independent of --tail", () => { const repo = project(); const { layer, http } = setupWorkers({ @@ -923,8 +873,6 @@ describe("workers logs", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The query orders newest-first, so a full page means there is more below it. - // Advancing the cursor on that page alone dropped the remainder for good. it.live("drains a burst larger than one page before advancing the cursor", () => { const repo = project(); const fullPage = Array.from({ length: 1000 }, (_, index) => @@ -934,8 +882,7 @@ describe("workers logs", () => { workdir: repo.dir, routes: { [LOGS_ROUTE]: [ - // Non-empty, so the run does not spend its second request on the - // deployed-worker check that an empty history triggers. + // Non-empty, so the run skips the deployed-worker check an empty history would trigger. logsResponse([workerLogRow({ id: "seed", tsMs: T1 - 100_000, message: "seed" })]), logsResponse(fullPage), logsResponse([workerLogRow({ id: "straggler", tsMs: T1, message: "older line" })]), @@ -946,25 +893,18 @@ describe("workers logs", () => { return Effect.gen(function* () { yield* workersLogs(flags({ follow: true }), followFor(0)); - // The second page is only requested because the first came back full. expect(http.requests).toHaveLength(3); expect(out.stdoutText).toContain("older line"); expect(out.stdoutText).toContain("burst 999"); - // Oldest first across pages, not merely within each one. expect(out.stdoutText.indexOf("older line")).toBeLessThan(out.stdoutText.indexOf("burst 0")); - // The short third page proved the window was empty below it, so nothing - // was skipped and the run stays quiet. expect(out.stderrText).not.toContain("Skipped part of a burst"); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The drain walks backwards from the newest rows, so running out of pages - // leaves the *oldest* part of the burst unfetched while the cursor advances - // past it. Those lines are gone, and a silent hole is worse than a noisy one. it.live("says so on stderr when a burst outruns the page budget", () => { const repo = project(); - // Five full pages, each older than the last so every page narrows the window - // and the loop runs to its bound rather than stopping early. + // Each page older than the last, so every page narrows the window and the + // loop runs to its bound. const pages = Array.from({ length: 5 }, (_unused, page) => logsResponse( Array.from({ length: 1000 }, (_row, index) => @@ -989,12 +929,9 @@ describe("workers logs", () => { return Effect.gen(function* () { yield* workersLogs(flags({ follow: true }), followFor(0)); - // The budget, and not one request more: the sixth page is never asked for. expect(http.requests).toHaveLength(6); expect(out.stderrText).toContain("Skipped part of a burst larger than 5000 lines"); - // Actionable rather than a bare apology. expect(out.stderrText).toContain("--kind"); - // The lines it did reach are still printed. expect(out.stdoutText).toContain("page 0 line 999"); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); @@ -1026,8 +963,6 @@ describe("workers logs", () => { return Effect.gen(function* () { yield* workersLogs(flags({ follow: true }), followFor(1)); - // A sustained burst would otherwise repeat the notice every interval and - // bury the very lines it is warning about. const notices = out.stderrText.split("Skipped part of a burst").length - 1; expect(notices).toBe(1); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); @@ -1048,8 +983,6 @@ describe("workers logs", () => { }); expect(processControl.exitCode).toBe(130); - // The point of recording rather than exiting: `process.exit` from inside - // the race branch would have killed the runtime before this ran. expect(telemetry.flushed).toBe(true); expect(processControl.exitCalls).toEqual([]); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); diff --git a/apps/cli/src/commands/experimental/workers/new/new.errors.ts b/apps/cli/src/commands/experimental/workers/new/new.errors.ts index 02cb4e379a..567b472dd1 100644 --- a/apps/cli/src/commands/experimental/workers/new/new.errors.ts +++ b/apps/cli/src/commands/experimental/workers/new/new.errors.ts @@ -7,11 +7,9 @@ import { /** * The resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a - * directory (`validateWorkdirIsDirectory`). Only reachable when the user - * explicitly set it — beats every prompt and filesystem write, so a typo'd - * `--workdir` can never scaffold a fresh `supabase/workers/…` tree (plus a new - * `config.toml`) at the wrong path. Mirrors `FunctionsNewWorkdirError`, - * `new`'s sibling in `supabase functions`. + * directory. Only reachable when the user explicitly set it — checked before + * every prompt and filesystem write, so a typo'd `--workdir` can never + * scaffold a fresh `supabase/workers/…` tree at the wrong path. */ export class WorkersNewWorkdirError extends Data.TaggedError("WorkersNewWorkdirError")<{ readonly message: string; diff --git a/apps/cli/src/commands/experimental/workers/new/new.handler.ts b/apps/cli/src/commands/experimental/workers/new/new.handler.ts index 37e2bd9b58..be5b91e58a 100644 --- a/apps/cli/src/commands/experimental/workers/new/new.handler.ts +++ b/apps/cli/src/commands/experimental/workers/new/new.handler.ts @@ -70,18 +70,11 @@ function defaultFirst<T>(values: ReadonlyArray<T>, defaultValue: T): Array<T> { } /** - * Whether this run has a terminal to ask on. - * - * `-o json|yaml|toml|env` leaves `output.format` as `text`, and the prompts go - * through Clack, which writes its terminal UI to stdout with no stream - * override — so a machine format is as non-interactive as a redirected stdout, - * whichever flag asked for it. - * - * `output.interactive` only tracks *stdout*, so on its own it still let - * `printf 'api\n' | supabase experimental workers new` feed the pipe straight - * into the name prompt instead of taking the documented non-interactive path. A - * prompt is only answerable from a keyboard, so stdin has to be a terminal too - * — the same pair `workers delete` guards its confirmation with. + * Whether this run has a terminal to ask on. `-o json|yaml|toml|env` leaves + * `output.format` as `text` but still writes Clack's UI to stdout, so it's as + * non-interactive as a redirected stdout. `output.interactive` only tracks + * stdout, so piped stdin also needs `tty.stdinIsTty` — a prompt is only + * answerable from a keyboard. */ const canPromptFor = Effect.fnUntraced(function* (machineOutput: boolean) { const output = yield* Output; @@ -92,12 +85,9 @@ const canPromptFor = Effect.fnUntraced(function* (machineOutput: boolean) { /** * The worker name, asked for when the command line did not carry one. * - * The name is the one input here that cannot be defaulted — it is the - * directory, the `config.toml` key and the hostname — so a bare - * `supabase experimental workers new` asks rather than failing the parse. The - * prompt validates against everything the command would otherwise refuse a - * moment later, so a mistyped or already-recorded name is corrected in place - * instead of ending the run. + * The name can't be defaulted — it's the directory, the config key, and the + * hostname — so a bare invocation asks rather than failing the parse. The + * prompt validates the same rules the command would otherwise enforce later. */ const resolveName = Effect.fnUntraced(function* (options: { readonly explicit: Option.Option<string>; @@ -184,11 +174,10 @@ const resolveSize = Effect.fnUntraced(function* (options: { }); /** - * Recorded on every scaffold, not just when it is asked for: `push` sends a - * complete spec each time, so a worker whose `exposure` is absent from - * `config.toml` is deployed public by the next bare `push`. Writing the value - * down — default included, the way `runtime` and `size` are — is what makes - * `--exposure private` stick past the deploy that chose it. + * Recorded on every scaffold, not just when asked for: `push` sends a + * complete spec each time, so an absent `exposure` in `config.toml` deploys + * public on the next bare `push`. Writing the value down, default included, + * is what makes `--exposure private` stick. */ const resolveExposure = Effect.fnUntraced(function* (options: { readonly explicit: Option.Option<WorkerExposure>; @@ -218,14 +207,10 @@ const resolveExposure = Effect.fnUntraced(function* (options: { /** * The instance count to record, and whether to record it at all. * - * Not prompted for, unlike the other dials: how many instances a worker needs is - * an operational answer nobody has while scaffolding it, so the flag records - * one when it is given and the file stays quiet when it is not. - * - * `undefined` — meaning "write no key" — for the default count, because an - * absent `instances` and `instances = 1` mean the same thing to `push`, and a - * scaffold should not commit a line that says nothing. A `0` is not that: it - * scales the worker to nothing, so it is written like any other explicit count. + * Not prompted for, unlike the other dials — nobody knows the right instance + * count while scaffolding. `undefined` (write no key) for the default, since + * an absent `instances` and `instances = 1` mean the same thing to `push`; a + * `0` is a real choice (scale to nothing), so it's always written. */ function recordedInstances(explicit: Option.Option<number>): number | undefined { const instances = Option.getOrUndefined(explicit); @@ -273,11 +258,10 @@ export const workersNew = Effect.fn("experimental.workers.new")(function* (flags const name = yield* resolveName({ explicit: flags.name, canPrompt, project }); yield* validateWorkerName(name); - // Refused before anything is asked or written. `new` creates a worker; - // changing one that already exists is a `config.toml` edit, and the file is - // the user's. Checking here rather than only in `planWorkerEntry` means the - // runtime and size prompts never run for a name that was going to be - // refused anyway; the name prompt rejects it up front for the same reason. + // Refused before anything is asked or written: `new` creates a worker, and + // changing one that already exists is a `config.toml` edit that belongs to + // the user. Checking here means the runtime/size prompts never run for a + // name that would be refused anyway. if (project.section.workers[name] !== undefined) { return yield* Effect.fail( new WorkerAlreadyConfiguredError({ @@ -287,18 +271,11 @@ export const workersNew = Effect.fn("experimental.workers.new")(function* (flags ); } - // A DEFAULTED workdir's reader (`workers list`/`push`/`status`, used - // via `loadWorkersProject`) can discover a config.json-only - // ancestor project by climbing (CLI-2285); this command's own writer - // above is TOML-only and never climbs, so the two can disagree about - // which project is "the" project. When they do, and that ancestor - // already configures this name, writing a same-named worker here would - // silently create a second, disagreeing `[workers.<name>]` under a - // different root instead of the collision already refused above for - // this command's OWN root. An explicit `--workdir`/`SUPABASE_WORKDIR` - // never has this gap — both views are pinned to the same root then — so - // this only runs for a defaulted workdir, and only costs an extra read - // when it is. + // A defaulted workdir can discover a config.json-only ancestor project that + // an explicit `--workdir` would not, so the two can disagree about which + // project is "the" project. When they do, and that ancestor already + // configures this name, writing here would create a second, disagreeing + // entry instead of refusing it. if (!cliSettings.explicitWorkdir) { const discovered = yield* loadWorkersProject().pipe(Effect.option); if ( @@ -315,21 +292,18 @@ export const workersNew = Effect.fn("experimental.workers.new")(function* (flags } } - // Resolved before anything is written, so cancelling any prompt leaves - // nothing behind — the name included. With nowhere to ask, the defaults - // stand; only the name has nothing to fall back to. + // Resolved before anything is written, so cancelling any prompt leaves nothing + // behind. With nowhere to ask, the defaults stand — only the name has no fallback. const runtime = yield* resolveRuntime({ explicit: flags.runtime, canPrompt }); const size = yield* resolveSize({ explicit: flags.size, canPrompt }); const exposure = yield* resolveExposure({ explicit: flags.exposure, canPrompt }); const instances = recordedInstances(flags.instances); - // Validated before anything is written: this is the directory the starter - // files land in, so a value naming the project root, `supabase/`, or - // anywhere outside the project must never get as far as the write below. - // - // `--source` resolves against the directory the user typed it in, the way a - // shell would read it: `--source generated` from `apps/web` means - // `apps/web/generated`. + // Validated before anything is written: this is the directory the starter files + // land in, so a value naming the project root, `supabase/`, or anywhere outside + // the project must never reach the write below. `--source` resolves against the + // directory the user typed it in, the way a shell would: `--source generated` + // from `apps/web` means `apps/web/generated`. const destination = Option.isSome(flags.source) ? yield* resolveWorkerSource({ projectRoot: project.projectRoot, @@ -341,20 +315,17 @@ export const workersNew = Effect.fn("experimental.workers.new")(function* (flags target: join(project.workersDir, name), subject: `The default directory for "${name}"`, // The default directory is `supabase/workers/<name>` with a validated - // name, so it cannot be the project root, `supabase/`, or a directory - // the CLI owns. A symlink escaping the project is the only way it - // reaches this failure, so that is what the suggestion names. + // name, so only a symlink escaping the project can reach this + // failure — which is what the suggestion names. suggestion: "supabase/workers, or a directory above it, is a symlink leading outside the project. Replace it with a real directory, or pass --source to scaffold somewhere else inside the project.", }); - // Nothing here replaces what is already on disk. Scaffolding over an - // existing directory would have to delete it first, and a command whose job - // is to create a worker has no business removing whatever happens to share - // its name — so it says what is in the way and leaves the choice to the user. + // Nothing here replaces what is already on disk: scaffolding over an existing + // directory would have to delete it first, which isn't this command's job — it + // names what's in the way and leaves the choice to the user. if (!(yield* destinationIsFree(destination))) { - // Absolute when `--workdir`/`SUPABASE_WORKDIR` was set explicitly — same - // rule as the success message below — since a project-root-relative + // Absolute when `--workdir` was set explicitly, since a project-root-relative // path would be misleading once `--workdir` differs from cwd. const shown = cliSettings.explicitWorkdir ? destination @@ -367,15 +338,14 @@ export const workersNew = Effect.fn("experimental.workers.new")(function* (flags ); } - // Recorded as forward slashes whatever platform wrote it. `config.toml` is - // committed and shared, and `path.relative` yields `packages\api` on - // Windows — a backslash the POSIX resolvers on every other machine read as - // a literal character in a filename rather than a separator. + // Recorded as forward slashes whatever platform wrote it: `config.toml` is + // shared, and `path.relative` yields backslashes on Windows that POSIX + // resolvers elsewhere would read as a literal filename character. const source = Option.isSome(flags.source) ? relative(project.projectRoot, destination).split(sep).join("/") : undefined; - // Planned before anything is written. Every way this can fail is knowable + // Planned before anything is written: every way this can fail is knowable // from the current config.toml, so finding out afterwards would leave a // scaffold on disk that nothing records. const configWrite = yield* planWorkerEntry({ @@ -401,12 +371,10 @@ export const workersNew = Effect.fn("experimental.workers.new")(function* (flags yield* commitWorkerEntry(configWrite); - // Relative to the project root when the workdir was defaulted — the - // common case, where it also reads as relative to the terminal the - // command was run from. An explicit `--workdir` breaks that: the project - // root can be nowhere near the actual cwd, so a relative path here would - // point somewhere the user never typed. The absolute path is unambiguous - // either way. + // Relative to the project root when the workdir was defaulted, since it also + // reads as relative to the terminal the command ran from. An explicit + // `--workdir` breaks that — the project root can be nowhere near the actual + // cwd — so the absolute path is used instead. const sourceDisplay = cliSettings.explicitWorkdir ? destination : displayPath(project.projectRoot, destination); @@ -417,9 +385,7 @@ export const workersNew = Effect.fn("experimental.workers.new")(function* (flags size, vcpu: vcpuForSize(size), exposure, - // The count a deploy will use, whether or not it was written down — a - // payload that omitted it for the default would read as "unknown" rather - // than "one". + // The count a deploy will use, whether or not it was written down. instances: instances ?? DEFAULT_WORKER_INSTANCES, source: sourceDisplay, config_path: project.configPath, @@ -436,25 +402,21 @@ export const workersNew = Effect.fn("experimental.workers.new")(function* (flags return; } - // Leads with a declarative line the way every other scaffold does - // (`functions new`: "Created new Function at supabase/functions/hello"), - // then the details. Guidance goes in a closing sentence rather than a - // pseudo-row, since no other command puts a next step inside its output - // table. + // Leads with a declarative line, then the details; guidance goes in a closing + // sentence rather than a pseudo-row, since no other command puts a next step + // inside its output table. yield* output.raw(`Created new Worker at ${bold(sourceDisplay, process.stdout)}\n`); yield* output.raw( renderWorkerDetails([ ["Runtime", runtime], ["Size", `${size} (${vcpuForSize(size)} vCPU)`], ["Access", exposure], - // `declared`, the way `workers status` labels the same number: nothing - // is running yet, so a bare count would read as a live tally. + // "declared", not a bare count: nothing is running yet. ["Instances", `${instances ?? DEFAULT_WORKER_INSTANCES} declared`], ]), ); - // On the success trailer rather than inline, the way `bootstrap` emits its - // "start your app" line: the shell prints trailers once at the end of the - // run, so the next step is the last thing on screen. + // On the success trailer rather than inline, so the next step is the last + // thing on screen. yield* emitSuccessTrailer( `Deploy it with ${aqua(`supabase experimental workers push ${name}`)}.\n`, ); diff --git a/apps/cli/src/commands/experimental/workers/new/new.integration.test.ts b/apps/cli/src/commands/experimental/workers/new/new.integration.test.ts index 2e4e74754f..c5740b735e 100644 --- a/apps/cli/src/commands/experimental/workers/new/new.integration.test.ts +++ b/apps/cli/src/commands/experimental/workers/new/new.integration.test.ts @@ -62,11 +62,8 @@ describe("workers new", () => { `${CONFIG_WITH_COMMENTS}\n[workers.api]\nruntime = "node"\nsize = "2gb"\nexposure = "public"\n`, ); - // Declarative line first, then the detail rows, then the next step — - // the shape `functions new` established. expect(out.stdoutText).toContain("Created new Worker at supabase/workers/api"); expect(out.stdoutText).toContain("Runtime"); - // The deploy hint is a success trailer, which lands on stderr. expect(out.stderrText).toContain("supabase experimental workers push api"); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); @@ -89,8 +86,6 @@ describe("workers new", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The prompt is the last place a mistyped or taken name can be corrected - // without ending the run, so it refuses both there rather than after asking. it.live("refuses a bad or already-recorded name at the name prompt", () => { const repo = project({ "supabase/config.toml": `${CONFIG_WITH_COMMENTS}\n[workers.api]\nruntime = "node"\nsize = "2gb"\nexposure = "public"\n`, @@ -113,22 +108,18 @@ describe("workers new", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Nowhere to ask means nothing to scaffold under: the name is the directory, - // the config key and the hostname, and none of those has a default. it.live.each([ { label: "not interactive", setup: { interactive: false } }, - // A TTY, but stdout was claimed by the payload, so a prompt would corrupt it. + // Stdout is a TTY, but claimed by the payload, so a prompt would corrupt it. { label: "-o json", setup: { goOutput: "json" as const } }, - // `printf 'orders\n' | supabase experimental workers new`: stdout is still a - // terminal, so `output.interactive` on its own would have fed the pipe - // straight into the name prompt instead of taking this documented path. + // Stdout is still a terminal, so `output.interactive` alone would have fed + // piped stdin straight into the name prompt instead of taking this path. { label: "piped stdin", setup: { stdinIsTty: false } }, ])("refuses a bare new when there is nowhere to ask ($label)", ({ setup }) => { const repo = project(); const { layer, out } = setupWorkers({ workdir: repo.dir, - // An answer is waiting, so a prompt would succeed rather than fail some - // other way. + // An answer is waiting, so a prompt would succeed rather than fail some other way. promptTextResponses: ["orders"], ...setup, }); @@ -140,8 +131,6 @@ describe("workers new", () => { if (!(error instanceof MissingWorkerNameError)) { return yield* Effect.die("expected MissingWorkerNameError"); } - // The retry has to name the path the command is actually registered at; - // `supabase workers new` is an unknown command. expect(error.suggestion).toContain("supabase experimental workers new"); expect(out.promptTextCalls).toEqual([]); expect(existsSync(join(repo.dir, "supabase", "workers"))).toBe(false); @@ -171,9 +160,6 @@ describe("workers new", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The whole reason `new` records it: `push` sends a complete spec every time, - // so an entry with no `exposure` is deployed public by the next bare `push`. - // Recording the answer is what makes a private worker stay private. it.live("records the chosen exposure so a later push keeps it", () => { const repo = project(); const { layer } = setupWorkers({ workdir: repo.dir }); @@ -185,8 +171,6 @@ describe("workers new", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The count a scaffold cannot guess: `--instances` has no prompt, so it is - // recorded when given and left out when not. it.live("records an instance count that differs from the default", () => { const repo = project(); const { layer } = setupWorkers({ workdir: repo.dir }); @@ -194,17 +178,11 @@ describe("workers new", () => { return Effect.gen(function* () { yield* workersNew(flags({ instances: Option.some(3) })); - // Bare, not quoted: the config schema types `instances` as a number, so a - // quoted count would render a config.toml that no longer loads. expect(repo.config()).toContain("instances = 3"); expect(repo.config()).not.toContain('instances = "3"'); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The end-to-end proof that the count is written as a number: the config - // schema types `instances` as one, so a quoted `"3"` renders a config.toml - // that no longer decodes — which only shows up on the *next* load, not on the - // write that caused it. Scaffolding a second worker is that next load. it.live("writes a count the config loader can read back", () => { const repo = project(); const { layer } = setupWorkers({ workdir: repo.dir }); @@ -218,8 +196,6 @@ describe("workers new", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Zero is an explicit count — it scales the worker to nothing — not an absent - // one, so it has to survive the "only record a non-default" rule. it.live("records a zero instance count", () => { const repo = project(); const { layer } = setupWorkers({ workdir: repo.dir }); @@ -231,8 +207,6 @@ describe("workers new", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // An absent `instances` and `instances = 1` mean the same thing to `push`, so - // the scaffold does not commit a line that says nothing. it.live("writes no instance count when nothing names one", () => { const repo = project(); const { layer } = setupWorkers({ workdir: repo.dir }); @@ -255,9 +229,6 @@ describe("workers new", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Written even when it is the default, the same way `runtime` and `size` are: - // an absent key and `public` mean the same thing to `push` today, but only the - // written one survives a change of default. it.live("records the default exposure when nothing names one", () => { const repo = project(); const { layer } = setupWorkers({ workdir: repo.dir, format: "json" }); @@ -269,8 +240,6 @@ describe("workers new", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The runtime and size prompts do have defaults to fall back on, so a piped - // stdin must leave them unasked rather than consuming the pipe. it.live("takes the defaults without prompting when stdin is piped", () => { const repo = project(); const { layer, out } = setupWorkers({ @@ -302,8 +271,6 @@ describe("workers new", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // A second `new` for the same name is refused rather than re-recorded. Changing - // a worker that exists is a `config.toml` edit, and the file is the user's. it.live("refuses a name that config.toml already records", () => { const repo = project(); const { layer, out } = setupWorkers({ workdir: repo.dir }); @@ -324,14 +291,11 @@ describe("workers new", () => { ).pipe(Effect.flip); expect(error).toBeInstanceOf(WorkerAlreadyConfiguredError); - // Refused before anything was asked, and the entry is byte-identical. expect(out.promptSelectCalls).toHaveLength(0); expect(repo.config()).toBe(recorded); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Refused whichever way the entry happens to be written — the decoded config - // is what answers "does this exist", so no TOML shape matters here. it.live.each(['workers.api.runtime = "node"', "[workers.api]"])( "refuses an entry recorded as %s", (entry) => { @@ -346,18 +310,11 @@ describe("workers new", () => { expect(error).toBeInstanceOf(WorkerAlreadyConfiguredError); expect(repo.config()).toBe(config); - // Nothing scaffolded either. expect(existsSync(join(repo.dir, "supabase", "workers", "api"))).toBe(false); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }, ); - // CLI-2285 review follow-up: a DEFAULTED workdir's reader (`workers - // list`/`push`/`status`) can climb to discover a config.json-only ancestor - // project, but this command's own TOML-only writer never climbs — without - // an extra check, `new` would silently write a same-named duplicate at the - // subdirectory instead of refusing it the way it already refuses a - // duplicate at its own root. it.live( "refuses a name the reader would discover in a config.json-only ancestor project (defaulted workdir)", () => { @@ -378,7 +335,6 @@ describe("workers new", () => { ).pipe(Effect.flip); expect(error).toBeInstanceOf(WorkerAlreadyConfiguredError); - // Nothing was scaffolded at the subdirectory either. expect(existsSync(join(sub, "supabase"))).toBe(false); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(cleanup))); }, @@ -399,9 +355,6 @@ describe("workers new", () => { const { layer } = setupWorkers({ workdir: sub, explicitWorkdir: true }); return Effect.gen(function* () { - // An explicit workdir never climbs for either the reader or the - // writer, so the ancestor's config.json is invisible to both — this - // is the established bare-directory scaffold, unaffected by the fix. yield* workersNew(flags({ name: Option.some("api"), runtime: Option.some("deno") })); expect(existsSync(join(sub, "supabase", "config.toml"))).toBe(true); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(cleanup))); @@ -444,7 +397,6 @@ describe("workers new", () => { expect(error).toBeInstanceOf(InvalidWorkerSourceError); } - // Nothing was written: the resolver refused before any directory was created. expect(existsSync(join(repo.dir, "README.md"))).toBe(true); expect(existsSync(join(repo.dir, "src", "app.ts"))).toBe(true); expect(repo.config()).toContain("project_id"); @@ -462,8 +414,6 @@ describe("workers new", () => { expect(readFileSync(join(created.dir, "supabase", "config.toml"), "utf8")).toBe( `[workers.api]\nruntime = "node"\nsize = "2gb"\nexposure = "public"\n`, ); - // An EXPLICIT --workdir has no cwd-relative reading, so the success - // message names the absolute path rather than a project-root-relative one. expect(out.stdoutText).toContain(`Created new Worker at ${workerDir}`); }).pipe( Effect.provide(layer), @@ -485,8 +435,6 @@ describe("workers new", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Scaffolding into an empty directory is fine — it is only a destination with - // contents that is refused. it.live("scaffolds into a directory that exists but is empty", () => { const repo = project(); mkdirSync(join(repo.dir, "supabase", "workers", "api"), { recursive: true }); @@ -509,7 +457,6 @@ describe("workers new", () => { ).pipe(Effect.flip); expect(error).toBeInstanceOf(WorkerDirectoryExistsError); - // No flag to suggest any more, so the advice has to be actionable on its own. const suggestion = error instanceof WorkerDirectoryExistsError ? error.suggestion : ""; expect(suggestion).toContain("Remove"); expect(suggestion).not.toContain("--force"); @@ -536,10 +483,6 @@ describe("workers new", () => { yield* workersNew(flags({ runtime: Option.some("node") })); const payload: unknown = JSON.parse(out.stdoutText); - // Every dial the scaffold settled, not just the two it is named for: a - // caller reading this payload is deciding what to deploy, and an omitted - // `exposure` or `instances` reads as "unknown" rather than as the default - // the run actually chose. expect(payload).toMatchObject({ worker_name: "api", runtime: "node", @@ -551,9 +494,6 @@ describe("workers new", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The count and the exposure are recorded sparsely — `instances` is left out - // of config.toml at the default — so the payload is the only place a caller - // can read what this scaffold will actually deploy as. it.live("reports the chosen exposure and count under -o json", () => { const repo = project(); const { layer, out } = setupWorkers({ workdir: repo.dir, goOutput: "json" }); @@ -587,18 +527,12 @@ describe("workers new", () => { }), ); - // `Access`, the way `workers status` and `push` label the same field. expect(out.stdoutText).toContain("Access"); expect(out.stdoutText).toContain("private"); - // `declared`, because nothing is running yet — a bare count would read as - // a live tally. expect(out.stdoutText).toContain("3 declared"); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Why the config edit is planned before the starter files are written: this - // failure is knowable up front, and discovering it afterwards would leave a - // scaffold on disk that nothing records. it.live("writes no scaffold at all when the config edit cannot be made", () => { const repo = project({ "supabase/config.toml": 'project_id = "demo"\n\nworkers.api.runtime = "node"\n', @@ -611,17 +545,11 @@ describe("workers new", () => { ).pipe(Effect.flip); expect(error).toBeInstanceOf(WorkerAlreadyConfiguredError); - // No directory, and config.toml exactly as it was. expect(existsSync(join(repo.dir, "supabase", "workers", "api"))).toBe(false); expect(repo.config()).toBe('project_id = "demo"\n\nworkers.api.runtime = "node"\n'); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The project config loader prefers `supabase/config.json` when one exists, - // and the entry writer is a TOML text editor. Without `tomlOnly` the two - // disagree: the plan targets the JSON file and appends a `[workers.api]` - // table to it, leaving the project config unparseable — after the scaffold is - // already on disk. it.live("leaves config.json alone in a project that has one", () => { const configJson = `${JSON.stringify({ project_id: "demo" }, null, 2)}\n`; const repo = project({ "supabase/config.json": configJson }); @@ -634,19 +562,12 @@ describe("workers new", () => { expect(readFileSync(jsonPath, "utf8")).toBe(configJson); expect(() => JSON.parse(readFileSync(jsonPath, "utf8"))).not.toThrow(); - // The worker is recorded in config.toml, which is the TOML editor's file. expect(repo.config()).toBe( `${CONFIG_WITH_COMMENTS}\n[workers.api]\nruntime = "node"\nsize = "2gb"\nexposure = "public"\n`, ); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `settings.workdir` is already an authoritative project root, so the config - // loader must not climb out of it. Without `search: false` it does: the entry - // is appended to the *ancestor's* config.toml recording `source = - // "supabase/workers/api"`, which resolves against the ancestor root to a - // directory the scaffold never created, while the scaffold itself lands under - // the workdir. Both sides have to name the same project. it.live("records the worker in --workdir's own project, not an ancestor's", () => { const repo = project({ "bare-dir/.keep": "" }); const workdir = join(repo.dir, "bare-dir"); @@ -655,11 +576,9 @@ describe("workers new", () => { return Effect.gen(function* () { yield* workersNew(flags({ name: Option.some("api"), runtime: Option.some("node") })); - // The ancestor project is untouched. expect(repo.config()).toBe(CONFIG_WITH_COMMENTS); expect(existsSync(join(repo.dir, "supabase", "workers", "api"))).toBe(false); - // The workdir got both the entry and the scaffold it points at. expect(readFileSync(join(workdir, "supabase", "config.toml"), "utf8")).toBe( '[workers.api]\nruntime = "node"\nsize = "2gb"\nexposure = "public"\n', ); @@ -667,10 +586,6 @@ describe("workers new", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // A sealed inline `[workers]` cannot be extended by appending a table, and - // the name is absent from the decoded section, so the already-configured - // check does not fire. Parsing the plan is what refuses it — before the - // scaffold is written, like every other refusal here. it.live("writes no scaffold when [workers] is a sealed inline table", () => { const before = 'project_id = "demo"\n\nworkers = { web = { runtime = "node" } }\n'; const repo = project({ "supabase/config.toml": before }); @@ -687,8 +602,6 @@ describe("workers new", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // A plain file must not read as an empty directory: that fails with a bare - // EEXIST from `makeDirectory` instead of naming what is in the way. it.live("refuses a plain file at the destination", () => { const repo = project({ "supabase/workers/api": "not a directory" }); const { layer } = setupWorkers({ workdir: repo.dir }); @@ -705,8 +618,6 @@ describe("workers new", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // A relative `--source` is something typed at a shell prompt, so it means - // what it would mean to the shell: relative to where you are. it.live("resolves a relative --source against the directory it was typed in", () => { const repo = project({ "apps/web/.keep": "" }); const { layer } = setupWorkers({ @@ -725,14 +636,10 @@ describe("workers new", () => { expect(existsSync(join(repo.dir, "apps", "web", "generated", "index.mjs"))).toBe(true); expect(existsSync(join(repo.dir, "generated"))).toBe(false); - // Persisted project-root-relative, with forward slashes on every platform. expect(repo.config()).toContain('source = "apps/web/generated"'); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Clack writes its prompt UI to stdout with no stream override, and `-o json` - // leaves `output.format` as `text` — so a prompt lands in front of the payload - // exactly as the notices did. it.live("does not prompt under -o json, so stdout stays parseable", () => { const repo = project(); const { layer, out } = setupWorkers({ @@ -747,16 +654,11 @@ describe("workers new", () => { yield* workersNew(flags({ name: Option.some("api") })); const payload: unknown = JSON.parse(out.stdoutText); - // The defaults stand, because there was nowhere to ask. expect(payload).toMatchObject({ runtime: "deno", size: "2gb" }); expect(out.promptSelectCalls).toEqual([]); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The prompts only ever offer values this CLI knows, so an unrecognized answer - // means the prompt layer handed back something off-menu. Recording it verbatim - // would put a runtime into config.toml that `push` then refuses; the default - // is the one answer that still scaffolds something deployable. it.live("falls back to the defaults when a prompt answers off-menu", () => { const repo = project(); const { layer } = setupWorkers({ @@ -794,17 +696,10 @@ describe("workers new", () => { ).pipe(Effect.flip); expect(error).toBeInstanceOf(InvalidWorkerSourceError); - // The config survived, which is the whole point. expect(repo.config()).toBe(CONFIG_WITH_COMMENTS); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // CLI-2285 regression: before this fix, a typo'd/nonexistent --workdir - // reached `fs.makeDirectory(destination, { recursive: true })` below with no - // prior existence check, silently scaffolding a fresh - // supabase/workers/<name>/ tree (plus a new config.toml) at the wrong path. - // `validateWorkdirIsDirectory` must now fail first, before anything on - // disk changes. it.live( "fails without scaffolding anything when --workdir names a directory that does not exist at all", () => { @@ -820,8 +715,6 @@ describe("workers new", () => { expect(rendered).toContain("WorkersNewWorkdirError"); expect(rendered).toContain("failed to change workdir: chdir"); - // The critical safety assertion: nothing was scaffolded at the bad - // path, and the ancestor project's own config is untouched. expect(existsSync(join(badWorkdir, "supabase"))).toBe(false); expect(repo.config()).toBe(CONFIG_WITH_COMMENTS); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); diff --git a/apps/cli/src/commands/experimental/workers/push/push.command.ts b/apps/cli/src/commands/experimental/workers/push/push.command.ts index d8be84d5db..4b2ea8963c 100644 --- a/apps/cli/src/commands/experimental/workers/push/push.command.ts +++ b/apps/cli/src/commands/experimental/workers/push/push.command.ts @@ -12,10 +12,8 @@ const config = { Argument.variadic(), ), instances: Flag.integer("instances").pipe( - // Bounded at the parser, the same way `[workers.<name>] instances` is bounded - // in the config schema. Left unchecked it reached the deploy endpoint — after - // the build context had been packaged and uploaded — as a scaling request the - // platform cannot honour. + // Bounded at the parser: left unchecked, a negative value reached the deploy + // endpoint after the build context was already packaged and uploaded. Flag.filter( (instances) => instances >= 0, (instances) => `--instances ${instances} is negative; pass zero or more.`, @@ -26,23 +24,19 @@ const config = { Flag.optional, ), exposure: Flag.choice("exposure", WORKER_EXPOSURES).pipe( - // A closed set at the parser, the way `new --runtime` and `new --size` are: - // the accepted values get listed in the refusal, and nothing unrecognized - // reaches the deploy endpoint after a build context has been uploaded. - // `[workers.<name>] exposure` stays a plain string, so a value the API - // grows before this CLI does can still be recorded there. + // A closed set at the parser, so nothing unrecognized reaches the deploy + // endpoint after a build context has been uploaded. `[workers.<name>] exposure` + // stays a plain string, so the API can grow new values before this CLI does. Flag.withDescription( "Whether the worker is reachable from the internet, overriding `exposure` in supabase/config.toml for this deploy. Falls back to the recorded value, then public.", ), Flag.optional, ), noWait: Flag.boolean("no-wait").pipe( - // The deploy POST is answered once the platform has accepted the spec and - // the uploaded context, and the server-side container build that follows - // routinely runs for minutes. Waiting stays the default so a plain push - // still reports the build's verdict, and `--no-wait` is the opt-out for the - // callers — an inner-loop redeploy, a fire-and-forget CI step — that only - // need the deploy accepted. + // The deploy POST returns once the platform accepts the spec and context; the + // server-side build that follows can run for minutes. Waiting stays the + // default so a plain push reports the build's verdict; `--no-wait` opts out + // for callers that only need the deploy accepted. Flag.withDescription( "Return once the deploy is accepted, without waiting for the server-side build to finish.", ), diff --git a/apps/cli/src/commands/experimental/workers/push/push.handler.ts b/apps/cli/src/commands/experimental/workers/push/push.handler.ts index 899c534842..bff5c66e33 100644 --- a/apps/cli/src/commands/experimental/workers/push/push.handler.ts +++ b/apps/cli/src/commands/experimental/workers/push/push.handler.ts @@ -59,27 +59,11 @@ import { import type { WorkersPushFlags } from "./push.command.ts"; /** - * `supabase experimental workers push [name...]` — build (when there is code to build) and - * deploy the worker into the linked project. Registered under `deploy` as an - * alias, for anyone reaching for the `supabase functions` verb out of habit. - * - * The runtime, size, exposure and source directory come from `[workers.<name>]` - * in `supabase/config.toml`. A directory pushed without ever running `new` gets - * its runtime guessed from marker files instead — reported, with a nudge to pin - * it down rather than re-guess on every push. - * - * A `dockerfile` worker is tarred and uploaded, and the build happens - * server-side from that context, never on your machine. A catalog runtime with - * code takes the same path, with the base image and a copy synthesized in place - * of your Dockerfile. Every runtime this CLI offers has code to package, so - * there is no path here that skips the upload. - * - * The command waits for that server-side build by default, so a plain push - * reports the build's verdict rather than only that the deploy was accepted. - * The build routinely runs for minutes, though, which makes every successful - * deploy as slow as the slowest one — so `--no-wait` returns as soon as the - * platform accepts the deploy, for an inner-loop redeploy or a CI step that - * only needs the spec on file. + * `supabase experimental workers push [name...]` — deploys the named workers + * (or every worker, if none are named), reading runtime, size, exposure, and + * source from `[workers.<name>]`; an unrecorded runtime is guessed and + * reported. Builds run server-side from an uploaded context and are waited on + * by default; `--no-wait` returns once the deploy is accepted. */ const resolveRuntime = Effect.fnUntraced(function* (options: { @@ -133,11 +117,8 @@ const resolveSize = Effect.fnUntraced(function* (options: { /** * `--instances` for one deploy, then the recorded count, then - * {@link DEFAULT_WORKER_INSTANCES}. Never left unset, because every deploy sends - * a complete spec and an omitted count rescales the worker. - * - * No unparseable case to report: the config schema and the flag are both bounded - * to a non-negative integer before the handler runs. + * {@link DEFAULT_WORKER_INSTANCES}. Never left unset, since every deploy sends + * a complete spec and an omitted count would rescale the worker. */ function resolveInstances(options: { readonly recorded: number | undefined; @@ -148,20 +129,11 @@ function resolveInstances(options: { /** * `--exposure` for one deploy, then the recorded exposure, then - * {@link DEFAULT_WORKER_EXPOSURE}. Never left unset, because every deploy sends a - * complete spec and an omitted exposure would re-expose a worker somebody had - * deliberately made private. - * - * `--exposure` is a `Flag.choice`, so only a recorded value can be unrecognized - * — and that is refused rather than coerced, the same way `resolveSize` treats a - * size it does not know: silently deploying a `private`-typo'd worker as public - * is the one outcome nobody asked for. - * - * The flag decides one deploy and nothing writes it down, so an override the - * config does not already agree with is reported the way `resolveRuntime` - * reports a guess: on stderr, naming the line to set. Without it, taking a - * worker off the internet with `--exposure private` lasts exactly until the next - * bare `push` puts it back. + * {@link DEFAULT_WORKER_EXPOSURE}. Never left unset, since an omitted exposure + * would re-expose a worker made private. An unrecognized recorded value is + * refused rather than coerced, and a diverging override is reported on + * stderr, since it applies to this deploy only and the next bare `push` would + * otherwise revert it. */ const resolveExposure = Effect.fnUntraced(function* (options: { readonly name: string; @@ -170,18 +142,15 @@ const resolveExposure = Effect.fnUntraced(function* (options: { }) { if (Option.isSome(options.override)) { const chosen = options.override.value; - // What a later bare `push` would resolve to: the recorded value if the CLI - // knows it, the default if there is none, and `undefined` for one it cannot - // read — which is not `chosen` either, so that case is nudged too. + // What a later bare `push` would resolve to, so a diverging override can be flagged. const withoutTheFlag = options.recorded === undefined ? DEFAULT_WORKER_EXPOSURE : parseWorkerExposure(options.recorded); if (withoutTheFlag !== chosen) { const output = yield* Output; - // stderr, so it never lands inside a payload stdout is carrying — and - // unguarded by format, like the runtime nudge: a CI run is exactly where - // a one-deploy exposure quietly reverting matters most. + // stderr and every format, so it never lands inside a payload stdout is + // carrying, and a CI run still sees the exposure quietly reverting. yield* output.raw( `--exposure ${chosen} applies to this deploy only: supabase/config.toml ${ options.recorded === undefined @@ -201,9 +170,7 @@ const resolveExposure = Effect.fnUntraced(function* (options: { if (recorded === undefined) { return yield* Effect.fail( new UnknownWorkerExposureError({ - // A blank value gets its own sentence: `an unknown exposure ""` reads - // like a parser quirk, when what actually happened is that the key is - // there and says nothing. + // A blank value gets its own sentence rather than reading as a parser quirk. detail: options.recorded.trim() === "" ? `supabase/config.toml records a blank exposure for "${options.name}".` @@ -218,12 +185,9 @@ const resolveExposure = Effect.fnUntraced(function* (options: { /** * What to do about a worker whose source directory is not there at all. * - * `supabase experimental workers new` is only an answer for a name the config has never - * heard of — `new` refuses any name already under `[workers.<name>]`, so - * offering it to a configured worker would answer with a second error. A - * configured worker is missing a directory, not a config entry, and when the - * entry pins an explicit `source` the path itself is as likely to be the - * mistake as the absent directory. + * `workers new` refuses any name already configured, so it isn't an answer + * here — the worker's directory is missing, not its config entry. When the + * entry pins an explicit `source`, the path itself may be the mistake. */ function missingSourceSuggestion(input: { readonly name: string; @@ -243,12 +207,9 @@ function missingSourceSuggestion(input: { /** * What to do about a source directory that exists but holds nothing to deploy. * - * Deliberately does not point at `supabase experimental workers new`. That command refuses - * any name already present in `config.toml`, which is where a pushed worker - * almost always comes from, and it refuses a directory that exists and is not - * empty — so for both callers here it would answer with a second error rather - * than a fix. The directory is already in place and already wired up; the only - * thing missing is the code. + * Doesn't point at `workers new`: it refuses any configured name and any + * non-empty directory, so both callers here would get a second error instead + * of a fix. The directory is already wired up; only the code is missing. */ function addYourCode(sourceDisplay: string): string { return `Add your worker's code to ${sourceDisplay}, then run this command again.`; @@ -283,10 +244,8 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { const sourceDisplay = displayPath(project.projectRoot, worker.sourceDir); - // Checked before the runtime is resolved, not after: with no recorded - // runtime, `resolveRuntime` classifies the directory and announces what it - // guessed. Doing that first meant reporting an inference about a path that - // does not exist, and only then failing on the path. + // Checked before the runtime is resolved: without this, an unrecorded runtime + // would be classified and announced for a path that doesn't exist. { const sourceMissing = new WorkerSourceMissingError({ detail: `There is no worker source at ${sourceDisplay}.`, @@ -297,11 +256,8 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { entry: worker.entry, }), }); - // Only "no such path" means the worker was never scaffolded. A permission - // or I/O error on the directory is a different problem with a different - // fix, and answering it with "there is no worker source, run `workers new`" - // both misdiagnoses it and points at a directory that already exists — so - // every other reason propagates as itself. + // Only "no such path" means the worker was never scaffolded; every other + // reason (permission, I/O) propagates as itself rather than misdiagnosing it. const info = yield* fs .stat(worker.sourceDir) .pipe( @@ -311,10 +267,8 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { : Effect.fail(error), ), ); - // Something is there, it is just not a directory. Reporting that as "there - // is no worker source" is false twice over: the path is occupied, and - // `workers new` refuses a destination that exists and is not a directory, - // so the scaffold suggestion would answer with a second error. + // Something is there, it's just not a directory — reporting "no worker + // source" would be false, and the path is occupied besides. if (info.type !== "Directory") { return yield* Effect.fail( new WorkerSourceMissingError({ @@ -323,13 +277,9 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { }), ); } - // An empty directory packages and deploys perfectly happily, producing an - // image with nothing in it — a success message for a worker that cannot - // serve anything. Refuse before uploading rather than after. - // - // Read errors propagate rather than reading as empty: a directory the CLI - // cannot open is not a directory with nothing in it, and the two want - // opposite things from the user. + // An empty directory packages and deploys happily, producing an image with + // nothing in it — refused here rather than after the upload. A read error + // propagates rather than reading as empty, since the two want opposite fixes. const contents = yield* fs.readDirectory(worker.sourceDir); if (contents.length === 0) { return yield* Effect.fail( @@ -347,10 +297,8 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { sourceDir: worker.sourceDir, }); - // Size: whatever `new --size` recorded, else the alpha envelope's own - // default. Never left unset, because a worker that is actually running always - // has some concrete size — and never silently coerced, because a size the CLI - // does not recognize is a config mistake worth naming. + // Never left unset, since a running worker always has a concrete size, and + // never silently coerced, since an unrecognized size is a config mistake worth naming. const size = yield* resolveSize({ name, recorded: worker.entry?.size }); const instances = resolveInstances({ @@ -358,9 +306,8 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { override: input.instances, }); - // Resolved before anything is packaged or uploaded, alongside the runtime and - // size, so a config that records an exposure this CLI does not know is refused - // while the refusal is still free. + // Resolved before anything is packaged or uploaded, so an unrecognized + // exposure is refused while the refusal is still free. const exposure = yield* resolveExposure({ name, recorded: worker.entry?.exposure, @@ -381,10 +328,8 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { "stderr", ); - // The guard above counts directory entries, so a tree of nothing but empty - // subdirectories reaches here and packages to zero files. For a catalog - // runtime that deploys an image with no handler in it — the exact "nothing - // to deploy" case that guard exists to refuse. + // The guard above only counts directory entries, so a tree of nothing but + // empty subdirectories still reaches here and packages to zero files. if (packaged.fileCount === 0) { return yield* Effect.fail( new WorkerSourceMissingError({ @@ -414,21 +359,16 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { }; const deploying = yield* output.task("Deploying worker..."); - // The response to the deploy itself is the last thing this command can learn - // without waiting: the platform answers it only after accepting the spec and - // the uploaded context, and it carries the accepted spec back. Everything - // after this point is the server-side container build. + // The last thing this command can learn without waiting: the platform answers + // the deploy only after accepting the spec and context, carrying it back. const accepted = yield* deployWorker(api, projectRef, name, { spec, contextUploadId }).pipe( Effect.tapError(() => deploying.fail()), ); - // Polled only when the deploy response left the build unresolved. - // `V2DeployAWorkerOutput` permits a terminal `active` or `failed` on the - // deploy itself, and that verdict is this deploy's — a fresh `GET` can only - // contradict it: `awaitWorkerBuild` reads a post-deploy 404 as "still - // building", so an already-`failed` deploy could burn the whole poll budget - // and surface as a timeout, and a concurrent deployment could answer with a - // state that belongs to someone else's build. + // Polled only when the deploy response left the build unresolved: a fresh GET + // can only contradict that verdict, since `awaitWorkerBuild` reads a + // post-deploy 404 as "still building" — polling an already-`failed` deploy + // could burn the whole budget and surface as a timeout instead. const settled = input.noWait || accepted.buildState !== "building" ? accepted @@ -442,10 +382,8 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { : Effect.void, }).pipe(Effect.tapError(() => deploying.fail())); - // Checked whether or not the build was waited on: the verdict can arrive on - // the deploy response as readily as on a poll. A spec already in `failed` is - // a refusal the command should report as one, rather than exiting zero on a - // worker that will never come up. + // Checked regardless of whether the build was waited on: the verdict can + // arrive on the deploy response as readily as on a poll. if (settled.buildState === "failed") { yield* deploying.clear(); return yield* Effect.fail( @@ -465,56 +403,36 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { ? workerUrl(projectRef, settings.projectHost, name) : undefined; - // Dropped while the build is still running, rather than passed through. - // `image_version` is optional-but-permitted on the deploy response, so a - // re-push of a worker that is already serving can echo the image it is - // serving *now* — the previous build's, not this one's. Rendered beside - // `State building` that names an image this deploy did not produce, and a - // script reading `image_version` next to `build_state: "building"` would take - // it for the new one. Only reachable under `--no-wait`; the default polls - // until the build leaves `building`, so `settled` carries the real image. + // Dropped while still building: `image_version` on the deploy response can + // echo a worker's previously serving image, not this deploy's, and a script + // reading it beside `build_state: "building"` would mistake it for the new + // one. Only reachable under `--no-wait` — the default polls until it settles. const imageVersion = settled.buildState === "building" ? undefined : settled.imageVersion; // Suppressed when `-o` is in play: the payload owns stdout, and these lines // would land in the middle of it. if (output.format === "text" && !input.machineOutput) { - // Declarative line first, then the details — the shape every other command - // that reports a completed remote change uses. `renderWorkerDetails` drops + // Declarative line first, then the details. `renderWorkerDetails` drops // empty-valued rows, so optional fields need no conditional spreads. yield* output.raw(`Deployed Worker ${aqua(name, process.stdout)} to project ${projectRef}\n`); yield* output.raw( renderWorkerDetails([ - // Labelled `State`, and placed first, the way `workers status` renders - // the same field: under `--no-wait` it is the one row that says the - // worker is not serving yet, so it should not be hunted for at the - // bottom of the block. + // Placed first: under `--no-wait` this is the one row saying the worker + // isn't serving yet, so it shouldn't be hunted for at the bottom. ["State", settled.buildState], ["Runtime", runtime], ["Size", formatApiSize(settled.spec.size)], - // Empty under `--no-wait`: this deploy's image does not exist until the - // build produces one, and `renderWorkerDetails` drops an - // empty-valued row. + // Empty under `--no-wait`, since this deploy's image doesn't exist yet. ["Image", imageVersion ?? ""], ["Access", settled.spec.exposure], ["URL", url ?? ""], ]), ); if (settled.buildState === "building") { - // A success trailer rather than an inline stderr line: this is a "what to - // run next" hint, which `stop`, `bootstrap`, `migration repair` and - // `gen signing-key` all route through `emitSuccessTrailer` so it prints - // once at the end of the run instead of scrolling away. It matters here - // more than for those: pushing several workers would otherwise bury each - // worker's hint under the next worker's packaging and deploy output. - // - // One short sentence per line, with the command aqua'd the way every - // other follow-up hint in this shell writes them. The single wrapped - // paragraph this replaced re-flowed differently at every terminal width - // and buried the command mid-sentence. - // - // No "drop `--no-wait` next time" line to go with it: reaching here means - // the caller asked not to wait, so the only thing left to tell them is - // where the build's verdict will show up. + // A success trailer, not an inline stderr line, so pushing several workers + // doesn't bury each hint under the next worker's output. One short + // sentence per line, since a single wrapped paragraph re-flowed + // unpredictably and buried the command mid-sentence. yield* emitSuccessTrailer( `\nYour build was submitted successfully.\n` + `Run ${aqua(`supabase experimental workers status ${name}${input.refSuffix}`)} to check on it.\n`, @@ -528,9 +446,9 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { size: settled.spec.size, exposure: settled.spec.exposure, instances: settled.spec.instances, - // Omitted rather than present-and-undefined: `-o toml` hands the payload to - // smol-toml, which cannot represent undefined and would throw *after* the - // upload and deploy had completed. Same reason `url` is spread below. + // Omitted rather than present-and-undefined: `-o toml` hands this to + // smol-toml, which can't represent undefined and would throw after the + // deploy completed. Same reason `url` is spread below. ...(imageVersion === undefined ? {} : { image_version: imageVersion }), build_state: settled.buildState, ...(url === undefined ? {} : { url }), @@ -538,16 +456,10 @@ const deployOneWorker = Effect.fnUntraced(function* (input: { }); /** - * Names the workers a failed run never got to. - * - * The loop stops on the first failure, so everything after it was never - * attempted — and the error itself only names the worker that broke. Left - * unsaid, the user has to reconstruct the remainder from argument order, or - * from the discovery walk's ordering when the push was a bare `push`. - * - * Written on stderr in every format, unlike the per-worker announcements: a - * machine-format run is a CI run, which is exactly where nobody is watching the - * loop and "what still needs deploying" is the question the failure raises. + * Names the workers a failed run never got to. The loop stops on the first + * failure, and the error itself only names the worker that broke, so this is + * how the remaining names get reported. Written on stderr in every format, + * since a machine-format run is a CI run where nobody is watching the loop. */ const reportUnattempted = Effect.fnUntraced(function* (skipped: ReadonlyArray<string>) { if (skipped.length === 0) { @@ -560,21 +472,12 @@ const reportUnattempted = Effect.fnUntraced(function* (skipped: ReadonlyArray<st }); /** - * Names the workers whose builds the run left running. - * - * Under `--no-wait` a worker is accepted while its build is still in flight, and - * its follow-up hint goes out as a success trailer. `runCli` drains trailers - * only on exit code 0 (`shared/cli/run.ts`, `afterSuccess`), so a later worker - * failing discards every hint the run had queued — including for builds that are - * still running on the platform, which the failure does nothing to stop. - * - * Reported here instead, on the path that actually runs. Same stderr-in-every- - * format rule as {@link reportUnattempted} and the same reason: a machine-format - * run is a CI run, and "what is still in flight" is as much a part of the - * failure's answer as "what never started". - * - * Empty on a waiting run, without needing to check the flag: a worker the run - * waited for has left `building` by the time it returns. + * Names the workers whose builds the run left running. Under `--no-wait` a + * worker's follow-up hint goes out as a success trailer, but trailers only + * drain on exit code 0 — so a later failure would otherwise discard the hint + * for a build still running on the platform. Reported here instead, on the + * path that actually runs, using the same stderr-in-every-format rule as + * {@link reportUnattempted}. */ const reportStillBuilding = Effect.fnUntraced(function* (building: ReadonlyArray<string>) { if (building.length === 0) { @@ -585,18 +488,13 @@ const reportStillBuilding = Effect.fnUntraced(function* (building: ReadonlyArray }); /** - * `supabase experimental workers push [name...]` — deploy the named workers, or every worker - * in the project when none are named, mirroring `supabase functions deploy`. - * - * Deploys run one at a time rather than concurrently: each is a server-side - * container build, and interleaving several would both hammer the alpha's - * per-project capacity and shred the progress output. The first failure stops - * the run, because a build that failed is usually the thing to fix before - * spending minutes on the rest. + * `supabase experimental workers push [name...]` — deploys the named workers, + * or every worker when none are named. * - * Under `--no-wait` that serialization only covers the package/upload/deploy - * legs; the builds themselves then run concurrently on the platform, which is - * what the caller asked for by opting out of the wait. + * Deploys run one at a time: each is a server-side container build, and several + * at once would hammer the alpha's per-project capacity. The first failure + * stops the run; under `--no-wait`, only the package/upload/deploy legs are + * serialized — the builds themselves run concurrently. */ export const workersPush = Effect.fn("experimental.workers.push")(function* ( flags: WorkersPushFlags, @@ -610,10 +508,9 @@ export const workersPush = Effect.fn("experimental.workers.push")(function* ( const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - // The ref is resolved outside the finalizers because caching it is one of - // them; everything that can fail on its own — loading `config.toml`, - // validating names, discovering workers — belongs inside, so a malformed - // config still flushes telemetry. Same shape as `config/push`. + // Resolved here, outside the block below, since caching it is one of that + // block's own finalizers — everything else that can fail belongs inside so + // those failures still flush telemetry. const projectRef = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { @@ -639,13 +536,13 @@ export const workersPush = Effect.fn("experimental.workers.push")(function* ( const names = [...new Set(requested)]; // Before the first deploy, not after the last one: this payload always - // carries a `workers` array, so `-o env` can never encode it, and finding - // that out at the end means failing with the remote project already changed. + // carries a `workers` array, which `-o env` can never encode, so finding + // that out at the end would mean failing with the remote project already changed. yield* rejectWorkersEnvOutput(); const machineOutput = yield* workersMachineOutputRequested(); - // Computed once for the whole run, the way `status` and `delete` do: an - // explicit `--project-ref` has to survive into every hint this push emits. + // Computed once for the whole run: an explicit `--project-ref` has to + // survive into every hint this push emits. const refSuffix = workersProjectRefSuffix(flags.projectRef); const deployed: Array<Record<string, unknown>> = []; // Accepted, but not finished: their builds outlive a failure further down @@ -653,18 +550,9 @@ export const workersPush = Effect.fn("experimental.workers.push")(function* ( const stillBuilding: Array<string> = []; for (const [index, name] of names.entries()) { if (names.length > 1 && !machineOutput && output.format === "text") { - // stderr, unblanked and labelled, the way `functions deploy` announces - // each function: a bare name with a leading blank line put a section - // header into whatever was consuming stdout. - // - // Counted, because each worker's package/upload/build takes minutes and - // the name alone says nothing about how much of the run is left. - // - // Text only, on both axes: `machineOutput` tracks `-o`, which leaves - // `output.format` as `text`, so neither check covers the other. This is - // progress rather than an outcome, and `--output-format json` asked for - // a stream of events — unlike the unattempted-workers report below, - // which every format gets because it says what still needs deploying. + // Progress, not an outcome, so this is text-only on both axes: + // `machineOutput` tracks `-o` (which leaves `output.format` as `text`), + // and `--output-format json` asked for a stream of events instead. yield* output.raw( `Deploying Worker ${index + 1}/${names.length}: ${aqua(name)}\n`, "stderr", @@ -684,8 +572,6 @@ export const workersPush = Effect.fn("experimental.workers.push")(function* ( ? {} : { pollRetrySchedule: options.pollRetrySchedule }), }).pipe( - // In flight before what never started: one is a thing the user now has - // to follow, the other a thing they have to re-run. Effect.tapError(() => reportStillBuilding(stillBuilding).pipe( Effect.andThen(reportUnattempted(names.slice(index + 1))), diff --git a/apps/cli/src/commands/experimental/workers/push/push.integration.test.ts b/apps/cli/src/commands/experimental/workers/push/push.integration.test.ts index 14933e7e2d..9ac569e990 100644 --- a/apps/cli/src/commands/experimental/workers/push/push.integration.test.ts +++ b/apps/cli/src/commands/experimental/workers/push/push.integration.test.ts @@ -47,8 +47,6 @@ function flags(overrides: Partial<WorkersPushFlags> = {}): WorkersPushFlags { names: ["api"], instances: Option.none(), exposure: Option.none(), - // Mirrors the command default: a push waits for the build, and only the - // scenarios that are about the early return opt out of it. noWait: false, projectRef: Option.none(), ...overrides, @@ -115,9 +113,8 @@ function stattableAsCurrentUser(path: string): boolean { } function push(flagOverrides: Partial<WorkersPushFlags> = {}) { - // Both schedules are injected: the outer poll and the per-read retry. The - // production retry is spaced in seconds, so leaving it in place made the - // transient-failure test wait on a real clock. + // Both schedules are injected so the transient-failure test doesn't wait on + // the production retry's real-seconds clock. return workersPush(flags(flagOverrides), { pollSchedule: IMMEDIATE, pollRetrySchedule: IMMEDIATE, @@ -162,7 +159,6 @@ describe("workers push", () => { expect(out.stdoutText).toContain("Runtime"); expect(out.stdoutText).toContain(`https://${WORKERS_PROJECT_REF}.supabase.co/workers/v1/api`); expect(out.stdoutText).toContain("v1"); - // The build settled, so there is nothing left to follow up on. expect(out.stderrText).not.toContain("supabase experimental workers status api"); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); @@ -181,17 +177,11 @@ describe("workers push", () => { ]); expect(out.stdoutText).toContain("Deployed Worker api"); - // No image exists yet, so the row is dropped rather than rendered empty. expect(out.stdoutText).not.toContain("Image"); expect(out.stderrText).toContain("supabase experimental workers status api"); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `V2DeployAWorkerOutput` permits a terminal state on the deploy response - // itself, and that verdict is this deploy's. A poll on top of it can only - // contradict it — `awaitWorkerBuild` reads a post-deploy 404 as "still - // building", so an already-settled deploy would burn the poll budget and - // surface as a timeout instead of the answer the platform already gave. describe("honours a terminal deploy response instead of polling", () => { const settledOnDeploy = (repoDir: string, state: "active" | "failed") => setupWorkers({ @@ -284,10 +274,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `[workers.*] runtime` and `size` are plain strings in the config schema, so - // an unrecognized value reaches the handler rather than failing the parse. - // Naming the accepted values beats echoing a schema error, and the refusal - // has to land before anything is packaged or uploaded. it.live("names the runtimes on offer when config records one it does not know", () => { const repo = project({ "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "cobol"\n`, @@ -367,9 +353,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The whole point of recording it: every deploy sends a complete spec, so a - // worker deliberately made private has to stay private across pushes rather - // than being re-exposed by the next one. it.live("keeps a worker private when config records it that way", () => { const repo = project({ "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsize = "2gb"\nexposure = "private"\n`, @@ -384,8 +367,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Hand-written config, so the casing is the user's own — `PRIVATE` plainly - // means `private`, and the canonical form is what gets sent. it.live("reads a recorded exposure case-insensitively", () => { const repo = project({ "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nexposure = "PRIVATE"\n`, @@ -414,9 +395,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `[workers.*] exposure` is a plain string in the config schema, so a typo - // reaches the handler. Coercing it to the default would deploy a `privat` - // worker to the whole internet — refused before anything is packaged instead. it.live("names the exposures on offer when config records one it does not know", () => { const repo = project({ "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nexposure = "privat"\n`, @@ -433,10 +411,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The blank case, which reads as "not recorded" if the config reader collapses - // it: absent means the `public` default, so a worker whose config plainly - // tried to say something would go to the whole internet. Refused like any - // other value the CLI does not recognize. it.live("refuses a blank recorded exposure instead of defaulting it to public", () => { const repo = project({ "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nexposure = ""\n`, @@ -447,18 +421,12 @@ describe("workers push", () => { const error = yield* push().pipe(Effect.flip); expect(error).toBeInstanceOf(UnknownWorkerExposureError); - // Named as blank rather than as an unknown `""`, which reads like a - // parser quirk instead of an empty key. expect((error as UnknownWorkerExposureError).detail).toContain("blank exposure"); expect((error as UnknownWorkerExposureError).suggestion).toContain("public, private"); - // Nothing was packaged, uploaded or deployed — least of all publicly. expect(http.requests).toHaveLength(0); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `--exposure` decides one deploy and nothing writes it down. Every deploy - // sends a complete spec, so a worker taken off the internet by the flag goes - // back on it at the next bare push — quietly, unless the run says so. describe("says when --exposure will not outlive the deploy", () => { const pushWith = (config: string, exposure: "public" | "private") => { const repo = project({ "supabase/config.toml": config }); @@ -476,7 +444,6 @@ describe("workers push", () => { yield* run(); expect(out.stderrText).toContain("records no exposure for api"); - // The exact line to set, the way the runtime guess names its own. expect(out.stderrText).toContain('[workers.api] exposure = "private"'); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); @@ -495,8 +462,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // A recorded value the CLI cannot read is not `chosen` either: the next bare - // push refuses rather than deploying, which is still not what this run did. it.live("nudges when the config records something it cannot read", () => { const { repo, layer, out, run } = pushWith( `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nexposure = "privat"\n`, @@ -510,8 +475,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Nothing drifts, so nothing to say — the flag restated what the config - // already holds, case-insensitively. it.live("stays quiet when the config already agrees", () => { const { repo, layer, out, run } = pushWith( `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nexposure = "PRIVATE"\n`, @@ -525,8 +488,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The same non-drift, reached the other way: no recorded exposure and a flag - // naming the default a bare push would have picked anyway. it.live("stays quiet when the flag restates the default", () => { const { repo, layer, out, run } = pushWith( `project_id = "demo"\n\n[workers.api]\nruntime = "node"\n`, @@ -541,8 +502,6 @@ describe("workers push", () => { }); }); - // The flag is the authority for the deploy it runs, so an unrecognized - // recorded value it replaces is moot rather than fatal. it.live("lets --exposure stand in for an exposure config records badly", () => { const repo = project({ "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nexposure = "privat"\n`, @@ -618,8 +577,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The reason is optional in the API contract, so the detail has to read as a - // sentence without one rather than trailing a bare colon. it.live("reports a failed build that came with no reason", () => { const repo = project(); const { layer } = setupWorkers({ @@ -640,10 +597,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The accepted spec is the platform's answer, not the request echoed back — so - // a worker the platform did not expose has no URL to print even when the deploy - // asked for `public`, and inventing one from the ref would name an address - // that does not resolve. it.live("omits the URL for a worker the platform did not expose publicly", () => { const repo = project(); const { layer, out } = setupWorkers({ @@ -673,9 +626,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The schedules every other test injects are a seam: the command itself calls - // the handler with no options at all. The stubbed worker settles on the first - // poll, so the production schedules never get to space anything out. it.live("deploys when called the way the command wires it, with no test seams", () => { const repo = project(); const { layer, out, http } = setupWorkers({ workdir: repo.dir, routes: routes() }); @@ -712,10 +662,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Every "run this next" string here is copy-pasted verbatim. From an unlinked - // checkout — or one linked elsewhere — dropping the `--project-ref` the user - // typed either fails to resolve or silently addresses a same-named worker in - // whatever project this checkout points at. describe("carries an explicit --project-ref into its hints", () => { const unlinked = (repoDir: string, routeOverrides = {}) => setupWorkers({ @@ -778,8 +724,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The mirror image: when the link supplied the ref, repeating it back is - // noise on a command that already resolves to the right project. it.live("but leaves it off when the link supplied the ref", () => { const repo = project(); const { layer, out } = setupWorkers({ workdir: repo.dir, routes: routes() }); @@ -808,12 +752,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `config.json` is a supported project format. `push` only reads the workers - // section, so it has to honour one: loading TOML-only left the section empty, - // which meant a guessed runtime and default size and instance count for a - // worker that had configured all three. - // The context is already uploaded by the time the deploy is refused, so the - // failure has to be reported as the deploy's, not the upload's. it.live("reports a rejected deploy after the context has been uploaded", () => { const repo = project(); const { layer, http } = setupWorkers({ @@ -856,15 +794,10 @@ describe("workers push", () => { exposure: "public", instances: 3, }); - // Every value came from config, so nothing was inferred from the files. expect(out.stderrText).not.toContain("guessed"); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The presigned URL's query string is a write-capable credential, so it must - // not ride along in the error text — which rules out the library's own - // `HttpClientError.message`, since that appends the method and URL that - // failed. A transport failure is the case that would carry it. it.live("keeps the presigned signature out of an upload transport failure", () => { const repo = project(); const { layer, http } = setupWorkers({ @@ -886,9 +819,7 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Both of the next two arrive as a 404 on the same route; only `error.code` - // separates them, so they are asserted against the bodies the API really - // sends rather than a shape of our own invention. + // Both of the next two arrive as a 404 on the same route; only `error.code` separates them. it.live("reports a project outside the alpha as unavailable", () => { const repo = project(); const { layer } = setupWorkers({ @@ -960,8 +891,6 @@ describe("workers push", () => { const error = yield* push().pipe(Effect.flip); expect(error).toBeInstanceOf(WorkerSourceMissingError); - // `api` is under `[workers.api]`, and `new` refuses a name the config - // already carries — so the answer is the absent directory, not a scaffold. expect((error as WorkerSourceMissingError).suggestion).not.toContain("workers new"); expect((error as WorkerSourceMissingError).suggestion).toContain( "supabase/workers/api and add your worker's code", @@ -980,19 +909,12 @@ describe("workers push", () => { expect(error).toBeInstanceOf(WorkerSourceMissingError); expect((error as WorkerSourceMissingError).detail).toContain("is empty"); - // `workers new` defines no `--force`, and refuses both a name already in - // `config.toml` and a directory that is not empty — so recovery advice - // that names it would answer with a second error instead of a fix. expect((error as WorkerSourceMissingError).suggestion).not.toContain("--force"); expect((error as WorkerSourceMissingError).suggestion).not.toContain("workers new"); expect(http.requests).toHaveLength(0); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The one case `workers new` really does answer: a name that reached `push` - // from argv alone, with no `[workers.<name>]` entry and nothing on disk. - // Names are only validated as DNS labels before dispatch, so this is - // reachable — a typo, or a worker nobody has scaffolded yet. it.live("offers to scaffold a worker the config has never heard of", () => { const repo = project({ "supabase/config.toml": 'project_id = "demo"\n' }); rmSync(join(repo.dir, "supabase", "workers", "api"), { recursive: true, force: true }); @@ -1009,9 +931,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // A worker whose `source` points somewhere that is not there: the path in - // config is as likely to be the mistake as the absent directory, so the - // suggestion names both. it.live("points at the config entry when a configured source is missing", () => { const repo = project({ "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsource = "./services/api"\n`, @@ -1031,10 +950,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // A file sitting where the source directory should be is not a missing - // worker: the path is occupied, and `workers new` refuses a destination that - // exists and is not a directory, so pointing there would answer with a second - // error. it.live("reports a file at the source path as not a directory", () => { const repo = project({}); const source = join(repo.dir, "supabase", "workers", "api"); @@ -1054,12 +969,8 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // "Cannot read it" and "it is not there" want opposite things from the user, - // and `Effect.option` on the stat collapsed them into the second — so an - // unreadable source was reported as an unscaffolded worker, with a suggestion - // to run `workers new` over a path that is already occupied. A symlink loop - // is the cheapest stat failure that is not a missing path, and unlike a - // chmod it behaves the same when the suite runs as root. + // A symlink loop is the cheapest stat failure that isn't a missing path, and + // unlike a chmod it behaves the same when the suite runs as root. it.live("reports an unstattable source rather than calling it missing", () => { const repo = project({}); const source = join(repo.dir, "supabase", "workers", "api"); @@ -1076,8 +987,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Same rule one line down: `orElseSucceed([])` on the read reported a - // directory the CLI cannot open as a directory with nothing in it. it.live("reports an unreadable source rather than calling it empty", () => { const repo = project({}); const source = join(repo.dir, "supabase", "workers", "api"); @@ -1111,10 +1020,6 @@ describe("workers push", () => { ); }); - // Packaging stores symlinks rather than following them, so a link out of the - // tree would package a path the build cannot resolve. It is refused while - // packaging — before a slot is minted — so nothing is uploaded for a context - // that could never build. it.live("refuses a source that links outside itself, before minting a slot", () => { const repo = project(); symlinkSync("../../config.toml", join(repo.dir, "supabase", "workers", "api", "escape.toml")); @@ -1147,7 +1052,6 @@ describe("workers push", () => { return Effect.gen(function* () { yield* push(); - // The blip was retried rather than aborting a deploy already in flight. expect( http.routeKeys.filter((key) => key === `GET ${workersRoute("/api")}`).length, ).toBeGreaterThan(1); @@ -1155,9 +1059,6 @@ describe("workers push", () => { }); it.live("acts on the workdir's project, not the process's directory", () => { - // `--workdir`/`SUPABASE_WORKDIR` names the project every command acts - // on, so the worker discovered here comes from that tree even though the - // process is somewhere else entirely. const repo = project(); const elsewhere = makeWorkersProject(); const { layer, http } = setupWorkers({ workdir: repo.dir, routes: routes() }); @@ -1201,15 +1102,12 @@ describe("workers push", () => { return Effect.gen(function* () { yield* push({ names: [] }); - // Both deployed, in a stable (sorted) order. expect(http.routeKeys).toContain(`POST ${workersRoute("/api/deploy")}`); expect(http.routeKeys).toContain(`POST ${workersRoute("/web/deploy")}`); expect(http.routeKeys.indexOf(`POST ${workersRoute("/api/deploy")}`)).toBeLessThan( http.routeKeys.indexOf(`POST ${workersRoute("/web/deploy")}`), ); expect(out.stdoutText).toContain("web"); - // Each worker is announced with its place in the run, and the run closes - // by naming everything it deployed. expect(out.stderrText).toContain("Deploying Worker 1/2: api"); expect(out.stderrText).toContain("Deploying Worker 2/2: web"); expect(out.stdoutText).toContain( @@ -1218,11 +1116,8 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The other half of that stat: an entry that is there but cannot be read is a - // real filesystem problem, not a name to skip. Dropping it would deploy a - // subset of the project and report success. Root ignores the permission bits, - // and CI sometimes runs as root, so this asserts the outcome that actually - // applies rather than skipping. + // Asserts against the outcome that actually applies (root ignores permission + // bits, and CI sometimes runs as root) rather than skipping. it.live("fails rather than skipping a workers entry it cannot stat", () => { const repo = project(); const workersRoot = join(repo.dir, "supabase", "workers"); @@ -1254,9 +1149,6 @@ describe("workers push", () => { ); }); - // A dangling link in the workers root is listed by the directory read but has - // nothing to stat. Discovery skips it rather than failing the whole run over a - // path that names no worker. it.live("skips a dangling link in the workers root while discovering", () => { const repo = project(); symlinkSync("nowhere", join(repo.dir, "supabase", "workers", "ghost")); @@ -1270,11 +1162,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // A bare `push` promises to deploy every worker in the project, and a worker - // with no config entry is known only by its directory. Reading an unlistable - // workers root as "no workers here" therefore answers a real filesystem - // problem with "nothing to deploy" — the same absence-versus-unreadable - // confusion as the source-directory guards, one level up. it.live("fails rather than reporting an unlistable workers root as empty", () => { const repo = project({ "supabase/config.toml": 'project_id = "demo"\n' }); const workersRoot = join(repo.dir, "supabase", "workers"); @@ -1332,18 +1219,12 @@ describe("workers push", () => { expect(error).toBeInstanceOf(WorkerBuildFailedError); expect(out.stderrText).toContain("Not attempted: web"); - // Named rather than deployed: the run really did stop. expect(http.routeKeys).not.toContain(`POST ${workersRoute("/web/deploy")}`); - // No summary either — nothing finished. expect(out.stdoutText).not.toContain("Deployed 2 Workers"); - // Nothing was left running: a waiting run has no build in flight to name. expect(out.stderrText).not.toContain("Still building"); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `runCli` drains success trailers only on exit code 0, so a later failure - // discards the follow-up hint for a build that is still running — and the - // failure does nothing to stop that build. The failure path has to say so. it.live("names the builds a failed --no-wait run left running", () => { const repo = project({ "supabase/config.toml": @@ -1372,8 +1253,6 @@ describe("workers push", () => { expect(error).toBeInstanceOf(WorkerBuildFailedError); expect(out.stderrText).toContain("Still building: api"); expect(out.stderrText).toContain("Not attempted: zap"); - // In flight before never started: one is a thing to follow, the other a - // thing to re-run. expect(out.stderrText.indexOf("Still building")).toBeLessThan( out.stderrText.indexOf("Not attempted"), ); @@ -1435,7 +1314,6 @@ describe("workers push", () => { const success = out.messages.findLast( (message) => message.type === "success" && message.data !== undefined, ); - // One entry per worker deployed, since a bare push can deploy several. expect(success?.data).toMatchObject({ project_ref: WORKERS_PROJECT_REF }); expect(success?.data?.["workers"]).toEqual([ { @@ -1452,10 +1330,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `image_version` is optional-but-permitted on the deploy response, so a - // re-push of a worker that is already serving can echo the image it is - // serving now — the previous build's. Reporting that beside `State building` - // names an image this deploy did not produce. describe("does not report the previous image while a re-push is still building", () => { const rePush = (repoDir: string, format?: "json") => setupWorkers({ @@ -1469,8 +1343,7 @@ describe("workers push", () => { name: "api", runtime: "node", buildState: "building", - // The worker was already live, so the platform echoes the image - // it is still serving. + // The platform echoes the image a re-pushed worker is still serving. imageVersion: "v7", }), }, @@ -1501,8 +1374,6 @@ describe("workers push", () => { const success = out.messages.findLast( (message) => message.type === "success" && message.data !== undefined, ); - // Whole-payload rather than a missing-key assertion: beside - // `build_state: "building"`, an `image_version` reads as this build's. expect(success?.data?.["workers"]).toEqual([ { worker_name: "api", @@ -1524,15 +1395,11 @@ describe("workers push", () => { return Effect.gen(function* () { yield* push(); - // The mirror case: blanking is tied to `building`, not to re-pushes. expect(out.stdoutText).toContain("v1"); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); }); - // Under `--no-wait` the payload reports the accepted deploy rather than a - // finished one: the build has not produced an image, and saying `active` - // would tell a script the worker is already serving. it.live("reports the build as still running in json mode under --no-wait", () => { const repo = project(); const { layer, out } = setupWorkers({ @@ -1561,8 +1428,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `--output-format json` asked for a stream of events, so progress does not - // belong in it — unlike the "not attempted" report, which every format gets. it.live("keeps per-worker progress out of json mode", () => { const repo = project({ "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\n\n[workers.web]\nruntime = "node"\n`, @@ -1593,9 +1458,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `-o env` cannot express the `workers` array. Discovering that at emit time - // meant failing with the project already changed, inviting a retry that - // deployed all over again. it.live("refuses -o env before making any request at all", () => { const repo = project(); const { layer, http } = setupWorkers({ @@ -1612,9 +1474,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The "nothing to deploy" guard counts directory entries, so without this a tree - // of empty subdirectories packages to zero files and deploys an image with no - // handler in it. it.live("refuses a source holding only empty directories, before minting a slot", () => { const repo = project({ "supabase/workers/api/nested/.keep": "" }); rmSync(join(repo.dir, "supabase", "workers", "api", "index.js")); @@ -1631,8 +1490,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The runtime guess is an inference about the contents of a directory, so it - // has no business being reported for a directory that is not there. it.live("does not report a guessed runtime when the source is missing", () => { const repo = project({ "supabase/config.toml": 'project_id = "demo"\n' }); rmSync(join(repo.dir, "supabase", "workers", "api"), { recursive: true, force: true }); @@ -1646,8 +1503,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `image_version` is optional in the response. Present-but-undefined made the - // TOML encoder throw, after the upload and deploy had already completed. it.live("encodes -o toml when the deployed worker has no image version", () => { const repo = project(); const { layer, out } = setupWorkers({ @@ -1669,8 +1524,6 @@ describe("workers push", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // A malformed config.toml must fail inside the finalizers, or the run skips the - // telemetry flush every invocation is supposed to perform. it.live("flushes telemetry when the project config cannot be loaded", () => { const repo = project({ "supabase/config.toml": "project_id = [unclosed\n" }); const { layer, telemetry } = setupWorkers({ workdir: repo.dir, routes: routes() }); diff --git a/apps/cli/src/commands/experimental/workers/status/status.handler.ts b/apps/cli/src/commands/experimental/workers/status/status.handler.ts index 43fc239126..bda112a0b3 100644 --- a/apps/cli/src/commands/experimental/workers/status/status.handler.ts +++ b/apps/cli/src/commands/experimental/workers/status/status.handler.ts @@ -42,10 +42,9 @@ export const workersStatus = Effect.fn("experimental.workers.status")(function* const telemetryState = yield* TelemetryState; const settings = yield* CommandSettings; - // The ref is resolved outside the finalizers because caching it is one of - // them; everything that can fail on its own — loading `config.toml`, - // validating the name, resolving the worker — belongs inside, so those - // failures still flush telemetry. Same shape as `config/push`. + // Resolved here, outside the block below, since caching it is one of that + // block's own finalizers — everything else that can fail belongs inside so + // those failures still flush telemetry. const projectRef = yield* resolver.resolve(flags.projectRef); const refSuffix = workersProjectRefSuffix(flags.projectRef); @@ -54,8 +53,8 @@ export const workersStatus = Effect.fn("experimental.workers.status")(function* const name = yield* validateWorkerName(flags.name); const worker = yield* describeWorkerForReporting(project, name); - // Up front, like the rest of the family: discovering an unencodable format - // at emit time means failing after the fetch has already been paid for. + // Checked up front: discovering an unencodable format at emit time would + // mean failing after the fetch has already been paid for. yield* rejectWorkersEnvOutput(); const fetching = yield* output.task("Fetching worker..."); @@ -78,13 +77,10 @@ export const workersStatus = Effect.fn("experimental.workers.status")(function* record.spec.exposure === "public" ? workerUrl(projectRef, settings.projectHost, name) : undefined; - // Reported only when an entry or the directory establishes it. With neither, - // the path is an inference about a worker that may have been deployed from - // another checkout. - // - // `sourceResolved` matters for the entry half: when the configured `source` - // could not be resolved, `sourceDir` is the *default* directory standing in - // for it, and printing that would name a path the entry does not. + // Reported only when an entry or the directory establishes it; with + // neither, the path is an inference about a worker deployed elsewhere. + // `sourceResolved` matters for the entry half: when `source` couldn't be + // resolved, `sourceDir` is the default directory standing in for it. const sourceDisplay = (worker.entry !== undefined && worker.sourceResolved) || worker.sourceExists ? displayPath(project.projectRoot, worker.sourceDir) @@ -113,10 +109,9 @@ export const workersStatus = Effect.fn("experimental.workers.status")(function* return; } - // One structured emission, in the structured branch only. Calling - // `output.success` before this check emitted the payload twice: the JSON - // layer appends each success to stdout, so `JSON.parse` failed, and - // `stream-json` saw two terminal result events. + // Checked after `emitWorkersMachineOutput`: calling `output.success` before + // it emitted the payload twice, since the JSON layer appends each success + // to stdout. if (output.format !== "text") { yield* output.success("", payload); return; @@ -131,8 +126,8 @@ export const workersStatus = Effect.fn("experimental.workers.status")(function* ["Access", record.spec.exposure], [ // Every number in the tally line comes from the tally: mixing - // `instances.ready` with `spec.instances` compares a snapshot against - // the desired count, which mid-scale renders fractions like `3/1 ready`. + // `instances.ready` with `spec.instances` would render fractions like + // `3/1 ready` mid-scale. "Instances", record.instances !== undefined ? `${record.instances.ready}/${record.instances.declared} ready, ${record.instances.live} live, ${record.instances.stale} stale` @@ -140,8 +135,6 @@ export const workersStatus = Effect.fn("experimental.workers.status")(function* ], ["URL", url ?? ""], ["Project", projectRef], - // `renderWorkerDetails` drops empty-valued rows, so an unknown - // source omits the row rather than printing a guess. ["Source", sourceDisplay ?? ""], ]; @@ -153,8 +146,7 @@ export const workersStatus = Effect.fn("experimental.workers.status")(function* // Not while it is being torn down: deletion is asynchronous, so a push here // races the tombstone or resurrects the very worker the user is removing. if (record.buildState === "failed" && record.deleting !== true) { - // Trailer, like every other "what to run next" line in this shell: the - // command reports a failed build but exits 0, so the trailer flushes. + // A trailer, since the command reports a failed build but exits 0. yield* emitSuccessTrailer( `Fix the issue, then re-run ${aqua(`supabase experimental workers push ${name}${refSuffix}`)}.\n`, ); diff --git a/apps/cli/src/commands/experimental/workers/status/status.integration.test.ts b/apps/cli/src/commands/experimental/workers/status/status.integration.test.ts index c3533cf7eb..0299517e5b 100644 --- a/apps/cli/src/commands/experimental/workers/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/workers/status/status.integration.test.ts @@ -71,8 +71,8 @@ describe("workers status", () => { }); it.live("reports the deployed runtime, not a stale config.toml entry", () => { - // config.toml says node; the deployment carries no spec.runtime, which the - // API only omits for a context-only (Dockerfile) build. + // The deployment carries no spec.runtime, which the API only omits for a + // context-only build. const repo = project(); const { layer, out } = setupWorkers({ workdir: repo.dir, @@ -139,9 +139,6 @@ describe("workers status", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Mid-scale the snapshot and the desired spec disagree; reading the numerator - // from one and the denominator from the other rendered fractions like - // `3/1 ready`. it.live("reads the whole tally from one snapshot while scaling", () => { const repo = project(); const { layer, out } = setupWorkers({ @@ -169,8 +166,6 @@ describe("workers status", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // Deletion is asynchronous, so pushing here races the tombstone or resurrects - // the worker the user is removing. it.live("withholds the build retry while the worker is being deleted", () => { const repo = project(); const { layer, out } = setupWorkers({ @@ -223,7 +218,6 @@ describe("workers status", () => { expect(out.stdoutText).toContain("failed"); expect(out.stdoutText).toContain("exit status 1"); - // The retry hint is a success trailer, which lands on stderr. expect(out.stderrText).toContain("supabase experimental workers push api"); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); @@ -303,9 +297,6 @@ describe("workers status", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // A `source` that escapes the project cannot be resolved, so the describe - // falls back to the default directory. Printing that named a path the entry - // does not, presenting a guess as established local state. it.live("omits the source when the configured one cannot be resolved", () => { const repo = project({ "supabase/config.toml": `project_id = "demo"\n\n[workers.api]\nruntime = "node"\nsource = "../../elsewhere"\n`, @@ -365,14 +356,10 @@ describe("workers status", () => { declared_instances: 1, instances: { declared: 1, live: 1, ready: 1, stale: 0 }, }); - // The detail lines are text-mode only. expect(out.stdoutText).toBe(""); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The JSON layer appends each success to stdout, so emitting the payload twice - // made `JSON.parse(stdout)` fail outright and gave `stream-json` two terminal - // result events. it.live("emits exactly one structured result in json mode", () => { const repo = project(); const { layer, out } = setupWorkers({ @@ -393,9 +380,6 @@ describe("workers status", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // A worker deployed from another checkout has no entry and no directory here, - // so `supabase/workers/<name>` is pure inference — reporting it as the - // worker's source named a path that was not there. it.live("omits the source for a worker with nothing local to point at", () => { const repo = project({ "supabase/config.toml": 'project_id = "demo"\n' }); const { layer, out } = setupWorkers({ @@ -416,9 +400,6 @@ describe("workers status", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `root` is an ordinary worker name: a valid DNS label, and `[workers]` has no - // reserved keys — `readWorkersSection` reads every table under it as a worker. - // Here as a guard against the name picking up a special case it never had. it.live("inspects a deployed worker named root", () => { const repo = project(); const { layer, out, http } = setupWorkers({ @@ -439,9 +420,6 @@ describe("workers status", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // `state_reason`, `image_version`, `deleting` and `instances_error` are all - // optional, so a healthy worker's payload is mostly holes. Pins that they are - // omitted rather than rendered. it.live("encodes TOML for a worker whose optional fields are absent", () => { const repo = project(); const { layer, out } = setupWorkers({ @@ -463,9 +441,6 @@ describe("workers status", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The project is consulted only for the optional Source row, so an unrelated - // local parse error should not stand between the user and a remote worker - // they named explicitly. it.live("inspects a remote worker despite an unparseable local config", () => { const repo = project({ "supabase/config.toml": "project_id = [unclosed\n" }); const otherRef = "qrstuvwxyzabcdefghij"; @@ -487,9 +462,6 @@ describe("workers status", () => { }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(repo.cleanup))); }); - // The URL is derived from the exposure the platform reports, not assumed: a - // worker it did not expose has no address to print, and the row is dropped - // rather than rendered empty. it.live("omits the URL for a worker that is not publicly exposed", () => { const repo = project(); const { layer, out } = setupWorkers({ diff --git a/apps/cli/src/commands/experimental/workers/workers-logs.format.ts b/apps/cli/src/commands/experimental/workers/workers-logs.format.ts index 706a83c182..c5e0b60723 100644 --- a/apps/cli/src/commands/experimental/workers/workers-logs.format.ts +++ b/apps/cli/src/commands/experimental/workers/workers-logs.format.ts @@ -5,26 +5,19 @@ import type { WorkerLogEntry } from "../../../shared/workers/worker-logs-api.ts" /** * Text rendering for `supabase experimental workers logs`. * - * Pure, like `workers.format.ts` beside it: no Effect, no services, so the line - * shapes and the level derivation are unit-testable directly. - * - * Kept apart from `workers.format.ts` because that file renders *resources* - a - * worker's details as a key/value block - while this renders a stream of events, - * one line each, with a different layout per stream. + * Pure, so line shapes and level derivation are unit-testable directly. Kept + * apart from `workers.format.ts`, which renders a worker's details as a + * key/value block rather than a stream of per-line events. */ export type WorkerLogLevel = "info" | "warn" | "error"; /** - * The level for one line, derived rather than read. - * - * `severity_text` on the row is not usable: every observed row of every stream - * carries `INFO`, including a 200 request log, so it is a pipeline default rather - * than a signal. Platform's own log presets do the same thing - they derive level - * from `log_attributes`, and no platform code branches on `severity_text`. - * - * Guest output has no level available without parsing tenant text, so it is - * reported absent rather than guessed at. + * The level for one line, derived rather than read: `severity_text` carries + * `INFO` on every observed row (including 200s), so it's a pipeline default, + * not a signal — the platform's own presets derive level from + * `log_attributes` too. Guest output has no level without parsing tenant + * text, so it's reported absent rather than guessed. */ export function workerLogLevel(entry: WorkerLogEntry): WorkerLogLevel | undefined { if (entry.stream === WORKER_LOG_STREAMS.requests) { @@ -45,12 +38,9 @@ export function workerLogLevel(entry: WorkerLogEntry): WorkerLogLevel | undefine } /** - * The escape-sequence and control-character patterns stripped from a guest line. - * - * Module constants so they compile once rather than per line, and so the - * `no-control-regex` suppression sits in one place: matching control characters is - * the entire purpose here, and every pattern is written with Unicode escapes so - * the source itself holds no raw control bytes. + * Escape-sequence and control-character patterns stripped from a guest line, + * as module constants so they compile once. Every pattern uses Unicode + * escapes so the source itself holds no raw control bytes. */ /* oxlint-disable no-control-regex */ /** OSC: ESC ] ... terminated by BEL or ESC backslash. */ @@ -62,27 +52,19 @@ const ESCAPE_SEQUENCE = /\u001b[@-Z\\-_]/gu; /** CRLF pairs, folded to a bare newline before lone carriage returns go. */ const CRLF_PAIR = /\u000d\u000a/gu; /** - * Leftover C0 controls and DEL, keeping only tab and newline. - * - * A carriage return is stripped rather than kept: it returns the cursor to - * column zero, so a line carrying one can overwrite the timestamp and stream tag - * already printed to its left and forge output that looks like the CLI's own. - * Real line breaks survive as the `CRLF_PAIR` fold above. + * Leftover C0 controls and DEL, keeping only tab and newline. A carriage + * return is stripped since it returns the cursor to column zero, letting a + * line overwrite the timestamp and stream tag already printed to its left. */ const C0_CONTROLS = /[\u0000-\u0008\u000b-\u001f\u007f]/gu; /* oxlint-enable no-control-regex */ /** * Control characters stripped from a line before it reaches a terminal. - * - * A `worker_guest_logs` message is bytes the tenant's own code printed, so it is - * the one untrusted string this CLI displays: left alone, a worker could emit - * ANSI escapes that reposition the cursor, recolour later output, or forge a line - * that looks like the CLI's own. - * - * Deliberately narrow. Tabs, and the interior newlines and indentation of a stack - * trace, are content the reader needs - only escape sequences and the other C0 - * controls go. + * `worker_guest_logs` is bytes the tenant's own code printed — the one + * untrusted string this CLI displays — so left alone it could reposition the + * cursor or forge a line that looks like the CLI's own. Tabs and interior + * newlines are kept; only escape sequences and other C0 controls go. */ function stripControlSequences(message: string): string { return message @@ -94,18 +76,11 @@ function stripControlSequences(message: string): string { } /** - * Colour for a level, or plain text. - * - * The stream is threaded through rather than a boolean because - * `aqua`/`red`/... already own the colour decision: they consult - * `NO_COLOR`, `CLICOLOR`, `CLICOLOR_FORCE`, `CI` and the stream's own - * `hasColors()`. Deciding here - from `isTTY`, say - would both duplicate that - * gate and get it wrong, since `CLICOLOR_FORCE=1` deliberately styles a piped - * stream. - * - * Only `warn` and `error` are coloured. `info` is the overwhelming majority of - * lines, and tinting all of them would make the exceptions harder to spot, not - * easier. + * Colour for a level, or plain text. The stream is threaded through rather + * than a boolean since `aqua`/`red`/... already own the colour decision + * (`NO_COLOR`, `CLICOLOR`, `CLICOLOR_FORCE`, `CI`, `hasColors()`); only + * `warn`/`error` are coloured, since tinting the overwhelming majority + * (`info`) would bury the exceptions instead of highlighting them. */ function colourise(text: string, level: WorkerLogLevel | undefined, stream: ColorStream): string { if (level === "error") { @@ -117,20 +92,11 @@ function colourise(text: string, level: WorkerLogLevel | undefined, stream: Colo const pad2 = (value: number): string => String(value).padStart(2, "0"); /** - * `HH:MM:SS` in the reader's own timezone. - * - * Local rather than UTC, matching the only other log-line format this shell - * prints - the `--debug` HTTP logger, which uses Go's `log.LstdFlags` - * (`debug-logger.layer.ts`). Someone reading a tail is asking "what just - * happened", and the answer is compared against their own clock. - * - * Machine output keeps the unambiguous forms, so nothing that gets parsed, - * sorted, or pasted into an issue depends on the reader's zone: the payload - * carries an ISO-8601 UTC `timestamp` alongside the raw epoch `timestamp_ms`. - * - * Time only, not a full timestamp: every line in one invocation falls inside a - * window of at most a day, so repeating the date on all hundred of them costs - * width the message needs. + * `HH:MM:SS` in the reader's own timezone, matching the only other log-line + * format this shell prints (the `--debug` HTTP logger). Machine output keeps + * unambiguous forms instead, carrying an ISO-8601 UTC `timestamp` and raw + * `timestamp_ms`. Date is omitted since a single invocation's lines all fall + * inside one day. */ function formatLogTime(timestampMs: number): string { const at = new Date(timestampMs); @@ -138,15 +104,10 @@ function formatLogTime(timestampMs: number): string { } /** - * The short label for a stream, and the column it sits in. - * - * Bracketed and left-aligned, following `next/commands/logs`, which distinguishes - * interleaved sources the same way (`[postgres] ready`). Padded so the messages - * line up: a ragged left edge is harder to scan than a slightly wider one. - * - * Abbreviated because the wire names are internal and long - `worker_guest_logs` - * would cost 19 columns to say "the worker's own output". The words match - * `--kind`, so what is printed is what the flag accepts. + * The short label for a stream, and the column it sits in. Bracketed and + * left-aligned so interleaved sources are easy to scan, and padded so + * messages line up. Abbreviated since the wire names are long internal + * strings; the words match `--kind`, so what's printed is what the flag accepts. */ const STREAM_TAGS: Readonly<Record<string, string>> = { [WORKER_LOG_STREAMS.app]: "app", @@ -158,9 +119,7 @@ const TAG_WIDTH = Math.max(...Object.values(STREAM_TAGS).map((tag) => tag.length /** * An unrecognised stream gets its raw name rather than a placeholder: the log - * contract is additive-only, and the name is more use than a `?` - at the cost of - * a wider column for that line only, which is the right trade for something that - * should not be appearing yet. + * contract is additive-only, so the name is more useful than a `?`. */ function streamTag(stream: string): string { return `[${STREAM_TAGS[stream] ?? stream}]`.padEnd(TAG_WIDTH); @@ -169,15 +128,10 @@ function streamTag(stream: string): string { /** * What one entry says, composed and sanitised but not coloured or prefixed. * - * Split out from the renderer because `stream-json` needs the same sentence: - * emitting `event_message` there instead dropped the status and duration from a - * request line and the structured reason from a build failure, and `log-entry` - * has no attributes field for a consumer to recover them from. - * - * Per-stream layouts rather than one shared format, because `event_message` - * means something different in each. On the request stream it is only `"GET /"` - * — the status and duration live in `log_attributes` — so the useful line has to - * be *composed*. + * Split out from the renderer since `stream-json` needs the same sentence: + * `event_message` alone would drop the status/duration or build reason, which + * live in `log_attributes` instead. Per-stream layouts, not one shared + * format, since `event_message` means something different on each stream. */ export function workerLogText(entry: WorkerLogEntry): string { if (entry.stream === WORKER_LOG_STREAMS.requests) { @@ -198,28 +152,19 @@ export function workerLogText(entry: WorkerLogEntry): string { .join(" "); } - // Guest output, and anything newer. The message is the payload. - // - // Every branch above sanitises too: a request `path` is chosen by whoever - // called the worker, and a build `reason` is relayed from the builder, so - // "the guest message is the only untrusted string" was never true. The - // `stream` the tag is derived from is not sanitised because it cannot carry - // anything: the query only returns rows whose stream is one of three - // literals. + // Guest output, and anything newer — the message is the payload. Every + // branch above sanitises too, since a request `path` or build `reason` is + // not guaranteed trustworthy either; `stream` needs no sanitising, since + // the query only returns one of three literal values. return stripControlSequences(entry.message); } /** - * One rendered line. - * - * Per-stream layouts rather than one shared format, because `event_message` means - * something different in each. On the request stream it is only `"GET /"` - the - * status and duration live in `log_attributes` - so the useful line has to be - * *composed*, and a single format wide enough for all three would be mostly empty - * for each of them. - * - * An unrecognised stream falls back to the bare message: the log contract is - * additive-only, so a stream this CLI has not heard of must still print. + * One rendered line. Per-stream layouts, not one shared format, since + * `event_message` means something different on each — a single format wide + * enough for all three would be mostly empty for each of them. An + * unrecognised stream falls back to the bare message, since the log contract + * is additive-only. */ export function renderWorkerLogLine( entry: WorkerLogEntry, diff --git a/apps/cli/src/commands/experimental/workers/workers-logs.format.unit.test.ts b/apps/cli/src/commands/experimental/workers/workers-logs.format.unit.test.ts index b95c9bf06c..2a498b0e5c 100644 --- a/apps/cli/src/commands/experimental/workers/workers-logs.format.unit.test.ts +++ b/apps/cli/src/commands/experimental/workers/workers-logs.format.unit.test.ts @@ -6,14 +6,10 @@ import { renderWorkerLogLine, workerLogLevel } from "./workers-logs.format.ts"; const ESCAPE = "\u001b"; /** - * Colour is decided by the stream, so the tests supply one — and by the - * environment, so the tests pin that too. `supportsColor` consults - * NO_COLOR / CLICOLOR / CLICOLOR_FORCE / CI *before* it ever asks the stream, - * so a fake stream alone does not make these deterministic: under CI, where - * `CI` is set, a `hasColors: () => true` stream still renders plain. - * - * Neutralised the same way `colors.unit.test.ts` does it — empty string - * reads as unset for every variable the gate consults. + * Colour depends on both the stream and the environment (`supportsColor` + * checks NO_COLOR/CLICOLOR/CLICOLOR_FORCE/CI before the stream), so a fake + * stream alone isn't deterministic — under CI a `hasColors: () => true` + * stream still renders plain unless the env vars are neutralised too. */ const PLAIN = { hasColors: () => false }; const COLOURED = { hasColors: () => true }; @@ -32,11 +28,8 @@ const AT = Date.parse("2026-08-31T14:45:32.576Z"); /** * The expected `HH:MM:SS` prefix for an instant, in this machine's zone. - * - * Derived rather than hardcoded: the renderer prints local time, so a literal - * `"14:45:32"` would pass only on a UTC machine and fail everywhere else. Written - * with the same field accessors the renderer uses, so what it pins is the format - * and the zone choice, not an arithmetic that could drift with the clock. + * Derived, not hardcoded, so this passes off a UTC machine too; uses the same + * field accessors the renderer does, so it pins the format and zone choice. */ function localTime(timestampMs: number): string { const at = new Date(timestampMs); @@ -91,8 +84,6 @@ describe("workerLogLevel", () => { }); it("reports no level for guest output rather than guessing one", () => { - // Nothing short of parsing tenant text could tell, so absent is the honest - // answer. expect(workerLogLevel(entry())).toBeUndefined(); }); @@ -109,8 +100,6 @@ describe("renderWorkerLogLine", () => { }); it("composes the request line from attributes, not the message", () => { - // On the wire `event_message` is only "GET /" - status and duration live in - // log_attributes, so the useful line has to be assembled. const line = renderWorkerLogLine( entry({ stream: "worker_ingress_logs", @@ -137,7 +126,6 @@ describe("renderWorkerLogLine", () => { }); it("falls back to the message for an unknown stream", () => { - // The log contract is additive-only, so a new stream must still print. const line = renderWorkerLogLine( entry({ stream: "worker_future_logs", message: "something new" }), { showStream: false, colorStream: PLAIN }, @@ -147,8 +135,7 @@ describe("renderWorkerLogLine", () => { }); it("renders local time, not UTC", () => { - // Pinned against a fixed offset rather than the ambient zone, so the choice is - // asserted on a UTC machine too, where local and UTC would otherwise coincide. + // Pinned against a fixed offset, so this also fails correctly on a UTC machine. const utc = new Date(AT).toISOString().slice(11, 19); const offsetMinutes = new Date(AT).getTimezoneOffset(); const line = renderWorkerLogLine(entry(), { showStream: false, colorStream: PLAIN }); @@ -193,8 +180,6 @@ describe("renderWorkerLogLine", () => { expect(line).toBe(`${T} kept`); }); - // A carriage return returns the cursor to column zero, so a line carrying one - // can overwrite the timestamp and tag already printed to its left. it("strips a carriage return so a line cannot overwrite its own prefix", () => { const line = renderWorkerLogLine(entry({ message: "harmless\r00:00:00 forged" }), { showStream: false, @@ -214,8 +199,6 @@ describe("renderWorkerLogLine", () => { ).toBe(`${T} first\nsecond`); }); - // The request path is chosen by whoever called the worker, so it is as - // untrusted as anything the worker printed itself. it("strips control sequences from request attributes", () => { const line = renderWorkerLogLine( entry({ @@ -234,7 +217,6 @@ describe("renderWorkerLogLine", () => { expect(line).not.toContain(ESCAPE); }); - // A build reason is relayed from the builder, which reports what it was given. it("strips control sequences from build attributes", () => { const line = renderWorkerLogLine( entry({ @@ -278,7 +260,6 @@ describe("renderWorkerLogLine", () => { colorStream: COLOURED, }); - // The timestamp stays plain so nothing a script greps on changes colour. expect(errorLine.startsWith(`${T} `)).toBe(true); expect(warnLine.startsWith(`${T} `)).toBe(true); expect(errorLine).toContain(`${ESCAPE}[31m`); @@ -330,7 +311,6 @@ describe("renderWorkerLogLine", () => { { showStream: true, colorStream: PLAIN }, ); - // A ragged left edge is harder to scan than a slightly wider one. expect(app.indexOf("workers shim")).toBe(build.indexOf("deploy_accepted")); }); From 525fdf13cfba47722b086c0214ab4cc92ba1b189 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 12:45:53 +0100 Subject: [PATCH 21/56] chore(repo): trim comments, shard 20 (1739 -> 934 comment lines) --- .../config/diff/diff.format.unit.test.ts | 15 +- .../src/commands/config/diff/diff.handler.ts | 117 ++---- .../config/diff/diff.integration.test.ts | 187 +++------- .../commands/config/diff/diff.live.test.ts | 15 +- .../src/commands/config/pull/pull.command.ts | 22 +- .../src/commands/config/pull/pull.e2e.test.ts | 24 +- .../src/commands/config/pull/pull.errors.ts | 114 ++---- .../src/commands/config/pull/pull.format.ts | 55 +-- .../src/commands/config/pull/pull.handler.ts | 155 +++----- .../config/pull/pull.integration.test.ts | 346 +++++------------- .../commands/config/pull/pull.live.test.ts | 11 +- .../cli/src/commands/config/pull/pull.plan.ts | 273 +++++--------- .../config/pull/pull.plan.unit.test.ts | 28 +- .../src/commands/config/pull/pull.scope.ts | 154 ++------ .../config/pull/pull.scope.unit.test.ts | 29 +- .../config/push/push.auth-email-content.ts | 14 +- .../push/push.auth-email-content.unit.test.ts | 23 +- .../config/push/push.branch-target.ts | 121 ++---- .../src/commands/config/push/push.command.ts | 12 +- .../commands/config/push/push.cost-matrix.ts | 9 +- .../src/commands/config/push/push.encoders.ts | 115 +++--- .../config/push/push.encoders.unit.test.ts | 64 +--- .../src/commands/config/push/push.errors.ts | 70 +--- 23 files changed, 566 insertions(+), 1407 deletions(-) diff --git a/apps/cli/src/commands/config/diff/diff.format.unit.test.ts b/apps/cli/src/commands/config/diff/diff.format.unit.test.ts index a00abc25f4..0041d98cc6 100644 --- a/apps/cli/src/commands/config/diff/diff.format.unit.test.ts +++ b/apps/cli/src/commands/config/diff/diff.format.unit.test.ts @@ -14,10 +14,6 @@ const EMPTY_CHANGE_SET: ConfigChangeSet = { describe("configApiScope", () => { test("lists record blocks the response carried, dropping non-records and empty records", () => { - // An EMPTY block record is how a permission-truncated response most - // plausibly reports a block it could not read — claiming it was - // "compared" while all its keys render (not returned) would be false, - // and with --exit-code that is a permanently red CI no file edit fixes. expect( configApiScope({ api: { max_rows: 5 }, @@ -57,10 +53,6 @@ describe("configScopeLine", () => { describe("configDiffSummaryMessage", () => { test("a missing block's caveat travels with the summary message, not just the text renderer", () => { - // Mirrors the hazard documented above: a partial API response (e.g. a - // scoped token returning `auth: {}`) must not report an unqualified "No - // config differences found." in machine `.message` — an agent echoing - // just `.message` would otherwise wrongly claim a full comparison. expect( configDiffSummaryMessage(EMPTY_CHANGE_SET, { present: ["api"], missing: ["auth"] }), ).toBe( @@ -98,11 +90,8 @@ describe("configDiffSummaryMessage", () => { }); describe("renderConfigDiffText", () => { - // Pins the exact byte shape once the per-change loop moved to - // `configRenderChangeLines` (`../config.format.ts`, shared with - // `config push`): one blank line between change blocks, one blank line - // between the last change block and the counts line, no blank line before - // the `Note:` lines. + // `configRenderChangeLines` (../config.format.ts) is shared with `config push`; its blank-line + // spacing between change blocks, the counts line, and the `Note:` lines must match here exactly. test("byte-identical after the per-change renderer moved to config.format.ts", () => { const changeSet: ConfigChangeSet = { changes: [ diff --git a/apps/cli/src/commands/config/diff/diff.handler.ts b/apps/cli/src/commands/config/diff/diff.handler.ts index a9e99fc1e4..7672a7bcc7 100644 --- a/apps/cli/src/commands/config/diff/diff.handler.ts +++ b/apps/cli/src/commands/config/diff/diff.handler.ts @@ -59,8 +59,7 @@ const mapBranchResolveError = mapHttpError({ statusMessage: unexpectedStatusMessage, }); -/** Error construction for `resolveConfigTarget` (`command-internal/project-target.ts`), keeping - * `config diff`'s own tagged error classes; the message wording is shared there. */ +// Maps resolveConfigTarget's generic errors onto config diff's own tagged error classes. const configTargetErrors = configTargetErrorsFor({ notLinked: ConfigDiffBranchNotLinkedError, parentRefInvalid: ConfigDiffParentRefInvalidError, @@ -81,13 +80,9 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF // An empty `--project-ref` value is absent, mirroring the resolver's own rule. const requested = Option.filter(flags.projectRef, (value) => value.length > 0); - // Resolved against `cliSettings.workdir` — the same root the project-ref - // resolver and the linked-project cache use — so `--workdir ../other` - // compares `../other`'s config.toml against `../other`'s linked project, - // never the invoking directory's file against another root's project. - // `loadLocalConfig` (`../config.load.ts`, shared with `config - // pull`/`config push`) owns the parse/duplicate-remote/missing-file - // message shapes; only this family's own tagged error class is local. + // Resolved against `cliSettings.workdir`, the same root the project-ref resolver and the + // linked-project cache use, so `--workdir ../other` compares that directory's own config + // against its own linked project. const loadConfig = (projectRef: string | undefined) => loadLocalConfig( cliSettings, @@ -95,48 +90,31 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF (message) => new ConfigDiffLoadConfigError({ message }), ); - // Written once the comparison target is known, so the linked-project cache - // finalizer below only fires for invocations that got that far — matching - // the family pattern of caching exactly the resolved ref. + // Set once the target ref resolves, so the Effect.ensuring cache write below only fires for + // invocations that got that far. let resolvedRef: string | undefined; yield* Effect.gen(function* () { - // 1. Reject the Go-compat `-o/--output` flag outright, before anything - // else this block does. `config diff` is a net-new TS command with no - // Go parity contract, so machine output goes through `--output-format` - // only (every value, `pretty` included — CLI-2156, per Colum). Checked - // first, ahead of the config load below, so an invalid invocation never - // burns a config read or a network call; still inside this - // `Effect.ensuring`-wrapped block, so telemetry flushes on the rejection - // the same as every other failure here. + // Reject `-o/--output` outright: this command only supports `--output-format`. Checked first + // so an invalid invocation never burns a config load or a network call. if (Option.isSome(goOutputFlag)) { return yield* new ConfigDiffOutputFlagUnsupportedError({ message: unsupportedOutputFlagMessage("config diff"), }); } - // 1.5. The resolved `--workdir`/`SUPABASE_WORKDIR` must exist and be a - // directory before anything else runs — distinguishes "the directory - // doesn't exist" (`failed to change workdir: chdir …`) from "it exists - // but holds no `supabase/` project" (the step-2 load below), and runs - // before the config read and every network call. + // Validated before the config load so a missing workdir surfaces its own chdir error + // rather than the generic "no supabase/ project" one. yield* validateWorkdirIsDirectory(cliSettings.workdir, fs).pipe( Effect.mapError((error) => new ConfigDiffWorkdirError({ message: error.message })), ); - // 2. Load and validate the local config BEFORE any network call or - // target resolution (never writes — this command is read-only by - // contract): a missing file must point at `supabase init` rather than - // the resolver's not-linked error, and a malformed document must not - // burn a branch-resolution round trip. This first load applies no - // `[remotes.*]` overlay — the overlay is keyed by the RESOLVED target - // ref, so a config that declares remotes is reloaded in step 4. + // Loaded before target resolution so a missing config points at `supabase init` rather than + // a not-linked error, and a malformed document doesn't burn a branch-resolution round trip. + // No `[remotes.*]` overlay yet -- it's keyed by the resolved ref, applied below. let loaded = yield* loadConfig(undefined); - // 3. Resolve the comparison target — hoisted into `resolveConfigTarget` - // (`command-internal/project-target.ts`, shared with `config pull`/`config push`, CLI-2064). See - // that function's doc comment for the full eager-parent-ref-before-any-spinner - // and lazy-UUID-parent-resolution rules this preserves. + // See resolveConfigTarget's doc comment for the target-resolution rules this preserves. const { ref, branch } = yield* resolveConfigTarget( requested, configTargetErrors, @@ -144,19 +122,10 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF ); resolvedRef = ref; - // 4. Apply the matching `[remotes.*]` overlay (ADR 0018) now that the - // target ref is known. Only a config whose remotes actually MATCH the - // resolved ref reloads — matched against the RAW, pre-`env()`- - // interpolation `project_id` literal (`rawDocument`), mirroring - // `@supabase/config`'s own remote-selection rule. An `env(REF)`-spelled - // `[remotes.*].project_id` that happens to RESOLVE to `ref` must not - // match here (CLI-2287): matching the resolved value would both apply an - // overlay the loader itself would never select, and force a second - // `loadLocalConfig` reload whose load-time deprecation warnings would - // then print twice. Every other config keeps the step-2 load. The narrow - // remaining double-print (a matching remote AND a deprecated section) is - // the price of validating before the network call, which is worse to - // give up. + // Reload only if a `[remotes.*]` entry matches the resolved ref (ADR 0018), matched against + // the raw pre-`env()` `project_id` literal so an `env(REF)` entry that merely resolves to + // `ref` isn't treated as a match -- that would reload the config and duplicate its load-time + // warnings. const remoteMatchesRef = remoteNameForProjectRef(loaded.rawDocument?.["remotes"], ref) !== undefined; if (remoteMatchesRef) { @@ -171,13 +140,9 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF }; yield* output.raw(configDiffComparisonLine(context), "stderr"); - // 5. Fetch the effective remote config (single read-only call) — via - // `executeRaw`, per ADR 0019 rule 2 ("required, not incidental"): the - // generated client's strict Schema.Struct decode drops excess properties - // and rejects unknown enum members (e.g. a new `pool_mode` value), so - // by the time the lenient config mirror ran on its output there would be - // nothing left to be lenient about. The caller owns the status check; - // `fromApiProjectConfig`'s lenient decode owns the body. + // Uses executeRaw (ADR 0019) rather than the generated client: its strict schema decode would + // drop excess properties and reject unknown enum values before the lenient decode below + // could see them. const fetching = output.format === "text" ? yield* output.task("Fetching remote config...") : undefined; const response = yield* api.executeRaw(operationDefinitions.v2GetProjectConfig, { ref }).pipe( @@ -210,21 +175,12 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF ); yield* fetching?.clear() ?? Effect.void; - // 6. Project the response through CLI-2230's convergence normalizer (ADR - // 0021). A response the registry cannot narrow (out-of-domain mapped - // values) is a response problem, not a transport one: - // `ProjectConfigParseError` stays in the typed channel with its own - // `suggestion` and its purpose-built actionability adapter - // (`externalActionabilityByTag` splits caller misuse from genuine - // response problems). Anything else escaping the normalizer would be a - // bug in this package pairing, so it stays a defect - // (`configProjectConfigTry`, shared with `config pull`/`config push`). + // configProjectConfigTry (ADR 0021) keeps a response the schema can't narrow as a typed + // ProjectConfigParseError; anything else escaping it is a defect. const remote = yield* configProjectConfigTry(() => fromApiProjectConfig(responseJson)); - // 7. Classify. The loaded pair carries the raw merged document (declared - // keys) and the env-var origins; `diffProjectConfig` derives the local - // convergence projection from it, so the same `ProjectConfigParseError` - // boundary applies here. + // diffProjectConfig derives the local convergence projection from the loaded document, so + // the same ProjectConfigParseError boundary applies here. const changeSet = yield* configProjectConfigTry(() => diffProjectConfig({ local: loaded, remote }), ); @@ -235,9 +191,8 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF ); yield* output.raw(configScopeLine(scope), "stderr"); - // 8. Emit: `--output-format json|stream-json` structured payload, or - // text. `-o/--output` never reaches here — step 1 rejects it outright, - // so this command has only the one machine-output mechanism. + // `-o/--output` never reaches here (rejected above), so `--output-format` is the only + // machine-output path. if (output.format !== "text") { yield* output.success( configDiffSummaryMessage(changeSet, scope), @@ -247,15 +202,9 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF yield* output.raw(renderConfigDiffText(changeSet, scope)); } - // 9. `--exit-code`: differences flip the exit status to 2 after the - // payload is out, without an error envelope corrupting machine output. - // Drift gets its OWN code — every failure exits 1, and a script's - // `config diff --exit-code || alert` must not fire on an expired token - // (`terraform plan -detailed-exitcode`'s 0/1/2 convention, with 1 kept - // for errors to match the rest of the CLI). In TEXT mode only, a stderr - // reason line precedes the exit so a CI log doesn't show only "exit code - // 2" with no explanation (db lint's fail-on reason line is the same - // idea); machine modes stay byte-identical. + // `--exit-code` sets exit 2 for drift, distinct from the 1 every other failure uses, so a + // script's `config diff --exit-code || alert` doesn't fire on an expired token. Text mode + // prints a stderr reason line first so a CI log isn't just "exit code 2". if (flags.exitCode && changeSet.counts.total > 0) { if (output.format === "text") { yield* output.raw("Exiting 2: configuration differences found (--exit-code).\n", "stderr"); @@ -263,10 +212,8 @@ export const configDiff = Effect.fn("config.diff")(function* (flags: ConfigDiffF yield* processControl.setExitCode(2); } }).pipe( - // CLI Invariant #1: telemetry flushes on EVERY invocation — - // including load/parse failures and branch-resolution failures — while - // the linked-project cache write needs a resolved ref, so it fires - // exactly when one exists. + // Telemetry flushes on every invocation; the linked-project cache write only fires once a + // ref has resolved. Effect.ensuring( Effect.suspend(() => resolvedRef === undefined ? Effect.void : linkedProjectCache.cache(resolvedRef), diff --git a/apps/cli/src/commands/config/diff/diff.integration.test.ts b/apps/cli/src/commands/config/diff/diff.integration.test.ts index 5abfbab080..ca4fa5aced 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -48,12 +48,8 @@ function writeProjectEnv(dotenv: string): void { writeFileSync(join(dir, ".env"), dotenv); } -/** - * Schema-valid v2 project-config body whose managed values all sit at the - * local schema defaults, so an empty config.toml diffs clean against it. - * Hoisted to `tests/helpers/config-fixtures.ts` so `config push`'s own - * integration suite can share it (CLI-2313 shard 5a). - */ +/** Schema-valid v2 project-config body whose managed values all sit at the local schema + * defaults, so an empty config.toml diffs clean against it. */ const v2Response = v2ProjectConfigResponse; /** V1GetABranch body for the branch-name `--project-ref` lookup. */ @@ -176,7 +172,6 @@ describe("config diff integration", () => { return Effect.gen(function* () { yield* configDiff(noFlags); - // Never writes: mtime and contents unchanged after a run with differences. expect(statSync(configPath).mtimeMs).toBe(before.mtimeMs); expect(readFileSync(configPath, "utf8")).toBe(before.contents); @@ -190,7 +185,6 @@ describe("config diff integration", () => { expect(out.stdoutText).toContain( "1 difference found (1 update, 0 remote-only, 0 local-only).", ); - // Differences without --exit-code leave the exit status alone. expect(processControl.exitCode).toBeUndefined(); expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cachedRef).toBe(VALID_REF); @@ -207,9 +201,6 @@ describe("config diff integration", () => { }); it.live("--exit-code sets exit 2 when differences are found", () => { - // Drift gets its own exit code (2) so scripts can tell it from failure - // (1) — `config diff --exit-code || alert` must not fire on an expired - // token. const { layer, processControl } = setup({ toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', }); @@ -220,9 +211,6 @@ describe("config diff integration", () => { }); it.live("--exit-code in text mode prints a stderr reason line before exiting 2", () => { - // A CI log that shows only "exit code 2" with no explanation is a - // regression — the reason line makes the drift-vs-failure distinction - // visible in the log itself, without touching machine-format bytes. const { layer, out, processControl } = setup({ toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', }); @@ -252,8 +240,7 @@ describe("config diff integration", () => { status: 200, body: v2Response({ attributes: (attributes) => { - // Drop site_url from the otherwise-complete auth record so the - // response genuinely does not carry the declared property. + // Drop site_url so the response genuinely omits the declared property. const { site_url: _siteUrl, ...auth } = attributes["auth"] as Record<string, unknown>; return { ...attributes, auth }; }, @@ -299,8 +286,7 @@ describe("config diff integration", () => { auth: { external_github_enabled: true, external_github_client_id: "id", - // The platform reports secret fields as HMAC digests, never - // plaintext — the digest must not surface either. + // The platform reports secret fields as HMAC digests, not plaintext. external_github_secret: "v1,whmac-sha256-digest-of-the-secret", }, }), @@ -313,9 +299,6 @@ describe("config diff integration", () => { expect(out.stdoutText).toContain( "Note: 1 credential value not compared (masked by the API): auth.external.github.secret", ); - // The secret STRING never leaks — neither the local plaintext resolved - // from the env var nor the API-reported HMAC digest, on either stream. - // Pins the "secrets never leak" claim against formatter changes. const everything = out.stdoutText + out.stderrText; expect(everything).not.toContain("shh"); expect(everything).not.toContain("whmac-sha256"); @@ -355,8 +338,6 @@ describe("config diff integration", () => { const serialized = JSON.stringify(success); expect(serialized).not.toContain("shh"); expect(serialized).not.toContain("whmac-sha256"); - // The message itself carries the masked caveat, so `.message` echoers - // never claim full sync. expect(success?.message).toContain("masked by the API"); }).pipe(Effect.provide(layer)); }); @@ -379,8 +360,6 @@ describe("config diff integration", () => { expect(out.stderrText).toContain( `Comparing against project ${VALID_REF} using [remotes.staging]`, ); - // The merged branch operand (max_rows = 1000) matches the remote, so the - // base config's 500 must NOT surface as drift. expect(out.stdoutText).toContain("No config differences found."); }).pipe(Effect.provide(layer)); }); @@ -388,12 +367,6 @@ describe("config diff integration", () => { it.live( "an env()-resolving [remotes.*] project_id does not match the target and does not double-warn (CLI-2287)", () => { - // The raw `project_id = "env(REMOTE_REF)"` literal never equals the - // resolved ref, even though the INTERPOLATED value does — matching the - // resolved value would both compare against the WRONG operand (Go's - // `loadFromFile` selection loop reads viper's raw values, before - // `env(...)` resolves) and reload the file a second time, printing the - // `[inbucket]` deprecation warning twice. const { layer, out } = setup({ toml: [ 'project_id = "test"', @@ -439,10 +412,8 @@ describe("config diff integration", () => { }); it.live("branch-name resolution uses the linked PARENT, not a branch ref in project-ref", () => { - // After `link <branch>`, `.temp/project-ref` holds the BRANCH's own ref, - // and the parent-scoped branches endpoint rejects branch refs — the - // parent must come from the parent-scoped resolver (which prefers the - // linked-project.json parent recovery), never the file verbatim. + // project-ref holds the branch's own ref; linked-project.json recovers the parent, which the + // parent-scoped branches endpoint requires. const temp = join(tempRoot.current, "supabase", ".temp"); mkdirSync(temp, { recursive: true }); writeFileSync(join(temp, "project-ref"), BRANCH_REF); @@ -463,9 +434,6 @@ describe("config diff integration", () => { }); it.live("a UUID --project-ref resolves directly, even in an unlinked directory", () => { - // The UUID endpoint (`GET /v1/branches/{id}`) does not use a parent - // project ref, so the lookup must not demand a linked directory — the - // parent is only resolved (lazily) for branch-NAME lookups. const { layer, api, out } = setup({ toml: 'project_id = "test"\n', v2: { status: 200, body: v2Response({ ref: BRANCH_REF }) }, @@ -476,7 +444,7 @@ describe("config diff integration", () => { const urls = api.requests.map((request) => request.url); expect(urls.some((url) => url.includes(`/v1/branches/${BRANCH_UUID}`))).toBe(true); expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); - // A UUID is an identifier, not a display name — never quoted as one. + // A UUID is an identifier, not a display name, so it isn't quoted. expect(out.stderrText).toContain( `Comparing against branch ${BRANCH_UUID} (project ref ${BRANCH_REF})`, ); @@ -510,8 +478,8 @@ describe("config diff integration", () => { expect(rendered).toContain("ConfigDiffBranchNotFoundError"); expect(rendered).toContain('Branch \\"ghost\\" not found'); expect(rendered).toContain("supabase branches list"); - // CLI Invariant #1: telemetry flushes on failure too; the - // linked-project cache stays untouched because no target ref resolved. + // Telemetry still flushes on failure; the linked-project cache stays untouched since no + // ref resolved. expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cachedRef).toBeUndefined(); }).pipe(Effect.provide(layer)); @@ -538,11 +506,9 @@ describe("config diff integration", () => { expect(Exit.isFailure(exit)).toBe(true); const rendered = JSON.stringify(exit); expect(rendered).toContain("ConfigDiffLoadConfigError"); - // `loadCliConfig` probes both filenames (`findCliProjectPaths`), so the - // message must not claim only `config.toml` was checked. + // loadCliConfig probes both config.toml and config.json, so the message names both. expect(rendered).toContain("supabase/config.toml or supabase/config.json: file not found"); expect(rendered).toContain("supabase init"); - // The load runs before any network call, and telemetry still flushes. expect(api.requests).toHaveLength(0); expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); @@ -551,13 +517,8 @@ describe("config diff integration", () => { it.live( "does not climb to an ancestor project's config when --workdir names a subdirectory with no config of its own", () => { - // CLI-2285 regression: an explicit --workdir is authoritative and must - // never let `loadCliConfig` climb past it — otherwise `config diff - // --workdir ./sub` from a project whose subdirectory has no - // supabase/ of its own would silently diff an unrelated PARENT - // project's config. The ancestor (tempRoot) genuinely has a valid - // config.toml and the subdirectory genuinely has none, so this - // exercises the real climb, not a tautology. + // The ancestor (tempRoot) genuinely has a config.toml and the subdirectory genuinely has + // none, so this exercises a real climb, not a tautology. writeConfig('project_id = "test"\n'); const sub = join(tempRoot.current, "nested", "dir"); mkdirSync(sub, { recursive: true }); @@ -568,15 +529,13 @@ describe("config diff integration", () => { const rendered = JSON.stringify(exit); expect(rendered).toContain("ConfigDiffLoadConfigError"); expect(rendered).toContain("file not found"); - // An EXPLICIT workdir never gets the ancestor-search-exhausted - // `supabase init` hint — it names the resolved directory instead, and - // points at the flag/env var that must change. + // An explicit workdir skips the ancestor-search "supabase init" hint; it names the + // resolved directory and the flag/env var to change instead. expect(rendered).not.toContain("supabase init"); expect(rendered).toContain("--workdir/SUPABASE_WORKDIR"); expect(rendered).toContain(sub); - // The ancestor genuinely has a valid project, so the message also - // hints at it — `missingProjectConfigMessageEffect`'s "Did you - // mean" enrichment, only reachable because the search above is real. + // The ancestor has a valid project, so the message also hints at it via the "Did you + // mean" enrichment. expect(rendered).toContain(`Did you mean --workdir ${tempRoot.current}?`); expect(api.requests).toHaveLength(0); expect(telemetry.flushed).toBe(true); @@ -587,9 +546,6 @@ describe("config diff integration", () => { it.live( "does not hint at an ancestor when explicit --workdir has no project anywhere above it", () => { - // Negative counterpart of the regression above: no ancestor, all the - // way up, has a project of its own — so the "Did you mean" enrichment - // must never fire (or crash) when its best-effort probe finds nothing. const { layer, api } = setup({ explicitWorkdir: true }); return Effect.gen(function* () { const exit = yield* configDiff(noFlags).pipe(Effect.exit); @@ -605,9 +561,6 @@ describe("config diff integration", () => { it.live( "an explicit --workdir naming a directory that does not exist at all fails before any config load", () => { - // Distinct from the "exists but holds no project" regression above: - // this path was never created, so `validateWorkdirIsDirectory` - // must fail first, before `loadCliConfig` is ever reached. const missing = join(tempRoot.current, "does-not-exist"); const { layer, api } = setup({ workdir: missing, explicitWorkdir: true }); return Effect.gen(function* () { @@ -624,10 +577,6 @@ describe("config diff integration", () => { it.live( "a defaulted workdir still resolves a config.json project root above a config-less subdirectory", () => { - // Complements the regression above: a defaulted (unset) --workdir must - // keep climbing so a config.json-only project invoked from a - // subdirectory still resolves — proving the fix didn't break the - // legitimate default-climb case. const dir = join(tempRoot.current, "supabase"); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, "config.json"), JSON.stringify({ project_id: "test" })); @@ -643,17 +592,14 @@ describe("config diff integration", () => { ); it.live("a malformed config aborts before any network call, even with a branch target", () => { - // A broken TOML must not burn a branch-resolution round trip — the local - // document is parsed and validated first. const { layer, api, telemetry } = setup({ toml: "not [valid toml\n" }); return Effect.gen(function* () { const exit = yield* configDiff({ ...noFlags, projectRef: Option.some("staging") }).pipe( Effect.exit, ); expect(Exit.isFailure(exit)).toBe(true); - // The message names the REAL file that failed to parse (`cause.path`), - // not a hardcoded `.toml` guess — rendered workdir-relative so it reads - // the same regardless of invocation cwd. + // The message names the actual file that failed to parse, workdir-relative regardless of + // invocation cwd. expect(JSON.stringify(exit)).toContain(`failed to parse ${join("supabase", "config.toml")}`); expect(api.requests).toHaveLength(0); expect(telemetry.flushed).toBe(true); @@ -693,17 +639,13 @@ describe("config diff integration", () => { const exit = yield* configDiff(noFlags).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(JSON.stringify(exit)).toContain("ConfigDiffReadNetworkError"); - // Telemetry still flushes on failure via Effect.ensuring. + // Telemetry still flushes even on failure. expect(telemetry.flushed).toBe(true); }).pipe(Effect.provide(layer)); }); it.live("an out-of-domain mapped value in the response keeps its typed parse error", () => { - // Wire-valid but semantically impossible: the registry's typed throw - // (ADR 0021 API-arm family) stays in the typed channel as - // ProjectConfigParseError, keeping its upstream suggestion and its - // purpose-built actionability adapter instead of masquerading as a - // network failure. + // See ADR 0021: this stays a typed ProjectConfigParseError, not a network failure. const { layer } = setup({ toml: 'project_id = "test"\n', v2: { @@ -725,18 +667,13 @@ describe("config diff integration", () => { const rendered = JSON.stringify(exit); expect(rendered).toContain("ProjectConfigParseError"); expect(rendered).toContain("Could not read the project config"); - // The upstream remedy survives to the renderer instead of being - // stringified away. expect(rendered).toContain("suggestion"); }).pipe(Effect.provide(layer)); }); it.live("an unknown enum value in the response degrades instead of failing", () => { - // ADR 0019 rule 2: the fetch goes through executeRaw, so the generated - // contract's closed enums (pooler.pool_mode is three literals there) - // never gate the response — a new platform enum member reaches the - // lenient config mirror, whose registry row omits the unrecognized value - // rather than failing the whole diff. + // ADR 0019: executeRaw bypasses the generated client's closed enums, so a new platform enum + // value degrades instead of failing. const { layer, out } = setup({ toml: 'project_id = "test"\n', v2: { @@ -768,8 +705,7 @@ describe("config diff integration", () => { expect(Exit.isFailure(exit)).toBe(true); const rendered = JSON.stringify(exit); expect(rendered).toContain("ConfigDiffReadStatusError"); - // 403 gets a purpose-written message naming the project instead of the - // raw `unexpected status 403: {"message":"forbidden"}` body dump. + // 403 gets a purpose-written message naming the project instead of the raw status/body dump. expect(rendered).toContain("Access denied"); expect(rendered).toContain(VALID_REF); }).pipe(Effect.provide(layer)); @@ -835,8 +771,8 @@ describe("config diff integration", () => { project_ref: VALID_REF, local_scope: "base", }); - // `schema_version` is the PAYLOAD contract's version; the user's - // `$schema` document reference travels separately as `config_schema`. + // schema_version is the payload contract's version; the user's $schema reference travels + // separately as config_schema. expect(data["schema_version"]).toBe(CONFIG_DIFF_PAYLOAD_VERSION); expect(data["schema_version"]).toBe(1); expect(typeof data["config_schema"]).toBe("string"); @@ -863,13 +799,8 @@ describe("config diff integration", () => { }); it.live("every -o/--output value is rejected outright before any work happens", () => { - // Net-new TS command, no Go parity contract: every value the shared - // global `-o`/`--output` flag can carry — the machine formats, `pretty`, - // and the `db query`-only `table`/`csv` values alike — is rejected with - // the exact message pointing at `--output-format` (CLI-2156, per Colum), - // before any config load, target resolution, or network call. - // Iterates the flag's own choice list, so a value added to the global - // flag automatically extends this rejection coverage. + // Iterates every value the global `-o`/`--output` flag can carry, so a value added there + // automatically extends this coverage. const values = GLOBAL_OUTPUT_FORMATS; const run = (goOutput: (typeof values)[number]) => { const { layer, api } = setup({ @@ -959,10 +890,9 @@ describe("config diff integration", () => { }); it.live("remote-only drift on a defaulted path shows the local schema default", () => { - // The someone-changed-it-in-the-dashboard case: the file never declares - // api.max_rows, the remote reports 250, and a `config push` would write - // the schema default 1000 over it — the output must say so instead of - // implying the key exists only remotely. + // The file never declares api.max_rows, so a config push would overwrite the remote's 250 + // with the schema default 1000; the output must say so, not imply the key exists only + // remotely. const { layer, out } = setup({ toml: 'project_id = "test"\n', v2: { @@ -986,12 +916,8 @@ describe("config diff integration", () => { }); it.live("the config file is read relative to --workdir, not the invoking directory", () => { - // `--workdir ../other` must compare `../other`'s config.toml against - // `../other`'s linked project — reading the invoking directory's file - // would silently diff the WRONG config (the resolver and linked-project - // cache already use the workdir). The ambient cwd here points somewhere - // with no supabase/ directory at all; only cliSettings.workdir knows - // where the project lives. + // The ambient cwd points at a directory with no supabase/ project at all, so only + // cliSettings.workdir can resolve it. const elsewhere = join(tempRoot.current, "unrelated-cwd"); mkdirSync(elsewhere, { recursive: true }); const { layer, out } = setup({ @@ -1005,10 +931,8 @@ describe("config diff integration", () => { }); it.live("hostile names cannot inject ANSI or forge output lines in text mode", () => { - // Path segments are attacker-influenced ([remotes.*] names and - // sms.test_otp keys are unconstrained TOML keys) — a name carrying an - // escape byte or newline must not reach the terminal raw, where it could - // recolor output or append a fake "No config differences found." line. + // [remotes.*] names are unconstrained TOML keys an attacker could control, so escape bytes + // must not reach the terminal raw. const { layer, out } = setup({ toml: [ 'project_id = "test"', @@ -1028,9 +952,6 @@ describe("config diff integration", () => { }); it.live("an empty block record is reported not-returned, not silently compared", () => { - // A permission-truncated `auth: {}` is schema-valid; claiming it was - // compared while every auth key silently vanishes would make a red CI - // unfixable by any file edit. const { layer, out } = setup({ toml: 'project_id = "test"\n', v2: { @@ -1047,9 +968,6 @@ describe("config diff integration", () => { }); it.live("a missing block's not-compared caveat travels with the machine `.message`", () => { - // A partial API response (e.g. a scoped token returning `auth: {}`) must - // not report an unqualified "No config differences found." — an agent - // echoing just `.message` would otherwise wrongly claim a full compare. const { layer, out } = setup({ toml: 'project_id = "test"\n', format: "json", @@ -1085,12 +1003,9 @@ describe("config diff integration", () => { }); it.live("a declared path push cannot communicate surfaces in the unmanaged note", () => { - // `auth.oauth_server.enabled` is now an ordinary comparable path - // (CLI-2314), so it no longer demonstrates this. Its sibling - // `authorization_url_path` still does: `DISABLED_SENTINEL_PRUNES` drops - // it from the local projection while the container is declared - // disabled, so a declared value disagreeing with the remote's cannot be - // a change entry — but it must not vanish silently either. + // DISABLED_SENTINEL_PRUNES drops authorization_url_path from the local projection while the + // container is declared disabled, so a disagreeing remote value can't be a change entry but + // must not vanish silently either. const { layer, out } = setup({ toml: 'project_id = "test"\n[auth.oauth_server]\nenabled = false\nauthorization_url_path = "/consent"\n', v2: { @@ -1119,11 +1034,6 @@ describe("config diff integration", () => { it.live( "a branch-NAME --project-ref in an unlinked dir fails immediately, naming the value", () => { - // Before the fix this fell through to `resolver.resolve`'s interactive - // project picker under a live "Resolving branch..." spinner and, in a - // non-interactive environment, surfaced the generic "Cannot find - // project ref. Have you run supabase link?" instead of a link-grade - // error naming the value the user actually passed. const { layer, api, telemetry, linkedProjectCache } = setup({ toml: 'project_id = "test"\n', linked: false, @@ -1137,8 +1047,6 @@ describe("config diff integration", () => { const rendered = JSON.stringify(exit); expect(rendered).toContain("ConfigDiffBranchNotLinkedError"); expect(rendered).toContain('\\"somebranch\\"'); - // Fails purely from local file/env state — no branches lookup, no - // project-picker prompt, no config-read call. expect(api.requests).toHaveLength(0); expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cachedRef).toBeUndefined(); @@ -1179,16 +1087,14 @@ describe("config diff integration", () => { const rendered = JSON.stringify(exit); expect(rendered).toContain("ConfigDiffBranchNotReadyError"); expect(rendered).toContain("has no project ref yet"); - // The placeholder ref never reaches the config-read call. expect(api.requests.some((request) => request.url.includes("/v2/projects/"))).toBe(false); }).pipe(Effect.provide(layer)); }); }); describe("config diff telemetry wiring", () => { - // Drives the exact `Command.withHandler` wiring (configDiffHandler) - // rather than the bare handler: the safeFlags guard lives in the wiring, - // and nothing validates `--project-ref` before instrumentation fires. + // Uses configDiffHandler (not the bare handler) since the safeFlags guard lives in the command + // wiring, not the handler itself. const wiringLayer = (analytics: ReturnType<typeof mockContextualAnalytics>, projectRef: string) => Layer.mergeAll( setup({ toml: 'project_id = "test"\n', analytics }).layer, @@ -1210,8 +1116,8 @@ describe("config diff telemetry wiring", () => { }); it.live("redacts a branch-name-shaped --project-ref", () => { - // `--project-ref` accepts branch names too (CLI-2167 vocabulary) — a - // user-created branch name must never reach PostHog verbatim. + // --project-ref also accepts branch names; a user-created name must never reach PostHog + // verbatim. const analytics = mockContextualAnalytics(); return Effect.gen(function* () { yield* Effect.exit( @@ -1224,14 +1130,9 @@ describe("config diff telemetry wiring", () => { }); describe("config diff -o/--output wrapper wiring", () => { - // The wrapper's own per-command `-o` enum check (`withCommandTelemetry`) - // defaults to the resource-command set (`env|pretty|json|toml|yaml`), which - // excludes `table`/`csv` — without `diff.command.ts`'s `outputFormats` - // override widening that set to the full global choice list, a `-o table` - // invocation would be rejected by the wrapper's generic pflag-style - // message before ever reaching this command's own, more specific - // rejection. Drives the real `configDiffHandler` wiring (not the bare - // handler) so this actually exercises that override. + // diff.command.ts's `outputFormats` override widens the flag's enum to the full choice list; + // without it, `-o table` would hit the wrapper's own generic rejection before reaching this + // command's specific one. Uses the real configDiffHandler wiring so this exercises that override. it.live("-o table reaches this command's own message, not the wrapper's generic one", () => { const { layer, api } = setup({ toml: 'project_id = "test"\n', goOutput: "table" }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/config/diff/diff.live.test.ts b/apps/cli/src/commands/config/diff/diff.live.test.ts index d19aa137c5..251962b46c 100644 --- a/apps/cli/src/commands/config/diff/diff.live.test.ts +++ b/apps/cli/src/commands/config/diff/diff.live.test.ts @@ -4,11 +4,9 @@ import { expect } from "vitest"; import { requireLiveSuccess, test } from "../../../../tests/helpers/live.ts"; -// Golden path only: the one thing mocks cannot prove is the real -// `GET /v2/projects/{ref}/config` response shape (the GoTrue-keyed auth -// record especially) decoding and classifying cleanly. Branch coverage lives -// in diff.integration.test.ts. The `workspace` fixture behind `cli` is a -// fresh `supabase init` project directory. +// Golden path only: the one thing mocks can't prove is the real +// `GET /v2/projects/{ref}/config` response shape (the GoTrue-keyed auth record especially) +// decoding and classifying cleanly. Branch coverage lives in diff.integration.test.ts. test("diffs a freshly-initialized config against the project", async ({ cli, project }) => { const result = await cli([ "config", @@ -18,7 +16,6 @@ test("diffs a freshly-initialized config against the project", async ({ cli, pro "--output-format", "json", ]); - // Read-only success regardless of drift (no --exit-code passed). requireLiveSuccess(result, "config diff"); expect(result.stderr).toContain(`Comparing against project ${project.ref} using base config`); const payload = JSON.parse(result.stdout) as { @@ -28,9 +25,9 @@ test("diffs a freshly-initialized config against the project", async ({ cli, pro expect(payload.scope.present).toContain("auth"); const changes = payload.changes.map(({ path, class: kind }) => ({ path: path.join("."), kind })); const classified = changes.map((change) => `${change.path} [${change.kind}]`).join("\n"); - // A fresh project legitimately drifts from the init template (confirmations, - // TOTP, site URL), so remote auth values must reach classification, while - // the registry's declared baselines suppress the platform's own defaults. + // A fresh project legitimately drifts from the init template (confirmations, TOTP, site URL), + // so remote auth values must reach classification while the registry's declared baselines + // suppress the platform's own defaults. expect( changes.some((change) => change.path.startsWith("auth.") && change.kind !== "local_only"), classified, diff --git a/apps/cli/src/commands/config/pull/pull.command.ts b/apps/cli/src/commands/config/pull/pull.command.ts index 6a91826852..cf74207183 100644 --- a/apps/cli/src/commands/config/pull/pull.command.ts +++ b/apps/cli/src/commands/config/pull/pull.command.ts @@ -10,9 +10,8 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { configPull } from "./pull.handler.ts"; const config = { - // `config diff`'s settled vocabulary (CLI-2167): one flag that accepts - // either a project ref or a branch of the linked project — no separate - // `--target`. + // Accepts either a project ref or a branch name/UUID of the linked project; there's no + // separate --target flag. projectRef: Flag.string("project-ref").pipe( Flag.withDescription( "Project ref of the Supabase project, or the name (or UUID) of one of its branches. Values that are exactly 20 lowercase letters are always treated as project refs.", @@ -27,8 +26,7 @@ const config = { ), dryRun: Flag.boolean("dry-run").pipe( Flag.withDescription("Show what would be written without touching the config file."), - // Without an explicit default a boolean flag is REQUIRED by the parser - // (same rule pinned by `diff.e2e.test.ts` for `--exit-code`). + // Without an explicit default, a boolean flag is required by the parser. Flag.withDefault(false), ), force: Flag.boolean("force").pipe( @@ -41,21 +39,17 @@ export type ConfigPullFlags = CliCommand.Command.Config.Infer<typeof config>; const configPullHandler = (flags: ConfigPullFlags) => configPull(flags).pipe( - // `--project-ref` accepts branch names here (CLI-2167 vocabulary), so its - // value is only safe to log verbatim when it is actually ref-shaped — a - // user-created branch name must never reach PostHog. Same guard as - // `link`/`config diff`. `--remote-label` is a free-form, user-chosen - // string and is NEVER safe to log verbatim. + // --project-ref accepts branch names too, so it's only safe to log verbatim when + // ref-shaped; a branch name must never reach PostHog. --remote-label is free-form user text + // and is never safe to log. withCommandTelemetry({ flags, safeFlags: Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) ? ["project-ref"] : [], - // Net-new TS command, no Go parity contract (CLI-2156): the handler - // itself rejects every `-o/--output` value with a message pointing at - // `--output-format`, so the full global choice set — single-sourced - // from the flag's own definition — is declared "allowed" here. + // The handler rejects every -o/--output value itself, so the full global choice set is + // declared "allowed" here rather than gated by this wrapper. outputFormats: GLOBAL_OUTPUT_FORMATS, }), withJsonErrorHandling, diff --git a/apps/cli/src/commands/config/pull/pull.e2e.test.ts b/apps/cli/src/commands/config/pull/pull.e2e.test.ts index 1279925b92..9ff46b0bc1 100644 --- a/apps/cli/src/commands/config/pull/pull.e2e.test.ts +++ b/apps/cli/src/commands/config/pull/pull.e2e.test.ts @@ -5,20 +5,16 @@ import { describe, expect, test } from "vitest"; import { runSupabase } from "../../../../tests/helpers/cli.ts"; -// A fake-but-well-formed token (bypasses the auth-layer's own eager -// `SUPABASE_ACCESS_TOKEN` check, same precedent as push's e2e test) so the -// run reaches this command's own handler instead of failing generically on -// "Access token not provided" first. +// A fake-but-well-formed token bypasses the eager SUPABASE_ACCESS_TOKEN check, so the run +// reaches this command's own handler instead of failing generically first. const TEST_TOKEN = "sbp_" + "a".repeat(40); describe("config pull CLI surface", () => { test("plain `config pull` parses — no boolean flag is accidentally required", async () => { - // Parser-level regression pin (mirrors `config diff`'s own, PR #6295 - // review): a `Flag.boolean` without `Flag.withDefault(false)` is a - // REQUIRED flag, so a missing default on `--dry-run`/`--force` would fail - // plain `supabase config pull` with `required flag(s) ... not set`. - // Integration tests hand the handler a pre-built flags object and never - // exercise the parser, so this must be pinned at the subprocess boundary. + // A Flag.boolean without Flag.withDefault(false) is a required flag, so a missing default + // on --dry-run/--force would fail plain `supabase config pull`. Integration tests hand the + // handler pre-built flags and never exercise the parser, so this needs pinning at the + // subprocess boundary. const cwd = await mkdtemp(join(tmpdir(), "supabase-config-pull-e2e-")); try { const { stdout, stderr } = await runSupabase(["config", "pull"], { @@ -27,11 +23,9 @@ describe("config pull CLI surface", () => { }); const combined = `${stdout}\n${stderr}`; expect(combined).not.toContain("required flag"); - // Positive anchor: this hermetic cwd has no `supabase/config.toml` or - // `supabase/config.json`, so a run that actually reached the handler - // (not just a binary-launch failure) surfaces this exact load error. - // A vacuously-green regression here (e.g. the binary failing to start - // at all) would NOT print this fragment. + // Positive anchor: this hermetic cwd has no config file, so only a run that actually + // reached the handler prints this exact load error — a vacuously-green regression (e.g. + // the binary failing to start) wouldn't. expect(combined).toContain( "failed to read supabase/config.toml or supabase/config.json: file not found. Run `supabase init` to create one.", ); diff --git a/apps/cli/src/commands/config/pull/pull.errors.ts b/apps/cli/src/commands/config/pull/pull.errors.ts index a8da29df9b..f277f54554 100644 --- a/apps/cli/src/commands/config/pull/pull.errors.ts +++ b/apps/cli/src/commands/config/pull/pull.errors.ts @@ -41,11 +41,7 @@ export class ConfigPullWorkdirError extends Data.TaggedError("ConfigPullWorkdirE } } -/** - * The Go-compat global `-o/--output` flag was passed. `config pull` is a - * net-new TS command with no Go parity contract, so machine output goes - * through `--output-format` only (mirrors `config diff`, CLI-2156). - */ +/** The global `-o/--output` flag was passed; this command only supports `--output-format`. */ export class ConfigPullOutputFlagUnsupportedError extends Data.TaggedError( "ConfigPullOutputFlagUnsupportedError", )<{ readonly message: string }> { @@ -60,37 +56,25 @@ const targetErrors = mintConfigTargetErrors("ConfigPull"); export const ConfigPullBranchNotFoundError = targetErrors.BranchNotFoundError; export type ConfigPullBranchNotFoundError = InstanceType<typeof ConfigPullBranchNotFoundError>; -/** - * `--project-ref` named a branch (by name), but no project is linked to - * search for branches under. Mirrors `config diff`'s - * `ConfigDiffBranchNotLinkedError`. - */ +/** `--project-ref` named a branch (by name), but no project is linked to search branches under. */ export const ConfigPullBranchNotLinkedError = targetErrors.BranchNotLinkedError; export type ConfigPullBranchNotLinkedError = InstanceType<typeof ConfigPullBranchNotLinkedError>; /** - * `--project-ref` named a branch (by name), and a parent-project candidate - * exists but is not ref-shaped — corrupt or stale linked state. Mirrors - * `config diff`'s `ConfigDiffParentRefInvalidError`. + * `--project-ref` named a branch (by name), and a parent-project candidate exists but is not + * ref-shaped — corrupt or stale linked state. */ export const ConfigPullParentRefInvalidError = targetErrors.ParentRefInvalidError; export type ConfigPullParentRefInvalidError = InstanceType<typeof ConfigPullParentRefInvalidError>; -/** - * The resolved branch has no project ref yet (still provisioning). Mirrors - * `config diff`'s `ConfigDiffBranchNotReadyError`. - */ +/** The resolved branch has no project ref yet (still provisioning). */ export const ConfigPullBranchNotReadyError = targetErrors.BranchNotReadyError; export type ConfigPullBranchNotReadyError = InstanceType<typeof ConfigPullBranchNotReadyError>; /** - * A transport failure reading remote state over HTTP — shared by BOTH the - * branch-lookup call (`resolveConfigTarget`'s `mapResolveError`) and - * the `/v2/projects/{ref}/config` read: the actionability is identical - * either way (an unreachable Management API), so this command keeps ONE - * network/status pair rather than `config diff`'s two (which predate this - * command's target-resolution reuse); the CALLER's own message text still - * distinguishes the two failure sites. + * A transport failure reading remote state over HTTP, shared by both the branch-lookup call and + * the `/v2/projects/{ref}/config` read — the actionability is identical either way. The caller's + * own message text distinguishes the two failure sites. */ export class ConfigPullReadNetworkError extends Data.TaggedError( "ConfigPullReadNetworkError", @@ -106,25 +90,17 @@ export class ConfigPullReadStatusError extends Data.TaggedError( "ConfigPullReadStatusError", )<StatusErrorArgs> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // A 404 names a user-selected resource (a wrong project ref, or a branch - // lookup) either way — user-actionable, matching `config diff`'s own - // rule for this same endpoint. + // A 404 names a user-selected resource (a wrong project ref, or a branch lookup) either way + // — user-actionable. return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); } } /** - * A label that would govern this pull — either `--remote-label` or a - * branch-derived name — names a block already tracking a different project - * (`resolveConfigPullDestination`, `label_collision`): either the - * SAME-named `[remotes.<label>]` whose own `project_id` differs from the - * target ref (or, for a branch-derived label, would have its `project_id` - * silently REPLACED, stranding its own overrides), or a nonexistent - * `--remote-label` while a DIFFERENT block already tracks this exact ref - * under another name. The constructed message must name the actually - * conflicting block (`conflictingBlock`) and its real `project_id`, and - * offer the matching remedy — a different `--remote-label`/rename, or - * dropping the flag to reuse the block that already tracks this ref. + * A label (`--remote-label` or a branch-derived name) already tracks a different project, or + * another block already tracks this ref under a different name. The message must name the + * actually conflicting block and its `project_id`, and suggest a different label or dropping + * the flag. */ export class ConfigPullRemoteLabelCollisionError extends Data.TaggedError( "ConfigPullRemoteLabelCollisionError", @@ -135,19 +111,10 @@ export class ConfigPullRemoteLabelCollisionError extends Data.TaggedError( } /** - * No `[remotes.*]` block's RAW `project_id` literal matches the target ref, - * but one block's `env(...)`-spelled `project_id` RESOLVES to it - * (`resolveConfigPullDestination`, `env_project_id`) — plan of record - * Decision 1: hard error, never reused, never rewritten, because the config - * LOADER matches `project_id` literally too — an `env()`-spelled block that - * merely resolves to a ref has never actually applied to any project - * (`supabase start`/`config push` have both been ignoring it). The - * constructed message must name the offending env var(s), explain that, and - * offer the two remedies: replace the `env(...)` literal with the literal - * project ref to make the block real, or pass `--remote-label` to write a - * new block instead. Only reached when no `--remote-label` was given — see - * `ConfigPullRemoteLabelCollisionError`'s own doc comment for why an - * explicit `--remote-label` is resolved first. + * No `[remotes.*]` block's raw `project_id` literal matches the target ref, though one block's + * `env(...)`-spelled `project_id` resolves to it — the config loader matches literally too, so + * that block has never actually applied to this project. The message must name the offending + * env var(s) and offer to replace the literal or pass `--remote-label` for a new block. */ export class ConfigPullRemoteEnvRefError extends Data.TaggedError("ConfigPullRemoteEnvRefError")<{ readonly message: string; @@ -158,13 +125,9 @@ export class ConfigPullRemoteEnvRefError extends Data.TaggedError("ConfigPullRem } /** - * The config file has uncommitted (or untracked) changes (§1.4's git dirty - * guard) and there is no human on hand to read the warning and answer the - * prompt honestly: either the run is non-interactive/machine-format, or - * `--yes` was passed (a TTY's own confirmation default flipping to "no" is - * not a safeguard once `--yes` answers it automatically) — abort rather than - * silently overwrite work the user hasn't committed. Only `--force` - * overrides this guard; `--yes` never does, on any TTY. + * The config file has uncommitted or untracked git changes and no human is available to confirm + * the overwrite (non-interactive, machine-format, or `--yes`) — aborts rather than silently + * overwriting uncommitted work. Only `--force` overrides this guard. */ export class ConfigPullUncommittedChangesError extends Data.TaggedError( "ConfigPullUncommittedChangesError", @@ -190,10 +153,9 @@ export class ConfigPullUnsupportedLayoutError extends Data.TaggedError( } /** - * The config file's bytes changed between the pre-confirmation read and the - * write step (`pull.handler.ts` step 12) — someone else edited it while the - * prompt was on screen. Refuses to write over a file it no longer has an - * accurate picture of; rerunning the command re-reads the current state. + * The config file's bytes changed between the pre-confirmation read and the write step — + * someone else edited it while the prompt was on screen. Refuses to write over a file it no + * longer has an accurate picture of; rerunning re-reads the current state. */ export class ConfigPullFileChangedError extends Data.TaggedError("ConfigPullFileChangedError")<{ readonly message: string; @@ -214,15 +176,10 @@ export class ConfigPullWriteError extends Data.TaggedError("ConfigPullWriteError } /** - * The post-plan convergence check (plan §1.9) found that one or more paths - * this run just planned to write STILL differ from the remote once the - * planned writes are applied to an in-memory projection — a defect in this - * command's own planner, never a user-facing condition (a genuine - * unpushable-family residual is a `warnings[]` entry, not this error; see - * `configPullConvergenceCheck`). Raised BEFORE any file write, so - * nothing was written when this fires. The constructed message must name the - * still-drifting paths and state that nothing was written and the bug should - * be reported. + * The post-plan convergence check found that a planned write still differs from the remote + * after being applied to an in-memory projection — a defect in this command's own planner, not + * a user-facing condition (see `configPullConvergenceCheck`). Raised before any file write; the + * message must name the still-drifting paths and ask the user to report the bug. */ export class ConfigPullPlanDefectError extends Data.TaggedError("ConfigPullPlanDefectError")<{ readonly message: string; @@ -233,16 +190,11 @@ export class ConfigPullPlanDefectError extends Data.TaggedError("ConfigPullPlanD } /** - * `pull.handler.ts`'s post-plan schema-validation gate (CLI-2064's live-bug - * fix) still finds the projected document unloadable after dropping every - * family it could identify as the cause (`dropConfigPullUnvalidatableFamilies`, - * up to its own round cap) — never a user-facing condition: dropping a - * family's writes restores that part of the document to its PRE-pull state, - * which loaded successfully at the start of this very command, so reaching - * this error means either the drop heuristic failed to identify the right - * family or something else is structurally broken. Raised BEFORE any file - * write, so nothing was written when this fires. The constructed message must - * say so and ask the user to report the bug. + * The post-plan schema-validation gate still finds the projected document unloadable after + * dropping every family it could identify as the cause (`dropConfigPullUnvalidatableFamilies`) + * — never user-facing, since a dropped family's writes restore that part of the document to its + * pre-pull, already-valid state. Raised before any file write; the message asks the user to + * report the bug. */ export class ConfigPullValidationFailedError extends Data.TaggedError( "ConfigPullValidationFailedError", diff --git a/apps/cli/src/commands/config/pull/pull.format.ts b/apps/cli/src/commands/config/pull/pull.format.ts index 8165ee252f..9385e4dd2e 100644 --- a/apps/cli/src/commands/config/pull/pull.format.ts +++ b/apps/cli/src/commands/config/pull/pull.format.ts @@ -21,16 +21,9 @@ import type { ConfigPullDestination } from "./pull.scope.ts"; import type { ConfigPullPlan } from "./pull.plan.ts"; /** - * Pure formatters, payload builders, and input adapters for `config pull` — - * no Effect, no services, unit-testable in isolation. The API-scope - * classification, target-naming phrase, value/path rendering, and - * masked/unmanaged/not-returned caveat wording shared with `config diff` live - * in `../config.format.ts` (hoisted, CLI-2064). `configPullPayload`/ - * `renderConfigPullText`/`ConfigPullOutcome`/`ConfigPullContext`/ - * `CONFIG_PULL_PAYLOAD_VERSION` are hoisted to - * `command-internal/config-pull-run.ts` (CLI-1272, reused by the `supabase - * pull` orchestrator) and re-exported here so this file's own call sites and - * test suite keep resolving them from the same path. + * Pure formatters, payload builders, and input adapters for `config pull` — no Effect, no + * services. Classification/rendering shared with `config diff` lives in `../config.format.ts`; + * the exports below come from `command-internal/config-pull-run.ts`, shared with `supabase pull`. */ export { CONFIG_PULL_PAYLOAD_VERSION, configPullPayload, renderConfigPullText }; @@ -51,45 +44,23 @@ export function configPullDestinationLine( } /** - * The label segment of `plan.createdTable` (always `["remotes", label]`, see - * {@link ConfigPullPlan.createdTable}'s own doc comment) — the only - * untrusted piece of that path, so every caller rendering it into TEXT output - * (the confirmation prompt, the render body's new-block note, the summary - * message's block-only wording) runs it through `sanitizeInlineName` - * here rather than re-deriving the indexing at each call site. + * The label segment of `plan.createdTable` (always `["remotes", label]`; see + * {@link ConfigPullPlan.createdTable}) — the only untrusted piece of that path. Centralizing the + * `sanitizeInlineName` call here avoids re-deriving the indexing at each render call site. */ export function configPullCreatedBlockLabel(createdTable: ReadonlyArray<string>): string { return sanitizeInlineName(createdTable[1] ?? ""); } /** - * One-line summary reflecting the run's ACTUAL outcome — the caveats travel - * with the machine-mode `message` field the same way `config diff`'s do, so - * an agent echoing just `.message` never mistakes a partial/declined/dry-run - * result for a completed write. Distinguishes "nothing to write" (no - * differences at all, no block to create) from "wrote nothing" (differences - * existed, but every one was skipped/declined/dry-run) — the two read very - * differently to a script deciding whether to alert. + * One-line summary reflecting the run's actual outcome, distinguishing "nothing to write" (no + * differences existed) from "wrote nothing" (differences existed but were all skipped, declined, + * or dry-run) and giving a block-only run (`plan.createdTable` set, no value writes) its own + * wording, distinct from both. * - * A BLOCK-ONLY run (`plan.createdTable` set, no value writes — a zero-drift - * branch target, CLI-2064's bug B) gets its own wording, distinguishable both - * from "nothing to write" (a block WAS created, or would be) and from a - * value-writing run (`counts.written` stays 0 either way, see - * `configPullPayload`) — and, within that wording, "no config - * differences to apply" (`counts.total === 0`: there was truly nothing to - * compare) is itself distinct from every difference having been SKIPPED - * (`counts.total > 0` but every one landed in `plan.skipped` — env() - * references, local-only fields, a dropped unvalidatable family, …): the - * block still only ever got created for its `project_id`, but claiming "no - * differences" would be false when differences existed and were simply never - * written. - * - * `opts.withCaveats` (default `true`) governs whether the masked/unmanaged/ - * not-returned `Note:`s are appended: the machine-mode `message` keeps them - * (an agent reading only `.message` must never miss a caveat), but the TEXT - * one-line disposition printed AFTER the change-by-change body omits them — - * that body already rendered the same `Note:` lines once, and repeating them - * verbatim in the final summary line said nothing new. + * @param opts.withCaveats - Appends the masked/unmanaged/not-returned `Note:`s (default `true`). + * The machine-mode `message` always keeps them; the text-mode one-line summary omits them since + * the change-by-change body already rendered them. */ export function configPullSummaryMessage( changeSet: ConfigChangeSet, diff --git a/apps/cli/src/commands/config/pull/pull.handler.ts b/apps/cli/src/commands/config/pull/pull.handler.ts index e7ee4eabb7..2f2ab4f3ec 100644 --- a/apps/cli/src/commands/config/pull/pull.handler.ts +++ b/apps/cli/src/commands/config/pull/pull.handler.ts @@ -45,34 +45,11 @@ import { import type { ConfigPullFlags } from "./pull.command.ts"; /** - * `config pull` — writes a remote project or branch's configuration into - * `supabase/config.toml`/`.json` (root, or an existing/new `[remotes.*]` - * block), after a confirmation prompt. Mirrors `config diff`'s target - * resolution, fetch, and classify steps (`../diff/diff.handler.ts`) - * step-for-step through the point where the two commands diverge (CLI-2064). - * - * The plan/apply run-core (opening the base config source, planning, - * applying, and the render/payload builders) is hoisted to - * `command-internal/config-pull-run.ts` (CLI-1272) so the `supabase pull` - * orchestrator (`commands/pull/`) can reuse it without importing this - * command family's own files. `configPull` (steps 1-4) rejects - * `-o/--output`, then opens the base config source via - * `openConfigPullSource` — a load with NO `[remotes.*]` overlay, - * paired with that SAME load's own captured on-disk text (`LoadedCliConfig.rawText`) - * — BEFORE any network call or target resolution, then resolves the target - * and delegates to `runConfigPull` (steps 5-15) — the reusable, - * target-agnostic body kept here since it is not itself reused by another - * command family; `supabase pull` composes the SAME plan/apply/render pieces - * into its own multi-step confirmation instead of calling `runConfigPull` - * directly. The plan's own `ConfigPullInput` sketch omits - * the loaded config/file text; this implementation carries them through - * explicitly (as a single `ConfigPullSource`) instead of - * reloading/re-reading inside `runConfigPull`, since `configPull` - * already holds both by the time it delegates. Pairing them behind one - * exported constructor — rather than two independently-assembled fields on - * `ConfigPullInput` — is what makes "loaded without overlay, text taken - * from that SAME load" true BY CONSTRUCTION rather than by caller convention - * (see `ConfigPullSource`'s own doc comment, `command-internal/config-pull-run.ts`). + * `config pull` writes a remote project or branch's configuration into `supabase/config.toml`/ + * `.json` (the config root, or an existing/new `[remotes.*]` block) after a confirmation prompt. + * Its target resolution, fetch, and classify steps mirror `config diff`'s, up to the point where + * the two commands diverge. The plan/apply run-core is hoisted to + * `command-internal/config-pull-run.ts` so the `supabase pull` orchestrator can reuse it. */ export { openConfigPullSource }; @@ -85,8 +62,7 @@ const mapBranchResolveError = mapHttpError({ statusMessage: unexpectedStatusMessage, }); -/** Error construction for `resolveConfigTarget` (`command-internal/project-target.ts`), keeping - * `config pull`'s own tagged error classes; the message wording is shared there. */ +// Maps resolveConfigTarget's generic errors onto config pull's own tagged error classes. const configTargetErrors = configTargetErrorsFor({ notLinked: ConfigPullBranchNotLinkedError, parentRefInvalid: ConfigPullParentRefInvalidError, @@ -94,26 +70,21 @@ const configTargetErrors = configTargetErrorsFor({ branchNotReady: ConfigPullBranchNotReadyError, }); -/** - * Steps 5-15 of `config pull`, reusable independently of the CLI flag - * surface (plan §1.6's library seam) — everything AFTER the target is known. - */ +/** Everything `config pull` does once the target is known, reusable independently of the CLI + * flag surface. */ export interface ConfigPullInput { readonly target: ConfigTarget; /** `--remote-label`, already filtered so an empty value reads as absent. */ readonly remoteLabel: string | undefined; readonly dryRun: boolean; readonly force: boolean; - /** `--yes` OR `SUPABASE_YES` (the GLOBAL flag — `resolveYes`, no - * project-`.env` fallback: unlike `config push`, this command never loads - * one). */ + /** `--yes` or `SUPABASE_YES` via `resolveYes`; unlike `config push`, this command never loads + * a project `.env` fallback. */ readonly yes: boolean; /** - * The base config load + its on-disk text (`configPull` steps 2-3), - * produced by {@link openConfigPullSource} BEFORE target resolution - * (a malformed config must not burn a branch-resolution round trip) — so - * `configPull` already holds it by the time it delegates here, - * passed through rather than reopened. + * The base config load and its on-disk text, produced by {@link openConfigPullSource} before + * target resolution so a malformed config doesn't burn a branch-resolution round trip; passed + * through rather than reopened. */ readonly source: ConfigPullSource; } @@ -129,10 +100,9 @@ export const runConfigPull = Effect.fnUntraced(function* (input: ConfigPullInput const { changeSet, scope, plan: finalPlan, context, configFilePath } = runPlan; const ref = context.projectRef; - // The TEXT one-line disposition drops the caveats (`opts.withCaveats: - // false`, item F.2 of CLI-2064's fix pass) — the change-by-change body - // above already rendered the same `Note:` lines once; the machine-mode - // `message` keeps them, since it is the only place an agent reads them. + // The text one-line disposition drops the caveats (opts.withCaveats: false) since the + // change-by-change body above already rendered the same Note: lines; the machine-mode message + // keeps them. const emitOutcome = (planForOutput: ConfigPullPlan, outcome: ConfigPullOutcome) => output.format !== "text" ? output.success( @@ -143,9 +113,8 @@ export const runConfigPull = Effect.fnUntraced(function* (input: ConfigPullInput `${configPullSummaryMessage(changeSet, scope, planForOutput, outcome, { withCaveats: false })}\n`, ); - // 9. `--dry-run`: preview only. Never runs the git check, never prompts, - // never touches the file. Comes before the `hasWork` short-circuit below — - // a planner defect must be visible even on a run that would do nothing. + // --dry-run previews only: no git check, no prompt, no write. Checked before the hasWork + // short-circuit below so a planner defect stays visible even on a run that would do nothing. if (input.dryRun) { if (output.format === "text") { yield* output.raw(renderConfigPullText(changeSet, scope, finalPlan, ref, context.configPath)); @@ -154,12 +123,9 @@ export const runConfigPull = Effect.fnUntraced(function* (input: ConfigPullInput return; } - // 9.5. Nothing planned AT ALL — no value write, no `[remotes.*]` block to - // create — success, no git check, no prompt. Doing this check BEFORE the - // git guard (rather than after, as it used to run) is what fixes bug A: a - // converged run never spawns `git status` at all, so an - // uncommitted-but-otherwise-clean config file never aborts a pull that was - // never going to touch it. + // Nothing planned at all (no value write, no block to create) succeeds with no git check and + // no prompt: a converged run never spawns `git status`, so an uncommitted-but-otherwise-clean + // file never aborts a pull that wouldn't touch it. if (!runPlan.hasWork) { if (output.format === "text") { yield* output.raw(renderConfigPullText(changeSet, scope, finalPlan, ref, context.configPath)); @@ -168,11 +134,9 @@ export const runConfigPull = Effect.fnUntraced(function* (input: ConfigPullInput return; } - // 10. Git dirty guard (plan §1.4), reached only when there's work to do. - // `--force` skips it entirely — no check, no warning, no prompt-default - // flip. `--yes` aborts rather than bypasses (CLI-2064 item C): no human is - // on hand to read the warning and answer the prompt honestly once `--yes` - // answers it automatically, on any TTY. + // Git dirty guard, reached only when there's work to do. --force skips it entirely; --yes + // aborts rather than bypasses it, since no human is available to confirm once --yes answers + // automatically. let dirty = false; if (!input.force) { const dirtyOption = yield* pathHasUncommittedChanges(configFilePath); @@ -186,9 +150,8 @@ export const runConfigPull = Effect.fnUntraced(function* (input: ConfigPullInput } } } - // Reuses the SAME `plan.warnings` hook the planner's own path-scoped - // warnings render through (`renderConfigPullText`'s "Warnings:" - // section) — a repository-level warning, no `path`. + // Reuses the same plan.warnings hook the planner's path-scoped warnings render through + // (renderConfigPullText's "Warnings:" section) — a repository-level warning, with no path. const planForRender: ConfigPullPlan = dirty ? { ...finalPlan, @@ -202,14 +165,10 @@ export const runConfigPull = Effect.fnUntraced(function* (input: ConfigPullInput ); } - // 11. Confirm. A run with at least one value write keeps the established - // "Apply N change(s)..." message even when it ALSO creates a block (the - // rendered body above already called that out) — naming the destination - // block too, when writing into one, so the prompt itself is unambiguous - // about WHERE (omitted for the config root); a block-ONLY run (no value - // writes — bug B's zero-drift branch target) gets its own message naming - // the block directly, since there is no per-change body to convey it - // otherwise. + // A run with at least one value write keeps the "Apply N change(s)..." message, naming the + // destination block when writing into one so the prompt is unambiguous about where. A + // block-only run (no value writes, a zero-drift branch target) gets its own message naming + // the block directly, since there's no per-change body to convey it. let confirmMessage: string; if (planForRender.writes.length > 0) { const destinationSuffix = @@ -220,35 +179,30 @@ export const runConfigPull = Effect.fnUntraced(function* (input: ConfigPullInput } else if (planForRender.createdTable !== undefined) { confirmMessage = `Create [remotes.${configPullCreatedBlockLabel(planForRender.createdTable)}] in ${context.configPath}?`; } else { - // Unreachable: `writes.length === 0` only reaches this branch when - // `hasWork` was true, which (post the step-9.5 short-circuit above) means - // `createdTable` must be set. + // Unreachable: writes.length === 0 only reaches this branch when hasWork was true, which + // (after the short-circuit above) means createdTable must be set. return yield* Effect.die( new Error("config pull: nothing to confirm — hasWork invariant violated"), ); } const confirmed = yield* promptYesNo(output, input.yes, confirmMessage, dirty ? false : true); if (!confirmed) { - // Mirrors `config push`'s own treatment of a declined confirmation (each - // service is marked "skipped" and the command still succeeds) — a - // decline is a normal, expected outcome, not a failure: exit code stays - // 0 in every format. + // A decline is a normal, expected outcome, not a failure: exit code stays 0 in every format, + // mirroring config push's own treatment. yield* emitOutcome(planForRender, { dryRun: false, declined: true }); return; } - // 12-13. Re-read against the step-3 baseline, apply, and write. + // Re-read against the baseline, apply, and write. yield* applyConfigPullRun({ runPlan, source: input.source }); - // 14. Final summary/payload. yield* emitOutcome(planForRender, { dryRun: false, declined: false }); }); /** - * `configPull` — the command-facing entry point (steps 1-4): rejects - * `-o/--output`, opens the base config source (`openConfigPullSource`) - * BEFORE any network call or target resolution, resolves the target, then - * delegates to {@link runConfigPull} for the rest. + * The command-facing entry point: rejects `-o/--output`, opens the base config source before + * any network call or target resolution, resolves the target, then delegates to + * {@link runConfigPull}. */ export const configPull = Effect.fn("config.pull")(function* (flags: ConfigPullFlags) { const goOutputFlag = yield* OutputFlag; @@ -265,38 +219,31 @@ export const configPull = Effect.fn("config.pull")(function* (flags: ConfigPullF Option.filter(flags.remoteLabel, (value) => value.length > 0), ); - // Written once the target is known, so the linked-project cache finalizer - // below only fires for invocations that got that far (mirrors `config - // diff`). + // Set once the target resolves, so the linked-project cache write below only fires for + // invocations that got that far. let resolvedRef: string | undefined; yield* Effect.gen(function* () { - // 1. Reject the Go-compat `-o/--output` flag outright, before anything - // else — `config pull` is a net-new TS command with no Go parity - // contract (CLI-2156, mirrors `config diff`). + // Reject -o/--output outright, before anything else: this command only supports + // --output-format. if (Option.isSome(goOutputFlag)) { return yield* new ConfigPullOutputFlagUnsupportedError({ message: unsupportedOutputFlagMessage("config pull"), }); } - // 1.5. The resolved `--workdir`/`SUPABASE_WORKDIR` must exist and be a - // directory before the base config source is opened — distinguishes - // "the directory doesn't exist" from "it exists but holds no - // `supabase/` project" (the step 2-3 load below). + // Validated before the base config source is opened so a missing workdir surfaces its own + // error rather than the generic "no supabase/ project" one. yield* validateWorkdirIsDirectory(cliSettings.workdir, fs).pipe( Effect.mapError((error) => new ConfigPullWorkdirError({ message: error.message })), ); - // 2-3. Open the base config source (load with NO `[remotes.*]` overlay, - // paired with its on-disk text) BEFORE any network call or target - // resolution — a missing file must point at `supabase init` rather than - // the resolver's not-linked error, and a malformed document must not - // burn a branch-resolution round trip. + // Opens the base config source (no [remotes.*] overlay, paired with its on-disk text) + // before any network call or target resolution, so a missing file points at supabase init + // and a malformed document doesn't burn a branch-resolution round trip. const source = yield* openConfigPullSource(); - // 4. Resolve the pull target — hoisted into `resolveConfigTarget` - // (`command-internal/project-target.ts`, shared with `config diff`/`config push`, CLI-2064). + // Resolves the pull target via resolveConfigTarget, shared with config diff/config push. const { ref, branch } = yield* resolveConfigTarget( requested, configTargetErrors, @@ -313,10 +260,8 @@ export const configPull = Effect.fn("config.pull")(function* (flags: ConfigPullF source, }); }).pipe( - // CLI Invariant #1: telemetry flushes on EVERY invocation — - // including load/parse failures and branch-resolution failures — while - // the linked-project cache write needs a resolved ref, so it fires - // exactly when one exists (mirrors `config diff`). + // Telemetry flushes on every invocation; the linked-project cache write only fires once a + // ref has resolved. Effect.ensuring( Effect.suspend(() => resolvedRef === undefined ? Effect.void : linkedProjectCache.cache(resolvedRef), diff --git a/apps/cli/src/commands/config/pull/pull.integration.test.ts b/apps/cli/src/commands/config/pull/pull.integration.test.ts index 5ccb4e4c30..9e8beb8d91 100644 --- a/apps/cli/src/commands/config/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/config/pull/pull.integration.test.ts @@ -43,14 +43,8 @@ import { } from "./pull.handler.ts"; import type { ConfigPullFlags } from "./pull.command.ts"; -/** - * Setup + fixtures mirror `../diff/diff.integration.test.ts` — same temp - * workdir helper, same mocked platform API/output/telemetry/linked-project - * services, same `v2Response()` schema-defaults fixture (an empty - * config.toml diffs clean against it). The core smoke cases below are - * structured so a follow-up pass can extend the `setup()` options and add - * more `describe`/`it.live` blocks without refactoring this shape. - */ +// Setup mirrors ../diff/diff.integration.test.ts: same temp workdir helper, mocked +// services, and v2Response() fixture. const tempRoot = useTempWorkdir("supabase-config-pull-int-"); @@ -89,14 +83,12 @@ function writeProjectEnv(dotenv: string): void { writeFileSync(join(dir, ".env"), dotenv); } -/** Save/restore a `process.env` var around an Effect — mirrors - * `push.integration.test.ts`'s `withDotenvPrivateKey` pattern. Set directly - * on `process.env` (not `supabase/.env`) — `pull.handler.ts`'s - * schema-validation gate resolves `env(VAR)` EXACTLY like the loader - * (`decodeCliConfigDocumentForValidationEffect`, `@supabase/config/internal`: - * process env layered with the project's own `.env`/`.env.local`), so either - * source works for both the initial load and the gate alike; a bare - * `process.env` var is the simplest one to set/restore from a test. */ +/** + * Save/restore a `process.env` var around an Effect. Set directly on `process.env` rather than + * `supabase/.env`: the schema-validation gate resolves `env(VAR)` the same way the loader does + * (process env layered with the project's own `.env` files), so either source works, and + * `process.env` is simplest to set/restore in a test. + */ function withProcessEnv<A, E, R>( name: string, value: string | undefined, @@ -115,11 +107,8 @@ function withProcessEnv<A, E, R>( ); } -/** Schema-valid v2 project-config body whose managed values all sit at the - * local schema defaults, so an empty config.toml diffs clean against it — - * copied from `diff.integration.test.ts` (same rationale: the largest, - * most transform-heavy mapping surface must run end to end and classify - * cleanly). */ +/** Schema-valid v2 project-config body whose managed values all sit at the local schema + * defaults, so an empty config.toml diffs clean against it. */ function v2Response( opts: { readonly ref?: string; @@ -286,13 +275,9 @@ const BRANCH_CONFIG = { }; /** - * Wraps `base` so every `promptConfirm` call first runs `onConfirm` (a - * synchronous side effect) before delegating to the real mock — used ONLY to - * simulate a concurrent edit landing on disk WHILE the confirmation prompt is - * "on screen" (the TOCTOU case `pull.handler.ts` step 12 guards against). - * Standard Effect composition: `Layer.effect` requiring `Output` gets - * `Layer.provide`d the base layer, producing a new `Layer<Output>` with no - * outstanding requirement. + * Wraps `base` so every `promptConfirm` call runs `onConfirm` first, simulating a concurrent + * edit landing on disk while the confirmation prompt is on screen (the TOCTOU case + * `pull.handler.ts`'s write step guards against). */ function withConfirmSideEffect( base: Layer.Layer<Output>, @@ -312,12 +297,9 @@ function withConfirmSideEffect( } /** - * Fakes the `git status --porcelain -- config.toml` subprocess the dirty - * guard (`git-status.ts`) issues — pattern copied from - * `git-status.unit.test.ts`'s own `mockSpawner`. Listed AFTER - * `buildTestRuntime` in the layer merge (below) so it overrides the - * real spawner `BunServices.layer` provides (last-wins, same precedent as - * `signing-key.integration.test.ts`'s `mockGitCheckIgnore`). + * Fakes the `git status --porcelain -- config.toml` subprocess the dirty guard issues. Listed + * after `buildTestRuntime` in the layer merge below so it overrides the real spawner + * (last-wins). */ function mockGitStatusSpawner( opts: { readonly dirty?: boolean; readonly spawnFails?: boolean } = {}, @@ -562,23 +544,11 @@ describe("config pull integration", () => { it.live( "a first-pull auth.oauth_server.enabled no longer trips the ADR 0021 unpushable warning (CLI-2314)", () => { - // Before CLI-2314, `applyPushUnmanagedOmissions` dropped the WHOLE - // `auth.oauth_server` subtree from the DOCUMENT arm unconditionally, - // so writing `enabled` here on a first pull immediately reclassified - // it as `unmanaged` — this test used to pin that "written here, but - // config push cannot send it back" warning. `enabled` is now an - // ordinary comparable path: writing it converges local and remote - // exactly, so no residual `unmanaged` entry exists to warn about, and - // it stays genuinely round-trippable. (Its siblings — - // `allow_dynamic_registration`/`authorization_url_path` — are still - // pruned while the container is declared disabled, see the - // "declared-but-unpushable" tests below, but that pruning runs - // symmetrically on both the local and remote arms, keyed on each - // side's OWN `enabled` value, so a value pull ever writes for them can - // never itself be the one that goes unmanaged: the remote only ever - // reports a real value for them while its own `enabled` is `true`, - // and pull always converges local's `enabled` to match in the same - // run.) + // `enabled` is now an ordinary comparable path: writing it converges local and remote + // exactly, so no residual `unmanaged` entry exists to warn about. Its siblings + // (`allow_dynamic_registration`/`authorization_url_path`) are still pruned while the + // container is disabled, but symmetrically on both arms, so a pull never writes a value + // for them that itself goes unmanaged. const { layer, out } = setup({ toml: 'project_id = "test"\n', yes: true, @@ -610,19 +580,12 @@ describe("config pull integration", () => { it.live( "a first-pull auth.rate_limit.email_sent DOES trip the ADR 0021 unpushable warning, for a sparse remote (review round, CLI-2314)", () => { - // `applyDisabledSentinels`'s cross-section rule (`project-config.ts`, - // search "Cross-section rule: the email rate limit") deletes - // `auth.rate_limit.email_sent` whenever `auth.email.smtp.enabled` - // decodes explicitly `false` — checked on BOTH arms. The stock fixture - // reports `smtp_host: ""` (an ordinary "not configured" response), - // which decodes `enabled: false` explicitly and would prune - // `email_sent` on the API arm too, leaving nothing to diff — deleting - // `smtp_host` from the response instead (never mentioning it, a - // genuinely SPARSE shape) is what spares `email_sent` there. Combined - // with the LOCAL document never declaring `[auth.email.smtp]` either - // (so `applyRawPresenceMask` re-masks the just-written value on the - // residual check), this reproduces the one live trigger for the - // "unpushable" warning today. + // applyDisabledSentinels's cross-section rule deletes auth.rate_limit.email_sent whenever + // auth.email.smtp.enabled decodes false, on both arms. The stock fixture's smtp_host: "" + // would decode enabled: false and prune email_sent on the API arm too; omitting smtp_host + // entirely (a genuinely sparse response) is what spares it there. Combined with the local + // document never declaring [auth.email.smtp], this reproduces the one live trigger for the + // unpushable warning. const { layer, out } = setup({ toml: 'project_id = "test"\n', yes: true, @@ -672,7 +635,7 @@ describe("config pull integration", () => { yield* configPull(noFlags); expect(readFileSync(configPath(), "utf8")).toBe(before); expect(out.promptConfirmCalls).toHaveLength(1); - // No `[remotes.*]` suffix for a root-bound write (CLI-2064 item F.5). + // No [remotes.*] suffix for a root-bound write. expect(out.promptConfirmCalls[0]?.message).toBe( `Apply 1 change(s) to ${join("supabase", "config.toml")}?`, ); @@ -712,12 +675,11 @@ describe("config pull integration", () => { expect(Exit.isFailure(exit)).toBe(true); const rendered = JSON.stringify(exit); expect(rendered).toContain("ConfigPullLoadConfigError"); - // A DEFAULTED workdir with no project keeps the established - // `supabase init` suggestion — only an EXPLICIT --workdir/SUPABASE_WORKDIR - // gets the resolved-path wording (see the CLI-2285 regression below). + // A defaulted workdir with no project keeps the supabase init suggestion; only an + // explicit --workdir/SUPABASE_WORKDIR gets the resolved-path wording. expect(rendered).toContain("supabase init"); - // The load runs before any network call or target resolution, so the - // linked-project cache never fires — no ref ever resolved. + // The load runs before any network call or target resolution, so the linked-project + // cache never fires. expect(api.requests).toHaveLength(0); expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cachedRef).toBeUndefined(); @@ -727,13 +689,8 @@ describe("config pull integration", () => { it.live( "does not climb to an ancestor project's config when --workdir names a subdirectory with no config of its own", () => { - // CLI-2285 regression: an explicit --workdir is authoritative and must - // never let `loadCliConfig` climb past it — a `config pull --workdir - // ./sub` from a project whose subdirectory has no supabase/ of its - // own must not silently overwrite an unrelated PARENT project's - // config. The ancestor (tempRoot) genuinely has a valid config.toml - // (captured below to prove it is never touched) and the subdirectory - // genuinely has none. + // The ancestor (tempRoot) genuinely has a valid config.toml (captured below to prove + // it's never touched); the subdirectory genuinely has none. const before = 'project_id = "test"\n[api]\nmax_rows = 500\n'; const sub = join(tempRoot.current, "nested", "dir"); mkdirSync(sub, { recursive: true }); @@ -746,14 +703,11 @@ describe("config pull integration", () => { const rendered = JSON.stringify(exit); expect(rendered).toContain("ConfigPullLoadConfigError"); expect(rendered).toContain("file not found"); - // An EXPLICIT workdir never gets the ancestor-search-exhausted - // `supabase init` hint — it names the resolved directory instead, and - // points at the flag/env var that must change. + // An explicit workdir skips the ancestor-search "supabase init" hint; it names the + // resolved directory and the flag/env var to change instead. expect(rendered).not.toContain("supabase init"); expect(rendered).toContain("--workdir/SUPABASE_WORKDIR"); expect(rendered).toContain(sub); - // Nothing was written to disk anywhere — the ancestor config file - // stays byte-identical and untouched. expect(statSync(path).mtimeMs).toBe(beforeStat.mtimeMs); expect(readFileSync(path, "utf8")).toBe(beforeStat.contents); }).pipe(Effect.provide(layer)); @@ -763,9 +717,6 @@ describe("config pull integration", () => { it.live( "an explicit --workdir naming a directory that does not exist at all fails before any config load", () => { - // Distinct from the "exists but holds no project" regression above: - // this path was never created, so `validateWorkdirIsDirectory` - // must fail first, and nothing is ever written to disk. const missing = join(tempRoot.current, "does-not-exist"); const { layer, api } = setup({ workdir: missing, explicitWorkdir: true }); return Effect.gen(function* () { @@ -775,16 +726,11 @@ describe("config pull integration", () => { expect(rendered).toContain("ConfigPullWorkdirError"); expect(rendered).toContain("failed to change workdir: chdir"); expect(api.requests).toHaveLength(0); - // Nothing was written to disk — the missing directory stays missing. expect(existsSync(missing)).toBe(false); }).pipe(Effect.provide(layer)); }, ); - // ------------------------------------------------------------------------- - // Scope resolution / --remote-label (CLI-2064 §1.1). - // ------------------------------------------------------------------------- - it.live( "reuses an existing [remotes.*] block regardless of its own label when its project_id matches the target", () => { @@ -894,8 +840,7 @@ describe("config pull integration", () => { expect(Exit.isFailure(exit)).toBe(true); const rendered = JSON.stringify(exit); expect(rendered).toContain("ConfigPullRemoteLabelCollisionError"); - // CLI-2064 item E: names the ACTUALLY conflicting block (`other`), - // not the requested-but-unused label. + // Names the actually conflicting block (`other`), not the requested-but-unused label. expect(rendered).toContain("[remotes.other] already tracks project"); expect(rendered).toContain(VALID_REF); expect(readFileSync(configPath(), "utf8")).toBe(before); @@ -958,10 +903,6 @@ describe("config pull integration", () => { it.live( "a branch-named target that collides with an existing, differently-tracked block fails with a collision error, and the file stays untouched (CLI-2064 item A)", () => { - // Before item A's fix, a branch named "staging" landing on an - // unrelated `[remotes.staging]` block returned `created: true` and the - // handler REPLACED that block's own `project_id`, stranding its stale - // overrides. const before = [ 'project_id = "test"', "[remotes.staging]", @@ -1009,10 +950,6 @@ describe("config pull integration", () => { }, ); - // ------------------------------------------------------------------------- - // Destination line ordering. - // ------------------------------------------------------------------------- - it.live( "the destination line prints to stderr before any network call, and survives a failed fetch", () => { @@ -1025,10 +962,6 @@ describe("config pull integration", () => { }, ); - // ------------------------------------------------------------------------- - // Atomicity. - // ------------------------------------------------------------------------- - it.live("no temp file is left behind in supabase/ after a successful write", () => { const before = 'project_id = "test"\n[api]\nmax_rows = 500\n'; const { layer } = setup({ toml: before, yes: true }); @@ -1043,12 +976,9 @@ describe("config pull integration", () => { it.live( "an editor refusal (an edit path through an inline table) leaves the file byte-identical and fails with ConfigPullUnsupportedLayoutError", () => { - // A genuine duplicate `[api]` table header (the plan's own example - // fixture) is rejected by `smol-toml` itself at LOAD time — the load - // step (`loadCliConfig`) would fail first with a parse error, never - // reaching `applyConfigEdits`. An inline table is the reachable - // equivalent: valid, loadable TOML whose surgical text-span editor - // still refuses to edit through it (`inline_table_on_path`). + // A genuine duplicate [api] table header would be rejected by smol-toml itself at load + // time, never reaching applyConfigEdits. An inline table is the reachable equivalent: + // valid, loadable TOML whose surgical text-span editor still refuses to edit through it. const before = 'project_id = "test"\napi = { max_rows = 500 }\n'; const { layer } = setup({ toml: before, yes: true }); return Effect.gen(function* () { @@ -1056,8 +986,7 @@ describe("config pull integration", () => { expect(Exit.isFailure(exit)).toBe(true); const rendered = JSON.stringify(exit); expect(rendered).toContain("ConfigPullUnsupportedLayoutError"); - // CLI-2064 item F.3: prose, not the raw reason token, plus a - // remediation sentence. + // Prose, not the raw reason token, plus a remediation sentence. expect(rendered).toContain("an inline table on this path"); expect(rendered).toContain("Rewrite it as a standard [table] section, then rerun."); expect(rendered).not.toContain("inline_table_on_path"); @@ -1066,10 +995,6 @@ describe("config pull integration", () => { }, ); - // ------------------------------------------------------------------------- - // Never-written classes. - // ------------------------------------------------------------------------- - it.live("a local-only declared property survives the write and is reported skipped", () => { const before = [ 'project_id = "test"', @@ -1196,9 +1121,8 @@ describe("config pull integration", () => { return Effect.gen(function* () { yield* configPull(noFlags); const after = readFileSync(configPath(), "utf8"); - // The `site_url` key stays byte-identical — the remote's env()-spelled - // value is never written — while the unrelated `max_rows` change, - // which carries no such risk, is written normally. + // site_url stays byte-identical since the remote's env()-spelled value is never + // written, while the unrelated max_rows change is written normally. expect(after).toContain('site_url = "https://local.example.com"'); expect(after).toContain("max_rows = 1000"); const success = out.messages.find((message) => message.type === "success"); @@ -1217,10 +1141,6 @@ describe("config pull integration", () => { }, ); - // ------------------------------------------------------------------------- - // Warnings (CLI-2064 §1.3, ADR 0023). - // ------------------------------------------------------------------------- - it.live("writing a dual-scope property to the config root warns before the prompt", () => { const before = 'project_id = "test"\n[auth]\nsite_url = "https://custom.example.com"\n'; const { layer, out } = setup({ toml: before, yes: true }); @@ -1267,17 +1187,11 @@ describe("config pull integration", () => { it.live( "a remote block's write that duplicates the config root's own value warns of redundancy", () => { - // Bonus coverage for §1.3's OTHER redundancy warning (`array_drift`'s - // sibling `duplicates_root`) — `array_drift` itself (an array-valued - // `remote_only` write into a block the root ALSO declares) turns out to - // be unreachable through the real loader: `applyRemoteOverride`'s - // overlay merge always inherits a root-declared path as "declared" in - // whatever document the diff is computed against, so a write the root - // also declares can only ever classify as "update", never - // "remote_only" (verified directly against `@supabase/config` — see - // the coverage-gaps note in the test report). It stays covered at the - // planner-unit level (`pull.plan.unit.test.ts`) via a synthetic - // changeSet/rootDocument pair the real loader cannot produce together. + // array_drift (an array-valued remote_only write into a block the root also declares) is + // unreachable through the real loader: applyRemoteOverride's overlay merge always + // inherits a root-declared path as "declared", so such a write can only classify as + // "update", never "remote_only". It's covered instead at the planner-unit level + // (pull.plan.unit.test.ts) via a synthetic changeSet/rootDocument pair. const before = [ 'project_id = "test"', "[api]", @@ -1299,16 +1213,11 @@ describe("config pull integration", () => { }, ); - // ------------------------------------------------------------------------- - // Uncommitted changes / dirty guard (CLI-2064 §1.4). - // ------------------------------------------------------------------------- - it.live( "uncommitted changes abort without --force in text+non-tty mode, leaving the file untouched", () => { const before = 'project_id = "test"\n[api]\nmax_rows = 500\n'; - // `--yes` is set to prove it does NOT override the dirty guard — only - // `--force` does. + // --yes is set to prove it doesn't override the dirty guard; only --force does. const { layer } = setup({ toml: before, gitDirty: true, yes: true }); return Effect.gen(function* () { const exit = yield* configPull(noFlags).pipe(Effect.exit); @@ -1438,12 +1347,6 @@ describe("config pull integration", () => { }, ); - // ------------------------------------------------------------------------- - // Zero-drift block creation (CLI-2064 bug B): a branch/`--remote-label` - // target with NOTHING to write still creates its `[remotes.*]` block, so - // block reuse engages on every later run instead of repeating forever. - // ------------------------------------------------------------------------- - it.live( "a zero-drift branch target still creates its [remotes.*] block; a second run reuses it and writes nothing", () => { @@ -1459,8 +1362,7 @@ describe("config pull integration", () => { expect(first.out.promptConfirmCalls).toHaveLength(1); expect(first.out.promptConfirmCalls[0]?.message).toContain("Create [remotes.staging] in"); expect(first.out.promptConfirmCalls[0]?.message).toContain(join("supabase", "config.toml")); - // The block-only body states its one action too (CLI-2064 item F.6), - // not only the confirmation prompt above. + // The block-only body states its one action too, not only the confirmation prompt above. expect(first.out.stdoutText).toContain( `New block [remotes.staging] will be created (project_id = ${BRANCH_REF}).`, ); @@ -1545,10 +1447,6 @@ describe("config pull integration", () => { }).pipe(Effect.provide(layer)); }); - // ------------------------------------------------------------------------- - // --yes / convergence / TOCTOU. - // ------------------------------------------------------------------------- - it.live("--yes skips the confirmation prompt entirely, even on a real TTY", () => { const before = 'project_id = "test"\n[api]\nmax_rows = 500\n'; const { layer, out } = setup({ toml: before, yes: true, stdinIsTty: true }); @@ -1597,10 +1495,6 @@ describe("config pull integration", () => { }, ); - // ------------------------------------------------------------------------- - // -o/--output rejection (CLI-2156). - // ------------------------------------------------------------------------- - it.live("every -o/--output value is rejected before any config load or network call", () => { const values = GLOBAL_OUTPUT_FORMATS; const run = (goOutput: (typeof values)[number]) => { @@ -1626,10 +1520,6 @@ describe("config pull integration", () => { }); }); - // ------------------------------------------------------------------------- - // Target resolution (mirrors `../diff/diff.integration.test.ts`). - // ------------------------------------------------------------------------- - it.live("a branch-named target resolves via the linked parent project", () => { const { layer, api, out } = setup({ toml: 'project_id = "test"\n', @@ -1783,10 +1673,6 @@ describe("config pull integration", () => { }).pipe(Effect.provide(layer)); }); - // ------------------------------------------------------------------------- - // Remaining local-load / config-read status branches (branch coverage). - // ------------------------------------------------------------------------- - it.live("a malformed config aborts before any network call, even with a branch target", () => { const { layer, api, telemetry } = setup({ toml: "not [valid toml\n" }); return Effect.gen(function* () { @@ -1899,10 +1785,6 @@ describe("config pull integration", () => { }).pipe(Effect.provide(layer)); }); - // ------------------------------------------------------------------------- - // config.json project. - // ------------------------------------------------------------------------- - it.live( "a config.json project is rewritten preserving key order and indent, changing only the drifted property", () => { @@ -1919,11 +1801,6 @@ describe("config pull integration", () => { }, ); - // ------------------------------------------------------------------------- - // Narrow branch-coverage fill-ins (spinner-suppression, decode failures, - // malformed response shapes, and the write/re-read error paths). - // ------------------------------------------------------------------------- - it.live("a branch-lookup transport failure maps to the read network error", () => { const { layer } = setup({ toml: 'project_id = "test"\n', branchByName: "fail" }); return Effect.gen(function* () { @@ -2057,9 +1934,8 @@ describe("config pull integration", () => { return Effect.gen(function* () { const exit = yield* configPull(noFlags).pipe(Effect.exit); chmodSync(dir, 0o700); - // Running as root (some CI/container setups) bypasses the permission - // bit entirely — skip the assertion rather than assert a false - // negative when that's the environment this runs under. + // Running as root (some CI/container setups) bypasses the permission bit, so skip the + // assertion instead of asserting a false negative. if (Exit.isFailure(exit)) { expect(JSON.stringify(exit)).toContain("ConfigPullWriteError"); } @@ -2125,11 +2001,9 @@ describe("config pull integration", () => { }); it.live("a single declared-but-unpushable property surfaces in the unmanaged note", () => { - // `enabled` is declared `false` (matching the remote, so it produces no - // change — it's now an ordinary comparable path, CLI-2314). Its sibling - // `allow_dynamic_registration` is what still demonstrates - // declared-but-unpushable: `DISABLED_SENTINEL_PRUNES` drops it from the - // local projection while the container is disabled. + // enabled matches the remote and produces no change; its sibling allow_dynamic_registration + // is what demonstrates declared-but-unpushable, since DISABLED_SENTINEL_PRUNES drops it from + // the local projection while the container is disabled. const before = [ 'project_id = "test"', "[auth.oauth_server]", @@ -2165,12 +2039,10 @@ describe("config pull integration", () => { it.live( "declared-but-unpushable properties surface in the unmanaged note, pluralized when there's more than one", () => { - // `enabled` is declared `false`, matching the remote (an ordinary - // comparable path, CLI-2314, that produces no change here). Its two - // siblings, `allow_dynamic_registration` and `authorization_url_path`, - // are what `DISABLED_SENTINEL_PRUNES` still drops from the local - // projection while the container is disabled — both surface as - // unpushable, exercising the pluralized note. + // enabled matches the remote and produces no change here; its two siblings + // (allow_dynamic_registration, authorization_url_path) are what DISABLED_SENTINEL_PRUNES + // drops from the local projection while the container is disabled, exercising the + // pluralized note. const before = [ 'project_id = "test"', "[auth.oauth_server]", @@ -2235,14 +2107,6 @@ describe("config pull integration", () => { }).pipe(Effect.provide(layer)); }); - // ------------------------------------------------------------------------- - // Fixpoint expansion + schema-validation gate (CLI-2064's live-dogfooding - // bug: pulling a value that GATES declared-but-unpushable siblings used to - // write only the gate, leaving its now-required siblings stale and - // bricking the next config load — ADR 0023 "written file always - // re-loads"). - // ------------------------------------------------------------------------- - const TWILIO_AUTH_TOKEN_VAR = "SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN"; const TWILIO_BEFORE = 'project_id = "test"\n' + @@ -2295,11 +2159,8 @@ describe("config pull integration", () => { expect(out.stdoutText).not.toContain("requires values pull cannot write"); expect(out.stdoutText).toContain("3 changes written."); - // The written file reloads cleanly (this is the actual CLI-2064 bug: - // before this fix, writing only `enabled` bricked the next load with - // "Missing required field in config: auth.sms.twilio.account_sid"). - // A second `config pull` run against the SAME remote converges to - // nothing left to write for this family. + // The written file reloads cleanly. A second config pull run against the same remote + // converges to nothing left to write for this family. const second = setup({ toml: after, yes: true, @@ -2324,20 +2185,16 @@ describe("config pull integration", () => { body: v2Response({ attributes: (attributes) => ({ ...attributes, - // An unrelated change so this run has something ELSE to write - // and confirm — proving the drop is scoped to the twilio - // family, not the whole run. + // An unrelated change so this run has something else to write, proving the drop + // is scoped to the twilio family, not the whole run. api: { ...(attributes["api"] as Record<string, unknown>), max_rows: 250 }, auth: { ...(attributes["auth"] as Record<string, unknown>), sms_provider: "twilio", sms_twilio_account_sid: "ACreal0000000000000000000000000", - // `sms_twilio_message_service_sid` is deliberately absent — - // the remote never reports it, so it can never be written; - // `message_service_sid` stays `""` after the fixpoint - // absorbs `account_sid`, and the projected file would fail - // to decode ("Missing required field in config: - // auth.sms.twilio.message_service_sid") once `enabled` is + // sms_twilio_message_service_sid is deliberately absent: the remote never + // reports it, so message_service_sid stays "" after the fixpoint absorbs + // account_sid, and the projected file would fail to decode once enabled is // written. }, }), @@ -2349,9 +2206,8 @@ describe("config pull integration", () => { const after = readFileSync(configPath(), "utf8"); // The unrelated change still wrote. expect(after).toContain("max_rows = 250"); - // The whole twilio family (including `account_sid`, which the - // fixpoint DID classify as writable) was dropped, not just the - // sibling that made the family unwritable. + // The whole twilio family (including account_sid, which the fixpoint did classify as + // writable) was dropped, not just the sibling that made it unwritable. expect(after).toContain("enabled = false"); expect(after).not.toContain("ACreal0000000000000000000000000"); expect(out.stdoutText).toContain( @@ -2363,18 +2219,16 @@ describe("config pull integration", () => { expect(out.stdoutText).toContain("auth.sms.twilio was not changed: it requires"); expect(out.stdoutText).toContain("auth.sms.twilio.message_service_sid"); - // The written file reloads cleanly — it was never actually touched - // for the twilio family, and the rest of the file is still valid. - // (`openConfigPullSource` re-reads and re-decodes the SAME - // config path this run just wrote; a failing decode would fail - // this `yield*`, failing the test.) + // The written file reloads cleanly — it was never touched for the twilio family. + // openConfigPullSource re-reads and re-decodes the same path; a failing decode would + // fail this yield*. const configText = readFileSync(configPath(), "utf8"); const reloaded = yield* openConfigPullSource(); expect(reloaded.loaded.config.auth.sms.twilio.enabled).toBe(false); - // A second run reports the SAME drift + the SAME skip (converged for - // the writable subset — the unrelated change has nothing left to - // write, the twilio family is drifting exactly as before). + // A second run reports the same drift and skip: the writable subset has converged + // (nothing left for the unrelated change), while the twilio family drifts exactly as + // before. const second = setup({ toml: configText, yes: true, @@ -2488,26 +2342,11 @@ describe("config pull integration", () => { }, ); - // ------------------------------------------------------------------------- - // Review findings (T0/T1): the schema-validation gate resolves env() EXACTLY - // like the loader (process env + project dotenv), a remote destination - // validates the overlay-merged projection too, and a decode failure that - // already existed BEFORE this pull's own writes is never attributed to the - // plan. - // ------------------------------------------------------------------------- - it.live( "a numeric env() value resolvable only via the project's supabase/.env validates and writes normally, nothing dropped (review T0)", () => { - // Before the fix, the validation gate resolved `env(...)` against bare - // `process.env` only — `PULL_TEST_NUMERIC_ENV` here resolves ONLY - // through the project's own `supabase/.env` (never set on - // `process.env`), so the gate used to see the literal string - // `"env(PULL_TEST_NUMERIC_ENV)"` where `max_rows` expects a number, - // fail to decode, and drop the WHOLE `[api]` family (no `enabled` - // sibling to narrow the drop to) — taking the sibling `schemas` write - // down with it even though nothing about ITS OWN value was ever - // unresolvable. + // PULL_TEST_NUMERIC_ENV resolves only through the project's own supabase/.env, never + // process.env, so this exercises the validation gate's own env(...) resolution path. const before = 'project_id = "test"\n[api]\nmax_rows = "env(PULL_TEST_NUMERIC_ENV)"\nschemas = ["public"]\n'; const { layer, out } = setup({ @@ -2530,17 +2369,11 @@ describe("config pull integration", () => { it.live( "a pre-write decode failure at a path unrelated to the plan is exempted: pull still writes its planned change and exits 0 (review T0)", () => { - // `runConfigPull` (exported precisely to be reusable independently - // of `configPull`'s own load step) is called directly here with a - // hand-augmented `source.loaded.rawDocument`/`.text`: the REAL loader - // itself unconditionally aborts the whole command on a root-level - // business-rule violation (verified directly against the real - // loader — a broken `[auth.sms.twilio]` at the config root is NEVER - // reachable past `openConfigPullSource`'s own initial load, by - // construction), so the only way to exercise the validation gate's OWN - // pre-existing exemption for a failure the rest of the command's load - // path would already have caught is to construct the source directly, - // the way `configPull` already does before delegating. + // runConfigPull is called directly with a hand-augmented source.loaded.rawDocument/.text: + // the real loader aborts the whole command on a root-level business-rule violation, so a + // broken [auth.sms.twilio] is never reachable past openConfigPullSource's initial load. + // Constructing the source directly is the only way to exercise the validation gate's own + // pre-existing exemption for a failure the load path would already have caught. const before = 'project_id = "test"\n[api]\nmax_rows = 500\n'; const { layer, out } = setup({ toml: before, yes: true }); return Effect.gen(function* () { @@ -2569,8 +2402,8 @@ describe("config pull integration", () => { }); const after = readFileSync(configPath(), "utf8"); expect(after).toContain("max_rows = 1000"); - // The pre-existing, unrelated twilio state is left exactly as it - // was — pull never attributes it to this run's own plan. + // The pre-existing, unrelated twilio state is left exactly as it was; pull never + // attributes it to this run's own plan. expect(after).toContain("[auth.sms.twilio]"); expect(after).toContain("enabled = true"); expect(out.stdoutText).not.toContain("would_invalidate"); @@ -2607,14 +2440,11 @@ describe("config pull integration", () => { ...(attributes["auth"] as Record<string, unknown>), sms_provider: "twilio", sms_twilio_account_sid: "ACreal0000000000000000000000000", - // `sms_twilio_message_service_sid` is deliberately absent — - // the remote never reports it, so it can never be written; - // writing `enabled`/`account_sid` alone into - // `[remotes.staging.*]` decodes fine RAW (remotes decode - // with business-rule checks disabled) but fails once this - // block is SELECTED and merged over root — exactly the - // projection a future `config pull`/`config push` targeting - // this project ref would use. + // sms_twilio_message_service_sid is deliberately absent: writing + // enabled/account_sid alone into [remotes.staging.*] decodes fine raw (remotes + // skip business-rule checks), but fails once this block is selected and merged + // over root — the same projection a future config pull/push targeting this ref + // would use. }, }), }), diff --git a/apps/cli/src/commands/config/pull/pull.live.test.ts b/apps/cli/src/commands/config/pull/pull.live.test.ts index d724da2a8d..acff78e51f 100644 --- a/apps/cli/src/commands/config/pull/pull.live.test.ts +++ b/apps/cli/src/commands/config/pull/pull.live.test.ts @@ -2,13 +2,10 @@ import { expect } from "vitest"; import { requireLiveSuccess, test } from "../../../../tests/helpers/live.ts"; -// Golden path only: the one thing mocks cannot prove is a real -// `GET /v2/projects/{ref}/config` response decoding, planning, and writing -// cleanly against a real (freshly-initialized) `supabase/config.toml`, and -// that a second run against the SAME project leaves nothing to write (the -// convergence check's own real-world counterpart — branch coverage lives in -// pull.integration.test.ts). The `workspace` fixture behind `cli` is a fresh -// `supabase init` project directory. +// Golden path only: the one thing mocks can't prove is a real +// `GET /v2/projects/{ref}/config` response decoding, planning, and writing cleanly against a +// real (freshly-initialized) config.toml, and that a second run against the same project leaves +// nothing to write. Branch coverage lives in pull.integration.test.ts. test("pulls remote config into a fresh project, leaving nothing to write on a second run", async ({ cli, project, diff --git a/apps/cli/src/commands/config/pull/pull.plan.ts b/apps/cli/src/commands/config/pull/pull.plan.ts index a5b7769b2d..0d3f794963 100644 --- a/apps/cli/src/commands/config/pull/pull.plan.ts +++ b/apps/cli/src/commands/config/pull/pull.plan.ts @@ -23,29 +23,16 @@ import { import type { ConfigPullDestination } from "./pull.scope.ts"; /** - * `config pull`'s write plan: classifies every `ConfigChange` `config diff`'s - * classifier already computed (CLI-2230/ADR-0022's convergence normalizer) - * into a planned write (replace or insert) or a skip with a reason, and - * derives the warnings a written value should carry. Pure and synchronous - * (CLI-2064) — no Effect, no services, no filesystem: this module only - * decides WHAT would change and WHERE (`documentPath`); applying it is - * `applyConfigEdits`'s job (`@supabase/config/internal`), and running it is - * `pull.handler.ts`'s. `diffProjectConfig` is a pure, synchronous import - * (no Effect, no services) — {@link expandConfigPullChangeSet} below - * calls it directly rather than taking it as an injected callback. + * `config pull`'s write plan: classifies each `ConfigChange` `config diff` already computed into + * a planned write (replace or insert) or a skip with a reason, and derives per-write warnings. + * Pure and synchronous — no Effect, no services, no filesystem; applying the plan is + * `applyConfigEdits`'s job, running it is `pull.handler.ts`'s. * - * Also owns the plan-level half of CLI-2064's fixpoint/validation fix (a live - * dogfooding bug: pulling a value that GATES other declared-but-unpushable - * siblings — e.g. flipping a disabled SMS provider's `enabled` on — used to - * write only the toggle, leaving its now-required siblings at their stale - * local values and bricking the next config load). {@link - * expandConfigPullChangeSet} re-classifies after projecting each - * round's writes so a newly-un-gated sibling is absorbed into the SAME plan - * (`pull.handler.ts`'s job to run it); {@link - * dropConfigPullUnvalidatableFamilies} is the write-side counterpart to - * `pull.handler.ts`'s post-plan schema-validation gate — when that gate finds - * the projected document still doesn't decode, it drops every write under the - * offending family here rather than write a file the CLI itself cannot load. + * Also absorbs cascading writes: pulling a value can gate other declared-but-unpushable siblings + * (e.g. enabling a disabled SMS provider un-gates its credentials). {@link + * expandConfigPullChangeSet} re-classifies after each round's writes so a newly un-gated sibling + * joins the same plan; {@link dropConfigPullUnvalidatableFamilies} drops every write under a + * family that still fails schema validation once applied, rather than write an unloadable file. */ export type ConfigPullSkipReason = @@ -77,51 +64,24 @@ type ConfigPullWarningKind = | "would_invalidate"; /** - * One field {@link dropConfigPullUnvalidatableFamilies} found still - * missing/invalid in `pull.handler.ts`'s schema-validation gate — carried on - * a `would_invalidate` warning so its note can name what actually blocked the - * family, not just the family itself. + * One field found still missing/invalid by the schema-validation gate, carried on a + * `would_invalidate` warning so its note can name what actually blocked the family. */ export interface ConfigPullMissingField { readonly path: ReadonlyArray<string>; - /** - * Set when this field's LOCAL (pre-pull) spelling is an unresolved - * `env(VAR)` reference — the exact variable name to surface in the note - * ("set VAR and rerun"). - */ + /** Set when this field's local (pre-pull) spelling is an unresolved `env(VAR)` reference — + * the variable name to surface in the note ("set VAR and rerun"). */ readonly envVariable?: string; } export interface ConfigPullWarning { readonly kind: ConfigPullWarningKind; /** - * Absent for `uncommitted_changes` — a repository-level warning, not a - * per-path one, constructed by `pull.handler.ts`'s own git dirty check - * (`§1.4`). Also absent from anything THIS module produces (the three - * path-bearing kinds below); `unpushable` is likewise constructed by - * `pull.handler.ts`, from its post-plan convergence check (plan §1.9, - * ADR 0021 decision 4) — a planned write that the convergence check finds - * reclassifies as `unmanaged` once applied, because the value just written - * made itself invisible to the projection again. CLI-2314 retired the one - * `DISABLED_SENTINEL_PRUNES`-family prune that could still reclassify a - * value after writing it (`auth.oauth_server`'s old unconditional - * removal) — see ADR 0021's CLI-2314 addendum — but a live trigger - * remains via a different mechanism: `applyDisabledSentinels`'s - * cross-section rule deletes `auth.rate_limit.email_sent` whenever - * `auth.email.smtp.enabled` decodes explicitly `false`, on BOTH arms — the - * API arm only spares it for a genuinely SPARSE response that omits - * `smtp_host` entirely (an ordinary `smtp_host: ""` response still prunes - * it there too). So a sparse remote reporting a real - * `rate_limit_email_sent` while omitting `smtp_host`, pulled into a - * document that never declares `[auth.email.smtp]`, re-masks the - * just-written path on the residual check (`pull.integration.test.ts` - * pins the exact construction). Retained as a structural safety net for - * any other asymmetric/cross-path prune too, not dead code. - * Always carries `path`. `would_invalidate` also carries - * `path` — the nearest enclosing family/provider table - * {@link dropConfigPullUnvalidatableFamilies} dropped every write - * under (e.g. `["auth","sms","twilio"]`), constructed by `pull.handler.ts` - * from its post-plan schema-validation gate. + * Absent only for `uncommitted_changes` (a repository-level warning, constructed by + * `pull.handler.ts`'s git dirty check). Every other kind always carries `path`, including + * `unpushable` (built by `pull.handler.ts`'s post-plan convergence check) — still reachable via + * `applyDisabledSentinels`'s cross-section pruning of `auth.rate_limit.email_sent`, so it is a + * structural safety net, not dead code. */ readonly path?: ReadonlyArray<string>; /** `would_invalidate` only — see {@link ConfigPullMissingField}. */ @@ -132,11 +92,7 @@ export interface ConfigPullPlan { readonly writes: ReadonlyArray<ConfigPullPlannedWrite>; readonly skipped: ReadonlyArray<ConfigPullSkip>; readonly warnings: ReadonlyArray<ConfigPullWarning>; - /** - * `["remotes", label]` when `destination` creates a brand new block, - * `undefined` otherwise — surfaced so a caller composing a message doesn't - * need to re-derive it from `destination` itself. - */ + /** `["remotes", label]` when `destination` creates a brand-new block, `undefined` otherwise. */ readonly createdTable: ReadonlyArray<string> | undefined; } @@ -144,12 +100,9 @@ export interface PlanConfigPullInput { readonly changeSet: ConfigChangeSet; readonly destination: ConfigPullDestination; /** - * The BASE config document — loaded with NO `[remotes.*]` overlay applied, - * regardless of `destination` — used only to detect `duplicates_root`/ - * `array_drift` (comparing a REMOTE-block write against what the config - * ROOT independently declares, which `changeSet`'s own operand cannot: it - * was diffed against whichever document `destination` itself resolved - * from, overlay included). + * The base config document, loaded with no `[remotes.*]` overlay regardless of `destination` — + * used only to detect `duplicates_root`/`array_drift` by comparing a remote-block write + * against what the config root independently declares. */ readonly rootDocument: Readonly<Record<string, unknown>>; /** Carried for parity with `resolveConfigPullDestination`'s own input @@ -175,19 +128,11 @@ function isConfigEditValue(value: unknown): value is ConfigEditValue { } /** - * True when `value` — or any element/leaf inside it — is itself spelled as an - * unresolved `env(VAR)` reference. Guards the REMOTE value's own spelling, - * distinct from `change.envVariables` (which flags the LOCAL declaration): - * the loader interpolates `env(VAR)` against THIS machine's environment on - * every subsequent load (`goViperCompat`'s lenient, unanchored-variable-name - * `ENV_CAPTURE_REGEX`), so writing a remote-controlled `env(...)` string - * verbatim would let the platform smuggle a request to read whatever this - * machine's environment happens to hold at that variable name — `config - * diff` would then render the resolved local secret, and `config push` would - * send it back to the platform (accepted security finding). Recurses into - * arrays and nested objects for defense in depth, even though a diff leaf is - * scalar/array today, never nested. The regex is anchored (`^env\(...\)$`), - * so a substring mention (`"see env(FOO) docs"`) does not match. + * True when `value` — or any element/leaf inside it — is itself spelled as an unresolved + * `env(VAR)` reference. Distinct from `change.envVariables` (which flags the local declaration): + * writing a remote-controlled `env(...)` string verbatim would let the platform read whatever + * this machine's environment holds at that variable name on the next load. The regex is anchored + * (`^env\(...\)$`), so a substring mention doesn't match. */ function containsRemoteEnvReference(value: ConfigEditValue): boolean { if (typeof value === "string") { @@ -228,24 +173,16 @@ function documentPathFor( } /** - * Classifies every comparable `ConfigChange` into a planned write or a skip, - * then derives per-write warnings. Skip precedence (checked in this order): - * a `local_only` change never has a remote value to write; an `env_reference` - * change (the local declared value resolved from `env()`) is NEVER replaced, - * regardless of class, so the user's env-var indirection is never silently - * erased; the `unwritable` case arises for a remote value `applyConfigEdits` - * cannot represent (`undefined`/`null`, or a shape outside `ConfigEditValue`) - * — expected to be rare given the registry's mapped value domains, but never - * assumed impossible; finally, a `remote_env_reference` change — the REMOTE - * value itself (only ever checked once it's already representable) is - * spelled as an unresolved `env(VAR)` reference — is never written either, - * since the loader would interpolate it against the LOCAL environment on the - * next load, turning a remote-controlled string into a local - * secret-exfiltration channel ({@link containsRemoteEnvReference}). + * Classifies every comparable `ConfigChange` into a planned write or a skip, then derives + * per-write warnings. Skip precedence: `local_only` never has a remote value to write; + * `env_reference` (a local value resolved from `env()`) is never replaced, regardless of class, + * so the user's env-var indirection is never silently erased; `unwritable` covers a remote value + * `applyConfigEdits` cannot represent; `remote_env_reference` (the remote value itself is an + * unresolved `env(VAR)` reference) is never written either, since the loader would interpolate it + * against the local environment on the next load ({@link containsRemoteEnvReference}). * - * `masked`/`unmanaged` paths never reach `changeSet.changes` by construction - * (`diffProjectConfig` excludes both before classification), so they never - * need a skip reason here — asserted by construction, not re-checked. + * `masked`/`unmanaged` paths never reach `changeSet.changes` (`diffProjectConfig` excludes both + * before classification), so they never need a skip reason here. */ export function planConfigPull(input: PlanConfigPullInput): ConfigPullPlan { const writes: Array<ConfigPullPlannedWrite> = []; @@ -278,10 +215,8 @@ export function planConfigPull(input: PlanConfigPullInput): ConfigPullPlan { const warnings: Array<ConfigPullWarning> = []; for (const write of writes) { if (input.destination.kind === "root" && isDualScopePath(write.change.path)) { - // Writing a dual-scope path to the config ROOT silently reconfigures - // `supabase start` too — both a replace (declared locally, differing - // from remote) and an insert (undeclared locally) carry this risk: the - // local default IS a legitimate local-dev value in its own right. + // Writing a dual-scope path to the config root also reconfigures `supabase start`; the + // local default is a legitimate local-dev value in its own right. warnings.push({ kind: "dual_scope", path: write.change.path }); continue; } @@ -297,9 +232,8 @@ export function planConfigPull(input: PlanConfigPullInput): ConfigPullPlan { Array.isArray(write.value) && configIsDeclaredAtPath(input.rootDocument, write.change.path) ) { - // Arrays REPLACE wholesale on override, never merge — giving - // `[remotes.*]` its own copy of a path the config root ALSO declares - // means the two copies can silently diverge from this point on. + // Arrays replace wholesale on override, never merge, so giving [remotes.*] its own copy + // of a path the config root also declares lets the two silently diverge. warnings.push({ kind: "array_drift", path: write.change.path }); } } @@ -313,12 +247,9 @@ export function planConfigPull(input: PlanConfigPullInput): ConfigPullPlan { } /** - * Intentionally does NOT also check {@link containsRemoteEnvReference}: this - * predicate only gates what gets PROJECTED onto the fixpoint's own internal - * `config`/`document` simulation below, never what actually reaches disk — - * `planConfigPull`, called once by the caller over the fixpoint's - * merged `changeSet`, is the sole gate for that, and every change observed - * here (including a `remote_env_reference` one) still reaches it via `seen`. + * Doesn't check {@link containsRemoteEnvReference}: this only gates what the fixpoint's internal + * simulation below projects, never what reaches disk — `planConfigPull` is the sole gate for + * that, and every change observed here still reaches it via `seen`. */ function isWritableChange(change: ConfigChange): boolean { return ( @@ -328,18 +259,14 @@ function isWritableChange(change: ConfigChange): boolean { ); } -/** Segment-wise path order, mirroring `@supabase/config`'s own `comparePaths` - * (`config-diff.ts`) — kept local rather than exported from there, since this - * is the only other place in the CLI that needs to re-sort a merged - * `ConfigChange` list. */ +/** Segment-wise path order, mirroring `@supabase/config`'s own `comparePaths`. */ function comparePaths(a: ReadonlyArray<string>, b: ReadonlyArray<string>): number { const length = Math.min(a.length, b.length); for (let index = 0; index < length; index++) { const left = a[index]; const right = b[index]; - // Both are always defined here (`index < length`, `length` the shorter - // array's own bound) — the `undefined` checks satisfy indexed-access - // typing without an `as string` cast, never actually reachable. + // Always defined here (index < length, the shorter array's bound); this check only + // satisfies indexed-access typing without an `as` cast. if (left === undefined || right === undefined) { continue; } @@ -357,21 +284,17 @@ function countsFor(changes: ReadonlyArray<ConfigChange>): ConfigChangeSet["count return { update, remote_only, local_only, total: update + remote_only + local_only }; } -/** Cap on how many rounds {@link expandConfigPullChangeSet} projects a - * round's writes and re-diffs — die-free: hitting the cap just stops - * absorbing further rounds rather than looping forever or throwing; - * `pull.handler.ts`'s schema-validation gate is the actual safety net against - * writing something invalid. 4 rounds comfortably covers every real - * dependency chain this registry has (a toggle gating at most a handful of - * sibling credential fields, none of which themselves gate further fields). */ +/** Cap on how many rounds {@link expandConfigPullChangeSet} projects writes and re-diffs. + * Hitting the cap just stops absorbing further rounds rather than looping or throwing; + * `pull.handler.ts`'s schema-validation gate is the actual safety net. 4 rounds comfortably + * covers every dependency chain this registry has. */ export const CONFIG_PULL_FIXPOINT_ROUND_CAP = 4; export interface ExpandConfigPullChangeSetInput { readonly initialChangeSet: ConfigChangeSet; - /** The BASE `{config, document}` pair `diffProjectConfig` diffed to produce - * `initialChangeSet` (`loaded.config`/`loaded.document ?? {}` — NOT - * destination-prefixed; every round projects a write at its OWN - * `change.path`, the same namespace `diffProjectConfig` classifies in). */ + /** The base `{config, document}` pair `diffProjectConfig` diffed to produce + * `initialChangeSet`; not destination-prefixed, since every round projects a write at its + * own `change.path`. */ readonly baseConfig: EffectiveConfig; readonly baseDocument: Readonly<Record<string, unknown>>; readonly valueOrigins: ReadonlyArray<CliConfigValueOrigin> | undefined; @@ -379,35 +302,23 @@ export interface ExpandConfigPullChangeSetInput { } export interface ConfigPullFixpointResult { - /** Every change ever observed across every round, in path order — a change - * that later converges (its own written value now matches remote) still - * appears here with the local/remote values it carried at DISCOVERY, since - * `pull.handler.ts`'s render/payload must still report it as written. */ + /** Every change observed across every round, in path order. A change that later converges + * still appears here with the values it carried at discovery, since the render/payload must + * report it as written. */ readonly changeSet: ConfigChangeSet; - /** - * The residual `diffProjectConfig` reported after the LAST round that - * projected a write — i.e. the state once every currently-known write has - * been applied. `pull.handler.ts`'s planner-defect/`unpushable` check - * consumes this exactly as it consumed a single round's residual before - * this fixpoint existed. - */ + /** The residual `diffProjectConfig` reported after the last round that projected a write — + * the state once every currently known write is applied. `pull.handler.ts`'s + * planner-defect/`unpushable` check consumes this. */ readonly residual: ConfigChangeSet; } /** - * Plan §1.9's convergence check, generalized from one round to a fixpoint - * (CLI-2064's live-bug fix): after planning, projecting the CURRENT plan's - * writes onto the local `{config, document}` pair and re-diffing against the - * SAME `remote` can surface brand new `update`/`remote_only` changes at paths - * that were `unmanaged` (ADR 0021's disabled-provider gates) before those - * writes landed — e.g. flipping a disabled SMS provider's `enabled` to `true` - * un-gates its credential siblings. Repeats until a round projects nothing new - * to write (or {@link CONFIG_PULL_FIXPOINT_ROUND_CAP} is hit), so a - * newly un-gated sibling gets exactly the SAME skip rules as any other change - * (`planConfigPull`, called once by the caller over this function's - * merged `changeSet` — never per round: warnings/skips must be derived from - * the union, not accumulated round-by-round, so a change appears exactly - * once). + * Projects the current plan's writes onto the local `{config, document}` pair and re-diffs + * against the same remote — this can surface new `update`/`remote_only` changes at paths that + * were `unmanaged` before those writes landed (e.g. enabling a disabled SMS provider un-gates its + * credential siblings). Repeats until a round projects nothing new (or + * {@link CONFIG_PULL_FIXPOINT_ROUND_CAP} is hit). `planConfigPull` is called once, over the + * merged `changeSet`, so a change appears exactly once rather than accumulating per round. */ export function expandConfigPullChangeSet( input: ExpandConfigPullChangeSetInput, @@ -460,17 +371,11 @@ export function expandConfigPullChangeSet( } /** - * The nearest enclosing "family/provider table" of a failing config path — - * `pull.handler.ts`'s schema-validation gate's own idea of the unit to drop - * together (plan of record: "drop every planned write under the nearest - * enclosing provider/family table"). Walks upward from `path`'s immediate - * parent looking for the deepest ancestor whose value (read off `document`, - * the same shape the failing decode saw) is a record declaring an `enabled` - * key — every gated family this registry has (SMS providers, SMTP, external - * providers, hooks, oauth_server, …) is exactly such a container. Falls back - * to `path`'s immediate parent when no ancestor matches (or `path` itself - * when it has no parent), so a family this heuristic doesn't recognize still - * drops at least the failing field's own container rather than nothing. + * The nearest enclosing "family/provider table" of a failing config path — the unit + * `pull.handler.ts`'s schema-validation gate drops together. Walks upward from `path`'s parent + * for the deepest ancestor that is a record declaring an `enabled` key (every gated family this + * registry has is exactly such a container), falling back to the immediate parent (or `path` + * itself) when nothing matches. */ export function configPullFamilyRootForPath( path: ReadonlyArray<string>, @@ -487,10 +392,9 @@ export function configPullFamilyRootForPath( } /** - * When the ORIGINAL (pre-pull) value at `path` is spelled as an unresolved - * `env(VAR)` literal, returns `VAR` — feeds the `would_invalidate` note's - * "set VAR and rerun" remediation. `document` must be in the same (raw, - * pre-write) namespace as `path`. + * When the original (pre-pull) value at `path` is spelled as an unresolved `env(VAR)` literal, + * returns `VAR` — feeds the `would_invalidate` note's "set VAR and rerun" remediation. + * `document` must be in the same (raw, pre-write) namespace as `path`. */ export function configPullEnvVariableAtPath( path: ReadonlyArray<string>, @@ -508,32 +412,21 @@ export interface ConfigPullWouldInvalidateFamily { readonly missingFields: ReadonlyArray<ConfigPullMissingField>; } -/** Whether `path` falls at or under `root` — the "belongs to this family" - * test shared by the write-drop and warning-drop passes below. Both sides of - * the comparison live in the SAME `ConfigChange.path` namespace (never - * `remotes.<label>`-prefixed, regardless of destination — see - * `pull.handler.ts`'s own family-root computation), so no destination-aware - * prefixing is needed here either. */ +/** Whether `path` falls at or under `root` — shared by the write-drop and warning-drop passes + * below. Both sides live in the same `ConfigChange.path` namespace (never + * `remotes.<label>`-prefixed), so no destination-aware prefixing is needed here. */ function isUnderFamilyRoot(root: ReadonlyArray<string>, path: ReadonlyArray<string>): boolean { return root.length <= path.length && root.every((segment, index) => segment === path[index]); } /** - * `pull.handler.ts`'s schema-validation gate's write-side counterpart: given - * the families it found still missing/invalid after projecting `plan`'s - * writes, moves every write whose path falls under one of those families' - * roots from `writes` to `skipped` (reason `would_invalidate`), drops every - * OTHER path-scoped warning (`dual_scope`/`duplicates_root`/`array_drift` - * from this module, `unpushable` from `pull.handler.ts`'s own convergence - * check) that falls under the same root — a warning surviving for a write - * that was just moved back to `skipped` would describe something that was - * never actually written (`unpushable`'s own wording literally says "was - * written here") — and only THEN appends one `would_invalidate` warning per - * family that actually had a write to drop (a family the caller flagged but - * which planned no write for — nothing left to reduce — contributes no - * warning; the caller treats that as "could not make progress" and stops - * retrying instead of looping on a no-op drop). Pure: the caller owns - * re-validating the reduced plan. + * The write-side counterpart to `pull.handler.ts`'s schema-validation gate: given the families + * still missing/invalid after projecting `plan`'s writes, moves every write under one of those + * roots from `writes` to `skipped` (`would_invalidate`), drops every other path-scoped warning + * under the same root (a warning describing a write that was just skipped would be wrong), and + * appends one `would_invalidate` warning per family that actually had a write to drop — a family + * with nothing to reduce contributes no warning, signaling the caller to stop retrying. Pure: the + * caller owns re-validating the reduced plan. */ export function dropConfigPullUnvalidatableFamilies( plan: ConfigPullPlan, diff --git a/apps/cli/src/commands/config/pull/pull.plan.unit.test.ts b/apps/cli/src/commands/config/pull/pull.plan.unit.test.ts index ea09575ec3..88930458b0 100644 --- a/apps/cli/src/commands/config/pull/pull.plan.unit.test.ts +++ b/apps/cli/src/commands/config/pull/pull.plan.unit.test.ts @@ -431,11 +431,9 @@ describe("expandConfigPullChangeSet", () => { }); test("absorbs a sibling ADR 0021 gates as unmanaged, once the gate itself is projected (CLI-2064 live-bug repro)", () => { - // Mirrors the live dogfooding bug exactly: `[auth.sms.twilio]` disabled - // locally with empty credential placeholders, remote has it enabled with - // real credentials. Round 1 only sees `enabled` (the sids are gated out - // as unmanaged pre-write); the fixpoint's round 2 must absorb both sids - // once projecting `enabled: true` un-gates them. + // [auth.sms.twilio] is disabled locally with empty placeholders; remote has it enabled with + // real credentials. Round 1 only sees `enabled` (the sids are unmanaged pre-write); round 2 + // must absorb both once `enabled: true` un-gates them. const baseConfig = { auth: { sms: { twilio: { enabled: false, account_sid: "", message_service_sid: "" } } }, } as unknown as EffectiveConfig; @@ -451,8 +449,6 @@ describe("expandConfigPullChangeSet", () => { local: { config: baseConfig, document: baseDocument }, remote, }); - // Sanity on the PRE-fixpoint diff — this is the bug: only `enabled` is a - // change, the sids are excluded as unmanaged. expect(initialChangeSet.changes.map((c) => c.path)).toEqual([ ["auth", "sms", "twilio", "enabled"], ]); @@ -479,16 +475,13 @@ describe("expandConfigPullChangeSet", () => { "auth.sms.twilio.message_service_sid", ]), ); - // The fixpoint's own residual (what the caller's planner-defect/unpushable - // check consumes) shows NOTHING left drifting — every absorbed write - // actually converges once applied. + // The residual (what the planner-defect/unpushable check consumes) shows nothing left + // drifting: every absorbed write converges once applied. expect(result.residual.changes).toEqual([]); expect(result.residual.unmanaged).toEqual([]); }); test("generalizes to a second, independent registry family (`auth.captcha`)", () => { - // A different `DISABLED_SENTINEL_PRUNES` family — confirms the fixpoint - // isn't special-cased to SMS providers. const baseConfig = { auth: { captcha: { enabled: false, provider: "hcaptcha" } }, } as unknown as EffectiveConfig; @@ -565,9 +558,6 @@ describe("expandConfigPullChangeSet", () => { (c) => c.path.join(".") === "auth.sms.twilio.account_sid", ); expect(accountSidChange?.envVariables).toEqual(["MY_VAR"]); - // Never projected (it's env-sourced, so `pull.plan.ts`'s own skip rule - // would never write it either) — it stays a residual, expected, not the - // "planner defect" the caller's own check would otherwise raise. expect(result.residual.changes.map((c) => c.path.join("."))).toEqual([ "auth.sms.twilio.account_sid", ]); @@ -689,11 +679,9 @@ describe("dropConfigPullUnvalidatableFamilies", () => { ]); expect(result.writes).toEqual([unrelatedWrite]); - // The dropped family's own `dual_scope`/`unpushable` warnings are gone — - // both described a write that no longer landed. The unrelated - // `duplicates_root` warning (a DIFFERENT path) and the path-less - // `uncommitted_changes` warning both survive untouched, and the new - // `would_invalidate` warning is appended last. + // The dropped family's dual_scope/unpushable warnings are gone; the unrelated + // duplicates_root warning (a different path) and the path-less uncommitted_changes warning + // survive untouched, with the new would_invalidate warning appended last. expect(result.warnings).toEqual([ { kind: "duplicates_root", path: ["api", "max_rows"] }, { kind: "uncommitted_changes" }, diff --git a/apps/cli/src/commands/config/pull/pull.scope.ts b/apps/cli/src/commands/config/pull/pull.scope.ts index 7c1414ef84..9f2ac47bc0 100644 --- a/apps/cli/src/commands/config/pull/pull.scope.ts +++ b/apps/cli/src/commands/config/pull/pull.scope.ts @@ -7,36 +7,10 @@ import { import { sanitizeInlineName } from "../../../command-internal/http-errors.ts"; /** - * `config pull`'s scope resolution: WHERE a pulled value gets written — - * `[remotes.<label>]` when the target project ref is already tracked by an - * existing block (or the target was named as a branch, or `--remote-label` - * forces one), the config ROOT otherwise. Pure and synchronous (CLI-2064) — - * no Effect, no services, matching `command-internal/project-target.ts`'s target - * resolution it composes with. - * - * Precedence, evaluated in this order: - * 1. `--remote-label` forces a destination — even for a ref-shaped target — - * and is resolved FIRST, before the env()-hard-error check below (a fresh - * `--remote-label` must never be refused merely because some UNRELATED - * block's `project_id` happens to resolve to the target ref). - * 2. Failing that, block reuse — `config diff`'s own reuse rule (ADR 0018) — - * applies regardless of how the target was named: `remoteNameForProjectRef` - * (`@supabase/config/internal`) against the RAW, pre-`env()`-interpolation - * literal (`LoadedCliConfig.rawDocument`), never the resolved value - * (`LoadedCliConfig.interpolatedRemotes`). See that function's own doc - * comment for why an `env(REF)`-spelled `project_id` that happens to - * RESOLVE to the target ref must never match here (Decision 1: hard - * error, never reused, never rewritten). - * 3. Failing that, an `env(...)`-resolving match ANYWHERE is the hard error - * (Decision 1) — checked only when no `--remote-label` was given (rule 1 - * already settled the destination for that case). - * 4. Failing that, a branch-named target creates/reuses `[remotes.<label>]`, - * applying the SAME named-label rule `--remote-label` uses (step 1) to its - * own derived label: a branch name that happens to already name an - * existing block tracking a DIFFERENT project is a collision too, not a - * silent overwrite of that block's `project_id` (this is what stranded - * stale overrides before this rule existed). - * 5. Otherwise: the config root. + * `config pull`'s destination resolution: where a pulled value gets written — `[remotes.<label>]` + * when the target ref is already tracked by an existing block, was named as a branch, or + * `--remote-label` forces one; the config root otherwise. See `resolveConfigPullDestination` for + * the exact precedence. */ interface ConfigPullDestinationRoot { @@ -46,11 +20,8 @@ interface ConfigPullDestinationRoot { interface ConfigPullDestinationRemote { readonly kind: "remote"; /** - * The `[remotes.<label>]` block's name — sanitized (control-char - * stripped, `sanitizeRemoteLabel`) when this destination CREATES the - * block; verbatim, matching the file's own declared name exactly, when - * REUSING an existing one (an already-written label is never rewritten by - * this resolver). + * The `[remotes.<label>]` block's name — sanitized when this destination creates the block; + * verbatim, matching the file's declared name, when reusing an existing one. */ readonly label: string; /** `true` when no existing block's `project_id` matched the target ref, so @@ -66,48 +37,26 @@ interface ConfigPullScopeOk { } /** - * The label that would govern this pull — either `--remote-label` or a - * branch-derived name — conflicts with an existing `[remotes.*]` block. - * `label` is the FINAL SANITIZED label that triggered the check (matching - * `sanitizeRemoteLabel(rawRequestedOrDerivedLabel)`, never the raw, - * unsanitized flag/branch value — a hostile value that only LOOKS distinct - * from an existing block's name before sanitization must still be caught). - * Two distinct situations both surface here (kept as one variant — the - * plan of record treats them as the same user-facing remediation shape, - * just worded differently by the caller depending on which applies): - * - The label names an EXISTING `[remotes.<label>]` block whose own - * `project_id` differs from the target ref: `conflictingBlock` equals - * `label` itself, and `conflictingProjectId` is that block's own RAW - * `project_id` literal — writing here would silently start tracking the - * wrong project's remote (or, for a branch-derived label, would REPLACE - * that unrelated block's `project_id`, stranding its own overrides). - * - The label names NOTHING existing, but a DIFFERENT block already - * matches the target ref (block reuse's own primary signal — only - * reachable via an explicit `--remote-label`, since a branch-derived - * label only ever reaches this function once block reuse has already - * come up empty): `conflictingBlock` is that OTHER block's own name, and - * `conflictingProjectId` is always the target ref itself, since that is - * exactly why it matched — signaling "this ref is already tracked - * elsewhere; drop --remote-label to reuse that block, or rename it". + * The label that would govern this pull conflicts with an existing `[remotes.*]` block — either + * that exact label already tracks a different project, or (only possible via `--remote-label`) a + * different existing block already tracks the target ref. `conflictingBlock` names the block that + * actually conflicts; `conflictingProjectId` is its `project_id`. */ export interface ConfigPullScopeLabelCollision { readonly ok: false; readonly reason: "label_collision"; readonly label: string; readonly conflictingProjectId: string; - /** The name of the block that actually conflicts — `label` itself for the - * same-name case, a DIFFERENT existing block's name for the - * ref-tracked-elsewhere case. */ + /** The name of the block that actually conflicts — `label` itself for the same-name case, a + * different existing block's name for the ref-tracked-elsewhere case. */ readonly conflictingBlock: string; } /** - * No `[remotes.*]` block's RAW `project_id` literal matches the target ref, - * but one block's `env(...)`-spelled `project_id` RESOLVES to it (plan of - * record, Decision 1: env()-spelled matches are a hard error — never reused, - * never rewritten. Reusing would silently start tracking a match the loader - * itself would never select for a read; rewriting the block's `project_id` - * would erase the user's env-var indirection). + * No `[remotes.*]` block's raw `project_id` literal matches the target ref, but one block's + * `env(...)`-spelled `project_id` resolves to it — a hard error, never reused or rewritten: + * reusing would track a match the loader itself would never select, and rewriting would erase + * the user's env-var indirection. */ interface ConfigPullScopeEnvProjectId { readonly ok: false; @@ -130,38 +79,25 @@ export interface ResolveConfigPullDestinationInput { readonly interpolatedRemotes: unknown; readonly projectRef: string; /** - * The branch NAME the target was resolved from, when `--project-ref` named - * one AND spelled it as a name rather than a UUID — `undefined` for a - * ref-shaped target, a linked-fallback target, AND a UUID branch target (a - * UUID is never a good label; see `targetWasBranch`). + * The branch name the target was resolved from, when `--project-ref` named one by name rather + * than UUID; `undefined` for a ref-shaped, linked-fallback, or UUID branch target (a UUID is + * never a good label). */ readonly branchLabelCandidate: string | undefined; /** - * Whether `--project-ref` named a branch at all (by name OR by UUID) — - * distinct from `branchLabelCandidate` being set: a UUID branch target - * still creates a `[remotes.*]` block, just falling back to the resolved - * project ref itself as the label instead of a name-derived one. + * Whether `--project-ref` named a branch at all, by name or UUID — distinct from + * `branchLabelCandidate`: a UUID branch target still creates a `[remotes.*]` block, falling + * back to the resolved ref as its label. */ readonly targetWasBranch: boolean; readonly requestedLabel: string | undefined; } /** - * Control-character strip for a label about to become both a persisted - * `[remotes.<label>]` document-path segment and inline text/JSON output — - * mirrors `sanitizeInlineName`'s hostile-string defense (PR #6168) so - * the label is clean at the point it is WRITTEN, not merely at the point - * it's later displayed (every future read of the file already gets a clean - * value, without relying on each consumer to re-sanitize it). QUOTING the - * label for TOML — bare-if-safe, basic-quoted otherwise — is - * `applyConfigEdits`'s job (`renderKeySegment`), not this function's. - * - * Every candidate label (`--remote-label`, branch-derived) is sanitized - * BEFORE it is compared against existing block names, not merely before it - * is written on the create path: comparing the raw, unsanitized value would - * let `--remote-label $'stag\x01ing'` (which sanitizes to `staging`) slip - * past an existing `[remotes.staging]` block tracking a different project - * entirely, instead of colliding with it. + * Strips control characters from a label before it becomes a `[remotes.<label>]` document-path + * segment or inline output. Quoting for TOML is `applyConfigEdits`'s job (`renderKeySegment`), + * not this function's. Every candidate label is sanitized before comparison too, so a value like + * `stag\x01ing` (which sanitizes to `staging`) can't evade colliding with an existing block. */ export function sanitizeRemoteLabel(label: string): string { return sanitizeInlineName(label); @@ -175,17 +111,9 @@ function extractEnvVariables(rawProjectId: string | undefined): ReadonlyArray<st return match?.[1] === undefined ? [] : [match[1]]; } -/** - * The one rule applied to a FINAL, SANITIZED label — whether it came from - * `--remote-label` or was derived from a branch name — that both - * `resolveConfigPullDestination`'s rule 1 and rule 4 delegate to: - * reuse when an existing block by that name already tracks the target ref, - * refuse when it merely resolves there via `env(...)`, collide when it - * tracks something else, and otherwise either collide (a DIFFERENT block - * already tracks the ref — only possible for the `--remote-label` caller, - * since `matchedByRef` is always `undefined` by the time the branch-derived - * caller reaches this function) or create a fresh block. - */ +// Applied to a final, sanitized label from either `--remote-label` or a branch name: reuse a +// block by that name that already tracks the target ref, refuse if it only resolves there via +// `env(...)`, or collide with whichever block actually conflicts — otherwise create fresh. function resolveNamedLabelDestination(input: { readonly finalLabel: string; readonly rawRemotes: unknown; @@ -242,10 +170,8 @@ export function resolveConfigPullDestination( ): ConfigPullScopeResult { const matchedByRef = remoteNameForProjectRef(input.rawRemotes, input.projectRef); - // 1. `--remote-label` forces a destination, resolved FIRST — even for a - // ref-shaped target — so its own remedy ("pass --remote-label") is never - // dead: an env()-spelled match ELSEWHERE in the file must never refuse a - // fresh, explicitly-requested block. + // --remote-label forces a destination, resolved first (even for a ref-shaped target), so an + // env()-spelled match elsewhere in the file never refuses a fresh, explicitly-requested block. if (input.requestedLabel !== undefined) { return resolveNamedLabelDestination({ finalLabel: sanitizeRemoteLabel(input.requestedLabel), @@ -256,15 +182,13 @@ export function resolveConfigPullDestination( }); } - // 2. Block reuse — the primary signal, regardless of how the target was - // named (mirrors `config diff`'s own reuse rule). + // Block reuse is the primary signal, regardless of how the target was named. if (matchedByRef !== undefined) { return { ok: true, destination: { kind: "remote", label: matchedByRef, created: false } }; } - // 3. env()-spelled match anywhere — hard error, never reused (Decision 1). - // Only reached when no `--remote-label` was given (step 1 already settled - // the destination for that case). + // An env()-spelled match anywhere is a hard error, never reused; only reached when no + // --remote-label was given. const envMatch = remoteProjectIdEntries(input.interpolatedRemotes).find( (entry) => entry.projectId === input.projectRef, ); @@ -280,11 +204,8 @@ export function resolveConfigPullDestination( }; } - // 4. Target was named as a branch (name or UUID) — apply the SAME - // named-label rule to its own derived label. `matchedByRef` is always - // `undefined` here (step 2 already returned otherwise), so this can only - // ever collide on a same-NAME, different-project block, never on "a - // different block already tracks the ref" (step 2 covers that case). + // A branch-named target applies the same named-label rule to its derived label; matchedByRef + // is always undefined here, so this can only collide on a same-name, different-project block. if (input.targetWasBranch) { const label = input.branchLabelCandidate ?? input.projectRef; return resolveNamedLabelDestination({ @@ -296,7 +217,6 @@ export function resolveConfigPullDestination( }); } - // 5. Otherwise: a ref-shaped `--project-ref` or linked-fallback target — - // write to the config root. + // Otherwise: a ref-shaped --project-ref or linked-fallback target writes to the config root. return { ok: true, destination: { kind: "root" } }; } diff --git a/apps/cli/src/commands/config/pull/pull.scope.unit.test.ts b/apps/cli/src/commands/config/pull/pull.scope.unit.test.ts index 7caba79946..1c080730c2 100644 --- a/apps/cli/src/commands/config/pull/pull.scope.unit.test.ts +++ b/apps/cli/src/commands/config/pull/pull.scope.unit.test.ts @@ -21,9 +21,6 @@ describe("resolveConfigPullDestination", () => { }); test("reuses the matched block regardless of how the target was named", () => { - // A branch-named target whose ref happens to already be tracked by an - // existing block reuses that block — never creates a second one labeled - // after the branch name. const rawRemotes = { staging: { project_id: TARGET_REF } }; expect( resolveConfigPullDestination({ @@ -162,10 +159,6 @@ describe("resolveConfigPullDestination", () => { }); test("a branch-derived label naming an existing block for a different project is a collision (CLI-2064 item A)", () => { - // Before this rule existed, a branch named like an EXISTING block (here, - // a branch called "staging" landing on an unrelated `[remotes.staging]`) - // returned `created: true` and the handler REPLACED that block's own - // `project_id`, stranding its stale overrides. const rawRemotes = { staging: { project_id: OTHER_REF } }; expect( resolveConfigPullDestination({ @@ -186,11 +179,8 @@ describe("resolveConfigPullDestination", () => { }); test("a --remote-label collision is caught even when the raw flag value differs from the block's name only by control characters", () => { - // The collision check compares the FINAL SANITIZED label against - // existing block names, not the raw flag value — otherwise - // `--remote-label $'stag\x01ing'` (sanitizing to "staging") would slip - // past an existing `[remotes.staging]` block tracking a different - // project instead of colliding with it. + // The collision check compares the final sanitized label against existing block names, not + // the raw flag value, so a hostile value can't slip past detection. const rawRemotes = { staging: { project_id: OTHER_REF } }; const hostileLabel = `stag${String.fromCharCode(1)}ing`; expect( @@ -212,9 +202,8 @@ describe("resolveConfigPullDestination", () => { }); test("--remote-label naming the same block as an env-spelled match is still a hard error", () => { - // The unified named-label rule's own env sub-case, exercised through - // `--remote-label` rather than the general env scan (the general scan - // only runs when no `--remote-label` was given). + // Exercises the named-label rule's env sub-case through --remote-label, rather than the + // general env scan (which only runs without --remote-label). const rawRemotes = { staging: { project_id: "env(SUPABASE_STAGING_REF)" } }; const interpolatedRemotes = { staging: { project_id: TARGET_REF } }; expect( @@ -255,12 +244,10 @@ describe("resolveConfigPullDestination", () => { }); test("--remote-label alongside an unrelated env-spelled match creates/uses the requested block instead of refusing", () => { - // CLI-2064 item B: `--remote-label` is honored ABOVE the env_project_id - // refusal — otherwise the refusal's own remedy ("pass --remote-label") - // would be dead. `custom` names nothing existing, and no OTHER block's - // RAW literal tracks the target ref, so this creates a fresh block; the - // env()-spelled "staging" block (which never applied to this project - // anyway) is left untouched. + // --remote-label is honored above the env_project_id refusal, or the refusal's own remedy + // ("pass --remote-label") would be dead. `custom` names nothing existing and no other + // block's raw literal tracks the ref, so this creates a fresh block, leaving the + // env()-spelled "staging" block untouched. const rawRemotes = { staging: { project_id: "env(SUPABASE_STAGING_REF)" } }; const interpolatedRemotes = { staging: { project_id: TARGET_REF } }; expect( diff --git a/apps/cli/src/commands/config/push/push.auth-email-content.ts b/apps/cli/src/commands/config/push/push.auth-email-content.ts index 42ee766044..0c33d49e08 100644 --- a/apps/cli/src/commands/config/push/push.auth-email-content.ts +++ b/apps/cli/src/commands/config/push/push.auth-email-content.ts @@ -1,13 +1,9 @@ /** - * `config push` reads HTML from `content_path` before building the auth push - * body. Both templates and notifications resolve relative paths from the - * project root (parent of `supabase/`); notifications additionally fall back - * to the legacy `supabase/`-relative location when the root-resolved file is - * missing, so configs written for older scaffolds keep working. Containment - * — confining the resolved path to the project root before it is read, since - * the loaded bytes are uploaded to whichever project the config names — is - * enforced centrally by `resolveEmailTemplateContentPath` in - * `config-validate.ts`, not locally in this module. + * `config push` reads HTML from `content_path` before building the auth push body. Templates + * and notifications resolve relative paths from the project root (parent of `supabase/`); + * notifications additionally fall back to the legacy `supabase/`-relative location when the + * root-resolved file is missing. Containment is enforced centrally by + * `resolveEmailTemplateContentPath` in `config-validate.ts`, not locally here. */ import type { CliConfig } from "@supabase/config"; diff --git a/apps/cli/src/commands/config/push/push.auth-email-content.unit.test.ts b/apps/cli/src/commands/config/push/push.auth-email-content.unit.test.ts index 92786ea13b..b18f45cac4 100644 --- a/apps/cli/src/commands/config/push/push.auth-email-content.unit.test.ts +++ b/apps/cli/src/commands/config/push/push.auth-email-content.unit.test.ts @@ -1,7 +1,3 @@ -/** - * Unit tests for push.auth-email-content.ts. - */ - import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { join, relative } from "node:path"; import { tmpdir } from "node:os"; @@ -10,10 +6,9 @@ import { afterEach, describe, expect, it } from "vitest"; import { loadAuthEmailContent } from "./push.auth-email-content.ts"; /** - * Builds the exact anchored containment-rejection regex for a given declared `content_path` — - * the thrown message echoes that DECLARED value (quoted) between the field name and "resolves - * outside the project root", not the fully-canonicalized target (a deliberate recon-leak - * mitigation — see `resolveEmailTemplateContentPath`'s own doc comment). + * Builds the anchored containment-rejection regex for a given declared `content_path` — the + * thrown message echoes that declared value (quoted), not the fully-canonicalized target, + * avoiding a recon leak. */ function containmentRejectionPattern(fieldPath: string, declaredContentPath: string): RegExp { const escaped = declaredContentPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); @@ -211,19 +206,15 @@ describe("loadAuthEmailContent", () => { expect(thrown).toBeInstanceOf(Error); const message = (thrown as Error).message; // A genuinely missing in-root file must surface the normal read-failure message, never the - // containment message — locks in that the symlinked-ancestor containment fix (see the - // dedicated symlink test below) doesn't regress into over-rejecting a legitimate missing - // file as "outside the project root". + // containment message, guarding against over-rejecting a missing file as "outside the + // project root". expect(message).not.toMatch(/resolves outside the project root/); expect(message).toMatch(/^Invalid config for auth\.email\.template\.invite\.content_path:/); }); it("does not raise the containment error for a template file missing behind a symlinked project root", () => { - // The project root itself is reached through a symlink (mirroring macOS's `/tmp` -> - // `/private/tmp`), and the configured template file doesn't exist. Before the CLI-2339 fix - // to `canonicalPathForContainment`, comparing a realpath'd root against a lexically-resolved - // (symlink-unaware) candidate would have misreported this as escaping the project root - // instead of a plain missing file. + // The project root itself is reached through a symlink (mirroring macOS's /tmp -> + // /private/tmp), and the configured template file doesn't exist. const realDir = mkdtempSync(join(tmpdir(), "auth-email-content-real-")); const linkContainer = mkdtempSync(join(tmpdir(), "auth-email-content-link-")); const symlinkedRoot = join(linkContainer, "project-root"); diff --git a/apps/cli/src/commands/config/push/push.branch-target.ts b/apps/cli/src/commands/config/push/push.branch-target.ts index 3dbd7a9cb3..3cc428de05 100644 --- a/apps/cli/src/commands/config/push/push.branch-target.ts +++ b/apps/cli/src/commands/config/push/push.branch-target.ts @@ -13,24 +13,17 @@ import { readProjectRefFile, tempPaths } from "../../../command-internal/temp-pa import { Output } from "../../../shared/output/output.service.ts"; /** - * What `ref` actually is, resolved for `config push`'s target-echo + branch - * gate (CLI-2168): + * What `ref` actually is, resolved for `config push`'s target-echo and branch confirmation gate: * - * - `"project"` — `ref` is the linked project itself. `name` is present - * when the live probe below found it. - * - `"branch"` — `ref` is CONFIRMED to be a preview branch (an explicit - * `--project-ref <name-or-uuid>`, or a live 404). `parentRef`/ - * `parentName`/`branch` are each present only when they could actually - * be determined — "confirmed branch, nothing else known" is exactly this - * shape with all three absent. - * - `"unknown"` — the live probe couldn't tell (a timeout, a transport - * failure, or a non-200/404 status, e.g. a scoped token that can write - * service config but can't read the project record). Never asserted as - * a branch: an uncertain outcome must not gate a plain, everyday push - * behind a confirmation that then auto-declines (and FAILS — see - * `push.handler.ts`) an unattended run over nothing more than network - * jitter. Never asserted as a project either — the target-echo line - * says plainly that it doesn't know. + * - `"project"` — `ref` is the linked project itself; `name` is present when the live probe + * found it. + * - `"branch"` — `ref` is confirmed to be a preview branch (an explicit `--project-ref + * <name-or-uuid>`, or a live 404); `parentRef`/`parentName`/`branch` are each present only + * when they could be determined. + * - `"unknown"` — the live probe couldn't tell (a timeout, a transport failure, or an + * unexpected status). Never asserted as a branch, since an uncertain outcome must not gate an + * unattended push behind a confirmation that then auto-declines it; never asserted as a + * project either — the target-echo line says plainly that it doesn't know. */ export type ConfigPushTarget = | { readonly kind: "project"; readonly ref: string; readonly name?: string } @@ -44,20 +37,15 @@ export type ConfigPushTarget = | { readonly kind: "unknown"; readonly ref: string }; /** - * A branch name/UUID the caller already resolved `ref` from (CLI-2289's - * `--project-ref <name-or-uuid>` path) — this makes the caller's knowledge - * that `ref` is a branch DEFINITIVE, so {@link resolveConfigPushTarget} - * never runs the live `getProject` probe for it: + * A branch name/UUID the caller already resolved `ref` from — this makes the caller's knowledge + * that `ref` is a branch definitive, so {@link resolveConfigPushTarget} never runs the live + * `getProject` probe for it: * - * - `"name"` — both fields were resolved eagerly, nothing more to recover. - * - `"uuid"` — certain to be a branch, but a UUID carries no display name - * and never forces its parent (`GET /v1/branches/{id}` alone resolves - * it), so it runs the SAME best-effort cache recovery a live 404 does, - * to fill in whatever it can. A discriminated union rather than an - * all-optional shape: the only two states a real caller ever produces - * are "both known" and "neither known" — a partial state (a parent - * without a name, or vice versa) is not a shape this resolver needs to - * handle, so it doesn't exist to be handled incorrectly. + * - `"name"` — both fields were resolved eagerly, nothing more to recover. + * - `"uuid"` — certain to be a branch, but a UUID carries no display name and never forces its + * parent, so it runs the same best-effort cache recovery a live 404 does. A discriminated + * union rather than an all-optional shape, since a partial state (a parent without a name, or + * vice versa) never occurs in practice. */ export type ConfigPushKnownBranch = | { readonly kind: "name"; readonly branchName: string; readonly parentRef: string } @@ -80,41 +68,15 @@ function isNotFound(cause: unknown): boolean { } /** - * Resolves what `ref` actually is, so `config push` can tell the user - * whether they're pushing to the linked project or one of its branches - * (CLI-2168), and so a branch push can be gated behind confirmation. NEVER - * FAILS — matching `findBranchName`'s own best-effort contract, this - * probe is diagnostic-only and must never abort a push that would otherwise - * succeed. + * Resolves what `ref` actually is, so `config push` can tell the user whether they're pushing to + * the linked project or a branch, and gate a branch push behind confirmation. Never fails: this + * probe is diagnostic-only and must never abort a push that would otherwise succeed. * - * - `opts.knownBranch?.kind === "name"`: no probe, no recovery — just - * enrich the parent's NAME from `.temp/linked-project.json` when its - * `ref` matches `parentRef`. - * - `opts.knownBranch?.kind === "uuid"`, or a live 404: `ref` is - * CONFIRMED a branch; run the shared best-effort recovery below. - * - Otherwise (`opts.knownBranch` absent): `GET /v1/projects/{ref}`, - * bounded at {@link BRANCH_LOOKUP_TIMEOUT} and wrapped in a - * `"Checking project..."` task. A 200 is a plain project. A TIMEOUT, a - * transport failure, or any status other than 200/404 is `"unknown"` — - * the task is marked failed (this diagnostic step genuinely didn't - * complete), but the push itself is never blocked by it. A 404 confirms - * a branch and falls into the same recovery as the `"uuid"` case. - * - * Shared best-effort recovery (a confirmed branch whose name/parent aren't - * fully known yet): - * 1. Read + parse `.temp/linked-project.json` — `cached`. No - * ref-pattern-valid, non-self-referential `cached.ref` → the bare - * `{ kind: "branch", ref }` shape, no further filesystem or API calls. - * 2. Otherwise, read `.temp/project-ref` — `fileRef` — and best-effort - * look up the branch's own name among the candidate parent's branches - * ({@link findBranchName}). - * 3. The branch-list lookup positively confirming the parent, OR `ref` - * being literally what `.temp/project-ref` currently holds — - * inheriting that file's own link-completed invariant (the same - * reasoning `linked-state.ts` documents for its own analogous - * case, restated here for a caller-supplied ref instead of a - * self-resolved one) — is what lets the parent claim stand. Neither → - * the bare `{ kind: "branch", ref }` shape, no parent claim at all. + * A known branch name/UUID skips the live probe. Otherwise `GET /v1/projects/{ref}` (bounded at + * {@link BRANCH_LOOKUP_TIMEOUT}) returns a project on 200, `"unknown"` on any other failure + * (never blocking the push), or falls into best-effort recovery on a 404: + * `.temp/linked-project.json` and `.temp/project-ref` supply the parent ref and branch name when + * they can be trusted, and a bare `{ kind: "branch", ref }` otherwise. */ export function resolveConfigPushTarget( ref: string, @@ -134,9 +96,8 @@ export function resolveConfigPushTarget( Effect.orElseSucceed(() => Option.none<CachedLinkedProject>()), ); - // Cheap path (a NAME target, CLI-2289): both fields already known — no - // probe, no best-effort recovery, just enrich the parent's NAME from - // cache when it happens to match. + // A name target: both fields already known, so just enrich the parent's name from cache + // when it happens to match. if (opts.knownBranch?.kind === "name") { const { branchName, parentRef } = opts.knownBranch; const cached = yield* readCachedParent; @@ -151,10 +112,9 @@ export function resolveConfigPushTarget( }; } - // `opts.knownBranch?.kind === "uuid"` means the caller already knows FOR - // CERTAIN `ref` is a branch: skip the live probe entirely. Otherwise, - // run the probe — its only two "certain" outcomes are 200 (return - // immediately) and 404 (fall through to the shared recovery below). + // A uuid known-branch means ref is certainly a branch, so skip the live probe. Otherwise + // the probe's only certain outcomes are 200 (return immediately) and 404 (fall through to + // shared recovery). if (opts.knownBranch === undefined) { const api = yield* CommandPlatformApi; const output = yield* Output; @@ -183,10 +143,8 @@ export function resolveConfigPushTarget( orElse: () => Effect.succeed<ProbeOutcome>({ kind: "unknown" }), }, ); - // A definitive answer (200 or 404) clears the task normally; an - // uncertain one (timeout, transport failure, or an unexpected status) - // marks it failed — the diagnostic step genuinely didn't complete, - // even though the push itself proceeds regardless. + // A definitive answer (200 or 404) clears the task; an uncertain one marks it failed + // since the diagnostic step didn't complete, though the push proceeds regardless. yield* (probe.kind === "unknown" ? probing?.fail() : probing?.clear()) ?? Effect.void; if (probe.kind === "project") { @@ -195,16 +153,13 @@ export function resolveConfigPushTarget( if (probe.kind === "unknown") { return { kind: "unknown", ref }; } - // probe.kind === "branch" (404) — fall into the shared recovery - // below, same as a CONFIRMED-but-incomplete `knownBranch` (a UUID - // target). + // probe.kind === "branch" (404) falls into the shared recovery below, same as an + // incomplete uuid knownBranch. } - // Shared recovery — a CONFIRMED branch (a UUID target, or a live 404) - // whose name/parent aren't known yet. Reads `.temp/project-ref` only - // once there's an actual cache candidate to correlate it against, so - // the common "cache absent" degradation path never touches the - // filesystem a second time. + // Shared recovery for a confirmed branch whose name/parent aren't known yet. Reads + // .temp/project-ref only once there's a cache candidate to correlate it against, so the + // common cache-absent path skips a second filesystem read. const cached = yield* readCachedParent; const candidateParentRef = Option.isSome(cached) && diff --git a/apps/cli/src/commands/config/push/push.command.ts b/apps/cli/src/commands/config/push/push.command.ts index 8e94414761..74f15df1cc 100644 --- a/apps/cli/src/commands/config/push/push.command.ts +++ b/apps/cli/src/commands/config/push/push.command.ts @@ -11,9 +11,8 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { configPush } from "./push.handler.ts"; const config = { - // `link`'s settled vocabulary (CLI-2167/CLI-2289): one flag that accepts - // either a project ref or a branch of the linked project — no separate - // `--target`. + // Accepts either a project ref or a branch name/UUID of the linked project; there's no + // separate --target flag. projectRef: Flag.string("project-ref").pipe( Flag.withDescription( "Project ref of the Supabase project, or the name (or UUID) of one of its branches. Values that are exactly 20 lowercase letters are always treated as project refs.", @@ -28,11 +27,8 @@ export type ConfigPushFlags = CliCommand.Command.Config.Infer<typeof config>; // `Command.withHandler` uses below (same precedent as `linkHandler`). export const configPushHandler = (flags: ConfigPushFlags) => configPush(flags).pipe( - // Nothing validates `--project-ref` before the instrumentation fires, so - // its value is only safe to log verbatim when it is actually ref-shaped — - // an arbitrary string (a typo, a value pasted from the wrong clipboard) - // must reach PostHog as "<redacted>". Same guard as `link`/`config diff` - // (documented safe list in apps/cli/CLAUDE.md). + // --project-ref is only safe to log verbatim when ref-shaped; an arbitrary string (a typo, + // a bad paste) must reach PostHog as "<redacted>". withCommandTelemetry({ flags, safeFlags: diff --git a/apps/cli/src/commands/config/push/push.cost-matrix.ts b/apps/cli/src/commands/config/push/push.cost-matrix.ts index 2dd13e48af..5c97cdd749 100644 --- a/apps/cli/src/commands/config/push/push.cost-matrix.ts +++ b/apps/cli/src/commands/config/push/push.cost-matrix.ts @@ -12,10 +12,8 @@ import { ConfigPushListAddonsStatusError, } from "./push.errors.ts"; -/** - * Cost matrix entry: the addon variant's display name and price description, - * used to render the cost-aware confirmation prompt (Go `push.CostItem`). - */ +/** Cost matrix entry: the addon variant's display name and price description, used to render + * the cost-aware confirmation prompt. */ export interface CostItem { readonly name: string; readonly price: string; @@ -93,7 +91,8 @@ interface ParsedAddon { }>; } -/** Tolerantly extracts `available_addons` with a string `type` (Go uses `string`, not an enum). */ +/** Tolerantly extracts `available_addons` with a string `type`, since the API response itself + * uses a plain string, not the enum the generated client declares. */ function readAddons(parsed: unknown): ReadonlyArray<ParsedAddon> { if (typeof parsed !== "object" || parsed === null) return []; const available = (parsed as { available_addons?: unknown }).available_addons; diff --git a/apps/cli/src/commands/config/push/push.encoders.ts b/apps/cli/src/commands/config/push/push.encoders.ts index 4313775f68..d5200824ec 100644 --- a/apps/cli/src/commands/config/push/push.encoders.ts +++ b/apps/cli/src/commands/config/push/push.encoders.ts @@ -1,18 +1,16 @@ /** - * Pure encoders that turn the changes routed to one `config push` resource - * into a v1 update-request body. + * Pure encoders that turn the changes routed to one `config push` resource into a v1 + * update-request body. * - * Encoders take the whole local AND remote projections, not just the - * changed leaves, because several endpoints need companion values the API - * requires together (`db_schema` when enabling the Data API, both CIDR - * arrays, the iceberg/vector containers' required inner keys, the active SMS - * provider's credential set). The change list decides *which* keys ship; a - * companion's value always prefers the project's CURRENT (`remote`) value - * over a schema-materialized local default, so an undeclared companion never - * gets clobbered with a default the user never asked for. Only when `remote` - * doesn't report a companion at all does resolution fall through to `local` - * (and, for the storage feature containers only, the decoded `config`) — and - * that fallback is disclosed via `forced`, never applied silently. + * Encoders take the whole local and remote projections, not just the changed leaves, because + * several endpoints need companion values the API requires together (`db_schema` when enabling + * the Data API, both CIDR arrays, the iceberg/vector containers' required inner keys, the active + * SMS provider's credential set). The change list decides *which* keys ship; a companion's value + * always prefers the project's current (`remote`) value over a schema-materialized local + * default, so an undeclared companion never gets clobbered with a default the user never asked + * for. Only when `remote` doesn't report a companion at all does resolution fall through to + * `local` (and, for the storage feature containers only, the decoded `config`) — and that + * fallback is disclosed via `forced`, never applied silently. */ import type { CliConfig, ConfigChange, ProjectConfig } from "@supabase/config"; @@ -52,10 +50,9 @@ export interface PushEncoderInput { } /** - * The storage encoder's own input: adds the decoded `config` as the LAST - * resort for the `storage.analytics`/`storage.vector` containers' required - * inner keys, which the local projection prunes entirely once disabled - * (CLI-2314 readiness). No other encoder needs this fourth tier. + * The storage encoder's own input: adds the decoded `config` as the last resort for the + * `storage.analytics`/`storage.vector` containers' required inner keys, which the local + * projection prunes entirely once disabled. No other encoder needs this fourth tier. */ export interface StorageEncoderInput extends PushEncoderInput { readonly config: CliConfig; @@ -133,9 +130,8 @@ export interface AuthEncoderInput extends PushEncoderInput { readonly now: Date; } -// --- shared reasons (D5; everything else is a defensive fallback that the -// mapping registry's own schema validation should make unreachable) -------- - +// Everything else is a defensive fallback that the mapping registry's own schema validation +// should make unreachable. const REASON_API_ENABLE_NEEDS_SCHEMA = "enabling the Data API needs at least one schema in api.schemas"; const REASON_SMS_ACTIVE_PROVIDER_ONLY = @@ -154,8 +150,6 @@ const REASON_COMPANION_TYPE_MISMATCH = "a required companion value has an unexpected type and could not be sent"; const REASON_NO_ENCODER = "config push has no encoder for this property"; -// --- generic path/value helpers --------------------------------------------- - function findChange( changes: ReadonlyArray<ConfigChange>, path: ReadonlyArray<string>, @@ -226,7 +220,8 @@ function resolveStorageFeatureLeaf( return fromConfig === undefined ? resolved : { value: fromConfig, source: "config" }; } -/** Records a companion as `forced` when it was NOT itself a routed change AND `remote` didn't report it. */ +/** Records a companion as `forced` when it wasn't itself a routed change and `remote` didn't + * report it. */ function pushForced( forced: Array<{ path: ReadonlyArray<string>; value: unknown }>, path: ReadonlyArray<string>, @@ -245,11 +240,9 @@ function findSecretDecision( } /** - * The paths a dropped container's `unencodable` entries must cover: its - * ordinary routed changes, PLUS its own secret's path when that secret was - * about to be sent — so a container triggered purely by a `send` secret - * (no ordinary field change at all) is never silently dropped with nothing - * reported. + * The paths a dropped container's `unencodable` entries must cover: its ordinary routed + * changes, plus its own secret's path when that secret was about to be sent — so a container + * triggered purely by a `send` secret is never silently dropped with nothing reported. */ function unencodableTargets( containerChanges: ReadonlyArray<ConfigChange>, @@ -363,8 +356,6 @@ function makeLeafAdder( }; } -// --- api --------------------------------------------------------------------- - function encodeApiBodyImpl(input: PushEncoderInput): PushEncoded<ApiUpdateBody> { const { changes, local, remote } = input; const encoded: Array<ReadonlyArray<string>> = []; @@ -376,14 +367,10 @@ function encodeApiBodyImpl(input: PushEncoderInput): PushEncoded<ApiUpdateBody> const extraSearchPathChange = findChange(changes, ["api", "extra_search_path"]); const maxRowsChange = findChange(changes, ["api", "max_rows"]); - // Whether the Data API is currently disabled and staying that way: `enabled` - // itself is not a routed change, and its resolved (remote-preferred) value - // is `false`. Every OTHER api.* change is then meaningless to send — there - // is no live Data API for the platform to apply it to — so each routes to - // `unencodable` instead of either silently riding along inside the `""` - // disable sentinel (a schemas-only change would otherwise be swallowed by - // it) or being sent on its own to an endpoint that ignores it while - // disabled. + // Whether the Data API is currently disabled and staying that way: `enabled` itself is not a + // routed change, and its resolved (remote-preferred) value is `false`. Every other api.* + // change is then meaningless to send, so each routes to `unencodable` instead of either riding + // along inside the `""` disable sentinel or being sent to an endpoint that ignores it. const apiDisabled = enabledChange === undefined && asBoolean(resolveLeaf(changes, ["api", "enabled"], remote, local).value) === false; @@ -466,8 +453,6 @@ function encodeApiBodyImpl(input: PushEncoderInput): PushEncoded<ApiUpdateBody> export const encodeApiBody = withExhaustiveness(encodeApiBodyImpl); -// --- db.settings --------------------------------------------------------- - function encodeDbSettingsBodyImpl(input: PushEncoderInput): PushEncoded<DbSettingsUpdateBody> { const { changes } = input; const body: Record<string, string | number | boolean> = {}; @@ -505,8 +490,6 @@ function encodeDbSettingsBodyImpl(input: PushEncoderInput): PushEncoded<DbSettin export const encodeDbSettingsBody = withExhaustiveness(encodeDbSettingsBodyImpl); -// --- db.network_restrictions ----------------------------------------------- - function encodeNetworkRestrictionsBodyImpl( input: PushEncoderInput, ): PushEncoded<NetworkRestrictionsUpdateBody> { @@ -559,8 +542,6 @@ function encodeNetworkRestrictionsBodyImpl( export const encodeNetworkRestrictionsBody = withExhaustiveness(encodeNetworkRestrictionsBodyImpl); -// --- db.ssl_enforcement ----------------------------------------------------- - function encodeSslEnforcementBodyImpl( input: PushEncoderInput, ): PushEncoded<SslEnforcementUpdateBody> { @@ -590,8 +571,6 @@ function encodeSslEnforcementBodyImpl( export const encodeSslEnforcementBody = withExhaustiveness(encodeSslEnforcementBodyImpl); -// --- storage ----------------------------------------------------------------- - interface StorageIcebergCatalogBody { readonly enabled: boolean; readonly maxNamespaces: number; @@ -778,8 +757,6 @@ function encodeStorageBodyImpl(input: StorageEncoderInput): PushEncoded<StorageU export const encodeStorageBody = withExhaustiveness(encodeStorageBodyImpl); -// --- auth ---------------------------------------------------------------- - /** `undefined` on a non-string or an unparseable duration — never `0`, so an invalid * declared value routes to `unencodable` (via `REASON_INVALID_DURATION`) instead of * silently pushing a zero duration. */ @@ -853,10 +830,9 @@ function encodeSmtpContainer( } return undefined; } - // A companion that DID resolve but to the wrong runtime type (never - // expected from a schema-typed `ProjectConfig`, but `remote` is untyped - // JSON off the wire) must never be silently coerced to `""`/`"0"` below — - // it makes the whole group unencodable instead. + // A companion that resolved but to the wrong runtime type (never expected from a schema-typed + // `ProjectConfig`, but `remote` is untyped JSON off the wire) must never be silently coerced + // to `""`/`"0"` below — it makes the whole group unencodable instead. if ( typeof hostR.value !== "string" || typeof portR.value !== "number" || @@ -1004,9 +980,9 @@ function encodeExternalProviderContainer( } return undefined; } - // Each companion resolved to SOME value above — but a wrong runtime type - // (client_id/url expect a string, email_optional/skip_nonce_check expect a - // boolean) must never be silently coerced to `""`/`false` below. + // Each companion resolved to some value above — but a wrong runtime type (client_id/url + // expect a string, email_optional/skip_nonce_check expect a boolean) must never be silently + // coerced to `""`/`false` below. if ( typeof clientIdR.value !== "string" || (urlR !== undefined && typeof urlR.value !== "string") || @@ -1068,11 +1044,10 @@ function encodeActiveSmsProviderBody( return asString(resolution.value) ?? ""; }; const secretFor = (key: string) => findSecretDecision(secrets, ["auth", "sms", provider, key]); - // Collected locally rather than pushed straight to `secretsEncoded`: a - // provider's own secret is applied to `body` INSIDE the switch below, - // before the `resolutions.some(source === "none")` check that can still - // discard the whole body — only merged into the caller's list once that - // check has passed (mirrors `forced`, pushed the same way just below). + // Collected locally rather than pushed straight to `secretsEncoded`: a provider's own secret + // is applied to `body` inside the switch below, before the `resolutions.some(source === + // "none")` check that can still discard the whole body — only merged into the caller's list + // once that check has passed (mirrors `forced`, pushed the same way just below). const sentSecretPaths: Array<ReadonlyArray<string>> = []; const applySecret = (body: Record<string, unknown>, key: string) => { const secret = secretFor(key); @@ -1162,13 +1137,12 @@ export interface PushAuthLeafSpec { } /** - * The auth encoder's flat leaf mappings — declared once here so - * `encodeAuthBody` and its key-name drift guard - * (`push.encoders.unit.test.ts`) iterate the SAME source of truth rather than - * risk disagreeing with each other about an `apiKey`. Every entry not - * covered here (the smtp/captcha/hook/external-provider/sms-provider - * containers, the email template/notification loops) builds its `apiKey` - * from a string template instead of a static path — see those functions. + * The auth encoder's flat leaf mappings — declared once here so `encodeAuthBody` and its + * key-name drift guard (`push.encoders.unit.test.ts`) iterate the same source of truth rather + * than risk disagreeing with each other about an `apiKey`. Every entry not covered here (the + * smtp/captcha/hook/external-provider/sms-provider containers, the email template/notification + * loops) builds its `apiKey` from a string template instead of a static path — see those + * functions. */ export const PUSH_AUTH_LEAF_MAP: ReadonlyArray<PushAuthLeafSpec> = [ // core scalars @@ -1404,10 +1378,9 @@ function encodeAuthBodyImpl( const unencodable: Array<{ path: ReadonlyArray<string>; reason: string }> = []; const extras: Array<{ path: ReadonlyArray<string>; label: "content" }> = []; const forced: Array<{ path: ReadonlyArray<string>; value: unknown }> = []; - // Secret paths a container actually placed a plaintext for — NOT every - // `send` decision: a container dropped as `unencodable` never reaches the - // point where its own secret gets assigned to `body`, so this can be a - // strict subset of `secrets.filter(status === "send")`. + // Secret paths a container actually placed a plaintext for, not every `send` decision: a + // container dropped as `unencodable` never reaches the point where its own secret gets + // assigned to `body`, so this can be a strict subset of `secrets.filter(status === "send")`. const secretsEncoded: Array<ReadonlyArray<string>> = []; const leaf = makeLeafAdder(changes, body, encoded, unencodable); diff --git a/apps/cli/src/commands/config/push/push.encoders.unit.test.ts b/apps/cli/src/commands/config/push/push.encoders.unit.test.ts index 1bc3ad0ab0..f36a8af485 100644 --- a/apps/cli/src/commands/config/push/push.encoders.unit.test.ts +++ b/apps/cli/src/commands/config/push/push.encoders.unit.test.ts @@ -1,7 +1,3 @@ -/** - * Unit tests for push.encoders.ts. - */ - import type { CliConfig, ConfigChange, ProjectConfig } from "@supabase/config"; import { comparableProjectConfigPaths, getDefaultCliConfig } from "@supabase/config"; import { AUTH_HOOK_NAMES, projectConfigMappingRows } from "@supabase/config/internal"; @@ -149,8 +145,6 @@ describe("encodeApiBody", () => { remote: { api: { enabled: true, schemas: ["public"] } }, }), ); - // remote says enabled=true, so schemas ship even though local's own - // `enabled` (a schema default the file never declared) says false. expect(result.body).toEqual({ db_schema: "public,private" }); expect(result.forced).toEqual([]); }); @@ -306,8 +300,7 @@ describe("encodeNetworkRestrictionsBody", () => { }); it("routes both CIDR paths to unencodable (REASON_GROUP_INCOMPLETE), not an empty array, when a companion cannot be resolved from remote or local", () => { - // Remote and local both lack `allowed_cidrs_v6` entirely — the whole - // group is incomplete, so nothing should be substituted with `[]`. + // allowed_cidrs_v6 is missing from both remote and local. const local: ProjectConfig = { db: { network_restrictions: { allowed_cidrs: ["10.0.0.0/8"] } }, }; @@ -435,11 +428,9 @@ describe("encodeStorageBody", () => { }, }, }; - // The real pipeline drops a disabled `storage.analytics`'s max_* siblings - // from the local projection (`DISABLED_SENTINEL_PRUNES`, leaving only - // `{enabled: false}`) — this directly exercises the encoder with that - // pruned shape, as a change constructed by hand rather than through the - // full diff pipeline. + // The real pipeline drops a disabled storage.analytics's max_* siblings from the local + // projection (DISABLED_SENTINEL_PRUNES); this constructs that pruned shape by hand instead + // of running the full diff pipeline. const result = encodeStorageBody( storageInput({ changes: [change(["storage", "analytics", "enabled"], false)], config }), ); @@ -813,8 +804,6 @@ describe("encodeAuthBody", () => { }); it("a secret-only trigger whose group is incomplete is reported unencodable, not silently dropped", () => { - // No ordinary change at all — only the `pass` secret is `send` — and the - // group's other companions cannot be resolved from remote or local. const local: ProjectConfig = { auth: { email: { smtp: { enabled: true } } } }; const secrets: ReadonlyArray<PushSecretDecision> = [ secretDecision({ @@ -1296,10 +1285,6 @@ describe("encodeAuthBody", () => { }); it("keeps encoding twilio when content_sid is unset everywhere (optional companion)", () => { - // `content_sid` is an `optionalKey` with no materialized default; a - // project that never set it must still be able to push its other - // twilio settings instead of the whole provider block becoming - // unencodable. const local: ProjectConfig = { auth: { sms: { @@ -1491,22 +1476,17 @@ describe("encodeAuthBody", () => { describe("auth encoder key-name drift guard", () => { /** - * Every `(configPath, apiKey)` pair the auth encoder maps, across the flat - * leaf table AND every string-built container (smtp, captcha, hooks, - * external providers, sms-provider credentials, email template - * subjects, email notification enabled+subject). For each pair, asserts - * the apiKey matches the registry's OWN `apiPath` for that configPath - * (`@supabase/config/internal`'s `projectConfigMappingRows`) — a guard - * against `push.encoders.ts` silently drifting from the registry `config - * pull`/`config diff` also read. + * Every `(configPath, apiKey)` pair the auth encoder maps, across the flat leaf table and + * every string-built container (smtp, captcha, hooks, external providers, sms-provider + * credentials, email template subjects, email notification enabled+subject). For each pair, + * asserts the apiKey matches the registry's own `apiPath` for that configPath + * (`@supabase/config/internal`'s `projectConfigMappingRows`), guarding against + * `push.encoders.ts` drifting from the registry `config pull`/`config diff` also read. * - * The leaf table (`PUSH_AUTH_LEAF_MAP`) is imported, not - * re-declared, so this guard tests the actual source of truth the encoder - * iterates — a change to that table is exercised here automatically. The - * container groups build their `apiKey` from a string template instead - * (`smtp_${field}`, `hook_${name}_enabled`, `external_${id}_client_id`, - * `sms_${provider}_${key}`, `mailer_subjects_${name}`, …), so their pairs - * are enumerated directly against the registry below. + * The leaf table (`PUSH_AUTH_LEAF_MAP`) is imported, not re-declared, so this guard tests the + * actual source of truth the encoder iterates. The container groups build their `apiKey` from + * a string template instead, so their pairs are enumerated directly against the registry + * below. */ interface LeafPair { readonly configPath: ReadonlyArray<string>; @@ -1639,16 +1619,12 @@ describe("auth encoder key-name drift guard", () => { describe("encoder exhaustiveness drift guard", () => { /** - * Feeds every {@link comparableProjectConfigPaths} entry that - * `pushResourceForPath` routes to a resource (excluding the two - * intentionally-unsupported prefixes, covered by `push.plan.unit.test.ts`'s - * own drift guard) to that resource's encoder as a single synthetic - * `update` change, and asserts the path lands in `encoded`, `unencodable`, - * or `extras` — never silently in none of the three. The classification - * itself doesn't matter (an incomplete container correctly reports - * `unencodable`); what matters is that every encoder's own switch/branch - * logic accounts for every path it can ever receive, backstopped by each - * encoder's `withExhaustiveness` wrapper (`push.encoders.ts`). + * Feeds every {@link comparableProjectConfigPaths} entry that `pushResourceForPath` routes to + * a resource (excluding the two intentionally-unsupported prefixes) to that resource's encoder + * as a single synthetic `update` change, and asserts the path lands in `encoded`, + * `unencodable`, or `extras` — never silently in none of the three. The classification itself + * doesn't matter; what matters is that every encoder's own switch/branch logic accounts for + * every path it can receive, backstopped by each encoder's `withExhaustiveness` wrapper. */ const ENCODE_BY_RESOURCE: Readonly< Record<PushResource, (changes: ReadonlyArray<ConfigChange>) => PushEncoded<unknown>> diff --git a/apps/cli/src/commands/config/push/push.errors.ts b/apps/cli/src/commands/config/push/push.errors.ts index 2940f0e252..45be9f6872 100644 --- a/apps/cli/src/commands/config/push/push.errors.ts +++ b/apps/cli/src/commands/config/push/push.errors.ts @@ -66,12 +66,8 @@ export class ConfigPushWorkdirError extends Data.TaggedError( } } -// --- branch/UUID resolution (CLI-2289) -------------------------------------- -// -// `--project-ref` accepts a project ref, or the name (or UUID) of one of its -// branches — mirrors `config diff`'s own error set 1:1, under push's own -// names (`diff.errors.ts`). - +// --project-ref accepts a project ref, or the name (or UUID) of one of its branches; mirrors +// config diff's own error set under push's own names. const targetErrors = mintConfigTargetErrors("ConfigPush"); /** `--project-ref` named a branch the parent project does not have. */ @@ -119,28 +115,19 @@ export class ConfigPushBranchResolveStatusError extends Data.TaggedError( } } -// --- live branch detection (CLI-2168) --------------------------------------- -// -// The `getProject` probe that tells the user whether `ref` is the linked -// project or one of its branches is entirely best-effort: a 404 is the -// branch signal (handled by `classifyProjectLookupError`), and any -// OTHER outcome — a timeout, a transport failure, or a non-200/404 status -// (e.g. a scoped token that can write service config but can't read the -// project record) — degrades to an uncertain target rather than aborting the -// push. There is deliberately no error class for this probe: it never fails -// the command. - -// --- branch confirmation gate (CLI-2168) ------------------------------------ +// The getProject probe that tells the user whether ref is the linked project or a branch is +// entirely best-effort: a 404 is the branch signal, and any other outcome (a timeout, a +// transport failure, or an unexpected status) degrades to an uncertain target rather than +// aborting the push. There is no error class for this probe — it never fails the command. -/** The user declined the branch confirmation gate. Mirrors `projects - * delete`/`db reset`'s identical top-level "are you sure" cancellation - * shape — declining now FAILS (exit 1), matching every other top-level - * confirmation gate in this codebase; the per-service `keep()` prompts below - * are unrelated and still exit 0 on decline. `suggestion` always names the - * `--yes`/`SUPABASE_YES` escape hatch — the interactive prompt label already - * carries an inline hint, but a machine-mode or non-TTY decline never renders - * that label at all (`promptYesNo` returns the default silently), so - * this is the only place those callers see it. */ +/** + * The user declined the branch confirmation gate. Mirrors `projects delete`/`db reset`'s + * identical top-level "are you sure" cancellation shape: declining fails (exit 1), matching + * every other top-level confirmation gate in this codebase; the per-service `keep()` prompts + * below are unrelated and still exit 0 on decline. `suggestion` always names the + * `--yes`/`SUPABASE_YES` escape hatch, since a machine-mode or non-TTY decline never renders the + * interactive prompt's own inline hint. + */ export class ConfigPushCancelledError extends Data.TaggedError("ConfigPushCancelledError")<{ readonly message: string; readonly suggestion?: string; @@ -150,8 +137,6 @@ export class ConfigPushCancelledError extends Data.TaggedError("ConfigPushCancel } } -// --- cost matrix (list addons) --------------------------------------------- - export class ConfigPushListAddonsNetworkError extends Data.TaggedError( "ConfigPushListAddonsNetworkError", )<DecodableNetworkErrorArgs> { @@ -171,8 +156,6 @@ export class ConfigPushListAddonsStatusError extends Data.TaggedError( } } -// --- effective project config read (GET /v2/projects/{ref}/config) -------- - /** * Transport failure or undecodable response reading the project's effective * configuration. `decode === true` reclassifies a 200 response the client @@ -204,12 +187,11 @@ export class ConfigPushConfigReadStatusError extends Data.TaggedError( } /** - * The effective project config read returned 200 with NO block populated at - * all (`scope.present` empty) — a scoped or otherwise restricted token most - * plausibly produces this. Pushing against an empty remote view would mean - * treating every locally declared property as a fresh write with no remote - * value to compare against, so this aborts before touching any resource - * rather than risk that. + * The effective project config read returned 200 with no block populated at all + * (`scope.present` empty) — a scoped or otherwise restricted token most plausibly produces + * this. Pushing against an empty remote view would mean treating every locally declared + * property as a fresh write with no remote value to compare against, so this aborts before + * touching any resource rather than risk that. */ export class ConfigPushConfigEmptyError extends Data.TaggedError( "ConfigPushConfigEmptyError", @@ -219,8 +201,6 @@ export class ConfigPushConfigEmptyError extends Data.TaggedError( } } -// --- api -------------------------------------------------------------------- - export class ConfigPushApiUpdateNetworkError extends Data.TaggedError( "ConfigPushApiUpdateNetworkError", )<DecodableNetworkErrorArgs> { @@ -238,8 +218,6 @@ export class ConfigPushApiUpdateStatusError extends Data.TaggedError( } } -// --- db.settings ------------------------------------------------------------ - export class ConfigPushDbUpdateNetworkError extends Data.TaggedError( "ConfigPushDbUpdateNetworkError", )<DecodableNetworkErrorArgs> { @@ -257,8 +235,6 @@ export class ConfigPushDbUpdateStatusError extends Data.TaggedError( } } -// --- db.network_restrictions ------------------------------------------------ - export class ConfigPushNetworkRestrictionsUpdateNetworkError extends Data.TaggedError( "ConfigPushNetworkRestrictionsUpdateNetworkError", )<DecodableNetworkErrorArgs> { @@ -276,8 +252,6 @@ export class ConfigPushNetworkRestrictionsUpdateStatusError extends Data.TaggedE } } -// --- db.ssl_enforcement ----------------------------------------------------- - export class ConfigPushSslEnforcementUpdateNetworkError extends Data.TaggedError( "ConfigPushSslEnforcementUpdateNetworkError", )<DecodableNetworkErrorArgs> { @@ -295,8 +269,6 @@ export class ConfigPushSslEnforcementUpdateStatusError extends Data.TaggedError( } } -// --- auth ------------------------------------------------------------------- - export class ConfigPushAuthUpdateNetworkError extends Data.TaggedError( "ConfigPushAuthUpdateNetworkError", )<DecodableNetworkErrorArgs> { @@ -314,8 +286,6 @@ export class ConfigPushAuthUpdateStatusError extends Data.TaggedError( } } -// --- storage ---------------------------------------------------------------- - export class ConfigPushStorageUpdateNetworkError extends Data.TaggedError( "ConfigPushStorageUpdateNetworkError", )<DecodableNetworkErrorArgs> { @@ -333,8 +303,6 @@ export class ConfigPushStorageUpdateStatusError extends Data.TaggedError( } } -// --- experimental.webhooks -------------------------------------------------- - export class ConfigPushEnableWebhookNetworkError extends Data.TaggedError( "ConfigPushEnableWebhookNetworkError", )<DecodableNetworkErrorArgs> { From ae2fcc54e7f529c26e2e70f52e925048aff62671 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 12:46:15 +0100 Subject: [PATCH 22/56] chore(repo): trim comments, shard 30 (453 -> 248 comment lines) --- .../migration/up/up.integration.test.ts | 18 ++--- .../src/commands/migration/up/up.live.test.ts | 9 +-- .../commands/network-bans/get/get.command.ts | 15 ++-- .../commands/network-bans/get/get.handler.ts | 11 ++- ...bans.experimental-gate.integration.test.ts | 26 +++---- .../network-bans/remove/remove.command.ts | 29 +++----- .../network-bans/remove/remove.handler.ts | 10 +-- .../remove/remove.integration.test.ts | 7 +- .../network-restrictions/get/get.command.ts | 15 ++-- .../network-restrictions.cidr.ts | 69 ++++++------------- ...ions.experimental-gate.integration.test.ts | 37 ++++------ .../network-restrictions.format.ts | 20 ++---- .../update/update.command.ts | 31 +++------ .../update/update.handler.ts | 13 ++-- .../update/update.integration.test.ts | 13 ++-- .../update/update.live.test.ts | 31 +++------ .../commands/orgs/create/create.handler.ts | 14 ++-- .../src/commands/orgs/list/list.handler.ts | 10 ++- .../src/commands/orgs/list/list.live.test.ts | 7 +- apps/cli/src/commands/orgs/orgs.errors.ts | 13 +--- apps/cli/src/commands/orgs/orgs.format.ts | 23 +++---- apps/cli/src/commands/orgs/orgs.go-payload.ts | 12 +--- .../postgres-config/delete/delete.command.ts | 23 +++---- .../delete/delete.command.unit.test.ts | 7 +- .../postgres-config/get/get.command.ts | 15 ++-- ...nfig.experimental-gate.integration.test.ts | 39 ++++------- .../postgres-config/postgres-config.shared.ts | 56 ++++++--------- .../postgres-config/update/update.command.ts | 23 +++---- .../update/update.command.unit.test.ts | 8 +-- 29 files changed, 198 insertions(+), 406 deletions(-) diff --git a/apps/cli/src/commands/migration/up/up.integration.test.ts b/apps/cli/src/commands/migration/up/up.integration.test.ts index 95d9844022..c90a54a8c1 100644 --- a/apps/cli/src/commands/migration/up/up.integration.test.ts +++ b/apps/cli/src/commands/migration/up/up.integration.test.ts @@ -92,17 +92,15 @@ function setup(workdir: string, opts: SetupOpts = {}) { extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), - // A migration file's statements arrive as one batch; replay them through - // `exec`/`query` so this suite's recordings and failure injection still apply. + // Replays each statement through exec/query so recordings and failure injection apply. execBatch: (statements) => sequentialExecBatch(session)(statements), }; return Effect.succeed(session); }, }); - // `loadProjectRef` gives an explicit `--project-ref` flag top precedence, same - // as Go's `flags.LoadProjectRef` — mirror that so a test can prove the flag - // (not just the hardcoded `VALID_REF` fallback) drives the linked ref. + // The flag value takes precedence over the VALID_REF fallback, so tests can assert the + // flag drives the linked ref. const projectRef = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(VALID_REF), resolveForLink: () => Effect.succeed(VALID_REF), @@ -159,13 +157,10 @@ describe("migration up", () => { yield* migrationUp(flags()); const stderr = stripAnsi(out.stderrText); const stdout = stripAnsi(out.stdoutText); - // The connection banner prints to stderr before dialing. expect(stderr).toContain("Connecting to local database..."); expect(stderr).toContain("Applying migration 20240102000000_b.sql..."); expect(stderr).toContain("Applying migration 20240103000000_c.sql..."); expect(stdout).toContain("Local database is up to date."); - // Lock the channel split: "Applying ..." is stderr - // and the final "up to date" is stdout — neither bleeds across. expect(stdout).not.toContain("Applying migration"); expect(stderr).not.toContain("Local database is up to date."); expect(insertedVersions(queries)).toEqual(["20240102000000", "20240103000000"]); @@ -208,7 +203,6 @@ describe("migration up", () => { const { layer, queries } = setup(tmp.current, { remote: ["20240102000000"] }); return Effect.gen(function* () { yield* migrationUp(flags({ includeAll: true })); - // The trailing pending set is appended after the out-of-order set. expect(insertedVersions(queries)).toEqual(["20240101000000", "20240103000000"]); }).pipe(Effect.provide(layer)); }); @@ -276,9 +270,7 @@ describe("migration up", () => { it.live( "applies on the project given via --project-ref --linked, overriding the linked ref", () => { - // up defaults to local; only with --linked does the flag's ref get cached. - // The fake resolver's own fallback (VALID_REF) represents whatever - // the workdir would resolve to absent the flag — the flag must win over it. + // VALID_REF is the resolver's fallback if the flag were absent; the flag must win. const FLAG_REF = "flagflagflagflagflag"; const { layer, cache } = setup(tmp.current, { args: ["--linked"], remote: [] }); return Effect.gen(function* () { @@ -293,8 +285,6 @@ describe("migration up", () => { ); it.live("rejects --project-ref on the default local target", () => { - // up defaults to local when no target flag is set — the guard must fire - // from the flag alone, with no explicit --local/--db-url needed. const FLAG_REF = "flagflagflagflagflag"; const { layer, execs, queries, cache } = setup(tmp.current, { remote: [] }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/migration/up/up.live.test.ts b/apps/cli/src/commands/migration/up/up.live.test.ts index 148379dbef..5ac6ada43d 100644 --- a/apps/cli/src/commands/migration/up/up.live.test.ts +++ b/apps/cli/src/commands/migration/up/up.live.test.ts @@ -19,9 +19,8 @@ test("applies a test-written migration to the remote database", async ({ const migrations = join(workspace.path, "supabase", "migrations"); await mkdir(migrations, { recursive: true }); - // The serial suite shares one remote project, so seed a local stub for every - // version already in remote history — otherwise `migration up` rejects them - // as missing locally. The history table may not exist yet on a fresh project. + // The serial suite shares one project; seed a stub for every version already in remote + // history, or `migration up` rejects it as missing locally. let remoteVersions: Array<{ version: string }> = []; try { remoteVersions = await queryLiveDb( @@ -29,9 +28,7 @@ test("applies a test-written migration to the remote database", async ({ "select version from supabase_migrations.schema_migrations order by version", ); } catch (error) { - // 42P01 (undefined relation) covers the fresh-project case where the - // history table or its schema does not exist yet; anything else is a real - // failure the test must surface. + // 42P01 = undefined relation, i.e. a fresh project without the migrations table yet. if ((error as { code?: string }).code !== "42P01") throw error; remoteVersions = []; } diff --git a/apps/cli/src/commands/network-bans/get/get.command.ts b/apps/cli/src/commands/network-bans/get/get.command.ts index 450961f899..568b55d67a 100644 --- a/apps/cli/src/commands/network-bans/get/get.command.ts +++ b/apps/cli/src/commands/network-bans/get/get.command.ts @@ -26,17 +26,12 @@ export const networkBansGetCommand = Command.make("get", config).pipe( Command.withShortDescription("Get the current network bans"), Command.withHandler((flags) => Effect.gen(function* () { - // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) - // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an - // invalid `-o` value must win over a missing `--experimental` flag. + // Validate the -o value before the --experimental gate, so an invalid value is + // reported even without --experimental set. yield* validateOutputFormat(RESOURCE_OUTPUT_FORMATS); - // Go gates `bansCmd` (network-bans) behind `--experimental` in PersistentPreRunE - // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). - // `managementApiRuntimeLayer` eagerly resolves an access token as part - // of building its `CommandPlatformApi` layer, so it must be provided AFTER - // the gate (inline here) rather than via `Command.provide` on the whole - // command — `Command.provide` would build the layer, and fail on a missing - // token, before this generator's first `yield*` ever runs. + // managementApiRuntimeLayer eagerly resolves an access token, so it's provided here + // (after the gate) rather than via Command.provide, which would build it — and fail + // on a missing token — before this generator's first yield* runs. yield* requireExperimental; return yield* networkBansGet(flags).pipe( withCommandTelemetry({ flags }), diff --git a/apps/cli/src/commands/network-bans/get/get.handler.ts b/apps/cli/src/commands/network-bans/get/get.handler.ts index 8de60574a3..30e7fce6c6 100644 --- a/apps/cli/src/commands/network-bans/get/get.handler.ts +++ b/apps/cli/src/commands/network-bans/get/get.handler.ts @@ -45,16 +45,14 @@ export const networkBansGet = Effect.fn("network-bans.get")(function* (flags: Ne const goOutput = Option.getOrUndefined(outputFlag); - // TS-native machine-readable modes skip the stderr heading for clean output. - // Go --output takes priority (CLAUDE.md item 6), so this only fires when the - // legacy flag is unset. + // Skips the stderr heading for json/stream-json output, but only when -o/--output + // is unset, since that flag takes priority. if (goOutput === undefined && (output.format === "json" || output.format === "stream-json")) { yield* output.success("", response); return; } - // Prints `DB banned IPs:` to stderr unconditionally before the format - // switch, including for `--output env` (which then errors). + // Emitted here so --output env still prints this heading before erroring below. yield* output.raw("DB banned IPs:\n", "stderr"); if (goOutput === "env") { @@ -71,8 +69,7 @@ export const networkBansGet = Effect.fn("network-bans.get")(function* (flags: Ne return; } - // Default and `--output {json,pretty}`. Go aliases `pretty` → `json` in - // `get.go:21-23` and falls through to `EncodeOutput(format, ips)`. + // Default output, and `--output pretty`, which aliases to json. yield* output.raw(encodeGoJson(response.banned_ipv4_addresses)); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); }).pipe(Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/network-bans/network-bans.experimental-gate.integration.test.ts b/apps/cli/src/commands/network-bans/network-bans.experimental-gate.integration.test.ts index 2ebf0ac0b1..6f145619ef 100644 --- a/apps/cli/src/commands/network-bans/network-bans.experimental-gate.integration.test.ts +++ b/apps/cli/src/commands/network-bans/network-bans.experimental-gate.integration.test.ts @@ -15,12 +15,9 @@ import { } from "../../../tests/helpers/command-mocks.ts"; import { networkBansCommand } from "./network-bans.command.ts"; -// See postgres-config.experimental-gate.integration.test.ts for the full -// rationale: this proves `--experimental` is wired into the actual -// `.command.ts` handler pipeline AND runs before -// `managementApiRuntimeLayer`'s eager access-token resolution -// (the `IsExperimental` check precedes `IsManagementAPI` in -// `apps/cli-go/cmd/root.go:91-109`). +// See postgres-config.experimental-gate.integration.test.ts for the rationale: proves the +// --experimental gate runs in the command pipeline before managementApiRuntimeLayer's eager +// access-token resolution. const tempRoot = useTempWorkdir("supabase-network-bans-experimental-int-"); @@ -38,12 +35,10 @@ function setup() { out, api, cliSettings: mockCommandSettings({ workdir: tempRoot.current }), - // The "gate open" case builds the real `managementApiRuntimeLayer` - // inline inside the command; its cliSettings/credentials layers read real - // files under homeDir and ambient env — an ambient SUPABASE_ACCESS_TOKEN, - // SUPABASE_EXPERIMENTAL, or OS keyring entry on the machine running the - // test would make these assertions non-deterministic. Isolate both, keeping - // only the keyring kill-switch set. + // The gate-open case builds the real managementApiRuntimeLayer inline, whose + // cliSettings/credentials layers read real files under homeDir and ambient env — an + // ambient access token, SUPABASE_EXPERIMENTAL, or keyring entry would make these + // assertions non-deterministic. runtimeInfo: isolatedHomeLayer(tempRoot.current, { SUPABASE_NO_KEYRING: "1" }), }); const layer = Layer.mergeAll( @@ -96,12 +91,7 @@ describe("network-bans experimental gate (Go PersistentPreRunE parity)", () => { it.live( "remove: malformed --db-unban-ip CSV fails at parse time with pflag's exact diagnostic, before the gate", () => { - // pflag's `readAsCSV` error aborts cobra's `ParseFlags` BEFORE - // `PersistentPreRunE`'s experimental-gate check, so the parse error - // must win even with `--experimental` unset. The rendered line — what - // `runCli`'s `handledProgram` writes to stderr via `normalizeCause` — - // matches pflag's own diagnostic (pflag v1.0.10 `errors.go:116` - // wrapping `encoding/csv`; `"1.2.3.4` is 8 bytes → EOF at column 9). + // `"1.2.3.4` is 8 bytes, so pflag's CSV reader hits EOF at column 9. const { layer, api } = setup(); return Effect.gen(function* () { const exit = yield* Effect.exit( diff --git a/apps/cli/src/commands/network-bans/remove/remove.command.ts b/apps/cli/src/commands/network-bans/remove/remove.command.ts index 1a35a05698..d83a2c91f4 100644 --- a/apps/cli/src/commands/network-bans/remove/remove.command.ts +++ b/apps/cli/src/commands/network-bans/remove/remove.command.ts @@ -13,15 +13,11 @@ import { } from "../../../telemetry/command-telemetry.ts"; import { networkBansRemove } from "./remove.handler.ts"; -// Go declares `--db-unban-ip` with pflag's `StringSliceVar` (`cmd/bans.go:48`), -// which CSV-splits each occurrence (`--db-unban-ip=1.2.3.4,5.6.7.8` → two IPs) -// and appends across repeats. Malformed CSV fails at parse time with pflag's -// exact diagnostic (see `stringSliceFlag`). Accepted approximation: -// given an invalid `-o` AND malformed CSV together, Go fails on whichever bad -// flag comes first in argv (pflag parses left-to-right); here the CSV error -// always wins, because the global `-o` is validated in-handler -// (`validateOutputFormat`) — same divergence class as the `-o` vs -// `--experimental` ordering note in the handler below. +/** + * CSV-splits each occurrence (`--db-unban-ip=1.2.3.4,5.6.7.8` → two IPs) and appends across + * repeats, failing at parse time with pflag's diagnostic on malformed CSV. If `-o` is also + * invalid, this error wins since `-o` is validated later, in the handler. + */ export const networkBansRemoveDbUnbanIpFlag = stringSliceFlag( "db-unban-ip", "IP to allow DB connections from.", @@ -42,17 +38,12 @@ export const networkBansRemoveCommand = Command.make("remove", config).pipe( Command.withShortDescription("Remove a network ban"), Command.withHandler((flags) => Effect.gen(function* () { - // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) - // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an - // invalid `-o` value must win over a missing `--experimental` flag. + // Validate the -o value before the --experimental gate, so an invalid value is + // reported even without --experimental set. yield* validateOutputFormat(RESOURCE_OUTPUT_FORMATS); - // Go gates `bansCmd` (network-bans) behind `--experimental` in PersistentPreRunE - // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). - // `managementApiRuntimeLayer` eagerly resolves an access token as part - // of building its `CommandPlatformApi` layer, so it must be provided AFTER - // the gate (inline here) rather than via `Command.provide` on the whole - // command — `Command.provide` would build the layer, and fail on a missing - // token, before this generator's first `yield*` ever runs. + // managementApiRuntimeLayer eagerly resolves an access token, so it's provided here + // (after the gate) rather than via Command.provide, which would build it — and fail + // on a missing token — before this generator's first yield* runs. yield* requireExperimental; return yield* networkBansRemove(flags).pipe( withCommandTelemetry({ flags }), diff --git a/apps/cli/src/commands/network-bans/remove/remove.handler.ts b/apps/cli/src/commands/network-bans/remove/remove.handler.ts index 7ce10fc7b6..99fff0bf30 100644 --- a/apps/cli/src/commands/network-bans/remove/remove.handler.ts +++ b/apps/cli/src/commands/network-bans/remove/remove.handler.ts @@ -33,9 +33,7 @@ export const networkBansRemove = Effect.fn("network-bans.remove")(function* ( const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - // Go resolves the project ref in `PersistentPreRunE` (`cmd/root.go:108-114`), - // before `RunE`'s `--db-unban-ip` validation (`internal/bans/update/update.go:12-25`) - // ever runs — so a bad ref must surface before a bad IP, not after. + // Resolves before validating --db-unban-ip, so a bad ref surfaces before a bad IP. const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { @@ -53,10 +51,8 @@ export const networkBansRemove = Effect.fn("network-bans.remove")(function* ( }) .pipe(Effect.catch(mapRemoveError)); - // Always prints the success line to stdout regardless of `--output`. - // The TS-native `--output-format json/stream-json` modes emit a - // structured success event instead, but only when `--output` is unset - // (CLAUDE.md item 6). + // Always prints to stdout regardless of --output; --output-format json/stream-json + // emit a structured event instead, but only when --output is unset. if ( Option.isNone(outputFlag) && (output.format === "json" || output.format === "stream-json") diff --git a/apps/cli/src/commands/network-bans/remove/remove.integration.test.ts b/apps/cli/src/commands/network-bans/remove/remove.integration.test.ts index c5220d4699..bd516e8b32 100644 --- a/apps/cli/src/commands/network-bans/remove/remove.integration.test.ts +++ b/apps/cli/src/commands/network-bans/remove/remove.integration.test.ts @@ -16,8 +16,8 @@ import { import { networkBansRemoveDbUnbanIpFlag } from "./remove.command.ts"; import { networkBansRemove } from "./remove.handler.ts"; -// Runs the real `--db-unban-ip` flag pipeline (pflag StringSlice CSV parity — -// `cmd/bans.go:48`) so these scenarios cover raw CLI values → request body. +// Runs the real --db-unban-ip flag pipeline so these scenarios cover raw CLI values +// through to the request body. const parseDbUnbanIp = (rawValues: ReadonlyArray<string>) => networkBansRemoveDbUnbanIpFlag.parse({ flags: { "db-unban-ip": rawValues }, arguments: [] }).pipe( Effect.map(([, values]) => values), @@ -239,9 +239,6 @@ describe("network-bans remove integration", () => { it.live( "surfaces the unresolved-ref error, not the invalid-IP error, when both are wrong", () => { - // Go resolves the project ref in PersistentPreRunE, before RunE's IP - // validation ever runs (cmd/root.go:108-114 vs internal/bans/update/update.go:12-25), - // so a bad ref must win over a bad IP — this is the regression CLI-1856 guards. const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ response: { status: 200, body: null } }); const cliSettings = mockCommandSettings({ diff --git a/apps/cli/src/commands/network-restrictions/get/get.command.ts b/apps/cli/src/commands/network-restrictions/get/get.command.ts index 95dece7436..bd6cb47544 100644 --- a/apps/cli/src/commands/network-restrictions/get/get.command.ts +++ b/apps/cli/src/commands/network-restrictions/get/get.command.ts @@ -26,17 +26,12 @@ export const networkRestrictionsGetCommand = Command.make("get", config).pipe( Command.withShortDescription("Get the current network restrictions"), Command.withHandler((flags) => Effect.gen(function* () { - // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) - // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an - // invalid `-o` value must win over a missing `--experimental` flag. + // Validate the -o value before the --experimental gate, so an invalid value is + // reported even without --experimental set. yield* validateOutputFormat(RESOURCE_OUTPUT_FORMATS); - // Go gates `restrictionsCmd` (network-restrictions) behind `--experimental` in - // PersistentPreRunE (root.go:91-96) BEFORE the `IsManagementAPI` login check - // (root.go:105-109). `managementApiRuntimeLayer` eagerly resolves an - // access token as part of building its `CommandPlatformApi` layer, so it must - // be provided AFTER the gate (inline here) rather than via `Command.provide` - // on the whole command — `Command.provide` would build the layer, and fail on - // a missing token, before this generator's first `yield*` ever runs. + // managementApiRuntimeLayer eagerly resolves an access token, so it's provided here + // (after the gate) rather than via Command.provide, which would build it — and fail + // on a missing token — before this generator's first yield* runs. yield* requireExperimental; return yield* networkRestrictionsGet(flags).pipe( withCommandTelemetry({ flags }), diff --git a/apps/cli/src/commands/network-restrictions/network-restrictions.cidr.ts b/apps/cli/src/commands/network-restrictions/network-restrictions.cidr.ts index d7faa08069..aa72cd71f2 100644 --- a/apps/cli/src/commands/network-restrictions/network-restrictions.cidr.ts +++ b/apps/cli/src/commands/network-restrictions/network-restrictions.cidr.ts @@ -5,21 +5,18 @@ export interface ParsedCidr { readonly address: string; readonly mask: number; /** - * Set when the input was an IPv4-mapped IPv6 form (RFC 4291 §2.5.5.2, - * e.g. `::ffff:10.0.0.1`). Holds the unwrapped IPv4 dotted-decimal so - * `isPrivateCidr` and partitioning use `net.IP.To4()`-driven semantics. - * `kind` is `"v4"` in this case; the original IPv6 mask range (0-128) - * still applies, so we keep `mask` as the caller supplied it. + * Set when the input was an IPv4-mapped IPv6 form (RFC 4291 §2.5.5.2, e.g. + * `::ffff:10.0.0.1`). Holds the unwrapped IPv4 dotted-decimal used for classification; + * `kind` is `"v4"` in this case, but `mask` keeps the original IPv6 range (0-128). */ readonly v4MappedAddress?: string; } /** - * Parses a CIDR string the same way `net.ParseCIDR` does for the inputs - * accepted by `supabase network-restrictions update`. + * Parses a CIDR string (e.g. `10.0.0.0/8`) for `supabase network-restrictions update`. * - * Returns `null` if the input is not a well-formed CIDR; callers translate - * `null` into `NetworkRestrictionsInvalidCidrError`. + * Returns `null` if the input is not a well-formed CIDR; callers translate `null` into + * `NetworkRestrictionsInvalidCidrError`. */ export function parseCidr(input: string): ParsedCidr | null { const slashIdx = input.indexOf("/"); @@ -29,8 +26,7 @@ export function parseCidr(input: string): ParsedCidr | null { const address = input.slice(0, slashIdx); const maskStr = input.slice(slashIdx + 1); if (maskStr.length === 0 || !/^\d+$/.test(maskStr)) return null; - // `Number.parseInt(..., 10)` accepts leading zeros (`/024` → 24). - // `net.ParseCIDR` does the same, so this is intentional. + // Leading zeros in the mask are accepted (`/024` → 24). const mask = Number.parseInt(maskStr, 10); const family = isIP(address); @@ -40,10 +36,8 @@ export function parseCidr(input: string): ParsedCidr | null { } if (family === 6) { if (mask < 0 || mask > 128) return null; - // `net.IP.To4()` unwraps IPv4-mapped IPv6 addresses (`::ffff:a.b.c.d`) - // to their v4 form before `IsPrivate()` and bucket classification. Mirror - // that here so `::ffff:10.0.0.1/128` is rejected as private and routed to - // `dbAllowedCidrs` (v4 list), not `dbAllowedCidrsV6`. + // Unwraps IPv4-mapped IPv6 addresses (`::ffff:a.b.c.d`) to their v4 form before private-range + // and bucket classification, so `::ffff:10.0.0.1/128` is treated as v4, not v6. const v4Mapped = extractIpv4MappedAddress(address); if (v4Mapped !== null) { return { kind: "v4", address, mask, v4MappedAddress: v4Mapped }; @@ -54,16 +48,14 @@ export function parseCidr(input: string): ParsedCidr | null { } /** - * Mirrors `net.IP.IsPrivate()` for the address families accepted by - * `parseCidr`. + * Checks whether a parsed CIDR falls in a private range. * * - IPv4 (RFC 1918): `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`. * - IPv6 (RFC 4193): `fc00::/7` — top 7 bits equal `1111110` (first byte `0xFC` or `0xFD`). */ export function isPrivateCidr(cidr: ParsedCidr): boolean { if (cidr.kind === "v4") { - // IPv4-mapped IPv6 inputs surface here with `v4MappedAddress` set; check - // that unwrapped form so `net.IP.To4()` semantics apply. + // IPv4-mapped IPv6 inputs surface here with `v4MappedAddress` set; check that unwrapped form. const octets = cidr.v4MappedAddress ?? cidr.address; const parts = octets.split("."); if (parts.length !== 4) return false; @@ -80,24 +72,17 @@ export function isPrivateCidr(cidr: ParsedCidr): boolean { } /** - * Detects the RFC 4291 IPv4-mapped IPv6 form and returns the unwrapped IPv4 - * dotted-decimal. Per the spec the high 80 bits must be zero and the next 16 - * bits must be exactly `0xFFFF` (case-insensitive `ffff`); anything else is a - * regular IPv6 address. - * - * Supports the canonical short form (`::ffff:a.b.c.d`) and the fully-expanded - * long form (`0:0:0:0:0:ffff:a.b.c.d`), which are the only forms Node's - * `isIP` produces a "6" verdict for and that `net.IP.To4()` unwraps. + * Detects the RFC 4291 IPv4-mapped IPv6 form (`::ffff:a.b.c.d`, or its fully-expanded + * `0:0:0:0:0:ffff:a.b.c.d`) and returns the unwrapped IPv4 dotted-decimal — the only forms + * Node's `isIP` reports as v6 for this shape. */ function extractIpv4MappedAddress(address: string): string | null { const lowered = address.toLowerCase(); - // Short form: ::ffff:a.b.c.d (overwhelmingly the common case). const shortPrefix = "::ffff:"; if (lowered.startsWith(shortPrefix)) { const candidate = lowered.slice(shortPrefix.length); return isIP(candidate) === 4 ? candidate : null; } - // Long form: 0:0:0:0:0:ffff:a.b.c.d const longMatch = /^(?:0:){5}ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(lowered); if (longMatch !== null && longMatch[1] !== undefined && isIP(longMatch[1]) === 4) { return longMatch[1]; @@ -106,30 +91,24 @@ function extractIpv4MappedAddress(address: string): string | null { } function parseFirstIpv6Byte(address: string): number | null { - // Addresses starting with `::` have a leading all-zero hextet, so the high - // byte is unambiguously 0 (which is not in `fc00::/7`). + // A leading "::" hextet is all zero, so the high byte is 0, outside fc00::/7. if (address.startsWith(":")) return 0; const firstColon = address.indexOf(":"); if (firstColon === -1) return null; const firstHextet = address.slice(0, firstColon); if (firstHextet.length === 0 || firstHextet.length > 4) return null; if (!/^[0-9a-fA-F]+$/.test(firstHextet)) return null; - // The first hextet is a big-endian 16-bit value. Left-padding to 4 hex - // digits and slicing the first two gives the *high* byte (which is what - // `net.IP`'s `ip[0]` corresponds to). For example `"fc"` represents `0x00fc`, so - // the high byte is `0x00` — correctly NOT in `fc00::/7`. + // The first hextet is big-endian; left-padding to 4 hex digits and taking the first two + // gives the high byte. E.g. "fc" is 0x00fc, so the high byte is 0x00 — not in fc00::/7. const padded = firstHextet.padStart(4, "0"); return Number.parseInt(padded.slice(0, 2), 16); } /** - * Validate every input string and partition into IPv4 / IPv6 lists in the - * expected order. + * Validates every input string and partitions them into IPv4/IPv6 lists. * - * Returns either the partitioned lists or a discriminated error describing - * which input failed and why. Callers translate the discriminated error into - * the appropriate `Data.TaggedError` so telemetry and stderr render the - * established verbatim message. + * Returns a discriminated error instead of throwing so callers can map it to the + * appropriate `Data.TaggedError` and render the established verbatim message. */ export function validateAndPartitionCidrs( inputs: readonly string[], @@ -157,12 +136,8 @@ export function validateAndPartitionCidrs( } /** - * Splits the V2 PATCH response shape (`config.dbAllowedCidrs: Array<{address, type}>`) - * into the two flat string arrays the output template expects. - * - * Lives next to `parseCidr` / `validateAndPartitionCidrs` because it performs - * the same v4/v6 partitioning, just sourced from the response shape instead of - * raw CLI input. + * Splits the V2 PATCH response shape (`config.dbAllowedCidrs: Array<{address, type}>`) into + * the two flat string arrays the output template expects. */ export function partitionPatchedCidrs( items: ReadonlyArray<{ readonly address: string; readonly type: "v4" | "v6" }> | undefined, diff --git a/apps/cli/src/commands/network-restrictions/network-restrictions.experimental-gate.integration.test.ts b/apps/cli/src/commands/network-restrictions/network-restrictions.experimental-gate.integration.test.ts index 6de4dc3abd..a158356c1b 100644 --- a/apps/cli/src/commands/network-restrictions/network-restrictions.experimental-gate.integration.test.ts +++ b/apps/cli/src/commands/network-restrictions/network-restrictions.experimental-gate.integration.test.ts @@ -16,12 +16,9 @@ import { } from "../../../tests/helpers/command-mocks.ts"; import { networkRestrictionsCommand } from "./network-restrictions.command.ts"; -// See postgres-config.experimental-gate.integration.test.ts for the full -// rationale: this proves `--experimental` is wired into the actual -// `.command.ts` handler pipeline AND runs before -// `managementApiRuntimeLayer`'s eager access-token resolution -// (the `IsExperimental` check precedes `IsManagementAPI` in -// `apps/cli-go/cmd/root.go:91-109`). +// See postgres-config.experimental-gate.integration.test.ts for the rationale: proves the +// --experimental gate runs in the command pipeline before managementApiRuntimeLayer's eager +// access-token resolution. const tempRoot = useTempWorkdir("supabase-network-restrictions-experimental-int-"); @@ -39,23 +36,19 @@ function setup() { out, api, cliSettings: mockCommandSettings({ workdir: tempRoot.current }), - // `RuntimeInfo` is ambient (not provided by `managementApiRuntimeLayer` - // itself), so the real `commandCredentialsLayer` built inline inside the - // command for the "gate open" case resolves ITS `RuntimeInfo` from this - // layer. Point homeDir at this test's isolated tempRoot so the layer's - // file-based token fallback (`<homeDir>/.supabase/access-token`) can't pick - // up a stray token left at the shared default `/tmp/supabase-cli-test-home`. + // managementApiRuntimeLayer doesn't provide RuntimeInfo itself; the real + // commandCredentialsLayer built inline for the gate-open case resolves it from here, so + // point homeDir at this test's tempRoot to avoid picking up a stray token from the shared + // default test home. runtimeInfo: mockRuntimeInfo({ homeDir: tempRoot.current }), }); const layer = Layer.mergeAll( runtime, CliOutput.layer(textCliOutputFormatter()), - // The "gate open" case reaches the real `managementApiRuntimeLayer` - // (provided inline inside the command, not by this test's mocked runtime), - // which reads credentials/env directly — an ambient SUPABASE_ACCESS_TOKEN, - // SUPABASE_EXPERIMENTAL, or OS keyring entry on the machine running the - // test would make these assertions non-deterministic. Wipe process.env - // down to just this and disable the keyring fallback. + // The gate-open case reaches the real managementApiRuntimeLayer (provided inline, not + // by this test's mocked runtime), which reads credentials/env directly — an ambient + // access token, SUPABASE_EXPERIMENTAL, or keyring entry would make these assertions + // non-deterministic. processEnvLayer({ SUPABASE_NO_KEYRING: "1" }), Layer.succeed( TelemetryRuntime, @@ -118,13 +111,7 @@ describe("network-restrictions experimental gate (Go PersistentPreRunE parity)", it.live( "update: malformed --db-allow-cidr CSV fails at parse time with pflag's exact diagnostic, before the gate", () => { - // pflag's `readAsCSV` error aborts cobra's `ParseFlags` BEFORE - // `PersistentPreRunE`'s experimental-gate check, so the parse error - // must win even with `--experimental` unset. The rendered line — what - // `runCli`'s `handledProgram` writes to stderr via `normalizeCause` — - // matches pflag's own diagnostic (pflag v1.0.10 `errors.go:116` - // wrapping `encoding/csv`; `"1.2.3.0/24` is 11 bytes → EOF at column - // 12). + // `"1.2.3.0/24` is 11 bytes, so pflag's CSV reader hits EOF at column 12. const { layer, api } = setup(); return Effect.gen(function* () { const exit = yield* Effect.exit( diff --git a/apps/cli/src/commands/network-restrictions/network-restrictions.format.ts b/apps/cli/src/commands/network-restrictions/network-restrictions.format.ts index e430dd98ab..a669053917 100644 --- a/apps/cli/src/commands/network-restrictions/network-restrictions.format.ts +++ b/apps/cli/src/commands/network-restrictions/network-restrictions.format.ts @@ -1,16 +1,6 @@ -// `fmt.Printf("%+v", x)` produces different bytes depending on whether `x` -// is a nil `*[]string` (the API field is absent) or a non-nil pointer to a -// possibly-empty slice. Specifically: -// -// *[]string(nil) -> "<nil>" -// &[]string{} -> "&[]" -// &[]string{"a"} -> "&[a]" -// &[]string{"a", "b"} -> "&[a b]" (single-space separated, no quoting) -// -// The GET and POST handlers print `resp.JSON*.Config.DbAllowedCidrs` directly, -// which is `*[]string` and therefore tri-state. The PATCH handler prints -// `&localSlice`, which is always non-nil and so renders as `&[]` or `&[...]`. -// `value === undefined` represents the GET/POST "field absent" case. +// `undefined` (field absent) renders as `<nil>`; an array, empty or not, renders as +// `&[]` / `&[a b]` (space-separated, unquoted). GET/POST responses can omit the field; +// PATCH always supplies a concrete array. interface PrintableStatus { readonly v4: readonly string[] | undefined; @@ -23,9 +13,7 @@ function formatGoSlice(value: readonly string[] | undefined): string { return `&[${value.join(" ")}]`; } -/** - * Reproduces the three-line `fmt.Printf` block byte-for-byte. - */ +/** Renders the established three-line status block byte-for-byte. */ export function printNetworkRestrictionsStatus(input: PrintableStatus): string { return ( `DB Allowed IPv4 CIDRs: ${formatGoSlice(input.v4)}\n` + diff --git a/apps/cli/src/commands/network-restrictions/update/update.command.ts b/apps/cli/src/commands/network-restrictions/update/update.command.ts index 73f8c5843c..d747103403 100644 --- a/apps/cli/src/commands/network-restrictions/update/update.command.ts +++ b/apps/cli/src/commands/network-restrictions/update/update.command.ts @@ -13,15 +13,11 @@ import { } from "../../../telemetry/command-telemetry.ts"; import { networkRestrictionsUpdate } from "./update.handler.ts"; -// Go declares `--db-allow-cidr` with pflag's `StringSliceVar` (`cmd/restrictions.go:40`), -// which CSV-splits each occurrence (`--db-allow-cidr=1.2.3.0/24,5.6.7.0/24` → two -// CIDRs) and appends across repeats. Malformed CSV fails at parse time with -// pflag's exact diagnostic (see `stringSliceFlag`). Accepted -// approximation: given an invalid `-o` AND malformed CSV together, Go fails on -// whichever bad flag comes first in argv (pflag parses left-to-right); here -// the CSV error always wins, because the global `-o` is validated in-handler -// (`validateOutputFormat`) — same divergence class as the `-o` vs -// `--experimental` ordering note in the handler below. +/** + * CSV-splits each occurrence (`--db-allow-cidr=1.2.3.0/24,5.6.7.0/24` → two CIDRs) and + * appends across repeats, failing at parse time with pflag's diagnostic on malformed CSV. + * If `-o` is also invalid, this error wins since `-o` is validated later, in the handler. + */ export const networkRestrictionsUpdateDbAllowCidrFlag = stringSliceFlag( "db-allow-cidr", "CIDR to allow DB connections from.", @@ -50,18 +46,13 @@ export const networkRestrictionsUpdateCommand = Command.make("update", config).p Command.withShortDescription("Update network restrictions"), Command.withHandler((flags) => Effect.gen(function* () { - // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) - // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an - // invalid `-o` value must win over a missing `--experimental` flag. + // Validate the -o value before the --experimental gate, so an invalid value is + // reported even without --experimental set. yield* validateOutputFormat(RESOURCE_OUTPUT_FORMATS); - // Go gates `restrictionsCmd` (network-restrictions) behind `--experimental` in - // PersistentPreRunE (root.go:91-96) BEFORE the `IsManagementAPI` login check - // (root.go:105-109) — and before RunE, so the gate also precedes this command's - // local CIDR validation. `managementApiRuntimeLayer` eagerly resolves an - // access token as part of building its `CommandPlatformApi` layer, so it must - // be provided AFTER the gate (inline here) rather than via `Command.provide` - // on the whole command — `Command.provide` would build the layer, and fail on - // a missing token, before this generator's first `yield*` ever runs. + // The gate also runs before this command's own CIDR validation. managementApiRuntimeLayer + // eagerly resolves an access token, so it's provided here (after the gate) rather than + // via Command.provide, which would fail on a missing token before this generator's + // first yield* runs. yield* requireExperimental; return yield* networkRestrictionsUpdate(flags).pipe( withCommandTelemetry({ flags }), diff --git a/apps/cli/src/commands/network-restrictions/update/update.handler.ts b/apps/cli/src/commands/network-restrictions/update/update.handler.ts index ef655819d7..aff28d7ea4 100644 --- a/apps/cli/src/commands/network-restrictions/update/update.handler.ts +++ b/apps/cli/src/commands/network-restrictions/update/update.handler.ts @@ -48,9 +48,8 @@ export const networkRestrictionsUpdate = Effect.fn("network-restrictions.update" const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - // Go validates every input before any I/O (`update.go:20-33`). Run the same - // pass first so a malformed CIDR short-circuits without resolving the ref - // or writing the linked-project cache. + // Validate every input before any I/O, so a malformed CIDR short-circuits without + // resolving the ref or writing the linked-project cache. const validation = validateAndPartitionCidrs(flags.dbAllowCidr, flags.bypassCidrChecks); if (!validation.ok) { if (validation.kind === "invalid") { @@ -86,9 +85,8 @@ export const networkRestrictionsUpdate = Effect.fn("network-restrictions.update" Effect.catch(mapUpdateError), ); yield* updating?.clear() ?? Effect.void; - // PATCH uses `&localSlice` in Go, which always renders as `&[]` / `&[...]` - // even when no items match a given type. Partition returns concrete arrays - // to match that always-non-nil semantic. + // The PATCH response always renders as `&[]`/`&[...]`, never `<nil>`; partition + // returns concrete arrays to match, even when a type has no items. const partitioned = partitionPatchedCidrs(response.config.dbAllowedCidrs); v4Out = partitioned.v4; v6Out = partitioned.v6; @@ -106,8 +104,7 @@ export const networkRestrictionsUpdate = Effect.fn("network-restrictions.update" Effect.catch(mapUpdateError), ); yield* updating?.clear() ?? Effect.void; - // POST `/apply` prints the response field directly; if the API omits - // either array it renders as `<nil>` (matches `*[]string(nil)`). + // POST /apply prints the response field directly; an omitted array renders as `<nil>`. v4Out = response.config.dbAllowedCidrs; v6Out = response.config.dbAllowedCidrsV6; applied = response.status === "applied"; diff --git a/apps/cli/src/commands/network-restrictions/update/update.integration.test.ts b/apps/cli/src/commands/network-restrictions/update/update.integration.test.ts index f08bebf080..69cc3c1547 100644 --- a/apps/cli/src/commands/network-restrictions/update/update.integration.test.ts +++ b/apps/cli/src/commands/network-restrictions/update/update.integration.test.ts @@ -21,8 +21,8 @@ import { import { networkRestrictionsUpdateDbAllowCidrFlag } from "./update.command.ts"; import { networkRestrictionsUpdate } from "./update.handler.ts"; -// Runs the real `--db-allow-cidr` flag pipeline (pflag StringSlice CSV parity — -// `cmd/restrictions.go:40`) so these scenarios cover raw CLI values → request body. +// Runs the real --db-allow-cidr flag pipeline so these scenarios cover raw CLI values +// through to the request body. const parseDbAllowCidr = (rawValues: ReadonlyArray<string>) => networkRestrictionsUpdateDbAllowCidrFlag .parse({ flags: { "db-allow-cidr": rawValues }, arguments: [] }) @@ -336,9 +336,8 @@ describe("network-restrictions update integration", () => { }); it.live("rejects an IPv4-mapped private IPv6 input (security regression guard)", () => { - // Without IPv4-mapped detection, ::ffff:10.0.0.0/104 would slip past the - // private check because the IPv6 first-byte is 0, not 0xfc. parseCidr now - // reclassifies these as v4 via `net.IP.To4()` semantics; the v4 path + // Without IPv4-mapped detection, ::ffff:10.0.0.0/104 would slip past the private check + // (its IPv6 first byte is 0, not 0xfc); parseCidr reclassifies it as v4 so the v4 path // catches 10.0.0.0/8. const { layer } = setup(); return Effect.gen(function* () { @@ -528,7 +527,7 @@ describe("network-restrictions update integration", () => { }).pipe(Effect.provide(layer)); }); - // Output modes — PATCH (cover encoders against V2 response shape) + // Output modes — PATCH it.live("emits a structured JSON success payload via --output-format=json after PATCH", () => { const { layer, out } = setup({ format: "json", patchResponse: PATCH_APPLIED }); @@ -641,7 +640,7 @@ describe("network-restrictions update integration", () => { }).pipe(Effect.provide(layer)); }); - // PersistentPostRun parity (telemetry + linked-project cache) + // Telemetry and linked-project cache it.live("flushes telemetry and writes linked-project cache on success", () => { const { layer, telemetry, cache } = setupTracked({ postResponse: POST_APPLIED }); diff --git a/apps/cli/src/commands/network-restrictions/update/update.live.test.ts b/apps/cli/src/commands/network-restrictions/update/update.live.test.ts index 2691c7b68c..c2dc0237d6 100644 --- a/apps/cli/src/commands/network-restrictions/update/update.live.test.ts +++ b/apps/cli/src/commands/network-restrictions/update/update.live.test.ts @@ -14,15 +14,10 @@ import { type LiveCli = LiveFixtures["cli"]; type LiveRun = Awaited<ReturnType<LiveCli>>; -// Every subprocess is bounded and the test's own timeout covers the longest -// path through them: four 60s commands (the restore is issued at most twice) -// plus two proof polls that can each run 102s (a 60s deadline that still -// finishes an in-flight 20s attempt, waits the 2s interval and runs one last -// 20s attempt), 444s in all, on top of the workspace fixture's own 60s init. -// The real bound this ceiling has to fit inside is the 20-minute Live E2E -// step budget that every serially-run live file shares. -// Once a test has timed out its fixtures are disposed, so a late restore -// cannot take effect and the shared project stays locked down. +// The worst case across four 60s commands (restore issued at most twice) and two 102s proof +// polls is 444s, plus the workspace fixture's ~60s init — all bounded by the 20-minute Live +// E2E step budget shared by every serial live file. A timed-out test disposes its fixtures, +// so a late restore can't run and the shared project stays locked down. const EXIT_TIMEOUT_MS = 60_000; const POLL_ATTEMPT_EXIT_TIMEOUT_MS = 20_000; const PROOF_TIMEOUT_MS = 60_000; @@ -130,13 +125,10 @@ test( "network-restrictions get capture for network-restrictions update", EXIT_TIMEOUT_MS, ); - // A never-configured project (both family keys absent) has no allowlist to - // post back, so that baseline is restored as allow-all rather than leaving - // the shared project locked down for every later live test that reaches - // the database directly. Any configured capture is restored as read — an - // explicitly empty (block-all) allowlist or an absent family beside a - // populated one included, since posting an empty family is restrict-all - // and therefore a faithful restore. + // A never-configured project has no allowlist to restore, so fall back to allow-all + // rather than leaving the shared project locked down for later tests. Any configured + // capture is restored as read, since posting an empty family is a faithful restrict-all + // restore. const baselineCidrs: AllowedCidrs = captured.configured ? captured.cidrs : ALLOW_ALL_CIDRS; let targetError: unknown; const cleanupErrors: Array<unknown> = []; @@ -159,10 +151,9 @@ test( targetError = error; } finally { try { - // One re-issue covers a restore that failed transiently. The proof runs - // whatever the restore reported: it alone shows whether the allowlist - // came back, and a restore killed while its request was still in flight - // exits non-zero after the platform may already have applied it. + // One re-issue covers a transient restore failure. The proof alone decides success: + // a restore killed mid-request can exit non-zero even though the platform already + // applied it. const restore = () => cli(updateArgs(baselineCidrs, flags), { exitTimeoutMs: EXIT_TIMEOUT_MS }); const first = await restore(); diff --git a/apps/cli/src/commands/orgs/create/create.handler.ts b/apps/cli/src/commands/orgs/create/create.handler.ts index 954fbf5b4f..b7a83a57da 100644 --- a/apps/cli/src/commands/orgs/create/create.handler.ts +++ b/apps/cli/src/commands/orgs/create/create.handler.ts @@ -29,10 +29,9 @@ export const orgsCreate = Effect.fn("orgs.create")(function* (flags: OrgsCreateF const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - // Spinner runs only in text mode — it would corrupt machine-readable - // stdout. The output-routing branches below dispatch on `goFmt`, but the - // spinner uses `output.format` because `--output pretty` keeps the format - // as "text" while requiring the table render; both paths need the spinner. + // Spinner only runs in text mode, since it would corrupt machine-readable stdout. It + // gates on output.format rather than goFmt because --output pretty keeps the format + // "text" while still rendering the table. const creating = output.format === "text" ? yield* output.task("Creating organization...") : undefined; const created: CreatedOrganization = yield* api.v1 @@ -45,10 +44,9 @@ export const orgsCreate = Effect.fn("orgs.create")(function* (flags: OrgsCreateF const goFmt = Option.getOrUndefined(goOutputFlag); - // `fmt.Println("Created organization:", id)` is printed once before the - // format switch. The preamble is repeated inside each Go-format branch - // rather than hoisted, so the TS `--output-format json` / `stream-json` - // paths (which emit a single structured event below) stay preamble-free. + // Printed once before the format switch, but only for the Go-format branches — the + // --output-format json/stream-json paths emit a single structured event instead and + // stay preamble-free. const preamble = `Created organization: ${created.id}\n`; if (goFmt === "json") { diff --git a/apps/cli/src/commands/orgs/list/list.handler.ts b/apps/cli/src/commands/orgs/list/list.handler.ts index 0b6e42f2d5..685081a4e9 100644 --- a/apps/cli/src/commands/orgs/list/list.handler.ts +++ b/apps/cli/src/commands/orgs/list/list.handler.ts @@ -33,10 +33,9 @@ export const orgsList = Effect.fn("orgs.list")(function* (_flags: OrgsListFlags) const telemetryState = yield* TelemetryState; yield* Effect.gen(function* () { - // Spinner runs only in text mode — it would corrupt machine-readable - // stdout. The output-routing branches below dispatch on `goFmt`, but the - // spinner uses `output.format` because `--output pretty` keeps the format - // as "text" while requiring the table render; both paths need the spinner. + // Spinner only runs in text mode, since it would corrupt machine-readable stdout. It + // gates on output.format rather than goFmt because --output pretty keeps the format + // "text" while still rendering the table. const fetching = output.format === "text" ? yield* output.task("Fetching organizations...") : undefined; const orgs: Organizations = yield* api.v1.listAllOrganizations().pipe( @@ -65,8 +64,7 @@ export const orgsList = Effect.fn("orgs.list")(function* (_flags: OrgsListFlags) return; } - // goFmt is undefined or "pretty" — defer to TS --output-format for - // JSON/stream-json, otherwise render the Glamour-styled table. + // goFmt is unset or "pretty" here; fall through to --output-format or the table. if (output.format === "json" || output.format === "stream-json") { yield* output.success("", { organizations: orgs }); return; diff --git a/apps/cli/src/commands/orgs/list/list.live.test.ts b/apps/cli/src/commands/orgs/list/list.live.test.ts index 19f8bbd19a..557ff8dbf3 100644 --- a/apps/cli/src/commands/orgs/list/list.live.test.ts +++ b/apps/cli/src/commands/orgs/list/list.live.test.ts @@ -3,11 +3,8 @@ import { test } from "../../../../tests/helpers/live.ts"; const LIVE_TIMEOUT_MS = 60_000; -// Harness smoke for the live Vitest project: the canonical example of a live -// test. It exercises the full path — built binary → temporary profile resolution -// → authenticated Management API request against the running platform — with a -// read-only call, so it is safe to run repeatedly and creates no resources. -// +// Exercises the full live path — built binary, profile resolution, authenticated Management +// API request — with a read-only call that's safe to rerun and creates no resources. test( "lists organizations for the authenticated token", { timeout: LIVE_TIMEOUT_MS }, diff --git a/apps/cli/src/commands/orgs/orgs.errors.ts b/apps/cli/src/commands/orgs/orgs.errors.ts index d325be8a10..2f51472677 100644 --- a/apps/cli/src/commands/orgs/orgs.errors.ts +++ b/apps/cli/src/commands/orgs/orgs.errors.ts @@ -6,11 +6,6 @@ import { statusCodeActionability, } from "../../shared/telemetry/error-actionability.ts"; -// --------------------------------------------------------------------------- -// HTTP-bound errors — one (Network + UnexpectedStatus) pair per `errors.Errorf` -// call site. -// --------------------------------------------------------------------------- - export class OrgsListNetworkError extends Data.TaggedError("OrgsListNetworkError")<{ readonly message: string; readonly decode?: boolean; @@ -57,12 +52,8 @@ export class OrgsCreateUnexpectedStatusError extends Data.TaggedError( } } -// --------------------------------------------------------------------------- -// Pure-path error — `orgs list --output env` is explicitly rejected. `orgs -// create` does NOT have an equivalent branch — the `EncodeOutput` env -// encoder happily flattens the single object into `ID=… NAME=… SLUG=…`. -// --------------------------------------------------------------------------- - +// `orgs create --output env` isn't rejected: a single organization flattens cleanly into +// `ID=... NAME=... SLUG=...`; only `orgs list`'s array output is unsupported. export class OrgsEnvNotSupportedError extends Data.TaggedError("OrgsEnvNotSupportedError")<{ readonly message: string; }> { diff --git a/apps/cli/src/commands/orgs/orgs.format.ts b/apps/cli/src/commands/orgs/orgs.format.ts index 37d1a22840..e78396bef8 100644 --- a/apps/cli/src/commands/orgs/orgs.format.ts +++ b/apps/cli/src/commands/orgs/orgs.format.ts @@ -2,25 +2,18 @@ import type { OrganizationResponseV1 } from "@supabase/api/effect"; import { renderGlamourTable } from "../../output/glamour-table.ts"; -// --------------------------------------------------------------------------- -// Pure formatter — no Effect / no service dependencies, kept unit-testable. -// -// A markdown-table + glamour pipeline wraps each cell in backticks and -// escapes `|` as `\|` in the markdown intermediate; glamour decodes the -// escape and strips the backticks. Our `renderGlamourTable` lays out cells -// directly, so we pass raw values (including any literal `|`). -// -// Note: API-supplied `id` / `name` strings are NOT stripped of ANSI escape -// sequences or other terminal control bytes before rendering — diverging -// here would mean scripts grepping table cells see different bytes than they -// do today. If a future security review decides to sanitize, it should land -// at the renderer (`glamour-table.ts`), not per-command. -// --------------------------------------------------------------------------- - const HEADERS = ["ID", "NAME"] as const; type Organization = typeof OrganizationResponseV1.Type; +/** + * Renders the `orgs list` / `orgs create` table. + * + * `renderGlamourTable` lays out cells directly, so raw `id`/`name` values pass through + * unescaped — including any literal `|` and without stripping ANSI or other control bytes, + * matching established output. Add sanitization in `renderGlamourTable` if it's ever needed, + * not here. + */ export function renderOrgsListTable(orgs: ReadonlyArray<Organization>): string { const rows = orgs.map((o) => [o.id, o.name]); return renderGlamourTable(HEADERS, rows); diff --git a/apps/cli/src/commands/orgs/orgs.go-payload.ts b/apps/cli/src/commands/orgs/orgs.go-payload.ts index 05f549ea07..8f26b520bf 100644 --- a/apps/cli/src/commands/orgs/orgs.go-payload.ts +++ b/apps/cli/src/commands/orgs/orgs.go-payload.ts @@ -6,23 +6,17 @@ import { goTomlListWrapper, } from "../../command-internal/go-struct-output.encoders.ts"; -/** - * Type shape for `api.OrganizationResponseV1` (`apps/cli-go/pkg/api/types.gen.go`). - * Shared by `orgs list` and `orgs create` for `-o yaml` / `-o toml` (CLI-1975). - */ +/** Struct shape for `-o yaml`/`-o toml` key casing, shared by `orgs list` and `orgs create`. */ export const GO_ORGANIZATION_RESPONSE: GoType = goStruct([ ["id", goString], ["name", goString], ["slug", goString], ]); -/** `orgs list -o yaml` encodes the bare `[]api.OrganizationResponseV1`. */ +/** `orgs list -o yaml` encodes the bare organization list. */ export const GO_ORGS_LIST: GoType = goSlice(GO_ORGANIZATION_RESPONSE); -/** - * `orgs list -o toml` wraps the slice: - * `struct{ Organizations []api.OrganizationResponseV1 `toml:"organizations"` }`. - */ +/** `orgs list -o toml` wraps the list under an `organizations` key. */ export const GO_ORGS_TOML_WRAPPER: GoType = goTomlListWrapper( "organizations", GO_ORGANIZATION_RESPONSE, diff --git a/apps/cli/src/commands/postgres-config/delete/delete.command.ts b/apps/cli/src/commands/postgres-config/delete/delete.command.ts index a9de970a6e..de55984cf7 100644 --- a/apps/cli/src/commands/postgres-config/delete/delete.command.ts +++ b/apps/cli/src/commands/postgres-config/delete/delete.command.ts @@ -13,10 +13,10 @@ import { } from "../../../telemetry/command-telemetry.ts"; import { postgresConfigDelete } from "./delete.handler.ts"; -// Go declares `--config` with pflag's `StringSliceVar` (`cmd/postgres.go:64`); -// malformed CSV fails at parse time with pflag's exact diagnostic (CLI-2005, -// see `stringSliceFlag`) — before the `--experimental` gate, matching -// cobra's ParseFlags-before-PersistentPreRunE ordering. +/** + * CSV-splits each occurrence into config keys, failing at parse time with pflag's diagnostic + * on malformed CSV — before the --experimental gate runs. + */ export const postgresConfigDeleteConfigFlag = stringSliceFlag( "config", "Config keys to delete (comma-separated)", @@ -41,17 +41,12 @@ export const postgresConfigDeleteCommand = Command.make("delete", config).pipe( Command.withShortDescription("Delete Postgres database config overrides"), Command.withHandler((flags) => Effect.gen(function* () { - // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) - // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an - // invalid `-o` value must win over a missing `--experimental` flag. + // Validate the -o value before the --experimental gate, so an invalid value is + // reported even without --experimental set. yield* validateOutputFormat(RESOURCE_OUTPUT_FORMATS); - // Go gates `postgresCmd` behind `--experimental` in PersistentPreRunE - // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). - // `managementApiRuntimeLayer` eagerly resolves an access token as part - // of building its `CommandPlatformApi` layer, so it must be provided AFTER - // the gate (inline here) rather than via `Command.provide` on the whole - // command — `Command.provide` would build the layer, and fail on a missing - // token, before this generator's first `yield*` ever runs. + // managementApiRuntimeLayer eagerly resolves an access token, so it's provided here + // (after the gate) rather than via Command.provide, which would build it — and fail + // on a missing token — before this generator's first yield* runs. yield* requireExperimental; return yield* postgresConfigDelete(flags).pipe( withCommandTelemetry({ flags }), diff --git a/apps/cli/src/commands/postgres-config/delete/delete.command.unit.test.ts b/apps/cli/src/commands/postgres-config/delete/delete.command.unit.test.ts index bf1813e657..21c61d5f2c 100644 --- a/apps/cli/src/commands/postgres-config/delete/delete.command.unit.test.ts +++ b/apps/cli/src/commands/postgres-config/delete/delete.command.unit.test.ts @@ -32,9 +32,6 @@ describe("postgres-config delete --config flag (pflag StringSlice parity)", () = }); test("keeps only the first CSV record of a multiline value (pflag reads ONE record)", async () => { - // Go-verified (CLI-2005): `postgres-config delete --config $'a\nb"c'` - // raises no parse error — pflag calls `csv.Reader.Read()` once, so the - // malformed second line is silently dropped. const [, values] = await Effect.runPromise( postgresConfigDeleteConfigFlag .parse({ @@ -60,7 +57,7 @@ describe("postgres-config delete --config flag (pflag StringSlice parity)", () = expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - // Matches pflag's own diagnostic (bare quote at byte 4 of `max"connections`). + // Bare quote at byte 4 of `max"connections`. expect(normalizeCause(exit.cause).message).toBe( 'invalid argument "max\\"connections" for "--config" flag: parse error on line 1, column 4: bare " in non-quoted-field', ); @@ -68,8 +65,6 @@ describe("postgres-config delete --config flag (pflag StringSlice parity)", () = }); test("rejects a blank-only value with pflag's EOF diagnostic", async () => { - // Go-verified (CLI-2005): `postgres-config delete --config $'\n'` → - // `invalid argument "\n" for "--config" flag: EOF`. const exit = await Effect.runPromise( postgresConfigDeleteConfigFlag .parse({ diff --git a/apps/cli/src/commands/postgres-config/get/get.command.ts b/apps/cli/src/commands/postgres-config/get/get.command.ts index f98f3a95b9..28d30092d7 100644 --- a/apps/cli/src/commands/postgres-config/get/get.command.ts +++ b/apps/cli/src/commands/postgres-config/get/get.command.ts @@ -26,17 +26,12 @@ export const postgresConfigGetCommand = Command.make("get", config).pipe( Command.withShortDescription("Get Postgres database config"), Command.withHandler((flags) => Effect.gen(function* () { - // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) - // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an - // invalid `-o` value must win over a missing `--experimental` flag. + // Validate the -o value before the --experimental gate, so an invalid value is + // reported even without --experimental set. yield* validateOutputFormat(RESOURCE_OUTPUT_FORMATS); - // Go gates `postgresCmd` behind `--experimental` in PersistentPreRunE - // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). - // `managementApiRuntimeLayer` eagerly resolves an access token as part - // of building its `CommandPlatformApi` layer, so it must be provided AFTER - // the gate (inline here) rather than via `Command.provide` on the whole - // command — `Command.provide` would build the layer, and fail on a missing - // token, before this generator's first `yield*` ever runs. + // managementApiRuntimeLayer eagerly resolves an access token, so it's provided here + // (after the gate) rather than via Command.provide, which would build it — and fail + // on a missing token — before this generator's first yield* runs. yield* requireExperimental; return yield* postgresConfigGet(flags).pipe( withCommandTelemetry({ flags }), diff --git a/apps/cli/src/commands/postgres-config/postgres-config.experimental-gate.integration.test.ts b/apps/cli/src/commands/postgres-config/postgres-config.experimental-gate.integration.test.ts index 12f8dda9eb..361bf92d10 100644 --- a/apps/cli/src/commands/postgres-config/postgres-config.experimental-gate.integration.test.ts +++ b/apps/cli/src/commands/postgres-config/postgres-config.experimental-gate.integration.test.ts @@ -15,15 +15,9 @@ import { } from "../../../tests/helpers/command-mocks.ts"; import { postgresConfigCommand } from "./postgres-config.command.ts"; -// This suite proves the `--experimental` gate is wired into the actual -// `.command.ts` handler pipeline (not just the shared helper in isolation), -// and — critically — that it runs BEFORE `managementApiRuntimeLayer` -// resolves an access token. The root `PersistentPreRunE` checks -// `IsExperimental` before the `IsManagementAPI` login check -// (`apps/cli-go/cmd/root.go:91-109`); `managementApiRuntimeLayer` -// eagerly fails on a missing token as part of its own layer construction, so -// wiring the gate anywhere except immediately before that layer is attached -// would let a missing-token error mask the missing-`--experimental` error. +// Proves the --experimental gate is wired into the actual command pipeline (not just the +// shared helper in isolation), and that it runs before managementApiRuntimeLayer resolves an +// access token — wiring it any later would let a missing-token error mask the missing-flag error. const tempRoot = useTempWorkdir("supabase-postgres-config-experimental-int-"); @@ -41,12 +35,10 @@ function setup() { out, api, cliSettings: mockCommandSettings({ workdir: tempRoot.current }), - // The "gate open" case builds the real `managementApiRuntimeLayer` - // inline inside the command; its cliSettings/credentials layers read real - // files under homeDir and ambient env — an ambient SUPABASE_ACCESS_TOKEN, - // SUPABASE_EXPERIMENTAL, or OS keyring entry on the machine running the - // test would make these assertions non-deterministic. Isolate both, keeping - // only the keyring kill-switch set. + // The gate-open case builds the real managementApiRuntimeLayer inline, whose + // cliSettings/credentials layers read real files under homeDir and ambient env — an + // ambient access token, SUPABASE_EXPERIMENTAL, or keyring entry would make these + // assertions non-deterministic. runtimeInfo: isolatedHomeLayer(tempRoot.current, { SUPABASE_NO_KEYRING: "1" }), }); const layer = Layer.mergeAll( @@ -76,9 +68,7 @@ describe("postgres-config experimental gate (Go PersistentPreRunE parity)", () = if (Exit.isFailure(exit)) { expect(JSON.stringify(exit.cause)).toContain("ExperimentalRequiredError"); } - // The gate must run before any API call (and before the eager - // access-token resolution inside `managementApiRuntimeLayer`) — - // a closed gate makes zero network requests. + // A closed gate makes zero network requests, before even the eager token resolution. expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)); }); @@ -89,10 +79,8 @@ describe("postgres-config experimental gate (Go PersistentPreRunE parity)", () = const exit = yield* Effect.exit( Command.runWith(testRoot, { version: "0.0.0-test" })([...args, "--experimental"]), ); - // No real access token is configured in this test environment, so the - // command still fails — but past the gate, at the auth-resolution step - // that `managementApiRuntimeLayer` performs, never with the - // experimental gate error once the flag is on. + // No real token is configured, so the command still fails — but past the gate, + // at managementApiRuntimeLayer's auth resolution. expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeText = JSON.stringify(exit.cause); @@ -104,11 +92,8 @@ describe("postgres-config experimental gate (Go PersistentPreRunE parity)", () = }); } - // pflag's `readAsCSV` error aborts cobra's `ParseFlags` BEFORE - // `PersistentPreRunE`'s experimental-gate check, so the parse error must - // win even with `--experimental` unset. The rendered line matches pflag's - // own diagnostic (pflag v1.0.10 `errors.go:116` wrapping `encoding/csv`) — - // same prior art as network-bans/network-restrictions. + // A malformed CSV value fails at parse time, before the experimental gate ever runs, + // with pflag's own diagnostic text. const malformedCsvCases: ReadonlyArray<{ readonly name: string; readonly args: ReadonlyArray<string>; diff --git a/apps/cli/src/commands/postgres-config/postgres-config.shared.ts b/apps/cli/src/commands/postgres-config/postgres-config.shared.ts index 540bf92d34..186323a70e 100644 --- a/apps/cli/src/commands/postgres-config/postgres-config.shared.ts +++ b/apps/cli/src/commands/postgres-config/postgres-config.shared.ts @@ -31,9 +31,8 @@ function sortConfigEntries(config: PostgresConfigMap): Array<[string, unknown]> function formatPrettyValue(value: unknown): string { if (typeof value === "string") return value; - // Go renders each cell with `%+v` (`get.go:32-35`) on values from - // `json.Unmarshal` into `map[string]any` — every JSON number is a `float64`, - // so e.g. `1000000` prints as `1e+06`, not `1000000`. + // Every number renders using float64 formatting, matching established output, so e.g. + // 1000000 prints as 1e+06, not 1000000. if (typeof value === "number") return goFormatFloat(value); if (typeof value === "boolean") return String(value); if (value === null) return "<nil>"; @@ -51,19 +50,16 @@ function encodeTomlScalar(value: unknown): string { if (typeof value === "string") return JSON.stringify(value); if (typeof value === "boolean") return value ? "true" : "false"; if (typeof value === "number") { - // The reference decoder unmarshals the API response into `map[string]any`, - // so every JSON number becomes a `float64`. Its TOML marshaller then prints - // integral floats with a `.0` suffix (e.g. `max_connections = 100.0`). The - // shared `encodeToml` (smol-toml) would emit `100` instead, so this command - // cannot use it without breaking byte-for-byte compatibility. + // Every number renders as a float64, so an integral value gets a `.0` suffix (e.g. + // `max_connections = 100.0`); the shared encodeToml (smol-toml) would emit `100` + // instead, breaking established output. return Number.isInteger(value) ? `${value}.0` : String(value); } if (value === null) return JSON.stringify("<nil>"); return JSON.stringify(JSON.stringify(value)); } -// Hand-rolled to reproduce `float64` TOML rendering (see `encodeTomlScalar`). -// Intentionally does not delegate to the shared `encodeToml`/smol-toml encoder. +// See encodeTomlScalar: this can't delegate to the shared encodeToml without breaking output. function encodePostgresConfigToml(config: PostgresConfigMap): string { const lines = sortConfigEntries(config).map( ([key, value]) => `${key} = ${encodeTomlScalar(value)}`, @@ -71,28 +67,22 @@ function encodePostgresConfigToml(config: PostgresConfigMap): string { return lines.length === 0 ? "" : lines.join("\n") + "\n"; } -// `strconv.Atoi` parses into a 64-bit int (`update.go:43`). +// Parses config values as 64-bit integers. const INT64_MIN = -(2n ** 63n); const INT64_MAX = 2n ** 63n - 1n; -// Exactly `strconv.ParseBool`'s accepted sets. `1`/`0` are also in them, but -// the integer branch below wins first, since `Atoi` runs before `ParseBool` -// (`update.go:43-48`). +// The literal sets that count as boolean values. `1`/`0` also match, but the integer branch +// above claims them first. const GO_TRUE_LITERALS = new Set(["1", "t", "T", "TRUE", "true", "True"]); const GO_FALSE_LITERALS = new Set(["0", "f", "F", "FALSE", "false", "False"]); /** - * Coercion chain for `--config key=value` (`update.go:41-49`): - * `strconv.Atoi` → `strconv.ParseBool` → keep as string. `Atoi` fails with - * `ErrRange` on digits beyond int64, and a pure digit string is not a - * `ParseBool` literal (only bare `1`/`0` are, and those fit in int64), so an - * overflowing integer falls through to the verbatim string. `ParseBool` is - * case-SENSITIVE over a fixed set — `tRuE` stays a string. + * Coerces a `--config key=value` value: integer, then boolean, then string. * - * Residual divergence: digits in `(2^53, 2^63)` fit int64, so the reference - * implementation sends an exact JSON integer, while JS `Number` loses - * precision there — `encodeGoStructJsonBody` is `JSON.stringify`, which - * cannot emit exact int64 tokens beyond `Number.MAX_SAFE_INTEGER`. + * An integer outside the 64-bit range falls through to the string; boolean matching is + * case-sensitive over a fixed literal set (`tRuE` stays a string). Integers between `2^53` + * and `2^63` still lose precision here, since the JSON encoder is `JSON.stringify`, which + * can't emit exact integers beyond `Number.MAX_SAFE_INTEGER`. */ export function parseConfigValue(value: string): string | number | boolean { if (/^[+-]?\d+$/.test(value)) { @@ -188,11 +178,9 @@ export const fetchCurrentPostgresConfig = Effect.fn("postgres-config.fetch-curre }); /** - * Per-operation error wiring for {@link putPostgresConfig}. Both `update` and - * `delete` issue the same PUT, but tag failures with their own error types and - * Go-parity message verbs. Passing the constructors and message templates as - * arguments (mirroring `mapHttpError`) keeps each call site's error - * channel precise instead of widening it to the union of both operations. + * Per-operation error wiring for {@link putPostgresConfig}. Both `update` and `delete` issue + * the same PUT but tag failures with their own error types and message verbs (mirroring + * `mapHttpError`), keeping each call site's error channel precise. */ export interface PutPostgresConfigErrors<SerErr, NetErr, StatErr, UnmErr> { readonly serializeError: (args: { readonly message: string }) => SerErr; @@ -218,9 +206,8 @@ export const putPostgresConfig = <SerErr, NetErr, StatErr, UnmErr>( const cliSettings = yield* CommandSettings; const tokenOpt = yield* resolveAccessToken; - // Use raw HTTP instead of the generated input schema: Go accepts arbitrary - // config keys from repeated `--config key=value`, while the typed client - // only models the currently known OpenAPI fields. + // Uses raw HTTP instead of the generated input schema, since --config accepts arbitrary + // keys the typed client's OpenAPI-modeled fields don't cover. const encodedBody = yield* Effect.try({ try: () => encodeGoStructJsonBody(config), catch: (cause) => @@ -268,9 +255,8 @@ export const writePostgresConfigOutput = Effect.fn("postgres-config.write-output const outputFlag = yield* OutputFlag; const goOutput = Option.getOrUndefined(outputFlag); - // The `--output` flag takes priority over the TS `--output-format` flag. - // `pretty` (and an unset flag) fall through to the human-readable table / - // structured-success path below. + // --output takes priority over --output-format; pretty (or unset) falls through to the + // table / structured-success path below. if (goOutput === "json") { yield* output.raw(encodeGoJson(config)); return; diff --git a/apps/cli/src/commands/postgres-config/update/update.command.ts b/apps/cli/src/commands/postgres-config/update/update.command.ts index 30f4c5b201..72fce371eb 100644 --- a/apps/cli/src/commands/postgres-config/update/update.command.ts +++ b/apps/cli/src/commands/postgres-config/update/update.command.ts @@ -13,10 +13,10 @@ import { } from "../../../telemetry/command-telemetry.ts"; import { postgresConfigUpdate } from "./update.handler.ts"; -// Go declares `--config` with pflag's `StringSliceVar` (`cmd/postgres.go:59`); -// malformed CSV fails at parse time with pflag's exact diagnostic (CLI-2005, -// see `stringSliceFlag`) — before the `--experimental` gate, matching -// cobra's ParseFlags-before-PersistentPreRunE ordering. +/** + * CSV-splits each occurrence into config overrides, failing at parse time with pflag's + * diagnostic on malformed CSV — before the --experimental gate runs. + */ export const postgresConfigUpdateConfigFlag = stringSliceFlag( "config", "Config overrides specified as a 'key=value' pair", @@ -47,17 +47,12 @@ export const postgresConfigUpdateCommand = Command.make("update", config).pipe( Command.withShortDescription("Update Postgres database config"), Command.withHandler((flags) => Effect.gen(function* () { - // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) - // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an - // invalid `-o` value must win over a missing `--experimental` flag. + // Validate the -o value before the --experimental gate, so an invalid value is + // reported even without --experimental set. yield* validateOutputFormat(RESOURCE_OUTPUT_FORMATS); - // Go gates `postgresCmd` behind `--experimental` in PersistentPreRunE - // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). - // `managementApiRuntimeLayer` eagerly resolves an access token as part - // of building its `CommandPlatformApi` layer, so it must be provided AFTER - // the gate (inline here) rather than via `Command.provide` on the whole - // command — `Command.provide` would build the layer, and fail on a missing - // token, before this generator's first `yield*` ever runs. + // managementApiRuntimeLayer eagerly resolves an access token, so it's provided here + // (after the gate) rather than via Command.provide, which would build it — and fail + // on a missing token — before this generator's first yield* runs. yield* requireExperimental; return yield* postgresConfigUpdate(flags).pipe( withCommandTelemetry({ flags }), diff --git a/apps/cli/src/commands/postgres-config/update/update.command.unit.test.ts b/apps/cli/src/commands/postgres-config/update/update.command.unit.test.ts index db9ecd632a..34f5d29866 100644 --- a/apps/cli/src/commands/postgres-config/update/update.command.unit.test.ts +++ b/apps/cli/src/commands/postgres-config/update/update.command.unit.test.ts @@ -32,9 +32,6 @@ describe("postgres-config update --config flag (pflag StringSlice parity)", () = }); test("keeps only the first CSV record of a multiline value (pflag reads ONE record)", async () => { - // Go-verified (CLI-2005): `postgres-config update --config $'a=1\nb"2'` - // raises no parse error — pflag calls `csv.Reader.Read()` once, so the - // malformed second line is silently dropped. const [, values] = await Effect.runPromise( postgresConfigUpdateConfigFlag .parse({ @@ -60,8 +57,7 @@ describe("postgres-config update --config flag (pflag StringSlice parity)", () = expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - // Matches pflag's own diagnostic (`"max_connections=100` is 20 bytes → - // EOF at column 21). + // `"max_connections=100` is 20 bytes, so pflag's CSV reader hits EOF at column 21. expect(normalizeCause(exit.cause).message).toBe( 'invalid argument "\\"max_connections=100" for "--config" flag: parse error on line 1, column 21: extraneous or missing " in quoted-field', ); @@ -69,8 +65,6 @@ describe("postgres-config update --config flag (pflag StringSlice parity)", () = }); test("rejects a blank-only value with pflag's EOF diagnostic", async () => { - // Go-verified (CLI-2005): `postgres-config update --config $'\n'` → - // `invalid argument "\n" for "--config" flag: EOF`. const exit = await Effect.runPromise( postgresConfigUpdateConfigFlag .parse({ From 99d07a73dd0c2c5d121f0e6530acfecc2fb0f273 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 12:47:27 +0100 Subject: [PATCH 23/56] chore(repo): trim comments, shard 05 (1774 -> 769 comment lines) --- .../cli/src/command-internal/container-cli.ts | 135 +--- .../db-bootstrap/await-storage-ready.ts | 42 +- .../await-storage-ready.unit.test.ts | 7 - .../db-bootstrap/bootstrap-config.ts | 144 ++-- .../db-bootstrap/container-lifecycle.ts | 318 +++------ .../container-lifecycle.unit.test.ts | 38 +- .../command-internal/db-bootstrap/db-setup.ts | 628 +++++------------- .../db-bootstrap/db-setup.unit.test.ts | 42 +- .../db-bootstrap/docker-create-args.ts | 426 +++--------- .../docker-create-args.unit.test.ts | 13 +- .../db-bootstrap/health-check.ts | 193 ++---- 11 files changed, 490 insertions(+), 1496 deletions(-) diff --git a/apps/cli/src/command-internal/container-cli.ts b/apps/cli/src/command-internal/container-cli.ts index 2f04c8b9b7..9318b3dcd8 100644 --- a/apps/cli/src/command-internal/container-cli.ts +++ b/apps/cli/src/command-internal/container-cli.ts @@ -9,25 +9,17 @@ import { } from "../shared/telemetry/error-actionability.ts"; /** - * Container CLIs tried in order: Docker is preferred, Podman is the fallback - * for Docker-less hosts (e.g. Podman-only Linux setups). - * - * Both helpers fall back to `podman` only when the `docker` executable cannot - * be spawned. Once a runtime starts, its container/daemon exit code and stderr - * propagate unchanged, so callers keep Docker's error semantics. This mirrors - * the `gen types --local` behaviour in `commands/gen/types/types.handler.ts`. + * Container CLIs tried in order: Docker preferred, Podman as the fallback for Docker-less hosts. + * A runtime's own exit code and stderr propagate unchanged once it starts, so callers keep + * Docker's error semantics regardless of which runtime answered. */ type Spawner = ChildProcessSpawner["Service"]; /** - * Raised when neither `docker` nor `podman` can be spawned at all (e.g. neither - * is installed or on `PATH`) — distinct from a spawned process exiting non-zero. - * Callers never need to match on this type directly, they fold it into their - * own tagged error via {@link describeContainerCliFailure} so the "no - * runtime found" root cause survives instead of collapsing into a generic - * "failed to ..." message. Exported only so the coverage test can verify its - * own actionability declaration. + * Raised when neither `docker` nor `podman` can be spawned at all, as opposed to a spawned + * process exiting non-zero. Callers fold this into their own tagged error via + * {@link describeContainerCliFailure} so the "no runtime found" root cause survives. */ export class ContainerRuntimeNotFoundError extends Data.TaggedError( "ContainerRuntimeNotFoundError", @@ -39,22 +31,11 @@ export class ContainerRuntimeNotFoundError extends Data.TaggedError( } } -/** - * Exported so `docker-suggest.ts`'s daemon-unreachable matcher can test - * against this literal directly instead of a hardcoded copy of the substring — - * keeping the producer and the classifier from drifting apart if this ever gets - * reworded. - */ +/** Matched by `docker-suggest.ts`'s daemon-unreachable classifier; keep the two in sync. */ export const containerRuntimeNotFoundMessage = "docker: command not found (podman also not found) — install Docker Desktop or Podman and ensure it is on PATH"; -/** - * Renders a caller-facing suffix for a `spawnContainerCli`/`containerCliExitCode` - * failure: the clear "neither runtime found" message when that's the cause, - * otherwise the underlying cause's own message (falling back to `String(cause)` - * for non-`Error` causes) so callers never collapse a real failure reason into a - * bare, uninformative "failed to ..." string. - */ +/** Formats a `spawnContainerCli`/`containerCliExitCode` failure cause for display. */ export function describeContainerCliFailure(cause: unknown): string { if (cause instanceof ContainerRuntimeNotFoundError) return cause.message; if (cause instanceof Error) return cause.message; @@ -65,9 +46,8 @@ export function describeContainerCliFailure(cause: unknown): string { export type ContainerRuntime = "docker" | "podman"; /** - * {@link spawnContainerCli}, but also reporting which runtime answered — for - * callers that print a command for the user to run themselves, where naming - * `docker` on a Podman-only host would be uncopyable advice. + * {@link spawnContainerCli}, but also reports which runtime answered, for callers that print a + * command for the user to copy — naming `docker` on a Podman-only host would be unusable. */ export const spawnContainerCliWithRuntime = ( spawner: Spawner, @@ -104,14 +84,10 @@ export const spawnContainerCli = ( ) => spawnContainerCliWithRuntime(spawner, args, options).pipe(Effect.map((it) => it.handle)); /** - * Run a container-CLI command and resolve to its exit code, mirroring the - * spawner's `exitCode` convenience for callers that only need the status. + * Runs a container-CLI command and resolves to its exit code. * - * `podmanArgs` lets a caller pass different argv to the Podman fallback than to - * Docker, for the rare case where the two aren't drop-in compatible on a given - * subcommand (e.g. `volume prune --all` — Docker-only, see - * `stop.handler.ts`'s volume-prune call). Defaults to reusing `args` unchanged, - * which is correct for every other call site. + * `podmanArgs` lets a caller pass different argv to the Podman fallback for a subcommand that + * isn't drop-in compatible between the two (e.g. `volume prune --all`, Docker-only). */ export const containerCliExitCode = ( spawner: Spawner, @@ -133,12 +109,7 @@ export const containerCliExitCode = ( ), ); -/** - * Folds a byte stream into a decoded string. Hoisted here (the shared home for - * container-CLI plumbing) so `container-lifecycle.ts`/`restart-services.ts`/ - * `docker-lifecycle.ts` — every module that spawns `docker`/`podman` and - * needs its stdout/stderr as text — stop each defining their own copy. - */ +/** Folds a byte stream into a decoded string. */ export function collectText(stream: Stream.Stream<Uint8Array, unknown>) { const decoder = new TextDecoder(); return Stream.runFold( @@ -149,21 +120,10 @@ export function collectText(stream: Stream.Stream<Uint8Array, unknown>) { } /** - * Docker's/Podman's "container doesn't exist" stderr shapes for `container inspect` — Docker's - * "No such container"/"No such object" (either casing depending on daemon version/CLI path) or - * Podman's own differently worded "no container with name or ID ... found: no such container" — - * the subprocess equivalent of `errdefs.IsNotFound` for a CLI-shelled-out (rather than - * Engine-API) caller. Case-insensitive and covers all three shapes so a lowercase Podman message - * is tolerated exactly like an uppercase Docker one — the same distinction - * `docker-image-resolve.ts`'s `isImageNotFoundMessage` draws for `image inspect`, and the - * same distinction the pre-existing Podman-aware parser in `commands/start/start.handler.ts`'s - * own (now-removed) local `isContainerNotFoundMessage` used to draw. Hoisted here (rather than - * left as separate per-caller copies) so every container-not-found check across the - * container-lifecycle/start/restart/health-check domain (`isLocalDbRunning`, - * `restartSatelliteService`, `reloadKong`, …) shares one predicate instead of - * re-deriving the same match with different Podman coverage — a reset excluding a satellite - * service (storage/auth/realtime/pooler) or Kong would otherwise report a hard restart/reload - * failure instead of tolerating the absent container. + * Matches "container doesn't exist" stderr from `container inspect`: Docker's "No such + * container"/"No such object" (casing varies by daemon version), or Podman's "no container with + * name or ID ... found: no such container". Case-insensitive so callers can tolerate an + * already-absent container instead of treating it as a hard failure. */ export function isContainerNotFoundMessage(message: string): boolean { return ( @@ -174,13 +134,11 @@ export function isContainerNotFoundMessage(message: string): boolean { } /** - * Runs a container-CLI command that must succeed outright — no tolerance for any - * failure mode (spawn failure, non-zero exit) — the shared shape behind every - * "docker verb target" primitive that fails hard on any problem - * (`removeContainer`/`removeVolume`/`restartContainer`; see - * `db-bootstrap/container-lifecycle.ts` and `db-bootstrap/restart-services.ts`). - * `verb` is the human-readable action embedded in the error message (e.g. - * `"remove container"` → `"failed to remove container: <cause>"`). + * Runs a container-CLI command that must succeed outright, failing on a spawn error or any + * non-zero exit. + * + * @param verb - Human-readable action embedded in the error message, e.g. `"remove container"` → + * `"failed to remove container: <cause>"`. */ export function runContainerCliExpectSuccess<E>( spawner: Spawner, @@ -214,13 +172,9 @@ export function runContainerCliExpectSuccess<E>( } /** - * Like {@link containerCliExitCode}, but also collecting the child's stdout — - * for callers that need the CLI's own report of what it did (e.g. the `docker - * … prune` deleted-ID lists backing `--debug` "Pruned …" reports in - * `DockerRemoveAll`). Collecting (i.e. reading) stdout - * also sidesteps the unread-pipe hang that `stdout: "ignore"` callers avoid by - * discarding it. stderr is discarded, matching the exit-code-only helper. - * `podmanArgs` has the same meaning as on {@link containerCliExitCode}. + * Like {@link containerCliExitCode}, but also returns the child's stdout — reading it instead of + * ignoring it avoids the process hanging on an unread pipe. `podmanArgs` behaves the same as on + * {@link containerCliExitCode}. */ export const containerCliExitCodeAndStdout = ( spawner: Spawner, @@ -247,10 +201,8 @@ export const containerCliExitCodeAndStdout = ( ), ), ); - // Subscribe to stdout concurrently with awaiting the exit code — Node's - // "exit" event can fire before a fast process's stdio pipes are drained, - // so a late subscriber would see an already-ended, empty stream (same - // pattern as `docker-lifecycle.ts`'s `spawnDockerPsLines`). + // Read stdout concurrently with the exit code: Node's "exit" event can fire before a fast + // process's stdio finishes draining, so awaiting the exit code first would race the stream. const [exitCode, stdout] = yield* Effect.all( [handle.exitCode.pipe(Effect.map(Number)), collectText(handle.stdout)], { concurrency: "unbounded" }, @@ -260,10 +212,8 @@ export const containerCliExitCodeAndStdout = ( ); /** - * Mirrors `versions.GreaterThanOrEqualTo` (`docker/api/types/versions`): - * splits each version on - * `.` and compares the parts numerically, component by component — not a - * naive string/float compare, which would misorder e.g. `"1.9"` vs `"1.10"`. + * Compares two dot-separated version strings numerically, component by component — a naive + * string/float compare would misorder e.g. `"1.9"` vs `"1.10"`. */ function isDockerApiVersionAtLeast(version: string, minVersion: string): boolean { const parts = version.split(".").map((part) => Number.parseInt(part, 10)); @@ -277,25 +227,12 @@ function isDockerApiVersionAtLeast(version: string, minVersion: string): boolean } /** - * Docker CLI's own `volume prune --all` flag is annotated `version: "1.42"` - * (vendored `docker/cli@v28.5.2`) and - * enforced by Cobra's `Args` validator *before* `RunE` runs: - * against a daemon with a lower negotiated - * API version, `docker volume prune --all ...` exits nonzero without pruning - * anything at all, rather than degrading gracefully. This gates the equivalent - * `all=true` filter on - * `Docker.ClientVersion() >= "1.42"`; there is no - * persistent Engine API client here to ask, so this asks the `docker` CLI - * itself via `docker version`. - * - * Deliberately does not fall back to Podman like {@link containerCliExitCode} - * does: Podman's `volume prune` never has an `--all` flag to gate in the - * first place (callers already omit it from their Podman argv - * unconditionally), so this check is meaningless on that path. Resolves to - * `false` (omit `--all`, matching how a pre-1.42 daemon's own `volume prune` - * already prunes every unused volume without it) whenever `docker` can't be - * spawned, its `version` call fails, or the reported version can't be read — - * the side that can never turn into a hard failure of the prune call itself. + * Checks whether the daemon's negotiated API version supports `docker volume prune --all` + * (added in 1.42). Older daemons reject the flag outright instead of ignoring it, so this must be + * checked via `docker version` before use; there's no persistent Engine API client to ask + * directly. Does not fall back to Podman, whose `volume prune` has no `--all` flag to gate. + * Resolves to `false` on any failure to spawn `docker` or read its version — the same effect as + * omitting `--all` on a pre-1.42 daemon, which already prunes every unused volume without it. */ export const dockerSupportsVolumePruneAllFlag = (spawner: Spawner) => Effect.scoped( diff --git a/apps/cli/src/command-internal/db-bootstrap/await-storage-ready.ts b/apps/cli/src/command-internal/db-bootstrap/await-storage-ready.ts index b72bb39379..24eb15c41e 100644 --- a/apps/cli/src/command-internal/db-bootstrap/await-storage-ready.ts +++ b/apps/cli/src/command-internal/db-bootstrap/await-storage-ready.ts @@ -1,27 +1,10 @@ /** - * Port of the storage-health gate `Run` runs inline before seeding buckets — not a - * standalone Go function despite the name here (`apps/cli-go/internal/db/reset/ - * reset.go:66-71`). Two things the seam this replaces got subtly wrong, corrected - * here: + * Gate that `db reset` runs before seeding storage buckets: skips seeding when the storage + * container is absent, and blocks on it becoming healthy otherwise. * - * 1. `resp, err := utils.Docker.ContainerInspect(ctx, utils.StorageId); if err != nil { - * return false, nil }` — ANY inspect error (not just "not found") maps to "absent" - * (`false`), matching Go exactly (`errdefs.IsNotFound` is never checked on this - * particular path). - * 2. `if resp.State.Health == nil || resp.State.Health.Status != types.Healthy { if err - * := start.WaitForHealthyService(ctx, 30*time.Second, utils.StorageId); err != nil { - * return false, err } }` — a container that EXISTS but is unhealthy (or has no - * healthcheck at all) triggers a real 30-SECOND wait, hardcoded independent of - * `db.health_timeout`; if that wait times out, the failure propagates and FAILS THE - * WHOLE RESET (not just "skip buckets") — dumping the storage container's logs to - * stderr on the way out, via `waitForHealthyServices`'s own existing behavior. - * - * Hoisted to `command-internal/db-bootstrap/` (CLI-2062): originally lived in - * `commands/db/reset/` since `db reset`'s own handler was its only caller — the - * bucket-seeding health gate has no equivalent in `db start`/`supabase start` at - * all (CLI-1955 review follow-up). `resetLocalDatabase` - * (`reset-local-database.ts`) is now a second caller (`db schema declarative`'s - * smart-target/sync recovery reset), so this moved alongside it. + * Any inspect error (not just "container not found") is treated as "absent". A container that + * exists but is unhealthy triggers a real wait, and a timeout there fails the whole reset rather + * than just skipping buckets. */ import { Effect, Result } from "effect"; @@ -34,17 +17,16 @@ import { waitForHealthyServices, type HealthCheckTimeoutError } from "./health-c type Spawner = ChildProcessSpawner["Service"]; -/** Go's hardcoded `30*time.Second` (`reset.go:68`) — independent of `db.health_timeout`. */ +/** Independent of `db.health_timeout`; storage bucket seeding always waits up to this long. */ const AWAIT_STORAGE_READY_TIMEOUT_SECONDS = 30; /** - * Resolves `true` when the storage container exists (so the caller should run the - * ported bucket-seeding core) and `false` when it does not (any inspect error) — - * matching Go, which silently skips buckets when storage is absent. Fails with - * {@link HealthCheckTimeoutError} when storage exists but never becomes healthy - * within 30 seconds — this is NOT swallowed into `false`, matching Go's own - * `return false, err` propagating the wait's error to the caller, which fails the - * entire reset. + * Resolves `true` when the storage container exists, `false` when it does not (including any + * inspect error, not just "not found"). + * + * @throws {@link HealthCheckTimeoutError} if storage exists but never becomes healthy within + * {@link AWAIT_STORAGE_READY_TIMEOUT_SECONDS}; that failure propagates rather than resolving to + * `false`. */ export function awaitStorageReady( spawner: Spawner, diff --git a/apps/cli/src/command-internal/db-bootstrap/await-storage-ready.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/await-storage-ready.unit.test.ts index 9244033d1a..55b5edb922 100644 --- a/apps/cli/src/command-internal/db-bootstrap/await-storage-ready.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/await-storage-ready.unit.test.ts @@ -65,7 +65,6 @@ describe("awaitStorageReady", () => { Effect.provide(unusedHttpClientLayer), Effect.map((ready) => { expect(ready).toBe(true); - // No `docker logs`/extra polling round needed — just the one inspect. expect(mock.spawned).toHaveLength(1); }), ); @@ -137,8 +136,6 @@ describe("awaitStorageReady", () => { Effect.provide(unusedHttpClientLayer), Effect.forkChild({ startImmediately: true }), ); - // Go's hardcoded 30-second wait (`start.WaitForHealthyService(ctx, 30*time.Second, - // utils.StorageId)`, reset.go:121) — 30 retries after the initial attempt. for (let i = 0; i < 30; i++) { yield* TestClock.adjust("1 seconds"); } @@ -170,12 +167,8 @@ describe("awaitStorageReady", () => { for (let i = 0; i < 29; i++) { yield* TestClock.adjust("1 seconds"); } - // Not yet exhausted — 29 retries is one short of the hardcoded 30-second cap. If this - // constant were ever accidentally shortened (e.g. to 3s), the fiber would already be - // done here, failing this assertion instead of silently passing. expect(fiber.pollUnsafe()).toBeUndefined(); - // The 30th second crosses the boundary. yield* TestClock.adjust("1 seconds"); const exit = yield* Fiber.await(fiber); expect(Exit.isFailure(exit)).toBe(true); diff --git a/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts b/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts index 50458e6b94..8282bdae18 100644 --- a/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts +++ b/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts @@ -1,24 +1,11 @@ /** - * The config-derivation prelude Go's `StartDatabase` (`apps/cli-go/internal/db/start/ - * start.go:133-190`) relies on being ALREADY resolved on `utils.Config` by the time it runs - * (Go's `Config.Load` folds every `SUPABASE_*` override into the single global `Config` - * struct once, at process start, before either `db start` or `supabase start`'s own `Run` - * ever executes) — every field here feeds either the Postgres container spec itself or the - * fresh-volume `SetupLocalDatabase`-equivalent pipeline. Shared by both callers of - * `startDatabase` (`./start-database.ts`) so a future Go change to one of these - * fields' derivation only needs to change in one TS home — see `apps/cli/CLAUDE.md`'s - * "Hoist Before You Duplicate" rule and CLI-1954's own report for why this was split out - * from `commands/start/start.handler.ts`. + * Resolves the config fields `startDatabase` needs from `config`/`projectEnvValues`, folding in + * any `SUPABASE_*` override. Shared by `db start` and `supabase start` so each field's derivation + * has one home. * - * Deliberately NOT included here (stays each caller's own concern, since it's either - * `supabase start`-only or caller-timing-sensitive — see `start-database.ts`'s header): - * `--exclude` gate evaluation, JWKS resolution (`supabase start` resolves it once, eagerly, - * for its long-running containers too; `db start` resolves it lazily, conditionally, deep - * inside the fresh-volume setup step, matching Go's own `initSchema15`-local - * `ResolveJWKS` call — the two callers' timing genuinely differs, so `startDatabase` - * takes this as a caller-supplied `Effect` instead), the Postgres registry-image resolve - * (`db start` resolves lazily, per-container; `supabase start` already resolved it as part - * of its own batched pre-pull before bring-up — same caller-supplied-`Effect` treatment). + * Excludes `--exclude` gate evaluation, JWKS resolution, and the Postgres registry-image resolve: + * their timing differs between the two callers, so `startDatabase` takes them as a + * caller-supplied `Effect` instead. See `start-database.ts`. */ import type { CliConfig } from "@supabase/config"; @@ -45,15 +32,9 @@ export interface DbBootstrapConfigInput { readonly projectEnvValues: Readonly<Record<string, string>> | undefined; readonly workdir: string; /** - * Config keys a matched `[remotes.<ref>]` block contributed at viper's OVERRIDE tier - * (Go's `v.Set`, applied ABOVE `AutomaticEnv` — `apps/cli-go/pkg/config/config.go: - * 724`) — see `db-config.toml-read.ts`'s `RemoteOverride. - * remoteOverrideKeys` doc comment for the full precedence rationale. Every - * `envOverride*` call below must NOT re-apply a `SUPABASE_*` value for a field - * the remote block already set. Defaults to empty: `db start`/`db reset` never resolve - * a remote block for this config read (see `buildLocalDbContainerInputs`'s own - * doc comment), so they're unaffected; `db diff --linked`/`db pull` (CLI-1956) pass - * the set their sibling `readDbToml` call already computed. + * Config keys a matched `[remotes.<ref>]` block set at override tier; every `envOverride*` call + * below must not re-apply a `SUPABASE_*` value for a field this set already covers. Defaults to + * empty for `db start`/`db reset`, which never resolve a remote block here. */ readonly remoteOverrideKeys?: ReadonlySet<string>; } @@ -65,14 +46,14 @@ export interface DbBootstrapConfig { readonly s3Region: string | undefined; readonly s3AccessKey: string | undefined; readonly s3SecretKey: string | undefined; - /** Go's one-shot fresh-DB setup jobs' own `Enabled` gates — see `start-database.ts`'s header. */ + /** Effective enabled flags read by the one-shot fresh-DB setup jobs, after any override. */ readonly realtimeEnabledForSetup: boolean; readonly storageEnabledForSetup: boolean; readonly authEnabledForSetup: boolean; readonly realtimeIpVersion: "IPv4" | "IPv6"; readonly realtimeMaxHeaderLength: number; readonly storageFileSizeLimit: CliConfig["storage"]["file_size_limit"]; - /** Pull/create image (`utils.Config.Db.Image` after slim rewrite). The caller still resolves the registry candidate itself. */ + /** Pull/create image; the caller still resolves the registry candidate itself. */ readonly postgresImage: string; /** Unprefixed docker.io identity for INITDB version-compare. Never a slim ghcr ref. */ readonly postgresConfigImage: string; @@ -82,14 +63,11 @@ export interface DbBootstrapConfig { } /** - * Wraps a synchronous `envOverride*` read that throws on a malformed value into a - * typed failure, matching Go's `Config.Load` hard-failing on a bad Viper decode - * (`pkg/config/config.go:749-756`) before any Docker work — instead of leaking an untyped - * Effect defect. Message format (`invalid config for <path>: <cause>`) matches - * `commands/start/start.handler.ts`'s own identically-shaped `wrapConfigOverride`, which - * this module doesn't import from (that one stays private to `start.handler.ts`, covering - * many more start-only fields; duplicating this ~10-line generic wrapper avoids a - * `command-internal/` -> `commands/start/` dependency for a trivial utility). + * Wraps a synchronous `envOverride*` read that throws on a malformed value into a typed failure + * instead of an untyped Effect defect. + * + * @param dottedFieldPath - Config path embedded in the error message (`invalid config for + * <path>: <cause>`). */ function wrapConfigOverride<T, E>( dottedFieldPath: string, @@ -106,14 +84,12 @@ function wrapConfigOverride<T, E>( } /** - * Resolves every field {@link startDatabase} (`./start-database.ts`) needs from - * `config`/`projectEnvValues`, in Go's own `Config.Load` sense: values already folded from - * any `SUPABASE_*` override, ready to feed the Postgres container spec and the fresh-volume - * setup pipeline. `mapConfigError` lets each caller tag a malformed-override failure with - * its own command-specific error type — `db start` uses `DbConfigLoadError`; - * `supabase start` uses its own `StartInvalidConfigError`, matching the class its - * existing tests already assert for these fields — mirroring the `mapConfigLoadError` - * idiom `local-project-context.ts`'s `loadLocalProjectContext` already uses. + * Resolves every field {@link startDatabase} needs from `config`/`projectEnvValues`: values + * already folded with any `SUPABASE_*` override, ready to feed the Postgres container spec and + * the fresh-volume setup pipeline. + * + * @param mapConfigError - Lets each caller tag a malformed-override failure with its own error + * type. */ export const resolveDbBootstrapConfig = <E>( fs: FileSystem.FileSystem, @@ -126,20 +102,14 @@ export const resolveDbBootstrapConfig = <E>( const remoteOverrideKeys = input.remoteOverrideKeys ?? new Set<string>(); const remoteWins = makeRemoteWins(remoteOverrideKeys); - // Go's `Config.Load` folds `SUPABASE_DB_MAJOR_VERSION` into `c.Db.MajorVersion` before the - // image-selection switch runs (`pkg/config/config.go:585-586,819-827`) — every later read of - // `utils.Config.Db.MajorVersion` sees this same value. Not wrapped: `checkDbToml` - // (called by both callers before this function) already validates this override. - // A matched remote block's `db.major_version` was installed at viper's OVERRIDE tier - // (above `AutomaticEnv`), so it must win over a conflicting `SUPABASE_DB_MAJOR_VERSION`. + // Not wrapped: checkDbToml already validates this override. A matched remote block's value + // wins over a conflicting SUPABASE_DB_MAJOR_VERSION. const majorVersion = remoteWins("db.major_version") ? config.db.major_version : envOverrideMajorVersion(config.db.major_version, projectEnvValues); - // `experimental.orioledb_version` -> `Config.Db.Image` rewrite (`pkg/config/config.go: - // 1041-1046`), plus its four sibling S3 fields Go reads into the Postgres container's `S3_*` - // env alongside it (`apps/cli-go/internal/db/start/start.go:70-77`). Both `envOverride` - // calls never throw (return the override or the configured value verbatim), so no wrap needed. - // Same remote-over-env precedence as `majorVersion` above applies to each of these. + // orioledb_version and the four S3 fields feed the Postgres container's image/env directly. + // `envOverride` never throws, so these don't need `wrapConfigOverride`. Same remote-over-env + // precedence as `majorVersion` applies to each. const orioledbVersion = remoteWins("experimental.orioledb_version") ? config.experimental.orioledb_version : envOverride( @@ -172,13 +142,9 @@ export const resolveDbBootstrapConfig = <E>( projectEnvValues, ); - // Go's one-shot fresh-DB setup jobs (`initSchema15`) read `utils.Config. - // {Realtime,Storage,Auth}.Enabled` — the EFFECTIVE, env-overridden value — and run - // regardless of `--exclude` (`internal/db/start/start.go:270,299,321`) whenever they - // actually execute. `supabase start` also reads the SAME override for its own `gates.*` - // (`start.gates.ts`'s `resolveStartGates`, wrapped there too) — this wrap is - // harmless, redundant belt-and-suspenders for that caller, and the ONLY protection `db - // start` has (it has no `--exclude`/`gates` equivalent at all). + // The one-shot fresh-DB setup jobs use the effective, overridden enabled value and run + // regardless of `--exclude`. Wrapping here is `db start`'s only protection against a + // malformed override; it has no `--exclude`/gates equivalent of its own. const realtimeEnabledForSetup = yield* wrapConfigOverride( "realtime.enabled", () => @@ -219,21 +185,14 @@ export const resolveDbBootstrapConfig = <E>( mapConfigError, ); - // Both the long-running Realtime container (`supabase start` only) AND the PG15+ one-shot - // Realtime setup job (both callers, via `startSetupLocalDatabase`) must see the SAME - // already-overridden values (Go's single `utils.Config.Realtime` source of truth, - // `internal/start/start.go:922,928`, `internal/db/start/start.go:283,290`). + // The long-running Realtime container and the one-shot PG15+ setup job must see the same + // overridden value. const realtimeIpVersion = yield* wrapConfigOverride( "realtime.ip_version", () => { - // `envOverrideRealtimeIpVersion` itself reads `process.env` unconditionally - // (`envOverride`'s own fallback, regardless of `projectEnvValues`), so it can't - // simply be called with a neutered `projectEnvValues` here — that would still let a - // raw shell `SUPABASE_REALTIME_IP_VERSION` beat the remote block's viper OVERRIDE-tier - // value. Skip the override call entirely on this branch instead, re-validating into - // the same narrow type (the value is already guaranteed one of these two literals by - // `@supabase/config`'s own schema decode — `stringEnum(["IPv4","IPv6"])` — this only - // narrows the TS type to match {@link DbBootstrapConfig.realtimeIpVersion}). + // `envOverrideRealtimeIpVersion` reads `process.env` unconditionally, so it can't be + // called at all when the remote block wins — a raw env var would still beat it. The + // throw below only narrows the type; the schema already guarantees one of these values. if (remoteWins("realtime.ip_version")) { const value = config.realtime.ip_version; if (value !== "IPv4" && value !== "IPv6") { @@ -254,15 +213,9 @@ export const resolveDbBootstrapConfig = <E>( mapConfigError, ); - // Same reasoning for Storage's file-size limit — both the long-running container - // (`supabase start` only) AND the one-shot storage migrate job (both callers) must see the - // same already-overridden value (Go's `internal/start/start.go:1004`, `internal/db/start/ - // start.go:307`, both reading the single `utils.Config.Storage.FileSizeLimit`). - // `@supabase/config`'s schema accepts `file_size_limit` as a plain string — it does not parse - // the size grammar itself, so a malformed value must be validated eagerly here (Go's - // `sizeInBytes.UnmarshalText`, `pkg/config/config.go:39-49`, decodes it unconditionally during - // `Config.Load`, before either caller touches Docker) rather than left to surface only when a - // container env builder happens to re-parse it. + // Same reasoning as Realtime's IP version above. `@supabase/config`'s schema stores + // `file_size_limit` as a plain string without parsing it, so a malformed value must be + // validated eagerly here rather than surfacing later inside a container env builder. const storageFileSizeLimit = remoteWins("storage.file_size_limit") ? config.storage.file_size_limit : (envOverride( @@ -276,10 +229,8 @@ export const resolveDbBootstrapConfig = <E>( mapConfigError, ); - // Go's `Config.Load` rewrites `c.Db.Image` from `supabase/.temp/postgres-version` (a - // linked-project pin written by `supabase link`) BEFORE either caller reads it - // (`pkg/config/config.go:827-863`) — never fails (a missing/unreadable pin file resolves to - // the embedded default), so no wrap needed. + // Reads a `supabase/.temp/postgres-version` pin written by `supabase link`; a missing or + // unreadable pin falls back to the embedded default, so this never fails. const { image: postgresImage, configImage: postgresConfigImage } = yield* resolveDbImage( fs, path, @@ -287,9 +238,8 @@ export const resolveDbBootstrapConfig = <E>( majorVersion, orioledbVersion, ); - // Ditto for `c.Realtime.Image`/`c.Storage.Image`/`c.Auth.Image` — read once, reused by the - // fresh-DB one-shot setup jobs' images regardless of whether this run's volume turns out to - // be fresh at all. Never fails, same reasoning. + // Read once and reused by the fresh-DB one-shot setup jobs regardless of whether this run's + // volume turns out to be fresh; never fails. const serviceVersionOverrides = yield* readServiceVersionOverrides( fs, path, @@ -297,9 +247,6 @@ export const resolveDbBootstrapConfig = <E>( majorVersion, ); - // Overridden by SUPABASE_DB_HEALTH_TIMEOUT — Go's Config.Load binds this generically before - // StartDatabase's health wait reads it (pkg/config/config.go:580-586, internal/db/start/ - // start.go:180). const dbHealthTimeout = remoteWins("db.health_timeout") ? config.db.health_timeout : envOverride("SUPABASE_DB_HEALTH_TIMEOUT", config.db.health_timeout, projectEnvValues); @@ -311,11 +258,8 @@ export const resolveDbBootstrapConfig = <E>( ), }); - // Go's `config.Load` reads `supabase/.temp/storage-migration` (written by `supabase link`) - // into `Config.Storage.TargetMigration` whenever present (`pkg/config/config.go:844-846`), - // feeding `DB_MIGRATIONS_FREEZE_AT` for the fresh-DB one-shot Storage migrate job. Any read - // error (including not-exist) or blank content resolves to "", matching Go's `err == nil && - // len(version) > 0` gate — never fails. + // Feeds `DB_MIGRATIONS_FREEZE_AT` for the one-shot Storage migrate job. Any read error + // (including not-exist) or blank content resolves to "". const storageTargetMigration = yield* fs .readFileString(tempPaths(path, workdir).storageMigration) .pipe( 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 595182f3bc..3a68a3fe22 100644 --- a/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.ts +++ b/apps/cli/src/command-internal/db-bootstrap/container-lifecycle.ts @@ -1,16 +1,11 @@ /** - * Port of Go's `DockerStart` (`apps/cli-go/internal/utils/docker.go:363-440`): - * given a fully-resolved {@link StartContainerSpec} (image already - * resolved by `image-prepull.ts` — see its doc comment), sets the two - * project-identity labels, provisions this container's own named volumes, - * builds the `docker create` argv (`docker-create-args.ts`), spawns `docker - * create`, copies any `secretFiles` into the just-created container via - * `docker cp` (see {@link copyStartSecretFilesIntoContainer}), then - * spawns `docker start`. + * Given a fully-resolved {@link StartContainerSpec}, sets the two project-identity labels, + * provisions the container's own named volumes, builds the `docker create` argv, spawns `docker + * create`, copies any `secretFiles` in via `docker cp` (see + * {@link copyStartSecretFilesIntoContainer}), then spawns `docker start`. * - * Network creation (`DockerNetworkCreateIfNotExists`) is deliberately NOT part - * of this per-container function — see {@link ensureNetwork}'s doc - * comment for why it is hoisted to run once instead of once per container. + * Network creation is not part of this per-container function; see {@link ensureNetwork} for why + * it runs once instead of once per container. */ import { Data, Effect, Stream } from "effect"; @@ -42,29 +37,17 @@ import { type StartContainerSpec, } from "./docker-create-args.ts"; -/** Structural element type of {@link StartContainerSpec.secretFiles} — not exported from `docker-create-args.ts`, so referenced positionally here. */ type StartSecretFileSpec = NonNullable<StartContainerSpec["secretFiles"]>[number]; -/** Structural element type of {@link StartContainerSpec.preStartArchives} — same reasoning as {@link StartSecretFileSpec}. */ type StartPreStartArchiveSpec = NonNullable<StartContainerSpec["preStartArchives"]>[number]; type Spawner = ChildProcessSpawner["Service"]; /** - * Go's `composeProjectLabel` (`apps/cli-go/internal/utils/docker.go:60`, - * unexported there). This port does not integrate with docker-compose - * anywhere (an intentional architecture decision), but the label is still set - * unconditionally, matching Go's own unconditional assignment - * (`docker.go:376`) regardless of whether compose is actually in use: external - * tooling that groups/filters containers by this label (Docker Desktop's - * Compose view, `docker compose ls`, the VS Code Docker extension) would - * otherwise silently stop recognizing the local stack's containers. - * - * A same-value private constant already exists at - * `shared/functions/functions-docker.ts` (`dockerComposeProjectLabel`, for the - * unrelated `functions deploy`/`functions serve` Docker Desktop extension - * gateway) but is neither exported nor in the same Docker-usage domain as - * `start` — not hoisted from there. + * Set unconditionally on every container, even though this CLI doesn't integrate with + * docker-compose: external tooling that groups/filters containers by this label (Docker + * Desktop's Compose view, `docker compose ls`, the VS Code Docker extension) would otherwise + * stop recognizing the local stack's containers. */ export const COMPOSE_PROJECT_LABEL = "com.docker.compose.project"; @@ -133,62 +116,34 @@ export type ContainerError = VolumeCreateError | ContainerCreateError | Containe export interface ContainerOpts { /** - * Go's `Config.ProjectId`, already sanitized (`sanitizeProjectId`) by - * the caller's config-load pipeline — `DockerStart` itself performs no - * sanitization, it just reads the already-sanitized singleton - * (`docker.go:375-376`). Merged onto both {@link CLI_PROJECT_LABEL} - * and {@link COMPOSE_PROJECT_LABEL}, overwriting any value the caller - * may have already set under those keys in `spec.labels` — exactly like - * Go's unconditional map assignment. + * Merged onto both {@link CLI_PROJECT_LABEL} and {@link COMPOSE_PROJECT_LABEL}, overwriting + * any value the caller already set for those keys in `spec.labels`. */ readonly projectId: string; - /** - * `os.Getenv("BITBUCKET_CLONE_DIR") != ""` — see `isBitbucketPipeline` - * (`shared/bitbucket-pipeline.ts`). Passed in rather than read here so - * this module stays a pure effect orchestrator with no direct env access, - * matching `applyBitbucketStartContainerFilter`'s own boolean-flag - * shape (`docker-create-args.ts`). - */ + /** Passed in (rather than read via env here) so this module stays a pure effect orchestrator. */ readonly isBitbucketPipeline: boolean; /** - * `CommandSettings.workdir` — the project's own working directory. Stamped onto every - * created container as {@link CLI_WORKDIR_LABEL} (see that constant's doc - * comment) so a later `stop`/{@link rollbackStart} can find this exact directory - * again from the container's own label, without depending on being invoked from the - * same cwd/`--workdir` `start` was. + * Stamped onto every created container as {@link CLI_WORKDIR_LABEL} so a later `stop` or + * {@link rollbackStart} can find this directory from the container's own label alone, without + * depending on the invoking cwd/`--workdir` matching `start`'s. * - * NOT used to stage {@link StartContainerSpec.secretFiles} on host disk anymore — - * those are delivered straight into the created container via `docker cp` (see - * {@link copyStartSecretFilesIntoContainer}), so they never touch host disk at - * all. This label is still load-bearing for OTHER host-persisted staging under the same - * `<workdir>/supabase/.temp/start-secrets/<containerName>/` tree that this function - * itself never writes — e.g. Edge Runtime's own env-file/multiline-env-script - * staging (`shared/functions/serve.ts`'s `startEdgeRuntimeContainer`), - * which `cleanupStartSecrets` (`start-secrets-cleanup.ts`) still reclaims - * by this same label once that container is torn down. + * `cleanupStartSecrets` also uses this label to reclaim other host-persisted staging under this + * project (e.g. Edge Runtime's env-file staging) once a container is torn down. */ readonly workdir: string; /** - * `DockerStart`'s platform-specific `extraHosts` package var - * (`docker_linux.go`/`docker_darwin.go`/`docker_windows.go`), merged onto - * EVERY container's `HostConfig.ExtraHosts` (`docker.go:378`) — Linux-only - * (`["host.docker.internal:host-gateway"]`); empty on Docker Desktop - * platforms, which already resolve that hostname natively. Merged here - * (not per-spec) for the same reason the two project-identity labels are: - * Go applies it identically to every container this orchestrator creates. + * Merged onto every container's `HostConfig.ExtraHosts`. Linux-only + * (`["host.docker.internal:host-gateway"]`); empty on Docker Desktop platforms, which already + * resolve that hostname natively. */ readonly extraHosts: ReadonlyArray<string>; } /** - * Extracts every named-volume source from `binds` (Go's `loader.ParseVolume` - * classification loop, `docker.go:388-399`): a bind is `source:target[:mode]` - * (see `docker-create-args.ts`'s `StartContainerSpec.binds` doc comment - * and its own worked examples in `docker-create-args.unit.test.ts`), and its - * source segment is a named volume exactly when - * {@link isBindMountSource} says it is NOT a bind-mount path. No dedupe - * is applied — Go's own `sources` slice doesn't dedupe either, and - * `Docker.VolumeCreate` is idempotent for a repeated name. + * Extracts every named-volume source from `binds` (`source:target[:mode]`; see + * `docker-create-args.ts`'s `StartContainerSpec.binds` doc comment). A source is a named volume + * exactly when {@link isBindMountSource} says it is not a bind-mount path. Not deduped — + * `docker volume create` is idempotent for a repeated name. */ function namedVolumeSources(binds: ReadonlyArray<string>): ReadonlyArray<string> { const sources: Array<string> = []; @@ -202,19 +157,15 @@ function namedVolumeSources(binds: ReadonlyArray<string>): ReadonlyArray<string> } /** - * Whether `docker`/`podman network create`'s stderr reports the network - * already existing — the CLI-subprocess equivalent of Go's - * `errdefs.IsConflict(err)` (`docker.go:70`), which inspects a structured - * Engine API error instead of stderr text. Docker's real message is `Error - * response from daemon: network with name <id> already exists`; Podman's is - * worded differently (`network name <id> already used`), hence the broader - * pattern rather than matching Docker's exact sentence. + * Whether `docker`/`podman network create`'s stderr reports the network already existing. + * Docker's message is "network with name <id> already exists"; Podman's is "network name <id> + * already used" — hence the broader pattern instead of matching Docker's exact wording. */ function isNetworkAlreadyExistsError(stderr: string): boolean { return /already exists|already used/iu.test(stderr); } -/** Go's `portErrorPattern` (`apps/cli-go/internal/utils/docker.go:657`). */ +/** Matches Docker's "port is already allocated" bind error, capturing the port spec. */ const PORT_BIND_ERROR_PATTERN = /Bind for (.*) failed: port is already allocated/; function parsePortBindError(stderr: string): string | undefined { @@ -222,20 +173,9 @@ function parsePortBindError(stderr: string): string | undefined { } /** - * A scoped-down port of Go's `suggestDockerStop` - * (`apps/cli-go/internal/utils/docker.go:667-686`): Go lists every running - * container, matches the failed host port against each container's own - * published ports, and reports either `supabase stop --project-id <id>` (the - * port owner carries the CLI project label) or a bare `docker stop <name>` - * (it doesn't) — then `docker.go:425-436` appends a further "configure a - * different port" suggestion on top of that. - * - * Reproducing the lookup would mean this pure CLI-orchestration function also - * lists and inspects every other running container purely to word a hint — - * disproportionate plumbing for a suggestion string. This reproduces only the - * detection Go itself starts from (the same regex match) and folds both of - * Go's suggestion branches into one still-actionable sentence that covers - * either case without the extra lookup. + * Suggests fixes for a "port already allocated" failure without inspecting every running + * container to identify the specific owner — disproportionate plumbing for a hint — folding both + * possible causes into one still-actionable sentence. */ function portConflictSuggestion(hostPort: string, serviceLabel: string): string { return ( @@ -246,29 +186,13 @@ function portConflictSuggestion(hostPort: string, serviceLabel: string): string } /** - * Go's `DockerNetworkCreateIfNotExists` (`docker.go:63-77`) via `docker - * network create --label ... <networkId>`, treating "already exists" as - * success. + * Creates the shared docker network for this project via `docker network create --label ...`, + * treating "already exists" as success. * - * Called ONCE, up front, by the caller orchestrating a whole `start` run — - * NOT per-container the way Go's `DockerStart` calls it on every single - * invocation. Go's repeated call is a no-op after the first (the network - * already exists), so this is a pure optimization, not a behavior change: a - * `start` run's containers are exclusively created by this same code path in - * one process, never interleaved with an external network deletion, so the - * network is guaranteed to still exist for every later `createContainer` - * call in the same run. - * - * Mirrors Go's own `isUserDefined(mode)` guard (`docker.go:65`, - * `docker_linux.go:10` and platform siblings) that runs first inside - * `DockerNetworkCreateIfNotExists` itself: `--network-id default|bridge|host| - * none` names a built-in Docker network that already exists and cannot be - * created (`docker network create host` errors with "operation is not - * permitted on predefined host network"), so this returns immediately without - * spawning `docker network create` at all for those names, reusing the same - * `isUserDefinedDockerNetwork` check `shared/functions/functions-docker.ts` - * already applies for the unrelated `functions deploy`/`functions serve` - * extension-gateway network. + * Called once per `start` run rather than once per container: the network can't be deleted + * externally mid-run, so it's guaranteed to still exist for every later {@link createContainer} + * call. Returns immediately for a built-in network name (`default`, `bridge`, `host`, `none`), + * which already exists and cannot be created. */ export function ensureNetwork( spawner: Spawner, @@ -336,25 +260,21 @@ export function ensureNetwork( } /** - * Whether `volume create`'s stderr reports the volume already existing — - * podman's "volume with name <name> already exists: volume already exists", - * either half. A "...but was not created for the current specification" - * conflict deliberately does not match, so a real spec conflict still fails. + * Whether `volume create`'s stderr reports the volume already existing (Podman's "volume with + * name <name> already exists: volume already exists", matching either half). A "...but was not + * created for the current specification" conflict does not match, so a real spec conflict still + * fails. */ function isVolumeAlreadyExistsError(stderr: string): boolean { return /volume (?:with name \S+ )?already exists/iu.test(stderr); } /** - * Go's per-source-name `Docker.VolumeCreate` call (`docker.go:407-415`) via - * `docker volume create --label ...`, treating "already exists" as success the - * same way {@link ensureNetwork} does; any other non-zero exit is a - * real failure. + * Creates a named volume via `docker volume create --label ...`, treating "already exists" as + * success, the same way {@link ensureNetwork} does; any other non-zero exit is a real failure. * - * Go's Engine API is idempotent for a repeated name, including against Podman's - * Docker-compat endpoint; `podman volume create` goes through libpod instead - * and rejects it, so every `stop`/`start` cycle aborted the bring-up on the - * volumes `stop` preserves (supabase/cli#6020). + * Podman's `volume create` (unlike Docker's) is not idempotent for a repeated name and rejects it + * outright, which would otherwise abort a `start` on volumes an earlier `stop` preserved. */ export function ensureVolume( spawner: Spawner, @@ -425,29 +345,11 @@ function isVolumeNotFoundMessage(message: string): boolean { } /** - * Go's pre-create existence check (`_, err := utils.Docker.VolumeInspect(ctx, - * utils.DbId); utils.NoBackupVolume = errdefs.IsNotFound(err)`, - * `apps/cli-go/internal/db/start/start.go:165-167`), run BEFORE the volume is - * created — `docker volume create` is idempotent, so creating first would lose - * whether the volume already existed. `docker volume inspect <name>` exits 0 - * when the volume exists; a confirmed "no such volume" resolves to `false`, - * matching Go's `errdefs.IsNotFound`. Any OTHER inspect failure (permission - * denied, daemon unreachable, …) resolves to `true` instead — Go's - * `errdefs.IsNotFound(err)` is `false` for any error that isn't specifically a - * not-found, so Go always defaults to treating the volume as pre-existing - * (protected from rollback's `volume prune`) unless it can positively confirm - * otherwise; collapsing every non-zero exit into "doesn't exist" would let an - * ambiguous inspect failure on a stack with real prior data get pruned by - * {@link rollbackStart} after any later failure — a data-loss - * regression Go's own gate doesn't have. Only a spawn failure (neither - * `docker` nor `podman` on `PATH`) is a real error here. - * - * A separate, additional export — NOT called from {@link ensureVolume} - * itself, whose existing idempotent-create behavior must not change. The caller - * orchestrating a `start` run checks this BEFORE creating the volume, to gate the - * `SetupLocalDatabase`-equivalent pipeline and bucket seeding on "was this a - * fresh volume", matching Go's exact check-before-create ordering - * (`internal/db/start/start.go:165-184`). + * Runs before the volume is created (creation is idempotent, so creating first would lose + * whether it already existed). A confirmed "no such volume" resolves to `false`; any other + * inspect failure resolves to `true` instead, so an ambiguous failure never lets + * {@link rollbackStart} prune a volume that may hold real prior data. Separate from + * {@link ensureVolume}, which must keep its own idempotent-create behavior unchanged. */ export function volumeExists( spawner: Spawner, @@ -490,14 +392,9 @@ export class ContainerRemoveError extends Data.TaggedError("ContainerRemoveError } /** - * Port of Go's `db reset`-only `Docker.ContainerRemove(ctx, DbId, - * container.RemoveOptions{Force: true})` (`apps/cli-go/internal/db/reset/reset.go:147-149`) - * via `docker container rm -f <id>`. Unlike most other container lookups in this codebase, - * Go does NOT tolerate a "not found" response here — a genuine remove failure is a hard - * `failed to remove container: %w` — so this propagates ANY non-zero exit without the - * usual "no such container" swallow. `-f` alone (no `-v`) matches Go's `RemoveOptions`, - * which sets `Force` but not `RemoveVolumes` — the paired named volume is removed - * separately by {@link removeVolume}. + * Removes a container via `docker container rm -f <id>`. Any non-zero exit is a real failure — + * unlike other container lookups here, a missing container is not swallowed into success. + * `-f` alone (no `-v`); the paired named volume is removed separately by {@link removeVolume}. */ export function removeContainer( spawner: Spawner, @@ -522,12 +419,9 @@ export class VolumeRemoveError extends Data.TaggedError("VolumeRemoveError")<{ } /** - * Port of Go's `db reset`-only `Docker.VolumeRemove(ctx, DbId, true)` - * (`apps/cli-go/internal/db/reset/reset.go:150-152`) via `docker volume rm -f <name>`. - * The `force` argument makes a MISSING volume a no-op (Docker's `DELETE /volumes/{name}` - * returns 204 even when the volume doesn't exist, once `force` is set — verified against - * a real Docker daemon), so — unlike {@link removeContainer} — no special-casing is - * needed here: any non-zero exit is a genuine failure. + * Removes a volume via `docker volume rm -f <name>`. `force` makes removing an already-missing + * volume a no-op, so — unlike {@link removeContainer} — no special-casing is needed here: any + * non-zero exit is a genuine failure. */ export function removeVolume( spawner: Spawner, @@ -549,22 +443,13 @@ function dockerCreateContainer( return Effect.scoped( Effect.gen(function* () { // `docker-create-args.ts` emits the key-only `-e KEY` form (never `-e KEY=value`) so - // secrets never appear in argv/`ps`/`/proc/<pid>/cmdline` (CWE-214/209) — Docker then - // resolves each key's value from THIS spawned process's own environment. `extendEnv: - // true` keeps the rest of the parent's env (PATH, the real DOCKER_HOST, …) so the docker - // CLI invocation itself still behaves correctly; `env` supplies the actual secret values. - // Matches the same pattern already used for `docker run` (`docker-run.layer.ts`) - // and image resolution (`docker-image-resolve.ts`). + // secrets never appear in argv/`ps`/`/proc/<pid>/cmdline`; Docker resolves each key's value + // from this spawned process's own environment instead. `extendEnv: true` keeps the rest of + // the parent's env (PATH, the real DOCKER_HOST, …) while `env` supplies the secret values. // - // Callers must have already stripped `isDockerClientEnvKey` keys (e.g. a - // container-facing `DOCKER_HOST`, set by Vector's spec for a tcp/npipe daemon host) from - // `env` before calling this function — those are emitted inline as `-e KEY=value` by - // `buildStartContainerCreateArgs` instead, since `extendEnv: true` merges `env` INTO - // this spawned process's own environment (per Effect's `ChildProcess` semantics, - // prioritizing `env`'s values), and that same environment is what the `docker`/`podman` - // CLI client itself reads `DOCKER_HOST` from to pick which daemon to talk to. Letting a - // container-facing `DOCKER_HOST` leak in here would hijack this `docker create` call's own - // daemon target before the container even exists. + // Callers must already have stripped `isDockerClientEnvKey` keys (e.g. a container-facing + // `DOCKER_HOST`) from `env` — those go inline as `-e KEY=value` instead, since merging one + // in here would hijack which daemon this `docker create` call itself talks to. const child = yield* spawnContainerCli(spawner, args, { stdin: "ignore", stdout: "pipe", @@ -713,11 +598,10 @@ const secretCopyFailure = (detail: string): ContainerCreateError => }); /** - * Streams all secret files as one archive after create and before start. `Bun.Archive` exposes no - * per-entry mode option, so the unit test pins its `0644` default. That mode keeps the files - * readable by non-root Kong/Postgres processes and matches Go's result. Once copied, the files - * live in the container filesystem, so normal restarts need no host artifact. This mirrors Go's - * path-independent Engine API delivery without exposing plaintext through host files or argv. + * Streams all secret files as one archive after create and before start. `Bun.Archive` exposes + * no per-entry mode option, so the unit test pins its `0644` default, which keeps the files + * readable by non-root Kong/Postgres processes. Once copied, the files live in the container + * filesystem, so normal restarts need no host artifact. */ function copyStartSecretFilesIntoContainer( spawner: Spawner, @@ -748,17 +632,13 @@ function copyStartSecretFilesIntoContainer( } /** - * `docker cp - <containerId>:<containerPath>` with one - * {@link StartContainerSpec.preStartArchives} entry's tar bytes on stdin — the ONE `docker - * cp` form that preserves each archive member's uid/gid inside the container (the host-path form - * rewrites ownership to root, which a restored Postgres data directory cannot survive; see that - * field's own doc comment). + * `docker cp - <containerId>:<containerPath>` for one {@link StartContainerSpec.preStartArchives} + * entry — the form that preserves each archive member's uid/gid; the host-path form resets + * ownership to root, which a restored Postgres data directory cannot survive. * - * Sequenced by {@link createContainer} between `docker create` and `docker start`, for the - * same two reasons the secret-file copies are: the container must exist for `docker cp` to have a - * target, and must not be running yet so its entrypoint never races the copy — which for an - * archive is not merely a race but the whole point, since the entrypoint's behavior depends on - * what it finds already unpacked. + * Sequenced by {@link createContainer} between `docker create` and `docker start`: the container + * must exist for `docker cp` to have a target and must not be running yet, since the entrypoint's + * behavior depends on what it finds already unpacked. */ function extractPreStartArchiveIntoContainer( spawner: Spawner, @@ -778,28 +658,9 @@ function extractPreStartArchiveIntoContainer( } /** - * Port of Go's `DockerStart` (`apps/cli-go/internal/utils/docker.go:363-440`), - * minus image resolution (already done by `image-prepull.ts`) and network - * creation (hoisted, see {@link ensureNetwork}): - * - * 1. Merge the two project-identity labels onto `spec.labels`. - * 2. Provision this container's own named volumes (skipped entirely under - * Bitbucket Pipelines, matching Go). - * 3. Apply the Bitbucket named-volume-bind / security-opt filter - * (`applyBitbucketStartContainerFilter`, already ported). - * 4. `docker create`. - * 5. Copy any `secretFiles` into the just-created (not yet started) container - * as one stdin tar archive via `docker cp` - * (`copyStartSecretFilesIntoContainer`) — a TS-port-only step with no - * Go equivalent, see `docker-create-args.ts`'s `secretFiles` doc comment. - * Runs strictly between `docker create` and `docker start`: the container - * must already exist for `docker cp` to have a target, and must not be - * running yet so its entrypoint never races the copy. - * 6. Unpack any `preStartArchives` into the same created-but-unstarted - * container via `docker cp -` (`extractPreStartArchiveIntoContainer`) - * — also TS-port-only, and for the shadow baseline cache's restored PGDATA - * the "not started yet" half of step 5's ordering is the entire point. - * 7. `docker start`. + * Provisions this container's named volumes (skipped under Bitbucket Pipelines), creates it, + * copies any `secretFiles` and `preStartArchives` in via `docker cp` while it's created but not + * yet running (so its entrypoint never races the copy), then starts it. * * Resolves to the created container's id/name on success. */ @@ -814,10 +675,8 @@ export function createContainer( [CLI_PROJECT_LABEL]: opts.projectId, [COMPOSE_PROJECT_LABEL]: opts.projectId, }; - // The workdir label is stamped on the CONTAINER only, not on its named volumes below - // (`ensureVolume` is passed `labels`, not `containerLabels`) — a volume's own - // name already carries the project id, and nothing ever reads a workdir label back off a - // volume the way `listContainerIdsAndNames` does for containers. + // The workdir label goes on the container only, not its named volumes below: a volume's name + // already carries the project id, and nothing reads a workdir label back off a volume. const containerLabels: Record<string, string> = { ...labels, [CLI_WORKDIR_LABEL]: opts.workdir, @@ -837,25 +696,20 @@ export function createContainer( const finalSpec = applyBitbucketStartContainerFilter(labeledSpec, opts.isBitbucketPipeline); const createArgs = buildStartContainerCreateArgs(finalSpec); - // `isDockerClientEnvKey` keys (e.g. Vector's container-facing `DOCKER_HOST`) are - // already emitted inline as `-e KEY=value` by `buildStartContainerCreateArgs` above — - // see `dockerCreateContainer`'s doc comment for why they must not also reach the - // spawned `docker create` process's own environment. + // `isDockerClientEnvKey` keys are already emitted inline as `-e KEY=value` above; see + // `dockerCreateContainer` for why they must not also reach this process's own environment. const createProcessEnv = Object.fromEntries( Object.entries(finalSpec.env).filter(([key]) => !isDockerClientEnvKey(key)), ); const containerId = yield* dockerCreateContainer(spawner, createArgs, createProcessEnv); yield* copyStartSecretFilesIntoContainer(spawner, containerId, finalSpec.secretFiles ?? []); - // Sequentially, not concurrently like the secret files: two archives could legitimately - // overlap in the container's filesystem, so the spec's own order has to be the applied order. + // Sequential, not concurrent like the secret files: archives can legitimately overlap in the + // container's filesystem, so the spec's own order must be the applied order. // - // A failed extraction removes the just-created container (best-effort, `-v` so an anonymous - // volume goes with it) before failing. The secret-file/`docker start` steps deliberately do - // NOT do this — their established post-create failure window leaves cleanup to the caller's - // finalizer (see `createShadowDatabase`'s doc comment, `shadow-database.ts`) — but - // `preStartArchives`' one producer (the shadow baseline cache's warm restore) recovers from - // this exact failure by provisioning a replacement, which must not accumulate an orphaned - // created container per recovery. + // A failed extraction removes the just-created container first (unlike a secret-file or + // `docker start` failure, which leave cleanup to the caller's finalizer): this step's one + // producer, the shadow baseline cache's warm restore, retries by provisioning a replacement + // container, and must not leak an orphaned one per retry. yield* Effect.forEach( finalSpec.preStartArchives ?? [], (archive) => extractPreStartArchiveIntoContainer(spawner, containerId, archive), 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 3c27fd2b8f..5c250bf23b 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 @@ -34,7 +34,7 @@ afterEach(() => { rmSync(workdir, { recursive: true, force: true }); }); -/** Matches the standing `mockSpawner` shape used across `docker-*.unit.test.ts` files, generalized to a per-call handler for multi-step orchestration (volume create -> container create -> container start). */ +/** Per-call handler, for multi-step orchestration (volume create -> container create -> start). */ function mockSpawner( handler: (args: ReadonlyArray<string>) => { exitCode: number; stdout?: string; stderr?: string }, ) { @@ -165,7 +165,6 @@ describe("createContainer", () => { expect(create).toContain("--label"); expect(create).toContain("com.supabase.cli.project=proj"); expect(create).toContain("com.docker.compose.project=proj"); - // Both the named-volume bind and the plain bind mount survive outside Bitbucket. expect(create).toContain("supabase_db_proj:/var/lib/postgresql/data"); expect(create).toContain("/repo/backup.sql:/etc/backup.sql:ro"); expect(create).toContain("--security-opt"); @@ -173,7 +172,6 @@ describe("createContainer", () => { const start = mock.spawned.find((args) => args[0] === "start"); expect(start).toEqual(["start", "container-id-123"]); - // Order matters: the volume must exist before `docker create` references it. expect(mock.spawned.map((args) => args[0])).toEqual(["volume", "create", "start"]); }), ); @@ -183,14 +181,6 @@ describe("createContainer", () => { it.live( "stamps the container (but not its named volumes) with a com.supabase.cli.workdir label matching opts.workdir", () => { - // Read back later by `listContainerIdsAndNames` so a subsequent `stop`/rollback can - // reclaim `cleanupStartSecrets`'s staged-secret directory from the CONTAINER's own - // label rather than the invoking caller's own cwd/`--workdir` (see that label's doc - // comment, `docker-ids.ts`). Volumes deliberately do NOT get this label — nothing - // ever reads it back off a volume, and the "merges project + compose labels..." test above - // already pins the volume's label list to exactly the two project-identity labels via - // `toEqual`, so a regression that leaked the workdir label onto volumes too would fail that - // test's exact-match assertion. const mock = alwaysSucceed(); return createContainer(mock.spawner, baseSpec, { projectId: "proj", @@ -209,11 +199,6 @@ describe("createContainer", () => { it.live( "passes the spec's env values through the spawned process's own environment, extending it", () => { - // `docker-create-args.ts` emits the key-only `-e KEY` form (never `-e KEY=value`) so - // secrets never appear in argv — Docker then resolves each key's value from the - // spawned `docker create` process's own environment. If `env`/`extendEnv` are ever - // dropped from the spawn call again, every `-e KEY` flag silently resolves to nothing - // and every container starts with none of its configured environment. const mock = alwaysSucceed(); const spec: StartContainerSpec = { ...baseSpec, @@ -240,12 +225,6 @@ describe("createContainer", () => { it.live( "excludes DOCKER_HOST from the spawned docker create process's own env, even though it's in spec.env (Vector's tcp/npipe daemon host)", () => { - // `env`/`extendEnv: true` merge `spec.env` INTO the spawned `docker create` process's own - // environment (see the previous test) — but `DOCKER_HOST` configures which daemon the - // `docker`/`podman` CLI CLIENT itself talks to, not a container env var read via `-e KEY`. - // Letting a container-facing `DOCKER_HOST` (e.g. Vector's `http://host.docker.internal:...` - // for a tcp/npipe daemon host) leak into the spawned process's own env would hijack which - // daemon this `docker create` call itself targets, before the container even exists. const mock = alwaysSucceed(); const spec: StartContainerSpec = { ...baseSpec, @@ -417,9 +396,6 @@ describe("createContainer secretFiles", () => { }); expect(containerId).toBe("container-id-snap"); - // `docker cp` runs strictly between `docker create` and `docker start` — the - // container must already exist for it to have a target, and must not be running - // yet so its entrypoint never races the copy. expect(mock.spawned.map((args) => args[0])).toEqual(["create", "cp", "start"]); const cp = mock.spawnedOptions.find((entry) => entry.args[0] === "cp"); @@ -643,13 +619,8 @@ describe("ensureNetwork", () => { ); it.live("skips docker network create for a container: network mode", () => { - // Go's `container.NetworkMode.IsUserDefined()` - // (`docker/api/types/container/hostconfig_unix.go:23-25`) explicitly - // excludes `IsContainer()` — `--network-id container:redis` attaches to - // another container's network stack, not a name `docker network create` - // could ever act on (review round on CLI-1963's `functions download` - // port, which surfaced the same gap in the shared - // `isUserDefinedDockerNetwork` predicate this helper reuses). + // `--network-id container:redis` attaches to another container's network stack, not a name + // `docker network create` could ever act on. const mock = mockSpawner(() => ({ exitCode: 1, stderr: "some failure" })); return ensureNetwork(mock.spawner, "container:redis", {}).pipe( Effect.map(() => { @@ -886,9 +857,6 @@ describe("createContainer with an empty containerName (the shadow database)", () }).pipe( Effect.map((containerId) => { expect(containerId).toBe("shadow-container-id"); - // `docker cp` addresses the container by the id `docker create` returned, never by - // name — the unnamed shadow container is delivered its secret the same way a named - // one is. expect(cpArgs).toEqual(["cp", "-", "shadow-container-id:/"]); }), ); 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 2338684c77..068f74d7cd 100644 --- a/apps/cli/src/command-internal/db-bootstrap/db-setup.ts +++ b/apps/cli/src/command-internal/db-bootstrap/db-setup.ts @@ -1,86 +1,12 @@ /** - * Post-Postgres-health local database setup pipeline — a port of Go's - * `SetupLocalDatabase` (`apps/cli-go/internal/db/start/start.go:359-381`, with one - * intentional divergence: see step 3 below on `api.auto_expose_new_tables`), run once - * the `db` container's healthcheck passes on a FRESH volume (Go's `NoBackupVolume` - * gate, `start.go:184` — the caller decides whether to invoke this at all; see - * `volumeExists` in `./container-lifecycle.ts`). The single exported - * entry point, {@link startSetupLocalDatabase}, runs the same call chain, - * in order: + * Local database setup pipeline run once the `db` container's healthcheck passes on a fresh + * volume (see `volumeExists` in `./container-lifecycle.ts`). {@link startSetupLocalDatabase} runs + * schema init, webhooks activation, API-privilege revocation, vault upsert, the roles.sql seed, + * then migrate-and-seed, in that order — vault runs before the seed so `roles.sql` can reference + * the upserted secrets. * - * 1. **`initSchema`** (`start.go:243-266`) — prints `Initialising schema...`, then - * branches on `db.major_version`: - * - **PG <= 14**: execs {@link START_DB_GLOBALS_SQL} (`utils.GlobalsSql`) - * then either {@link START_DB_INITIAL_SCHEMA_13_SQL} or - * {@link START_DB_INITIAL_SCHEMA_14_SQL} (`InitSchema14`, `start.go:256-266`, - * keyed on `major_version == 13`), each via `execSqlFile` against a temp file. - * - **PG >= 15** (`initSchema15`, `start.go:334-357`): runs up to three one-shot, - * foreground Docker jobs (`utils.DockerRunJob` = `DockerRunOnceWithStream`, a - * run-to-completion container on the SAME Docker network as `db` — Go's - * `DockerStart` defaults `NetworkMode` to `utils.NetId` when unset, - * `docker.go:379-383`), each gated on its own service's `enabled` flag and none - * of which touch `conn` directly. The realtime one-shot still - * runs so user migrations see the tenant before long-running - * containers boot. Storage and auth use the resolved image and - * the same argv on both families. - * - `initRealtimeJob` (`start.go:268-295`) — reuses - * `./realtime-env.ts`'s `buildRealtimeEnv`, which builds - * the byte-identical env-var literal Go's own `initRealtimeJob` embeds - * verbatim (both are the same Go `Env` list, just addressed from two call - * sites: the long-running container and this one-shot job). - * - `initStorageJob` (`start.go:297-317`) — a DELIBERATELY SMALLER, differently- - * keyed env set than the long-running Storage container's own - * `storage.service.ts` builder (`PGRST_JWT_SECRET` not `AUTH_JWT_SECRET`, - * `STORAGE_FILE_BACKEND_PATH` not `FILE_STORAGE_BACKEND_PATH`, `REGION` not - * `STORAGE_S3_REGION`, no JWKS) — built locally, not reused. - * - `initAuthJob` (`start.go:319-332`) — ditto, a minimal env distinct from - * `gotrue.service.ts`'s full container builder. - * 2. **Database Webhooks activation** — installs `pg_net` when the user opted - * into `experimental.webhooks`, unless the setup caller disables user extension - * activation. Legacy callers may explicitly request the historical `pg_net` - * baseline independently of that user config. - * 3. **API privileges** — tri-state on `api.auto_expose_new_tables`: unset and `true` - * are both a no-op (keep the bundled initial-schema grants); an explicit `false` - * execs {@link START_REVOKE_API_PRIVILEGES_SQL} via a temp file, same as the - * schema SQL above. - * 4. **Vault upsert** (`start.go:390-393`) — `upsertVaultSecrets`, run BEFORE - * the custom-roles seed "so roles.sql can reference them" (Go's own comment). - * 5. **Custom-roles seed** (`start.go:394-398` + `pkg/migration/seed.go:84-97`) — - * prints "Seeding globals from roles.sql..." UNCONDITIONALLY, BEFORE checking - * whether `supabase/roles.sql` even exists (Go's `SeedGlobals` prints first, - * then attempts the read), then execs the file via `execSqlFile` only when - * it's actually present. A missing file is tolerated (Go's `errors.Is(err, - * os.ErrNotExist)` check, reproduced here as an existence check ahead of the read - * rather than a caught not-found error — see the call site's own comment for why); - * any other read/exec error propagates. - * 6. **`apply.MigrateAndSeed`** (`start.go:368`, via the already-ported - * `migrateAndSeed`) with the caller-supplied {@link - * StartSetupLocalDatabaseInput.version} — `""` (every pending migration) for - * `db start`'s own call, matching `SetupLocalDatabase`'s call in the `start` - * context; `db reset`'s PG15 recreate (the function's OTHER real Go caller, - * `resetDatabase15`, `reset.go:169`) passes its own resolved reset version instead. - * {@link StartSetupLocalDatabaseInput.seedFlags} applies `db reset`'s - * `--no-seed`/`--sql-paths` overrides on top of the loaded `[db.seed]` config first - * (a no-op for `db start`, which has neither flag) — see - * {@link resolveResetSeedConfig}. - * - * Go's `initCurrentBranch` (`start.go:233-241`, writes `supabase/.branches/ - * _current_branch` = `"main"` if absent) is NOT part of this pipeline, even though - * it's exported from this module ({@link startInitCurrentBranch}): in Go it's - * called by `StartDatabase` (the caller of `SetupLocalDatabase`) UNCONDITIONALLY, - * regardless of `NoBackupVolume` (`start.go:184-189`) — unlike everything above, - * which only runs on a fresh volume. `start.handler.ts` calls it directly, outside - * the `isFreshVolume` gate that wraps {@link startSetupLocalDatabase}; `db - * reset` never calls it at all (Go's own `resetDatabase`/`resetDatabase15` never - * call `initCurrentBranch` either). - * - * This module also duplicates ONE config-load pass: `checkDbToml` is called - * internally (not threaded in from the caller) to resolve `[db.vault]`, `[db.seed]`, - * `db.migrations.enabled`, and the effective `api.auto_expose_new_tables` tri-state — - * the same accepted duplication `db start`'s own handler (`commands/db/start/ - * start.handler.ts`) already takes independently of the top-level `supabase start` - * command's own config resolution; `db reset`'s own caller duplicates it again for the - * same reason. + * {@link startInitCurrentBranch}, also exported here, is not part of this pipeline: callers + * invoke it unconditionally, outside the fresh-volume gate. */ import type { CliConfig } from "@supabase/config"; @@ -119,13 +45,7 @@ import { startInternalDbPassword, startInternalDbUrl } from "./internal-db-conne type Spawner = ChildProcessSpawner["Service"]; -/** - * Go's inline `RevokeDefaultDataApiPrivilegesSql` constant (`start.go:405-412`) — - * NOT a `//go:embed` file (unlike the three large SQL templates), so transcribed - * directly here rather than as a sibling `templates/*.sql.ts` module. Exported for the - * shadow baseline cache's embedded-SQL digest (`shadow-cache.ts`), which must re-key - * whenever this text changes across CLI releases. - */ +/** Exported so the shadow baseline cache (`shadow-cache.ts`) can key its digest on this text. */ export const START_REVOKE_API_PRIVILEGES_SQL = ` alter default privileges for role postgres in schema public revoke select, insert, update, delete on tables from anon, authenticated, service_role; @@ -136,26 +56,20 @@ alter default privileges for role postgres in schema public `; /** - * Exported for the shadow baseline cache's embedded-SQL digest (`shadow-cache.ts`), same as - * {@link START_REVOKE_API_PRIVILEGES_SQL}: a webhooks-enabled baseline bakes this - * statement into PGDATA, so the digest must re-key whenever this text changes across releases. + * Exported so the shadow baseline cache (`shadow-cache.ts`) can key its digest on this text, same + * as {@link START_REVOKE_API_PRIVILEGES_SQL}. */ export const START_ENABLE_DATABASE_WEBHOOKS_SQL = "create extension if not exists pg_net schema extensions;"; -// The historical PG14 dump installs pg_net because later statements grant on -// its schema. Remove it after the dump so the final baseline still follows the -// user's webhooks setting. Enabled projects recreate it after the dump, when -// the bundled event trigger can apply the intended grants. +// The PG14 dump installs pg_net for its later grant statements; remove it after the dump so the +// baseline still follows the user's webhooks setting. Enabled projects recreate it afterward. const START_REMOVE_DATABASE_WEBHOOKS_SQL = "drop extension if exists pg_net;"; /** - * A SQL exec (schema/globals/API-privileges) or one-shot service-migration Docker - * job failed, or the scratch temp directory/file could not be created. The Docker - * job branch's message mirrors Go's `DockerRunOnceWithStream` failure shape - * (`errors.Errorf("error running container: %w", err)`, `apps/cli-go/internal/ - * utils/docker.go:469-487,559-591` — Go discards the container's own stdout/stderr - * outside `--debug`, so only the exit code is meaningful here too). + * A SQL exec, one-shot service-migration Docker job, or scratch temp file/directory creation + * failed. The Docker job's own stdout/stderr are discarded outside `--debug`, so only its exit + * code is surfaced here. */ export class DbSetupError extends Data.TaggedError("DbSetupError")<{ readonly message: string; @@ -203,33 +117,21 @@ export type StartSetupLocalDatabaseError = | MigrationSeedError | ImagePrepullError; -/** Already-resolved Docker images for the three PG15+ one-shot migrate jobs (`initSchema15`'s `initJobs`). */ +/** Already-resolved Docker images for the three PG15+ one-shot migrate jobs. */ export interface StartDbSetupImages { - /** `utils.Config.Realtime.Image`, resolved by the caller (not part of the decoded `CliConfig` schema — `toml:"-"`). */ + /** Resolved by the caller; not part of the decoded `CliConfig` schema. */ readonly realtime: string; - /** `utils.Config.Storage.Image`, ditto. */ readonly storage: string; - /** `utils.Config.Auth.Image`, ditto. */ readonly auth: string; } /** - * Computes the three PG15+ one-shot setup jobs' PINNED image names (`initSchema15`'s - * `initRealtimeJob`/`initStorageJob`/`initAuthJob`) for {@link resolveDbSetupPrelude}, - * the ONE place every real caller (`db start`'s fresh-volume branch, `db reset`'s PG15 - * recreate, and the shadow-database variant's `setupShadowDatabase`/ - * `migrateShadowDatabase`) reaches this resolution from — see that function's own doc - * comment. Mirrors Go's `initSchema15`, which uses the SAME already-pin-rewritten - * `utils.Config.{Realtime,Storage,Auth}.Image` fields the long-running containers would use, - * regardless of `--exclude` — resolved via `resolvePinnedImage`, not the raw Dockerfile - * default, so a linked project's version pins apply here too. Deliberately does NOT resolve - * these against the registry (`ensureImagesCached`) as a batch: Go resolves (and pulls) - * each one-shot job's own image individually, sequentially, right before THAT job runs - * (`DockerRunJob` -> `DockerStart` -> `DockerResolveImageIfNotCached`, `start.go:334-355`, - * `docker.go:363-365`) — {@link runStartMigrateJob} does that lazily itself, right - * before running each job (see its own doc comment): a batch resolve here would let one - * unreachable image fail the WHOLE setup before an earlier job Go would already have run - * to completion ever gets to run. + * Computes the three PG15+ one-shot setup jobs' pinned image names via {@link resolvePinnedImage}, + * so a linked project's version pins apply here too. + * + * Does not resolve these against the registry as a batch: {@link runStartMigrateJob} resolves + * each job's image lazily, right before that job runs, so one unreachable image can't fail the + * whole setup before an earlier job gets to run. */ function resolveDbSetupImages( serviceVersionOverrides: LocalServiceVersionOverrides, @@ -242,40 +144,16 @@ function resolveDbSetupImages( } /** - * Prints the banner + resolves JWKS (lazily, only when `majorVersion >= 15` AND - * `realtimeEnabledForSetup`) + the PG15+ one-shot job images' PINNED names (via - * {@link resolveDbSetupImages}) — the exact prelude BOTH {@link runFreshDbSetup} - * (the real local `db` container) and `shadow-database.ts`'s - * `setupShadowDatabase`/`migrateShadowDatabase` need before calling - * {@link setupDatabase}. Hoisted here (CLI-1956 review follow-up) so the shadow path - * shares this exact resolution instead of keeping its own copy, which had silently drifted (a - * dead, never-forwarded `jwtExpiry` field on the shadow's own setup-input shape). Structurally - * typed against just the fields this needs (not the full {@link FreshDbSetupInput}) so - * both that type and `shadow-database.ts`'s `ShadowDbSetupInput` — which is itself - * derived from it — satisfy this signature without an explicit cast. - * - * The banner print lives HERE, not in {@link setupDatabase}'s own `initSchema` step, - * even though Go's `initSchema` (`start.go:243-254`) prints it immediately before branching on - * `MajorVersion` and, for PG15+, calling `initSchema15` -> `Config.Auth.ResolveJWKS` - * (`start.go:334-343`) — i.e. in Go, the print and the JWKS fetch are two steps of the SAME - * `initSchema` call, print first. This module's `jwks` field is a plain, already-resolved - * `string` on {@link SetupDatabaseInput} (not a lazy effect `setupDatabase` itself - * runs), so it MUST be resolved by the caller before `setupDatabase` is ever invoked — - * printing the banner here, immediately before that resolution, is the only way to reproduce - * Go's exact observable order (banner, THEN a possible JWKS failure) without restructuring - * `setupDatabase`'s input to carry a lazy JWKS effect instead. Previously the print lived - * solely in `startInitSchema` below, AFTER this whole prelude — so a JWKS discovery - * failure (realtime enabled, PG15+, third-party JWKS unreachable) meant `db diff --linked`/ - * `db pull`'s native shadow-provisioning path failed BEFORE ever printing "Initialising - * schema...", where Go always prints it first (review: PRRT_kwDOErm0O86W6R-O). + * Prints the setup banner, then lazily resolves JWKS (only when `majorVersion >= 15` and + * `realtimeEnabledForSetup`) and the PG15+ one-shot job images — the shared prelude both + * {@link runFreshDbSetup} and the shadow-database setup path need before calling + * {@link setupDatabase}. Structurally typed against just the fields it needs so both + * {@link FreshDbSetupInput} and `shadow-database.ts`'s derived `ShadowDbSetupInput` satisfy this + * signature without a cast. * - * The `majorVersion >= 15` gate matters, not just an optimization: Go's `initSchema` - * (`apps/cli-go/internal/db/start/start.go:243-253`) returns via `InitSchema14` for - * `MajorVersion <= 14` WITHOUT ever calling `initSchema15`, so `Config.Auth.ResolveJWKS` - * (`start.go:338`, only reached from `initSchema15`) never runs at all on PG13/14 — even - * with realtime enabled. `ResolveJWKS` can perform live discovery/JWKS HTTP requests for - * configured `auth.third_party` providers, so resolving it unconditionally on PG14 is not - * just wasted work: it can fail (or hang) when Go's own shadow/setup never would. + * The banner prints before `jwks` resolves, so a JWKS failure (an unreachable `third_party` + * provider) surfaces after the banner instead of before it. The `majorVersion >= 15` gate means + * JWKS's potential live HTTP request never runs at all on PG13/14, even with realtime enabled. */ export const resolveDbSetupPrelude = <E>(setup: { readonly majorVersion: number; @@ -292,29 +170,17 @@ export const resolveDbSetupPrelude = <E>(setup: { }); /** - * Input to {@link setupDatabase} — Go's EXPORTED `SetupDatabase(ctx, conn, host, w, - * fsys)` (`start.go:383-399`): `initSchema -> ApplyApiPrivileges -> vault upsert -> - * SeedGlobals(roles.sql)`, deliberately WITHOUT `apply.MigrateAndSeed` (that extra step is - * what makes {@link StartSetupLocalDatabaseInput}/{@link startSetupLocalDatabase} - * bigger — see that interface's own doc comment). Extracted as its own exported shape - * (CLI-1956) so shadow-database provisioning (`shadow-database.ts`) can reach the exact same - * platform-baseline pipeline the real local `db` container's fresh-volume setup does, without - * also replaying migrations a second time or reaching `migrateAndSeed`'s - * declarative-schema-files branch, neither of which Go's own shadow provisioning - * (`setupShadowConn`) ever does either. + * Input to {@link setupDatabase}: schema init, API-privilege revocation, vault upsert, and the + * roles.sql seed — without migrate-and-seed (see {@link StartSetupLocalDatabaseInput}, which adds + * that step). Its own exported shape so shadow-database provisioning can reach the same + * platform-baseline pipeline without replaying migrations or reaching the + * declarative-schema-files branch. */ export interface SetupDatabaseInput { /** - * An already-open session to the local Postgres database, dialed the same way - * Go's `ConnectLocalPostgres(ctx, pgconn.Config{})` does (`internal/utils/ - * connect.go:144-167`): the HOST-facing address (`getHostname()` + - * `db.port`, user `postgres`, `isLocal: true`) — the SAME shape `db- - * config.layer.ts`'s own `--local` branch already dials (`db-config. - * layer.ts:518-529`). This is deliberately NOT the internal Docker-network `db` - * container address the PG15+ one-shot jobs below connect through (see - * `networkId`/`dbHost`) — the two addressing schemes are independent, exactly - * like Go's `conn` (host-facing) vs. `host` parameter (`utils.DbId`) in - * `SetupDatabase(ctx, conn, utils.DbId, w, fsys)`. + * An already-open session to the local Postgres database, dialed via the host-facing address — + * not the internal Docker-network address the PG15+ one-shot jobs below connect through (see + * `networkId`/`dbHost`). */ readonly session: DbSession; readonly fs: FileSystem.FileSystem; @@ -325,90 +191,56 @@ export interface SetupDatabaseInput { readonly config: CliConfig; /** Effective `[experimental.webhooks].enabled`, including supported environment overrides. */ readonly webhooksEnabled: boolean; - /** `db.major_version` (13-17) — Go's `utils.Config.Db.MajorVersion`, resolved by the caller once, ahead of the `db` container's own image tag selection. */ + /** `db.major_version` (13-17), resolved by the caller once, ahead of the `db` container's own image tag selection. */ readonly majorVersion: number; /** - * The internal Docker-network address the PG15+ one-shot jobs connect through — Go's - * `host` parameter to `SetupDatabase(ctx, conn, host, w, fsys)` (`start.go:383`). The real - * local `db` container's own caller (`runFreshDbSetup`) passes - * `serviceContainerName("db", projectId)` (Go's `utils.DbId`, threaded straight - * through unchanged from before CLI-1956); the shadow-database variant - * (`shadow-database.ts`) passes the shadow container's own 12-char short id instead (Go's - * `container[:12]`, `apps/cli-go/internal/db/diff/diff.go:172` / `internal/migration/ - * squash/squash.go:96`) — empirically verified to resolve via Docker's embedded DNS even - * though the shadow container has no name/alias at all (see `shadow-database.ts`'s - * header). This field was hardcoded inside this module prior to CLI-1956; it is now the - * caller's responsibility, the one genuine parameterization this port needed for shadow - * provisioning to reuse `SetupDatabase` at all. + * The internal Docker-network address the PG15+ one-shot jobs connect through. The real local + * `db` container's caller passes its service container name; the shadow-database variant + * passes the shadow container's own short id instead, which resolves via Docker's embedded DNS + * even though that container has no name or alias. */ readonly dbHost: string; /** - * Go's `Config.ProjectId` — labels the PG15+ one-shot job containers - * (`com.supabase.cli.project`/`com.docker.compose.project`, see {@link - * runStartMigrateJob}), matching Go's `DockerStart`, which sets both - * unconditionally for every container it starts (`docker.go:371-376`). Independent of - * {@link dbHost}: this labels the one-shot job containers THEMSELVES, not the (possibly - * different) container `dbHost` addresses. + * Labels the PG15+ one-shot job containers. Independent of {@link dbHost}: this labels the + * job containers themselves, not the (possibly different) container `dbHost` addresses. */ readonly projectId: string; - /** The `start` run's Docker network id (Go's `utils.NetId` or the `--network-id` override) — every PG15+ one-shot job joins it, matching `DockerStart`'s own default (`docker.go:379-383`). */ + /** The `start` run's Docker network id; every PG15+ one-shot job joins it. */ readonly networkId: string; - /** `LocalConfigValues.dbUrl` — reused (not recomputed) to derive the internal DB password via `startInternalDbPassword`, matching every other `start/services/*.service.ts` builder. */ + /** Reused (not recomputed) to derive the internal DB password via `startInternalDbPassword`. */ readonly dbUrl: string; /** `LocalConfigValues.jwtSecret`. */ readonly jwtSecret: string; - /** `resolveLocalJwks`'s resolved JWKS JSON string (only read when `realtime.enabled`) — already built by the caller, not recomputed here. */ + /** Resolved JWKS JSON string (only read when `realtime.enabled`); already built by the caller. */ readonly jwks: string; - /** `LocalConfigValues.apiUrl` — the auth job's `API_EXTERNAL_URL` falls back to this, `/auth/v1`-suffixed, only when {@link authExternalUrl} is unset. */ + /** The auth job's `API_EXTERNAL_URL` falls back to this, `/auth/v1`-suffixed, when {@link authExternalUrl} is unset. */ readonly apiUrl: string; /** - * Raw `auth.external_url` (already `SUPABASE_AUTH_EXTERNAL_URL`-overridden - * by the caller) — Go's `Config.Auth.ExternalUrl`/`AuthExternalURL()` - * (`pkg/config/config.go:543-545`, `auth.go:401-405`): an explicit value - * wins over the `apiUrl`-derived fallback, same as `gotrue.service.ts`'s - * `BuildGotrueEnvInput.authExternalUrl` for the long-running - * container — this one-shot job must resolve to the SAME value so a fresh - * database's auth migration never disagrees with the container it's - * migrating for. + * An explicit value wins over the {@link apiUrl}-derived fallback. This one-shot job must + * resolve to the same value the long-running auth container uses, so a fresh database's auth + * migration never disagrees with the container it's migrating for. */ readonly authExternalUrl?: string; - /** - * `LocalConfigValues.authSiteUrl` (already `SUPABASE_AUTH_SITE_URL`- - * overridden by the caller) — Go's `initAuthJob` reads the same overridden - * `utils.Config.Auth.SiteUrl` the long-running GoTrue container does - * (`apps/cli-go/internal/db/start/start.go:327`, `internal/start/ - * start.go:1365`), so this one-shot job must resolve to the SAME value, - * not the raw `config.auth.site_url` — same rationale as - * {@link authExternalUrl} above. - */ + /** Must resolve to the same value the long-running auth container uses; see {@link authExternalUrl}. */ readonly siteUrl: string; /** `LocalConfigValues.anonKey`. */ readonly anonKey: string; /** `LocalConfigValues.serviceRoleKey`. */ readonly serviceRoleKey: string; - /** Go's `utils.Config.Storage.TargetMigration` (`toml:"-"`, resolved from a version-pin file) — the caller passes `""` when absent, matching Go's zero-value default. */ + /** Resolved from a version-pin file; the caller passes `""` when absent. */ readonly storageTargetMigration: string; readonly images: StartDbSetupImages; /** - * Project-`.env`-scoped `SUPABASE_INTERNAL_IMAGE_REGISTRY`/mirror overrides — threaded - * through to each one-shot migrate job's OWN per-image `ensureImagesCached` resolve - * (see {@link runStartMigrateJob}), matching Go's real process-env registry override, - * which applies uniformly to every `DockerResolveImageIfNotCached` call regardless of which - * code path triggers it. `DockerRun.runCapture`'s own ambient-only ChildProcessSpawner- - * scoped ancestor resolver (used for the long-running containers) does NOT see this — it only - * reads bare `process.env`. + * Project-`.env`-scoped registry/mirror overrides, threaded to each one-shot job's own + * per-image resolve (see {@link runStartMigrateJob}). The long-running containers' own + * ambient-only resolver does not see this; it only reads `process.env`. */ readonly projectEnvValues: Readonly<Record<string, string>> | undefined; - /** - * `--debug` — threaded to each PG15+ one-shot migrate job (see - * {@link runStartMigrateJob}'s own doc comment) so a failed Realtime/Storage/Auth - * migration job's own stderr is visible, matching Go's `initSchema15` passing - * `utils.GetDebugLogger()` as the job's stderr writer (`start.go:349-353`). - */ + /** Threaded to each one-shot job so a failed migration job's own stderr is visible under `--debug`. */ readonly debug: boolean; - /** `toml.baseline.apiAutoExposeNewTables` — the `api.auto_expose_new_tables` tri-state, threaded straight into {@link applyApiPrivileges}. */ + /** The `api.auto_expose_new_tables` tri-state, threaded straight into {@link applyApiPrivileges}. */ readonly apiAutoExposeNewTables: Option.Option<boolean>; - /** `toml.vault` — Go's `utils.Config.Db.Vault`, threaded straight into {@link upsertVaultSecrets}. */ + /** Threaded straight into {@link upsertVaultSecrets}. */ readonly vault: ReadonlyArray<VaultSecret>; } @@ -432,41 +264,29 @@ export interface StartSetupLocalDatabaseInput extends Omit< "apiAutoExposeNewTables" | "vault" | "webhooksEnabled" > { /** - * `--experimental`/`SUPABASE_EXPERIMENTAL`, resolved by the caller (Go's - * `viper.GetBool("EXPERIMENTAL")`) — threaded straight into - * {@link migrateAndSeed}'s own `experimental` gate (`internal/migration/apply/ - * apply.go:19`); `setupDatabase`/Go's own `SetupDatabase` have no use for it — - * only this function's own trailing `MigrateAndSeed` call does. + * Threaded straight into {@link migrateAndSeed}'s own `experimental` gate; {@link setupDatabase} + * has no use for it. */ readonly experimental: boolean; /** - * The migration version to reapply (Go's `apply.MigrateAndSeed(ctx, version, ...)`). - * `db start`'s own caller always passes `""` (Go's `SetupLocalDatabase(ctx, "", ...)`, - * `start.go:185` — every pending migration). `db reset`'s PG15 recreate - * (`resetDatabase15`, `reset.go:169`) passes its own RESOLVED reset version instead — - * the one genuine difference between the two Go callers of this shared function. + * The migration version to reapply. `db start` always passes `""` (every pending migration); + * `db reset`'s PG15 recreate passes its own resolved reset version instead. */ readonly version: string; /** - * `db reset`'s `--no-seed`/`--sql-paths` overrides (Go's `applyDbResetSeedFlags`, - * `cmd/db.go:567-583`, mutating the global `utils.Config.Db.Seed` BEFORE `reset.Run` - * — read by this same `MigrateAndSeed` call on the PG15 recreate path). `db start` - * has neither flag, so its caller passes `{ noSeed: false, sqlPaths: [] }`, which - * {@link resolveResetSeedConfig} reduces to the loaded `[db.seed]` config - * unchanged. + * `db reset`'s `--no-seed`/`--sql-paths` overrides. `db start` has neither flag, so its caller + * passes `{ noSeed: false, sqlPaths: [] }`, which {@link resolveResetSeedConfig} reduces to the + * loaded `[db.seed]` config unchanged. */ readonly seedFlags: { readonly noSeed: boolean; readonly sqlPaths: ReadonlyArray<string> }; } /** - * Applies `db reset`'s `--no-seed`/`--sql-paths` overrides to an already-resolved - * `[db.seed]` config, mirroring Go's `applyDbResetSeedFlags` (`cmd/db.go:567-583`): - * `--no-seed` disables seeding outright; otherwise a non-empty `--sql-paths` - * force-enables seeding and overrides `sqlPaths` (each pattern resolved against - * `supabase/` the same way Go's own `resolveSeedSqlPaths` does); an empty - * `--sql-paths` is a no-op. The two flags are mutually exclusive (validated by the - * caller — `db/reset/reset.handler.ts`'s `validateDbResetSeedFlags` port — before - * this ever runs), matching Go's own `if noSeed { ...; return } ...` early return. + * Applies `db reset`'s `--no-seed`/`--sql-paths` overrides to an already-resolved `[db.seed]` + * config: `--no-seed` disables seeding outright; otherwise a non-empty `--sql-paths` force-enables + * seeding and overrides `sqlPaths` (each pattern resolved against `supabase/`); an empty + * `--sql-paths` is a no-op. The two flags are mutually exclusive, validated by the caller before + * this runs. */ export function resolveResetSeedConfig( seed: SeedConfig, @@ -487,11 +307,8 @@ const errMessage = (e: unknown): string => : String(e); /** - * Writes `sql` to `<tmpDir>/<filename>` and execs it via `execSqlFile` - * (Go's `migration.NewMigrationFromReader(strings.NewReader(sql))` + - * `file.ExecBatch(ctx, conn)` on an in-memory string — there is no on-disk file in - * Go at all; this port needs one only because `execSqlFile` reads from the - * filesystem like every other `execMigrationBatch` caller). + * Writes `sql` to `<tmpDir>/<filename>` and execs it via `execSqlFile`, which only reads SQL from + * the filesystem. */ const execSqlConstant = Effect.fnUntraced(function* ( session: DbSession, @@ -521,16 +338,10 @@ const execSqlConstant = Effect.fnUntraced(function* ( }); /** - * Port of Go's EXPORTED `InitSchema14` (`start.go:256-266`) — execs ONLY the - * major-version-appropriate initial-schema SQL, deliberately WITHOUT - * {@link START_DB_GLOBALS_SQL}. Go's own `initSchema` wrapper (the PG<=14 - * branch below, `startInitSchemaPre15`) execs globals.sql itself, immediately - * before calling `InitSchema14` — but `db reset`'s PG14 path (`reset.go:176-186` - * `initDatabase`) calls `start.InitSchema14` DIRECTLY, skipping globals.sql - * entirely. Exported so `command-internal/db-bootstrap/recreate-local-database.ts` - * can reproduce that exact (if surprising) Go asymmetry instead of reusing - * {@link startInitSchemaPre15}, which would run globals.sql an extra time Go - * never does on the reset path. + * Execs only the major-version-appropriate initial-schema SQL, without + * {@link START_DB_GLOBALS_SQL}. {@link startInitSchemaPre15} execs globals.sql itself before + * calling this; `db reset`'s PG14 path calls this directly instead, skipping globals.sql. + * Exported so `recreate-local-database.ts` can reproduce that same asymmetry. */ export const initSchema14 = Effect.fnUntraced(function* ( session: DbSession, @@ -545,11 +356,9 @@ export const initSchema14 = Effect.fnUntraced(function* ( }); /** - * Port of Go's `initSchema`'s PG<=14 branch (`start.go:245-251`): execs - * {@link START_DB_GLOBALS_SQL} then {@link initSchema14}. Only - * reached for `majorVersion <= 14` (the caller, `startInitSchema`, gates on - * that) — used by `db start`'s fresh-volume setup ONLY; `db reset`'s PG14 path - * calls {@link initSchema14} directly instead (see its own doc comment). + * Execs {@link START_DB_GLOBALS_SQL} then {@link initSchema14}, for `majorVersion <= 14`. Used + * by `db start`'s fresh-volume setup only; `db reset`'s PG14 path calls {@link initSchema14} + * directly instead. */ const startInitSchemaPre15 = Effect.fnUntraced(function* ( session: DbSession, @@ -563,34 +372,14 @@ const startInitSchemaPre15 = Effect.fnUntraced(function* ( }); /** - * Runs one PG15+ one-shot service-migration job to completion (Go's - * `utils.DockerRunJob` = `DockerRunOnceWithStream`, `docker.go:457-459,469-487`): - * foreground, same Docker network as `db`, no entrypoint override (Go's plain - * `Cmd` field), stdout always discarded (Go's own `stdout` writer here is always - * `io.Discard`, `start.go:352`) and stderr teed to the parent process's own stderr ONLY - * under `--debug` — Go passes `logger := utils.GetDebugLogger()` as the job's stderr - * writer (`os.Stderr` under `--debug`, else `io.Discard`, `logger.go:10-15`) — so a - * fresh-volume Realtime/Storage/Auth migration job's own diagnostics are visible when - * `db start --debug`/`supabase start --debug` is used, not just its exit code. A - * non-zero exit fails with the same shape as Go's `error running container: <cause>`. + * Runs one PG15+ one-shot service-migration job to completion: foreground, on the same Docker + * network as `db`, labeled with `opts.projectId` so an interrupted run's orphaned container stays + * discoverable by `supabase stop`. Stdout is discarded; stderr is teed to the parent's own stderr + * only under `--debug`. * - * Resolves `opts.image` itself, individually, right here — via `ensureImagesCached` - * (NOT `DockerRun.runStream`'s own ambient-only resolver, which never sees - * `opts.projectEnvValues`) — immediately before running THIS job, matching Go's - * `DockerRunJob` -> `DockerStart` -> `DockerResolveImageIfNotCached` (`docker.go:363-365`) - * resolving each one-shot job's own image individually, sequentially, exactly where it's - * used: neither caller pre-pulls these three images as a batch ahead of time (see - * `start-database.ts`'s own doc comment for why), and Go's registry-override env var applies - * uniformly to every `DockerResolveImageIfNotCached` call, including project-`.env`-scoped - * values — this call must see the same override the long-running containers' own resolve does. - * - * Labels the container with `com.supabase.cli.project`/`com.docker.compose.project` - * (`opts.projectId`), matching Go's `DockerStart`, which sets both unconditionally for - * every container it starts, one-shot jobs included (`docker.go:371-376`) — so if the - * client is interrupted or the daemon disconnects while this job is still running, the - * orphaned container is still discoverable (and removable) by `supabase stop`/rollback's - * project-label filter (`docker-remove-all.ts`), not left invisible to both - * (review: Codex, PR #6022). + * Resolves `opts.image` itself via `ensureImagesCached` immediately before running this job — the + * long-running containers' own ambient-only resolver never sees `opts.projectEnvValues`, and + * neither caller pre-pulls these three images as a batch. */ const runStartMigrateJob = Effect.fnUntraced(function* ( spawner: Spawner, @@ -601,7 +390,7 @@ const runStartMigrateJob = Effect.fnUntraced(function* ( readonly networkId: string; readonly projectId: string; readonly projectEnvValues: Readonly<Record<string, string>> | undefined; - /** `--debug` — Go's `utils.GetDebugLogger()`, see this function's own doc comment. */ + /** Tees the job's stderr to the parent's own stderr when true. */ readonly debug: boolean; }, ) { @@ -609,9 +398,7 @@ const runStartMigrateJob = Effect.fnUntraced(function* ( const runtimeInfo = yield* RuntimeInfo; const resolvedImages = yield* ensureImagesCached(spawner, [opts.image], opts.projectEnvValues); const resolvedImage = resolvedImages.get(opts.image) ?? opts.image; - // Go's `DockerStart` unconditionally appends the Linux-only `host.docker.internal: - // host-gateway` extra host for every container it starts (`docker_linux.go`), - // including one-shot jobs routed through the same `DockerStart` path. + // Every container gets this extra host on Linux; Docker Desktop platforms resolve it natively. const extraHosts = runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; const runOpts: DockerRunOpts = { image: resolvedImage, @@ -626,16 +413,11 @@ const runStartMigrateJob = Effect.fnUntraced(function* ( [CLI_PROJECT_LABEL]: opts.projectId, [COMPOSE_PROJECT_LABEL]: opts.projectId, }, - // Already resolved, immediately above — `DockerRun.runCapture`'s own ambient-only - // resolver must not re-resolve it (it doesn't see `opts.projectEnvValues` at all). + // Already resolved above; the ambient-only resolver doesn't see `opts.projectEnvValues`. skipImageResolve: true, }; - // `runStream` (not `runCapture`) so stdout is actually discarded chunk-by-chunk as it - // arrives, matching Go's `io.Discard` writer for this job (`start.go:352`, and this - // function's own doc comment above) at constant memory — `runCapture` would instead - // buffer the ENTIRE stdout stream into `stdoutChunks` even though nothing here ever - // reads it, which a large/verbose migration job's output could grow without bound - // (review: Codex, PR #6022). + // `runStream`, not `runCapture`: this discards stdout chunk-by-chunk at constant memory. + // `runCapture` would buffer the entire stream into memory even though nothing here reads it. const result = yield* docker .runStream(runOpts, { onStdout: () => Effect.void, teeStderr: opts.debug }) .pipe( @@ -657,7 +439,7 @@ const runStartMigrateJob = Effect.fnUntraced(function* ( } }); -/** Go's `initStorageJob` env (`start.go:297-317`) — deliberately distinct from `storage.service.ts`'s full container env, see this module's header. */ +/** A smaller, differently-keyed env than the long-running Storage container's own builder. */ function startStorageMigrateEnv(input: { readonly targetMigration: string; readonly anonKey: string; @@ -678,13 +460,13 @@ function startStorageMigrateEnv(input: { STORAGE_BACKEND: "file", STORAGE_FILE_BACKEND_PATH: "/mnt", TENANT_ID: "stub", - // TODO (matches Go's own TODO, `start.go:311`): https://github.com/supabase/storage-api/issues/55 + // TODO: https://github.com/supabase/storage-api/issues/55 REGION: "stub", GLOBAL_S3_BUCKET: "stub", }; } -/** Go's `initAuthJob` env (`start.go:319-332`) — deliberately distinct from `gotrue.service.ts`'s full container env, see this module's header. */ +/** A minimal env distinct from `gotrue.service.ts`'s full container builder. */ function startAuthMigrateEnv(input: { readonly apiUrl: string; readonly authExternalUrl: string | undefined; @@ -693,10 +475,7 @@ function startAuthMigrateEnv(input: { readonly dbHost: string; readonly dbPassword: string; }): Record<string, string> { - // Go's `AuthExternalURL()` (`pkg/config/config.go:543-545` -> `auth.GetExternalURL`): - // an explicit `auth.external_url` wins outright; only derive from `apiUrl` - // when it's unset — matching `gotrue.service.ts`'s identical preference - // chain for the long-running container. + // Matches `gotrue.service.ts`'s identical preference chain for the long-running container. const authExternalUrl = input.authExternalUrl !== undefined && input.authExternalUrl.length > 0 ? input.authExternalUrl @@ -716,9 +495,8 @@ function startAuthMigrateEnv(input: { } /** - * Port of Go's `initSchema15` (`start.go:334-357`): up to three one-shot - * migrate jobs, each gated on its own service's `enabled` flag, run in Go's - * fixed order (realtime, storage, auth). + * Runs up to three one-shot migrate jobs, each gated on its own service's `enabled` flag, in + * order: realtime, storage, auth. */ const startInitSchema15 = Effect.fnUntraced(function* ( spawner: Spawner, @@ -728,9 +506,8 @@ const startInitSchema15 = Effect.fnUntraced(function* ( const dbPassword = startInternalDbPassword(input.dbUrl); if (input.config.realtime.enabled) { - // Realtime's ENTRYPOINT (`tini` + `/app/entry.sh`) migrates, seeds - // when `SEED_SELF_HOST=true`, then `exec "$@"`. Passing only `cmd` (no - // entrypoint override) runs that one-shot before user migrations. + // Realtime's own entrypoint handles migrate + seed before `exec`; passing only `cmd` (no + // entrypoint override) runs that one-shot job ahead of user migrations. yield* runStartMigrateJob(spawner, { image: input.images.realtime, networkId: input.networkId, @@ -753,19 +530,9 @@ const startInitSchema15 = Effect.fnUntraced(function* ( }); } if (input.config.storage.enabled) { - // `startStorageMigrateEnv` parses `storage.file_size_limit` via - // `ramInBytes`, which throws on a malformed value — a plain synchronous - // throw here would become an uncaught Effect defect (`Effect.tapError`'s - // rollback trigger below only fires on typed `Fail` causes, never `Die` - // ones), leaking Postgres's already-created container/network/volume. - // Go fails this same malformed value at TOML-decode time, before any - // Docker work (`sizeInBytes.UnmarshalText`, `pkg/config/config.go:41-47`) - // — this can't be replicated literally here since Postgres is already up - // by this step, but surfacing it as a typed `DbSetupError` so - // rollback actually runs is the achievable equivalent, matching the same - // fix already applied to `resolveDbHealthTimeoutSeconds` and the - // long-running Storage container's own file-size-limit parsing - // (`start.handler.ts`). + // `ramInBytes` throws on a malformed value; wrapped so that surfaces as a typed + // `DbSetupError` instead of an untyped defect, which would skip the rollback below (its + // `Effect.tapError` only fires on typed failures) and leak the already-created resources. const storageEnv = yield* Effect.try({ try: () => startStorageMigrateEnv({ @@ -814,13 +581,8 @@ const startInitSchema15 = Effect.fnUntraced(function* ( }); /** - * Port of Go's `initSchema` (`start.go:243-254`) MINUS the banner print: branches on PG major - * version — unconditionally, for both branches. The banner itself - * (`fmt.Fprintln(w, "Initialising schema...")`, printed before the `if - * utils.Config.Db.MajorVersion <= 14` check) now prints from - * {@link resolveDbSetupPrelude}, the caller-side step that runs immediately before this - * one — see that function's own doc comment for why the print had to move there instead of - * staying here. + * Branches on `majorVersion`. The setup banner prints from {@link resolveDbSetupPrelude}, the + * caller-side step that runs immediately before this one, not from here. */ const startInitSchema = Effect.fnUntraced(function* ( spawner: Spawner, @@ -835,16 +597,13 @@ const startInitSchema = Effect.fnUntraced(function* ( }); /** - * Applies the `api.auto_expose_new_tables` tri-state: unset and `true` both keep the - * bundled initial-schema grants (no-op), matching the cloud default of auto-exposing - * new `public` entities; only an explicit `false` execs - * {@link START_REVOKE_API_PRIVILEGES_SQL}. Runs regardless of PG major version - * (unlike `initSchema`, this always execs SQL over `session` directly — it is never - * part of the PG15+ one-shot Docker jobs). Exported (and taking `session`/`fs`/`path` - * directly, not the whole {@link StartSetupLocalDatabaseInput}) because `db - * reset`'s PG14 `initDatabase` path calls it too, after its own init-schema step and - * with none of `setupDatabase`'s other steps (vault/roles.sql/migrate+seed) — - * see `command-internal/db-bootstrap/recreate-local-database.ts`. + * Applies the `api.auto_expose_new_tables` tri-state: unset and `true` are both a no-op (keeps + * the bundled initial-schema grants); an explicit `false` execs + * {@link START_REVOKE_API_PRIVILEGES_SQL}. Always execs over `session` directly, regardless of PG + * major version — never part of the PG15+ one-shot Docker jobs. + * + * Takes `session`/`fs`/`path` directly rather than the whole {@link StartSetupLocalDatabaseInput} + * because `db reset`'s PG14 path calls it too, without any of `setupDatabase`'s other steps. */ export const applyApiPrivileges = Effect.fnUntraced(function* ( session: DbSession, @@ -884,10 +643,8 @@ export const applyDatabaseWebhooks = Effect.fnUntraced(function* ( }); /** - * Drops pg_net. Hoisted so the fresh-setup PG14 dump cleanup, the `db reset` PG14 - * path, and the existing-volume webhooks convergence all run the identical statement - * instead of one of them silently diverging (`db reset` on PG14 with webhooks - * disabled used to leave pg_net installed while fresh setup removed it). + * Drops pg_net; shared by the fresh-setup PG14 dump cleanup, `db reset`'s PG14 path, and the + * existing-volume webhooks convergence, so all three run the identical statement. */ export const removeDatabaseWebhooks = Effect.fnUntraced(function* ( session: DbSession, @@ -906,15 +663,9 @@ export const removeDatabaseWebhooks = Effect.fnUntraced(function* ( }); /** - * Port of Go's `initCurrentBranch` (`start.go:233-241`): writes - * `supabase/.branches/_current_branch` = `"main"` (Go's `CurrBranchPath`, - * `apps/cli-go/internal/utils/misc.go:99` = `filepath.Join(SupabaseDirPath, - * ".branches", "_current_branch")`) only if it doesn't already exist. No existing - * TS constant for this path — `commands/db/branch/*` are Management-API - * cloud-branch commands, unrelated to this local file — so it's inlined here, - * the only current consumer (per "Hoist Before You Duplicate"). Exported (rather - * than folded into {@link startSetupLocalDatabase}) because Go calls it - * unconditionally, not just on a fresh volume — see this module's header. + * Writes `supabase/.branches/_current_branch` = `"main"` if it doesn't already exist. Exported + * separately from {@link startSetupLocalDatabase} because callers invoke it unconditionally, not + * just on a fresh volume. */ export const startInitCurrentBranch = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, @@ -941,12 +692,8 @@ export const startInitCurrentBranch = Effect.fnUntraced(function* ( }), ), ); - // Go's `utils.WriteFile` writes through `afero.WriteFile(fsys, path, contents, 0644)` - // (`internal/utils/misc.go:280-286`) — an explicit mode, not the platform default. Effect's - // `writeFileString` falls back to Node's default file mode (`0666` before the umask) when no - // `mode` is given, so under a permissive/group-writable umask (`000`/`002`) this file could be - // created `0666`/`0664` instead of Go's `0644`, making project branch metadata writable by - // additional local users. + // 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) => @@ -959,10 +706,9 @@ export const startInitCurrentBranch = Effect.fnUntraced(function* ( }); /** - * Runs Go's EXPORTED `SetupDatabase(ctx, conn, host, w, fsys)` (`start.go:383-399`) — - * see {@link SetupDatabaseInput}'s own doc comment for exactly what's in and out of - * scope. Extracted out of {@link startSetupLocalDatabase} (CLI-1956) so shadow-database - * provisioning can reuse this exact sequence without also reaching `apply.MigrateAndSeed`. + * Runs schema init through the custom-roles seed; see {@link SetupDatabaseInput} for exactly + * what's in and out of scope. Extracted from {@link startSetupLocalDatabase} so shadow-database + * provisioning can reuse this exact sequence without also reaching migrate-and-seed. */ export const setupDatabase = ( spawner: Spawner, @@ -973,16 +719,14 @@ export const setupDatabase = ( | DbSetupError | MigrationVaultError | ImagePrepullError - // A batched SQL file whose pooled connection cannot be acquired fails with the - // driver's own connect error, surfaced verbatim (never relabeled as a setup - // failure) like every other connection failure on this path. + // A pooled-connection acquire failure surfaces the driver's own connect error verbatim, never + // relabeled as a setup failure. | DbConnectError, Output | DockerRun | RuntimeInfo > => Effect.gen(function* () { const { session, fs, path, workdir } = input; - // initSchema -> user/baseline extension activation -> ApplyApiPrivileges. yield* Effect.scoped( Effect.gen(function* () { const tmpDir = yield* fs @@ -1012,20 +756,12 @@ export const setupDatabase = ( }), ); - // "Create vault secrets first so roles.sql can reference them" (start.go:390). + // Runs before the roles seed so `roles.sql` can reference these secrets. yield* upsertVaultSecrets(session, input.vault); - // Custom-roles seed (start.go:394-398, pkg/migration/seed.go:84-97): Go's - // `SeedGlobals` prints "Seeding globals from roles.sql..." BEFORE attempting - // to read the file, then tolerates a missing file (`errors.Is(err, - // os.ErrNotExist)`); any other read/exec error propagates. Reproduced here as - // an unconditional print followed by an existence check ahead of the read - // (via `execSqlFile`, not `seedGlobals` — reusing `seedGlobals` - // would need the missing-file case to unwind through `execMigrationBatch`'s - // shared, flattened error-mapping contract in `migration-apply.ts`, - // which every other caller of that file also relies on and which is out of - // scope to change here) rather than a caught not-found error, since there is - // no meaningful TOCTOU concern in this CLI context. + // Prints unconditionally, before checking whether the file exists. A missing file is + // tolerated; any other read/exec error propagates. Checked via an existence check ahead of + // the read rather than a caught not-found error — no meaningful TOCTOU concern here. const customRolesPath = path.join(workdir, "supabase", "roles.sql"); const output = yield* Output; yield* output.raw(`Seeding globals from ${path.basename(customRolesPath)}...\n`, "stderr"); @@ -1050,43 +786,30 @@ export const setupDatabase = ( }); /** - * Runs the full `SetupLocalDatabase`-equivalent sequence — see this module's - * header for the exact Go call chain and line-range citations. Call once, right - * after the `db` container's healthcheck passes on a fresh volume (Go's - * `NoBackupVolume` gate); the caller decides that gating, this function performs - * no health/readiness checks of its own. + * Runs the full setup sequence described in this module's header. Call once, right after the + * `db` container's healthcheck passes on a fresh volume; the caller decides that gating — this + * function performs no health/readiness checks of its own. */ export const startSetupLocalDatabase = ( spawner: Spawner, input: StartSetupLocalDatabaseInput, ): Effect.Effect< void, - // `DbConnectError` rides alongside the alias (as in `runFreshDbSetup`) - // because a batch that cannot check a connection out of the pool fails with the - // driver's connect error verbatim, suggestion included. + // Rides alongside the alias because a pooled-connection acquire failure surfaces the driver's + // connect error verbatim, suggestion included. StartSetupLocalDatabaseError | DbConnectError, Output | DockerRun | RuntimeInfo | FileSystem.FileSystem | Path.Path > => Effect.gen(function* () { const { session, fs, path, workdir } = input; - // `warnOnUnresolvedEnv: false` — both `start.handler.ts` and `db/start/ - // start.handler.ts` already ran an earlier, same-invocation `checkDbToml` - // purely for its Go-parity validation side effect (their own callers discard the - // result) before ever reaching this fresh-volume setup, so that earlier call - // already printed Go's single `assertEnvLoaded` OrioleDB S3 WARN, if any. Without - // this, this module's own accepted duplicate config-load pass (see this module's - // header) would print the SAME warning a second time — a real, observable stderr - // divergence from Go's exactly-once `flags.LoadConfig`, unlike the harmless - // resolved-value duplication the header describes. + // `warnOnUnresolvedEnv: false`: the caller's own handler already ran an earlier `checkDbToml` + // pass before reaching this fresh-volume setup, which already printed any unresolved-env + // warning. Without this, this module's own duplicate config-load pass would print it twice. const toml = yield* checkDbToml(fs, path, workdir, undefined, { warnOnUnresolvedEnv: false, }); - // SetupDatabase: initSchema -> ApplyApiPrivileges -> vault secrets -> custom-roles seed - // (start.go:383-399) — extracted to {@link setupDatabase} so shadow-database - // provisioning (CLI-1956) can reuse this exact sequence without also reaching - // `apply.MigrateAndSeed` below. yield* setupDatabase(spawner, { ...input, webhooksEnabled: toml.webhooksEnabled, @@ -1094,19 +817,8 @@ export const startSetupLocalDatabase = ( vault: toml.vault, }); - // apply.MigrateAndSeed(ctx, version, conn, fsys) — `db start`'s own caller always - // passes `version: ""` (every pending migration, matching `SetupLocalDatabase`'s - // own call in the `start` context, `start.go:185,368`); `db reset`'s PG15 recreate - // passes its own resolved reset version instead (`resetDatabase15`, `reset.go:169`) - // — see `input.version`'s own doc comment. `experimental`/`pgDeltaEnabled`/ - // `schemaPaths` gate `migrateAndSeed`'s own declarative-schema-files branch - // (apply.go:19) — see its doc comment; `toml.pgDelta.enabled` and `toml.schemaPaths` - // are this module's own already-loaded config (the latter already resolved + - // `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` env-overridden by `checkDbToml`, - // `db-config.toml-read.ts`), not re-read from the caller's raw, unresolved - // `CliConfig`. `input.seedFlags` applies `db reset`'s own `--no-seed`/ - // `--sql-paths` overrides on top of the loaded `[db.seed]` config — a no-op for - // `db start`, which has neither flag. + // `toml.pgDelta.enabled`/`toml.schemaPaths` are this module's own already-loaded, + // env-overridden config, not re-read from the caller's raw `CliConfig`. yield* migrateAndSeed(session, fs, path, workdir, input.version, { migrationsEnabled: toml.migrationsEnabled, seed: resolveResetSeedConfig(toml.seed, input.seedFlags, path), @@ -1115,31 +827,22 @@ export const startSetupLocalDatabase = ( schemaPaths: toml.schemaPaths, localDatabaseWebhooksEnabled: toml.webhooksEnabled, }); - - // `initCurrentBranch` (start.go:233-241) is NOT called here — see this - // module's header for why it moved to the caller instead. }); /** - * The `setup` shape shared by BOTH real Go callers of {@link - * startSetupLocalDatabase} — `db start`'s own fresh-volume branch - * (`start-database.ts`'s `startDatabase`) and `db reset`'s PG15 recreate - * composition (`recreate-local-database.ts`'s `recreateLocalDatabase15`) — - * everything {@link startSetupLocalDatabase} needs, minus what {@link - * runFreshDbSetup} itself already resolves/threads through (`session`, - * `images`). The two callers used to each declare an identical copy of this - * interface; hoisted here alongside {@link runFreshDbSetup} itself - * (CLI-1955 review follow-up). + * The `setup` shape shared by `db start`'s fresh-volume branch and `db reset`'s PG15 recreate: + * everything {@link startSetupLocalDatabase} needs, minus what {@link runFreshDbSetup} itself + * already resolves and threads through (`session`, `images`). */ export interface FreshDbSetupInput<E> { readonly majorVersion: number; - /** Already spliced with the caller's own realtime/storage/auth enabled-for-setup + ip_version/max_header_length/file_size_limit overrides — see `bootstrap-config.ts`'s `DbBootstrapConfig`. */ + /** Already spliced with the caller's own service-enabled/override values; see `bootstrap-config.ts`'s `DbBootstrapConfig`. */ readonly config: StartSetupLocalDatabaseInput["config"]; - /** Threaded straight through to {@link StartSetupLocalDatabaseInput.experimental} — see its own doc comment. */ + /** Threaded straight through to {@link StartSetupLocalDatabaseInput.experimental}. */ readonly experimental: boolean; readonly dbUrl: string; readonly jwtSecret: string; - /** Lazy — evaluated only when reached AND `majorVersion >= 15` AND `realtimeEnabledForSetup` (see {@link resolveDbSetupPrelude}'s own doc comment for the Go citation). See `start-database.ts`'s header for why this is caller-supplied rather than resolved here unconditionally. */ + /** Lazy; evaluated only when `majorVersion >= 15` and `realtimeEnabledForSetup` (see {@link resolveDbSetupPrelude}). Caller-supplied rather than resolved here unconditionally. */ readonly jwks: Effect.Effect<string, E>; readonly apiUrl: string; readonly authExternalUrl: string | undefined; @@ -1152,10 +855,7 @@ export interface FreshDbSetupInput<E> { readonly authEnabledForSetup: boolean; readonly serviceVersionOverrides: LocalServiceVersionOverrides; readonly projectEnvValues: Readonly<Record<string, string>> | undefined; - /** - * `--debug` — threaded straight through to {@link - * StartSetupLocalDatabaseInput.debug}; see its own doc comment. - */ + /** Threaded straight through to {@link StartSetupLocalDatabaseInput.debug}. */ readonly debug: boolean; } @@ -1206,8 +906,8 @@ export const runDatabaseWebhooksSetup = (input: { Effect.map((migrations) => migrations.some( (migration) => - // NULL/`{}` history rows become `[]`. That is incomplete evidence, - // not proof the migration did not install pg_net — preserve. + // A `[]` here could mean incomplete history evidence, not a confirmed absence of + // pg_net — treat it as installed, to be safe. migration.statements.length === 0 || migration.statements.some(statementInstallsPgNet), ), @@ -1236,19 +936,10 @@ export const runDatabaseWebhooksSetup = (input: { ); /** - * Runs {@link startSetupLocalDatabase} against a freshly-provisioned local - * Postgres — the exact sequence BOTH real Go callers run once Postgres's own - * healthcheck passes on a fresh database (`db start`'s fresh-volume branch and - * `db reset`'s PG15 recreate, see {@link FreshDbSetupInput}'s own doc - * comment): dial the host-facing session (Go's `ConnectLocalPostgres`), resolve - * JWKS + the three PG15+ one-shot job images' PINNED names via {@link - * resolveDbSetupPrelude} (the same hoisted prelude the shadow-database variant - * uses), then run {@link startSetupLocalDatabase} - * itself. `version`/`seedFlags` are the one genuine difference between the two - * callers (`db start` always passes `""`/`{noSeed:false, sqlPaths:[]}`; `db - * reset` passes its own resolved reset version/flags) — threaded straight - * through by the caller, matching each one's own `StartSetupLocalDatabaseInput` - * field of the same name. + * Runs {@link startSetupLocalDatabase} against a freshly-provisioned local Postgres: dials the + * host-facing session, resolves the {@link resolveDbSetupPrelude} prelude, then runs the setup + * pipeline. `version`/`seedFlags` are the one difference between callers — `db start` always + * passes `""`/a no-op override, `db reset` passes its own resolved reset version and flags. */ export const runFreshDbSetup = <E>( spawner: Spawner, @@ -1273,11 +964,9 @@ export const runFreshDbSetup = <E>( Effect.gen(function* () { const { setup } = input; const dbPassword = startInternalDbPassword(setup.dbUrl); - // Go's `SetupLocalDatabase` dials this first host-facing connect exactly - // once (`start.go:360-363`); we deliberately diverge and retry dial-level - // failures: the container's internal health check says nothing about the - // HOST side, where Docker Desktop (Windows/WSL2) can publish the port a - // few seconds late (#6136). + // Retries dial-level failures: the container's internal health check says nothing about + // the host side, where Docker Desktop (Windows/WSL2) can publish the port a few seconds + // late. const session = yield* connectLocalPostgres({ hostname: input.hostname, dbPort: input.dbPort, @@ -1294,9 +983,6 @@ export const runFreshDbSetup = <E>( config: setup.config, experimental: setup.experimental, majorVersion: setup.majorVersion, - // Go's `utils.DbId` — the internal Docker-network address the PG15+ one-shot - // jobs connect through. Unchanged from before CLI-1956, just now an explicit - // parameter on `SetupDatabaseInput` instead of computed inside it. dbHost: localDbContainerId(input.projectId), projectId: input.projectId, networkId: input.networkId, 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 efb1f72a8e..83ea77ad29 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 @@ -26,18 +26,12 @@ import { const decodeConfig = Schema.decodeUnknownSync(CliConfigSchema); /** - * Fingerprints unique to each transcribed SQL constant — see `db-setup.ts`'s - * templates. No trailing `;`: `splitAndTrim` strips it from every - * executed statement before `session.exec` sees it. + * Fingerprints unique to each transcribed SQL constant, with no trailing `;` (`splitAndTrim` + * strips it before `session.exec` sees it). * - * `GLOBALS`/`SCHEMA_13`/`REVOKE_PRIVILEGES` are checked as SUBSTRINGS: each is - * embedded inside a larger executed block (a preceding comment, a `DO`-style - * conditional, or the sibling `alter default privileges` line). `SCHEMA_14` - * is checked with `.endsWith` instead — `CREATE SCHEMA IF NOT EXISTS graphql` - * is itself preceded by a comment block (so a plain equality check would - * fail), but a naive substring check would also match 14.sql's unrelated - * `CREATE SCHEMA IF NOT EXISTS graphql_public` statement, since `graphql` is - * a prefix of `graphql_public`. + * `GLOBALS`/`SCHEMA_13`/`REVOKE_PRIVILEGES` are checked as substrings, since each is embedded in + * a larger executed block. `SCHEMA_14` is checked with `.endsWith` instead: a substring check + * would also match 14.sql's unrelated `graphql_public` schema statement. */ const GLOBALS_FINGERPRINT = "CREATE ROLE anon"; const SCHEMA_13_FINGERPRINT = @@ -88,10 +82,7 @@ function mockDockerRun(opts: { exitCode?: number } = {}) { stderr: "", }); }, - // `runStartMigrateJob` (`db-setup.ts`) discards stdout via `runStream` (not - // `runCapture`), matching Go's `io.Discard` writer for these one-shot jobs — this - // suite's `docker.runs`/`captureOptsCalls` assertions track THIS method's calls, not - // `runCapture`'s (which nothing under test still calls). + // Tracks `runStream`'s calls (not `runCapture`'s): `runStartMigrateJob` calls `runStream`. runStream: (runOpts, streamOpts) => { runs.push(runOpts); captureOptsCalls.push({ teeStderr: streamOpts.teeStderr }); @@ -102,10 +93,8 @@ function mockDockerRun(opts: { exitCode?: number } = {}) { } /** - * A `ChildProcessSpawner` where `docker image inspect <image>` always exits 0 (image - * already cached) — feeds `runStartMigrateJob`'s own per-image `ensureImagesCached` - * resolve (see `db-setup.ts`), so every job's `image` resolves to the SAME raw string this - * suite's `baseInput` already asserts on, without needing a real Docker daemon. + * A `ChildProcessSpawner` where `docker image inspect <image>` always exits 0, so every job's + * image resolves to the raw string `baseInput` asserts on, without a real Docker daemon. */ function mockAlwaysCachedSpawner(): ChildProcessSpawner.ChildProcessSpawner["Service"] { return ChildProcessSpawner.make((_command) => @@ -460,10 +449,6 @@ describe("startSetupLocalDatabase", () => { it.effect( "the auth job's GOTRUE_SITE_URL reflects the caller's resolved siteUrl, not the raw config value", () => { - // `siteUrl` is already SUPABASE_AUTH_SITE_URL-overridden by the caller - // (`start.handler.ts`'s `values.authSiteUrl`) — the one-shot auth - // migration job must agree with the long-running GoTrue container, - // not fall back to reading the un-overridden `config.auth.site_url`. const workdir = makeWorkdir(); const { session } = fakeSession(); const out = mockOutput(); @@ -640,8 +625,6 @@ describe("startSetupLocalDatabase", () => { const docker = mockDockerRun(); return run(baseInput(workdir, session, { majorVersion: 14 }), out, docker).pipe( Effect.map(() => { - // Go's `SeedGlobals` prints before attempting the read (`pkg/migration/ - // seed.go:84-97`) — a missing roles.sql is tolerated, not skipped. expect(out.rawChunks.map((c) => c.text)).toContain( "Seeding globals from roles.sql...\n", ); @@ -727,9 +710,8 @@ describe("resolveDbSetupPrelude", () => { }); /** - * `supabase start` on an EXISTING volume never replays migrations, so this - * convergence is the only thing that can reconcile the volume's pg_net with the - * current `[experimental.webhooks]` setting — in both directions, and without ever + * On an existing volume, `supabase start` never replays migrations, so this convergence is the + * only thing that reconciles the volume's pg_net with the current webhooks setting, without * dropping an extension a user's own migration created. */ describe("runDatabaseWebhooksSetup", () => { @@ -837,8 +819,7 @@ describe("runDatabaseWebhooksSetup", () => { ])( "preserves pg_net when an applied history row records $description for statements", ({ historyValue }) => { - // Older volumes store NULL/`{}` in `schema_migrations.statements`. That is - // incomplete evidence, not proof the migration did not install pg_net. + // Older volumes store NULL/`{}` for `schema_migrations.statements`. const { execSql, effect } = converge(false, { appliedStatements: [historyValue] }); return effect.pipe( Effect.map(() => { @@ -849,7 +830,6 @@ describe("runDatabaseWebhooksSetup", () => { ); it.effect("preserves pg_net when the migration history cannot be read", () => { - // Erring toward not dropping: an unreadable history is treated as ownership. const { execSql, effect } = converge(false, { historyUnavailable: true }); return effect.pipe( Effect.map(() => { diff --git a/apps/cli/src/command-internal/db-bootstrap/docker-create-args.ts b/apps/cli/src/command-internal/db-bootstrap/docker-create-args.ts index 64e99aa576..d379bc7a39 100644 --- a/apps/cli/src/command-internal/db-bootstrap/docker-create-args.ts +++ b/apps/cli/src/command-internal/db-bootstrap/docker-create-args.ts @@ -1,356 +1,151 @@ /** - * Assembles `docker create <flags> <image> [cmd...]` argv for the per-service - * containers Go's `supabase start` creates directly against the Docker Engine - * SDK (`utils.DockerStart`, `apps/cli-go/internal/utils/docker.go:363-440`). + * Builds `docker create` argv for the per-service containers `supabase start` launches. * - * Go never shells out to `docker` — it builds `container.Config` / - * `container.HostConfig` / `network.NetworkingConfig` structs and calls - * `Docker.ContainerCreate` over the Engine API. This module is the CLI-shell - * translation of that same struct surface into `docker create` CLI flags, so - * every field here is documented against the exact Go struct field (and, where - * useful, the exact call site) it reproduces. - * - * Unlike the Engine API, `docker create`'s argv has no inherent "field order" - * — Docker parses flags independently of position. The order emitted by - * {@link buildStartContainerCreateArgs} below is therefore this module's - * own fixed, deterministic convention (grouped: identity → env → volumes → - * ports → healthcheck → restart/security → network → labels → - * entrypoint/image/cmd), chosen for readability and stable unit-test - * snapshots — it carries no Go parity obligation, unlike every flag-to-field - * mapping it emits. - * - * Surveyed call sites (every `container.Config{`/`network.NetworkingConfig{` - * block across the Go source, per the task's "read every one" instruction). - * The `internal/start/start.go` lines below are deleted in CLI-1966; last - * present at commit a253ccba2: - * - `apps/cli-go/internal/start/start.go:350-394` Logflare/analytics - * - `apps/cli-go/internal/start/start.go:396-484` Vector - * - `apps/cli-go/internal/start/start.go:486-627` Kong - * - `apps/cli-go/internal/start/start.go:629-851` GoTrue - * - `apps/cli-go/internal/start/start.go:853-901` Mailpit/Inbucket - * - `apps/cli-go/internal/start/start.go:903-958` Realtime - * - `apps/cli-go/internal/start/start.go:960-992` PostgREST/Rest - * - `apps/cli-go/internal/start/start.go:994-1057` Storage - * - `apps/cli-go/internal/start/start.go:1059-1099` Storage ImgProxy - * - `apps/cli-go/internal/start/start.go:1110-1146` pg-meta - * - `apps/cli-go/internal/start/start.go:1148-1191` Studio - * - `apps/cli-go/internal/start/start.go:1193-1268` Pooler - * - `apps/cli-go/internal/db/start/start.go:63-131` Postgres (the db container) - * - * Fields deliberately NOT modelled, because none of the 13 call sites above - * set them on `container.Config`/`container.HostConfig` (verified by grepping - * every block for these field names): `User`, `WorkingDir`, `Tty`, `CapAdd`, - * `Ulimits`, `ShmSize`. Add them here — following the same "optional field, - * flag omitted unless present" shape as every other field below — the day a - * call site actually needs one; there is no value in modelling Docker surface - * this builder never has to reproduce. - * - * Two fields have no Go struct equivalent at all: - * {@link StartContainerSpec.secretFiles} and - * {@link StartContainerSpec.preStartArchives}. The first exists purely - * because this module's own "shell out to `docker create`" architecture (unlike - * Go's direct Engine API calls) has an argv-exposure problem - * `container.Config`/`container.HostConfig` never had; the second because Go - * destroys its shadow container on every run and so never needs to seed a - * container's filesystem before it starts. See each field's doc comment, and - * `container-lifecycle.ts`'s `createContainer`, for how both are - * delivered. + * Flag order is a fixed convention (identity, env, volumes, ports, healthcheck, restart and + * security, network, labels, entrypoint/image/cmd) so unit-test snapshots stay stable. */ import type { PlatformError, Stream } from "effect"; import { bindMountSpecSource, isBindMountSource } from "../docker-bind-classify.ts"; -/** - * `container.HealthConfig` (`docker/docker/api/types/container`). Not - * exported on its own — callers reference it structurally through - * {@link StartContainerSpec.healthcheck}; nothing outside this module - * needs to name the shape directly. - */ interface StartHealthcheckSpec { /** - * Go's `HealthConfig.Test` exec form (`["CMD", ...args]`) or shell form - * (`["CMD-SHELL", script]`). See {@link buildHealthCmdArg} for exactly - * how each form becomes the single `--health-cmd` string docker CLI expects. + * Exec form (`["CMD", ...args]`) or shell form (`["CMD-SHELL", script]`); see + * {@link buildHealthCmdArg} for how each becomes the single `--health-cmd` string. */ readonly test: ReadonlyArray<string>; - /** `HealthConfig.Interval`, already-whole seconds → `--health-interval <n>s`. */ + /** Emitted as `--health-interval <n>s`. */ readonly intervalSeconds?: number; - /** `HealthConfig.Timeout` → `--health-timeout <n>s`. */ + /** Emitted as `--health-timeout <n>s`. */ readonly timeoutSeconds?: number; - /** `HealthConfig.Retries` → `--health-retries <n>`. */ + /** Emitted as `--health-retries <n>`. */ readonly retries?: number; - /** - * `HealthConfig.StartPeriod` → `--health-start-period <n>s`. Only Logflare - * and Mailpit set this (`start.go:371`, `start.go:882`); every other - * healthcheck leaves Go's zero value, which docker treats as "unset" — so - * this stays optional and the flag is omitted, not emitted as `0s`. - */ + /** Emitted as `--health-start-period <n>s`; omitted when unset. */ readonly startPeriodSeconds?: number; } -/** - * `-p <hostPort>:<containerPort>[/<protocol>]` — one - * `container.HostConfig.PortBindings` entry. Not exported; referenced - * structurally through {@link StartContainerSpec.ports}. - */ +/** One `-p <hostPort>:<containerPort>[/<protocol>]` entry. */ interface StartPortBindingSpec { readonly hostPort: string; readonly containerPort: string; - /** Defaults to `"tcp"` (omitted from the flag) — no call site uses `"udp"` today. */ + /** Defaults to `"tcp"`, omitted from the flag. */ readonly protocol?: "tcp" | "udp"; } -/** - * `--expose <containerPort>[/<protocol>]` — one `container.Config.ExposedPorts` - * entry with no matching `PortBindings` entry (kept separate from - * {@link StartPortBindingSpec} on purpose, see the doc comment on - * {@link StartContainerSpec.exposedPorts}). Not exported; referenced - * structurally through {@link StartContainerSpec.exposedPorts}. - */ +/** One `--expose <containerPort>[/<protocol>]` entry, with no matching `-p` publish; see {@link StartContainerSpec.exposedPorts}. */ interface StartExposedPortSpec { readonly containerPort: string; readonly protocol?: "tcp" | "udp"; } -/** - * One entry packed into a container's in-memory secret tar archive — see - * {@link StartContainerSpec.secretFiles}'s doc comment for the full contract. Not exported - * on its own — callers reference it structurally through that field; nothing outside this module - * needs to name the shape directly. - */ +/** One entry packed into a container's in-memory secret tar archive; see {@link StartContainerSpec.secretFiles}. */ interface StartSecretFileSpec { - /** The exact fixed path to materialize INSIDE the container. */ + /** The path to materialize inside the container. */ readonly containerPath: string; - /** Secret content encoded only into the in-memory archive — never host disk or argv. */ + /** Never written to host disk or argv; only the in-memory archive. */ readonly content: string; } -/** - * One tar archive to extract into the created-but-not-yet-started container — see - * {@link StartContainerSpec.preStartArchives}'s doc comment for the full contract. Not - * exported on its own: callers reference it structurally through that field. - */ +/** One tar archive to extract into the created-but-not-yet-started container; see {@link StartContainerSpec.preStartArchives}. */ interface StartPreStartArchiveSpec { - /** - * The directory INSIDE the container the archive's members are unpacked relative to, i.e. - * `docker cp - <id>:<containerPath>`. Always a POSIX container path, never a host path. - */ + /** The directory inside the container to unpack into, i.e. `docker cp - <id>:<containerPath>`. */ readonly containerPath: string; /** - * The tar bytes, as a lazily-consumed stream (typically `FileSystem.stream(hostTarPath)`). - * A stream rather than a host path on purpose: it keeps the archive's own storage decisions - * with the producer, and `createContainer` needs no `FileSystem` in its own context to - * deliver it. + * A stream rather than a host path: it keeps the archive's storage decisions with the + * producer, and `createContainer` needs no `FileSystem` to deliver it. */ readonly tar: Stream.Stream<Uint8Array, PlatformError.PlatformError>; } export interface StartContainerSpec { - /** `container.Config.Image` (already resolved/pulled — resolution is out of scope here). */ + /** Already resolved/pulled; resolution is out of scope here. */ readonly image: string; /** - * The 4th `DockerStart` positional argument — `--name`. An empty string mirrors Go - * passing `""` (e.g. `CreateShadowDatabase`, `apps/cli-go/internal/db/diff/diff.go:150`) - * and lets Docker auto-generate one — {@link buildStartContainerCreateArgs} omits - * `--name` entirely in that case (docker rejects an explicit empty `--name` value, unlike - * the Engine API's empty `containerName` positional, which it happily treats as "generate - * one"). Every real service container still passes a non-empty name, unchanged. + * An empty string lets Docker auto-generate a name; {@link buildStartContainerCreateArgs} + * omits `--name` entirely in that case, since Docker rejects an explicit empty value. */ readonly containerName: string; - /** - * `container.Config.Hostname`. Only Logflare sets this (`start.go:353`, - * `Hostname: "127.0.0.1"`); every other service leaves it unset and relies - * on Docker's default (the container's own short ID). - */ + /** Omitted uses Docker's default (the container's own short ID). */ readonly hostname?: string; /** - * `container.Config.Env`, reshaped from Go's `KEY=value` string slice into a - * map. Emitted as the key-only `-e KEY` form — see the doc comment on - * {@link buildStartContainerCreateArgs} — so secret values (JWT - * secrets, SMTP passwords, API keys — every one of these containers carries - * at least one) never appear in this process's own argv. + * Emitted as the key-only `-e KEY` form (see {@link buildStartContainerCreateArgs}) so + * secret values never appear in this process's own argv. */ readonly env: Readonly<Record<string, string>>; /** - * Entrypoint/`Cmd`-script secret content that must land at a specific path - * INSIDE the container without ever appearing in this process's own - * `docker create` argv (`ps aux`/`/proc/<pid>/cmdline`, CWE-214/522) — the - * entrypoint/`Cmd` analogue of {@link env}'s key-only `-e KEY` protection. - * Kong/Postgres/Supavisor all heredoc or shell-embed secret-bearing content - * (Kong's `kong.yml`/TLS private key, Postgres's pgsodium root key, - * Supavisor's rendered `pooler.exs`) directly into their entrypoint script - * or `Cmd` — safe in Go's Engine-API architecture (never a subprocess's own - * argv) but not in this port's, which shells out to a real `docker create`. - * - * NOT consumed here: {@link buildStartContainerCreateArgs} stays - * pure/no-I/O and never reads this field. `container-lifecycle.ts`'s - * `createContainer` is the sole consumer — once `docker create` returns - * a container id, it builds one in-memory Bun tar archive containing every - * entry at its exact `containerPath` with mode `0644` (so non-root Kong/ - * Postgres readers do not hit `EACCES`; see `container-lifecycle.ts`'s - * `copyStartSecretFilesIntoContainer` doc comment), then streams that - * archive on stdin to `docker cp - <id>:/` before `docker start`. Plaintext - * never touches host disk, a host bind mount, or the subprocess argv. - * Generic by design — any future - * service's spec can set this, not just the three call sites that need it - * today, and it makes no difference whether `containerName` is set: `docker - * cp` addresses the container by the id `docker create` returns, not by - * name, so the shadow database's own unnamed container (`db-bootstrap/ - * shadow-database.ts`) is delivered its pgsodium root key the exact same way. - * - * The pathless `docker cp` stream uses the same Docker CLI/Engine API - * connection as `docker create`/`docker start`, so — unlike the - * bind-mount approach this replaced (supabase/cli#6022) — it works - * identically whether `DOCKER_HOST`/Docker-context points at a local or a - * REMOTE daemon: a bind mount's host-side path is resolved by the daemon - * itself - * (https://docs.docker.com/engine/storage/bind-mounts/#considerations-and-constraints), - * so it silently broke against a remote daemon (a scenario this codebase - * otherwise explicitly supports — see `hostname.ts`'s - * `getHostname`) even though the daemon itself was reachable. With no - * client-side source path, the stream also works when the Docker CLI is - * confined behind a private filesystem namespace. This matches Go's own heredoc/`Cmd`-embed - * delivery (the content travels inside the container-create request itself, - * over the Engine API) for that same reason. + * Secret content that must land at a specific path inside the container without ever + * appearing in this process's own `docker create` argv. Not consumed here — + * {@link buildStartContainerCreateArgs} stays pure/no-I/O; `container-lifecycle.ts`'s + * `createContainer` is the sole consumer, delivering it via `docker cp` after create and + * before start, which works identically against a local or remote daemon. */ readonly secretFiles?: ReadonlyArray<StartSecretFileSpec>; /** - * Tar archives to unpack into the container's own filesystem AFTER `docker create` and - * strictly BEFORE `docker start` — the shape `docker cp - <id>:<containerPath>` (tar on - * stdin) implements. No Go equivalent: Go's shadow container is destroyed on every run, so - * nothing in the Go CLI ever seeds a container's filesystem ahead of its entrypoint. + * Unpacked into the container after `docker create` and before `docker start`, via a + * tar-stream `docker cp` that preserves each member's uid/gid — unlike a host-path `docker cp`, + * which resets ownership to root and a restored Postgres data directory cannot survive. * - * The one consumer today is the shadow baseline cache (`shadow-cache.ts`), which restores a - * previously exported PGDATA directory so the `supabase/postgres` entrypoint finds a - * `PG_VERSION` file and skips `initdb` entirely. The delivery form matters and is not - * interchangeable with {@link secretFiles}': `docker cp <hostDir> <id>:<path>` resets the - * copied tree's ownership to the archive-extracting user (root), and Postgres refuses to - * start on a data directory it does not own, whereas the tar-STREAM form preserves each - * member's uid/gid verbatim. - * - * NOT consumed here — {@link buildStartContainerCreateArgs} stays pure/no-I/O and never - * reads this field, exactly like {@link secretFiles}. `container-lifecycle.ts`'s - * `createContainer` is the sole consumer. + * Not consumed here — {@link buildStartContainerCreateArgs} stays pure/no-I/O. + * `container-lifecycle.ts`'s `createContainer` is the sole consumer. */ readonly preStartArchives?: ReadonlyArray<StartPreStartArchiveSpec>; /** - * `container.Config.Entrypoint`'s first element. Docker CLI's `--entrypoint` - * only accepts a single executable/script name (unlike the Engine API field, - * which is a full argv array) — the remaining Go `Entrypoint` elements are - * reproduced via {@link cmd} instead, exactly like the existing `docker run` - * precedent (`docker-run.service.ts`'s `entrypoint`/`cmd` split). - * E.g. Go's `Entrypoint: ["sh", "-c", script]` (Logflare/Vector/Kong/db) - * becomes `entrypoint: "sh", cmd: ["-c", script]`. + * Docker CLI's `--entrypoint` accepts only a single executable/script name; a multi-element + * entrypoint (e.g. `["sh", "-c", script]`) splits into `entrypoint: "sh"` and + * `cmd: ["-c", script]`. */ readonly entrypoint?: string; /** - * Trailing argv tokens placed after the image. Always emitted — unlike the - * `docker run` precedent's comment (which only ever pairs `cmd` with - * `entrypoint`), Pooler (`start.go:1234-1237`) sets `container.Config.Cmd` - * with NO `Entrypoint` at all: it overrides the pooler image's default `CMD` - * while keeping its own `ENTRYPOINT`. Docker CLI's trailing-tokens-after-image - * convention already covers both cases identically (args to `--entrypoint` - * when set, or a `CMD` override of the image default when not), so `cmd` is - * independent of {@link entrypoint} here. + * Trailing argv tokens placed after the image; independent of {@link entrypoint} — a spec can + * set `cmd` alone to override just the image's default `CMD` while keeping its `ENTRYPOINT`. */ readonly cmd?: ReadonlyArray<string>; /** - * `container.HostConfig.Binds` — `"source:target[:mode]"` bind-mount strings - * or `"volumeName:target"` named-volume strings (Go's `loader.ParseVolume` - * classification, see {@link isBindMountSource}). Named-volume - * creation itself (`Docker.VolumeCreate`, `docker.go:407-415`) is a - * higher-level orchestration concern, not this pure argv builder's job. + * `"source:target[:mode]"` bind-mount strings or `"volumeName:target"` named-volume strings + * (see {@link isBindMountSource}). Named-volume creation itself is a higher-level + * orchestration concern, not this pure argv builder's job. */ readonly binds: ReadonlyArray<string>; - /** `container.HostConfig.VolumesFrom` — ImgProxy only (`start.go:1084`, mounts Storage's volumes). */ + /** Mounts another container's volumes (used to share Storage's volumes with ImgProxy). */ readonly volumesFrom?: ReadonlyArray<string>; /** - * `container.HostConfig.Tmpfs` (`map[mountPath]mountOptions`) — only the - * Postgres container sets this, and only on PG ≤ 14 - * (`apps/cli-go/internal/db/start/start.go:127-129`: - * `map[string]string{"/docker-entrypoint-initdb.d": ""}`). An empty options - * string means "no extra tmpfs mount options" (`--tmpfs <path>` with no - * `:options` suffix); a non-empty value is joined as `<path>:<options>`. + * Maps mount path to mount options. An empty options string omits the `:options` suffix on + * `--tmpfs <path>`; a non-empty value is joined as `<path>:<options>`. */ readonly tmpfs?: Readonly<Record<string, string>>; /** - * `container.HostConfig.PortBindings` — ports published to the host via - * `-p`. Distinct from {@link exposedPorts}: publishing a port with `-p` - * already implies exposing it (docker CLI infers `ExposedPorts` from `-p` - * the same way Go's own `container.Config.ExposedPorts` happens to overlap - * with `PortBindings` in the Logflare example, `start.go:373` vs `:377`). + * Ports published to the host via `-p`. Distinct from {@link exposedPorts}: publishing a + * port with `-p` already implies exposing it. */ readonly ports?: ReadonlyArray<StartPortBindingSpec>; /** - * `container.Config.ExposedPorts` entries that have NO matching - * `PortBindings` entry — i.e. ports declared reachable on the Docker network - * but never published to the host. This is a real, recurring pattern, not a - * one-off: GoTrue (`start.go:825`, port 9999) and Realtime (`start.go:930`, - * port 4000) expose a port with zero `PortBindings`; Kong exposes 3 ports - * (`start.go:602-606`) but only ever publishes one of them depending on TLS; - * Pooler exposes 3 ports (`start.go:1238-1242`) but only ever publishes the - * one matching its configured pool mode. A single `ports` field (CLI `-p`) - * cannot express "exposed but not published", so it is kept as its own field - * rather than folded into {@link ports} with an optional host side. + * Ports declared reachable on the Docker network but never published to the host. Kept + * separate from {@link ports} because a single `-p`-based field cannot express "exposed but + * not published". */ readonly exposedPorts?: ReadonlyArray<StartExposedPortSpec>; - /** `container.Config.Healthcheck`. Omitted entirely for Kong and PostgREST — see `start.go:975` ("PostgREST does not expose a shell for health check") — so no `--health-*` flags are emitted for those services. */ + /** Omitted for services with no way to health-check themselves (e.g. no shell in the image). */ readonly healthcheck?: StartHealthcheckSpec; - /** - * `container.HostConfig.RestartPolicy.Name`. Every one of the 13 surveyed - * call sites uses `container.RestartPolicyUnlessStopped` (verified — grep - * `RestartPolicy` across `start.go`/`db/start/start.go` returns only - * `RestartPolicyUnlessStopped`), but the full docker CLI `--restart` enum is - * supported for completeness/future callers, per the task brief. - */ + /** Every current caller uses `"unless-stopped"`; the full `--restart` enum is supported for future callers. */ readonly restartPolicy?: "unless-stopped" | "no" | "always" | "on-failure"; /** - * `container.HostConfig.AutoRemove` — only the shadow-database container sets this - * (`CreateShadowDatabase`, `apps/cli-go/internal/db/diff/diff.go:144`), via `--rm`. - * `AutoRemove` only fires once the container's own main process exits on its own; it - * does NOT make an explicit remove redundant for a still-running container (verified - * empirically), so callers still remove the shadow explicitly once they are done with - * it — see `shadow-database.ts`'s `removeShadowDatabase`. + * `--rm` only fires once the container's own process exits on its own; it does not make an + * explicit remove redundant for a still-running container. */ readonly autoRemove?: boolean; - /** - * `container.HostConfig.SecurityOpt`. Only Vector sets this - * (`start.go:441`, `"label:disable"`, when mounting a non-root Docker - * socket) — cleared globally under Bitbucket Pipelines, see - * {@link applyBitbucketStartContainerFilter}. - */ + /** Cleared globally under Bitbucket Pipelines; see {@link applyBitbucketStartContainerFilter}. */ readonly securityOpt?: ReadonlyArray<string>; - /** - * `container.HostConfig.ExtraHosts`. Populated by `DockerStart` itself from - * the platform-specific `extraHosts` package var (`docker_linux.go`, - * `docker_darwin.go`, `docker_windows.go`), not by any individual - * `container.Config` literal — every container gets the same extra hosts. - * That orchestration (merging in the platform default) is a caller concern; - * this builder just emits whatever is passed here. - */ + /** Merging in the platform default extra host is a caller concern; this builder just emits whatever is passed here. */ readonly extraHosts?: ReadonlyArray<string>; - /** - * `container.HostConfig.NetworkMode`, resolved by `DockerStart` - * (`docker.go:379-383`) to either the `--network-id` override or - * `utils.NetId` — never set per-container in any surveyed call site. - */ + /** The Docker network every container joins; resolved by the caller. */ readonly networkId: string; /** - * `network.NetworkingConfig.EndpointsConfig[NetId].Aliases` — the per-service - * alias array (e.g. `utils.RealtimeAliases = ["realtime", tenantId]`, - * `apps/cli-go/internal/utils/config.go:40`). The container's OWN name is - * already DNS-resolvable on a user-defined network without any alias; this - * field only adds the short/extra names other containers' env vars and - * templates reference by. + * The container's own name is already DNS-resolvable on a user-defined network; this field + * only adds extra names other containers' env vars and templates reference by. */ readonly networkAliases?: ReadonlyArray<string>; - /** - * `container.Config.Labels`, merged in by `DockerStart` itself - * (`docker.go:372-376`: `CliProjectLabel`/`composeProjectLabel`, both the - * sanitized project id) rather than set per-service — this builder emits - * whatever map is passed here, unconditionally. - */ + /** The caller merges in the project-identity labels; this builder emits whatever map is passed here. */ readonly labels: Readonly<Record<string, string>>; } @@ -372,32 +167,11 @@ function shellQuoteArg(arg: string): string { } /** - * Converts Go's `HealthConfig.Test` into the single string `docker create - * --health-cmd` expects. - * - * Docker CLI's `--health-cmd` flag has no exec-form equivalent: whatever - * string it is given always becomes `HealthConfig.Test = ["CMD-SHELL", - * value]` on the resulting container (there is no CLI flag that produces the - * exec-form `["CMD", ...]` Go sometimes uses directly over the Engine API — - * only a Dockerfile `HEALTHCHECK` instruction or the raw API can do that). So: - * - * - `["CMD-SHELL", script]` (pg-meta/Studio, `start.go:1125`/`:1166`) — `script` - * is already the exact string Go's own `CMD-SHELL` form would run verbatim - * via the container's `/bin/sh -c`; it is forwarded completely unmodified. - * No quoting is applied on this side because this string travels as a - * single argv element to the spawned `docker` process (no host-side shell - * parses it) — only Docker's own eventual in-container `/bin/sh -c - * <value>` interprets it, exactly reproducing Go's `Test` value untouched. - * - `["CMD", ...args]` (exec form — e.g. Logflare's `["CMD", "curl", "-sSfL", - * "--head", "-o", "/dev/null", "http://127.0.0.1:4000/health"]`, - * `start.go:365-366`) — since `--health-cmd` always produces a `CMD-SHELL` - * test, each `args` element is POSIX-shell-quoted individually (via - * {@link shellQuoteArg}) and joined with spaces, so that when Docker - * later runs `/bin/sh -c "<joined>"` inside the container, the shell - * re-splits it back into the exact same argv the exec form specified — - * including an argument containing spaces or embedded quotes, which none of - * the current 14 services happen to need but which this conversion must - * still get right. + * Converts a healthcheck `Test` array into the single string `docker create --health-cmd` + * expects — there's no CLI equivalent for an exec-form test, so a `["CMD-SHELL", script]` test + * forwards unmodified, and a `["CMD", ...args]` test has each argument shell-quoted (see + * {@link shellQuoteArg}) and joined with spaces so Docker's own `/bin/sh -c` re-splits it back + * into the same argv. */ export function buildHealthCmdArg(test: ReadonlyArray<string>): string { const [mode, ...rest] = test; @@ -433,19 +207,11 @@ function buildHealthcheckArgs(healthcheck: StartHealthcheckSpec): ReadonlyArray< } /** - * Docker/Podman CLI env vars that configure the CLIENT itself — which daemon - * it connects to — rather than a value for the container being created. A - * container spec's own `env` can legitimately need to set one of these (e.g. - * Vector's `DOCKER_HOST=http://host.docker.internal:<port>`, set by - * `resolveVectorDockerSocketPlan` for a `tcp`/`npipe` daemon host so - * Vector can reach the real daemon from inside its own container), but that - * value must never be inherited by the spawned `docker`/`podman create` - * PROCESS's own environment: doing so would hijack which daemon that process - * itself talks to before the container even exists (see - * `dockerCreateContainer`, `container-lifecycle.ts`, which filters these - * keys out of the env it hands to the spawned process for exactly this - * reason). These are not secrets, so unlike the rest of `spec.env` they are - * safe to emit inline as `-e KEY=value` instead of the key-only form. + * Docker/Podman CLI env vars that configure the client itself (which daemon it connects to), + * rather than a container env value. A spec's own `env` can legitimately set one of these (e.g. + * Vector's container-facing `DOCKER_HOST`), but it must never reach the spawned `docker create` + * process's own environment, which would hijack which daemon that process itself talks to. + * These aren't secrets, so unlike the rest of `spec.env` they're safe to emit inline. */ const DOCKER_CLIENT_ENV_KEYS: ReadonlySet<string> = new Set([ "DOCKER_HOST", @@ -453,13 +219,10 @@ const DOCKER_CLIENT_ENV_KEYS: ReadonlySet<string> = new Set([ "DOCKER_CERT_PATH", "DOCKER_CONTEXT", "DOCKER_API_VERSION", - // `docker/cli`'s own `EnvOverrideConfigDir` (`cli/config/config.go:25`) — the same env var - // `getHostname`'s `dockerConfigDir()` reads to locate `config.json`/the context store. - // Without this, a project dotenv that sets ONLY `DOCKER_CONFIG` (no `DOCKER_HOST`/ - // `DOCKER_CONTEXT`) would never reach `process.env` via `local-project-context.ts`'s - // Docker-client-env loop, so both hostname resolution and every `docker`/`podman` subprocess - // this process spawns would silently fall back to the ambient `~/.docker` config instead of the - // project-selected one — the same class of bug already fixed for `DOCKER_HOST`/`DOCKER_CONTEXT`. + // Locates `config.json`/the context store. Without this, a project dotenv setting only + // `DOCKER_CONFIG` (no `DOCKER_HOST`/`DOCKER_CONTEXT`) would never reach `process.env`, so + // hostname resolution and spawned subprocesses would silently fall back to the ambient + // `~/.docker` config instead of the project-selected one. "DOCKER_CONFIG", ]); @@ -469,23 +232,13 @@ export function isDockerClientEnvKey(key: string): boolean { } /** - * Assemble the `docker create` argv for one `supabase start` service - * container. Pure (no Effect) so every flag mapping is unit-testable in - * isolation, matching the `buildDockerArgs` (`docker run`) precedent. + * Assembles the `docker create` argv for one `supabase start` service container. Pure (no + * Effect) so every flag mapping is unit-testable in isolation. The caller prepends only the + * `docker`/`podman` binary itself. * - * `"create"` is argv[0] — the caller (`container-cli.ts`'s - * `spawnContainerCli`/`containerCliExitCode`) prepends only the `docker`/ - * `podman` binary itself, exactly like `buildDockerArgs` returning - * `"run"` as its own argv[0]. - * - * Env is emitted in the key-only `-e KEY` form (never `-e KEY=value`) for the - * same CWE-214/209 reason as `buildDockerArgs`: these containers' - * env carries JWT secrets, SMTP credentials, API keys, and DB passwords, none - * of which may appear in this process's own argv (`ps aux` / - * `/proc/<pid>/cmdline`). The spawned `docker create`'s own child environment - * supplies each value — a later caller's responsibility, not this builder's. - * The exception is {@link isDockerClientEnvKey} keys, which are emitted - * inline as `-e KEY=value` instead — see that function's doc comment. + * Env is emitted in the key-only `-e KEY` form, never `-e KEY=value`, so secret values never + * appear in this process's own argv; the spawned `docker create` process's own environment + * supplies each value. {@link isDockerClientEnvKey} keys are the exception, emitted inline. */ export function buildStartContainerCreateArgs(spec: StartContainerSpec): ReadonlyArray<string> { return [ @@ -520,20 +273,9 @@ export function buildStartContainerCreateArgs(spec: StartContainerSpec): Readonl } /** - * Mirror Go's `DockerStart` Bitbucket Pipelines handling - * (`apps/cli-go/internal/utils/docker.go:400-405`): when `BITBUCKET_CLONE_DIR` - * is set, that runner disallows named volumes and `--security-opt`, so Go - * drops named-volume binds and clears `SecurityOpt` before starting any - * container. Mirrors `applyBitbucketDockerFilter` - * (`docker-run.args.ts`) for the `docker create` shape — e.g. the - * Postgres container's `<projectId>_db:/var/lib/postgresql/data` named-volume - * bind is dropped while a bind-mount stays; Vector's non-root Docker-socket - * bind mount (already a bind mount, not a named volume) is unaffected either - * way, but its `SecurityOpt: ["label:disable"]` is cleared. - * - * `volumesFrom` and `tmpfs` are untouched: Go's Bitbucket branch only ever - * reassigns `hostConfig.Binds` and clears `hostConfig.SecurityOpt` - * (`docker.go:401-405`) — it does not touch `VolumesFrom` or `Tmpfs`. + * Bitbucket Pipelines runners disallow named volumes and `--security-opt`, so this drops + * named-volume binds (bind mounts stay) and clears `securityOpt`. `volumesFrom` and `tmpfs` are + * untouched. */ export function applyBitbucketStartContainerFilter( spec: StartContainerSpec, diff --git a/apps/cli/src/command-internal/db-bootstrap/docker-create-args.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/docker-create-args.unit.test.ts index 5015155320..21f248b19a 100644 --- a/apps/cli/src/command-internal/db-bootstrap/docker-create-args.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/docker-create-args.unit.test.ts @@ -8,10 +8,8 @@ import { type StartContainerSpec, } from "./docker-create-args.ts"; -// Mirrors the Logflare/analytics container (`start.go:350-394`): the fullest -// worked example in the Go source — Hostname, Entrypoint+Cmd, exec-form -// Healthcheck with StartPeriod, ExposedPorts alongside a matching -// PortBinding, RestartPolicy, and network aliases. +// Exercises every optional field: Hostname, Entrypoint+Cmd, exec-form Healthcheck with +// StartPeriod, ExposedPorts alongside a matching PortBinding, RestartPolicy, network aliases. const full: StartContainerSpec = { image: "supabase/logflare:1.0.0", containerName: "supabase_analytics_proj", @@ -147,7 +145,6 @@ describe("buildStartContainerCreateArgs", () => { const args = buildStartContainerCreateArgs(full); expect(args).toContain("DB_PASSWORD"); expect(args.some((a) => a.includes("super-secret"))).toBe(false); - // Every -e argument is a bare key: no '=' anywhere in an -e value. const envValues = args.flatMap((a, i) => (args[i - 1] === "-e" ? [a] : [])); expect(envValues.every((v) => !v.includes("="))).toBe(true); }); @@ -166,7 +163,6 @@ describe("buildStartContainerCreateArgs", () => { const dockerHostIndex = args.indexOf("DOCKER_HOST=http://host.docker.internal:2375"); expect(dockerHostIndex).toBeGreaterThan(-1); expect(args[dockerHostIndex - 1]).toBe("-e"); - // The genuine secret alongside it must still stay key-only. expect(args).toContain("API_KEY"); expect(args.some((a) => a.includes("super-secret"))).toBe(false); }); @@ -177,9 +173,7 @@ describe("buildStartContainerCreateArgs", () => { expect(isDockerClientEnvKey("DOCKER_CERT_PATH")).toBe(true); expect(isDockerClientEnvKey("DOCKER_CONTEXT")).toBe(true); expect(isDockerClientEnvKey("DOCKER_API_VERSION")).toBe(true); - // `docker/cli`'s `EnvOverrideConfigDir` (`cli/config/config.go:25`) — also read by - // `getHostname`'s `dockerConfigDir()`, so a project-dotenv-only override must reach - // `process.env` the same way `DOCKER_HOST`/`DOCKER_CONTEXT` already do (review: PRRT_kwDOErm0O86Vk-ex). + // Also read by `getHostname`'s `dockerConfigDir()`. expect(isDockerClientEnvKey("DOCKER_CONFIG")).toBe(true); expect(isDockerClientEnvKey("DB_PASSWORD")).toBe(false); }); @@ -279,7 +273,6 @@ describe("buildStartContainerCreateArgs", () => { const args = buildStartContainerCreateArgs(spec); expect(args.some((a) => a.includes("top-secret-content"))).toBe(false); expect(args.some((a) => a.includes("/etc/secret.yml"))).toBe(false); - // Only the spec's own `binds` entries are ever emitted — secretFiles contributes nothing. expect(args.filter((a) => a === "-v")).toHaveLength(1); }); }); 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 c217822a57..1b8757bcaa 100644 --- a/apps/cli/src/command-internal/db-bootstrap/health-check.ts +++ b/apps/cli/src/command-internal/db-bootstrap/health-check.ts @@ -1,13 +1,8 @@ /** - * Port of Go's `WaitForHealthyService`/`IsServiceReady` - * (`apps/cli-go/internal/db/start/start.go:192-231`, - * `apps/cli-go/internal/status/status.go:147-168`): a single shared probe - * across every still-unhealthy started container, on a 1-second constant - * backoff, for up to `timeoutSeconds` retries (Go's default `serviceTimeout = - * 30 * time.Second`) — NOT independent per-container timers. Each tick probes - * every still-unhealthy container, narrows the "still watching" set to just - * the ones that failed this round (a healthy container stops being probed), - * and only the final timeout's failures surface to the caller. + * A single shared probe across every still-unhealthy started container, on a 1-second constant + * backoff for up to `timeoutSeconds` retries — not independent per-container timers. Each tick + * narrows the "still watching" set to just the containers that failed this round, and only the + * final timeout's failures surface to the caller. */ import { Data, Duration, Effect, Schedule, Stream } from "effect"; @@ -27,39 +22,28 @@ import { kongAuthHeaders } from "../kong-auth.ts"; type Spawner = ChildProcessSpawner["Service"]; -/** Go's `serviceTimeout` (`apps/cli-go/internal/start/start.go:161`, deleted in CLI-1966; last present at commit a253ccba2). */ +/** Default retry budget when the caller doesn't specify one. */ const HEALTH_CHECK_TIMEOUT_SECONDS = 30; -/** - * Go's `healthProbeTimeout` (`apps/cli-go/internal/status/status.go:209`): caps - * a single HTTP readiness probe so a hung response cannot stall the - * surrounding retry loop. - */ +/** Caps a single HTTP readiness probe so a hung response cannot stall the retry loop. */ const HTTP_PROBE_TIMEOUT_SECONDS = 10; -/** `apps/cli-go/internal/status/status.go:161` — PostgREST does not support native Docker healthchecks. */ +/** PostgREST does not support native Docker healthchecks. */ const POSTGREST_READY_PATH = "/rest-admin/v1/ready"; /** - * `apps/cli-go/internal/status/status.go:163-166` — Edge Runtime bypasses its - * native Docker healthcheck too ("native health check logs too much - * hyper::Error(IncompleteMessage)"), through the exact same - * {@link checkHttpReady}/Kong-gateway path as PostgREST, just its own - * path and container id. Go's `checkHTTPHead` even shares one lazily-built - * `healthClient` across both call sites (`status.go:202-219`) — the closest - * equivalent here is {@link HealthCheckPostgrestGateway} being reused - * as-is (name notwithstanding — its shape is generic, not - * PostgREST-specific) for both {@link WaitForHealthyServicesOptions.postgrest} - * and {@link WaitForHealthyServicesOptions.edgeRuntime}. + * Edge Runtime also bypasses its native Docker healthcheck (too noisy) and goes through the + * same {@link checkHttpReady}/Kong-gateway path as PostgREST — hence + * {@link HealthCheckPostgrestGateway}'s generic shape being reused for both + * {@link WaitForHealthyServicesOptions.postgrest} and {@link WaitForHealthyServicesOptions.edgeRuntime}. */ const EDGE_RUNTIME_READY_PATH = "/functions/v1/_internal/health"; /** Identifies a single container's readiness failure this round. */ export interface HealthCheckFailure { /** - * The container NAME (`supabase_<service>_<project id>`), not the opaque id - * `docker create` returns — both work with `docker container inspect`/`docker - * logs`, but only the name tells a user which service failed. + * The container name (`supabase_<service>_<project id>`), not the opaque id `docker create` + * returns — only the name tells a user which service failed. */ readonly containerId: string; readonly reason: string; @@ -74,18 +58,14 @@ class HealthCheckProbeError extends Data.TaggedError("HealthCheckProbeError")<{ } } -/** - * The retry loop's final, and only surfaced, failure — mirrors Go returning - * `errors.Join(errHealth...)` from the last failed `probe()` call once - * `backoff.Retry` gives up (`start.go:210-214`). - */ +/** The retry loop's final, and only surfaced, failure. */ export class HealthCheckTimeoutError extends Data.TaggedError("HealthCheckTimeoutError")<{ readonly message: string; readonly unhealthy: ReadonlyArray<HealthCheckFailure>; /** - * Kept out of {@link message} so `Output.fail` renders it unstyled and drops - * the generic "rerun with --debug" line: once an exact command is named, - * pointing at an HTTP request logger is a non-sequitur (as in CLI-1973). + * Kept out of {@link message} so `Output.fail` renders it unstyled and drops the generic + * "rerun with --debug" line: once an exact command is named, pointing at an HTTP request + * logger is a non-sequitur. */ readonly suggestion?: string; }> { @@ -95,32 +75,11 @@ export class HealthCheckTimeoutError extends Data.TaggedError("HealthCheckTimeou } /** - * PostgREST's local Kong gateway coordinates, mirroring Go's - * `fetcher.NewServiceGateway(utils.Config.Api.ExternalUrl, - * utils.Config.Auth.SecretKey.Value, ...)` (`status.go:213-218`). TLS/CA trust - * for a local https gateway is the caller's responsibility when composing the - * `HttpClient.HttpClient` layer this module requires — same split as - * `storage-gateway.ts`/`storageGatewayFetch`. - * - * `start.command.ts` composes the `HttpClient.HttpClient` this module - * requires via `httpClientLayer` (itself `FetchHttpClient`-backed, and - * on its own CA-unaware; see that layer's own header) — the same layer - * `db reset`/`seed buckets` compose for the equivalent gateway calls. - * `start.handler.ts` layers a CA-trusting override on top of that: when - * `api.tls.enabled`, `apiExternalUrl` is `https://` against Kong's - * self-signed local cert (`KONG_LOCAL_CA_CERT`, or a validated - * `api.tls.cert_path` override), so before calling - * {@link waitForHealthyServices} it resolves that same CA via - * `storage-credentials.ts`'s `resolveStorageCredentials` (the - * mechanism `seed buckets`/`storage`/`db reset` already use) and, when a - * local CA resolves, overrides `FetchHttpClient.Fetch` with - * `storageGatewayFetch` around the health-check call via - * `Effect.provideService`. That override only takes effect against a - * `FetchHttpClient`-backed `HttpClient.HttpClient` — exactly what - * `httpClientLayer` provides — so a stack started with - * `[api.tls] enabled = true` now gets a `checkHttpReady` probe that - * trusts the local Kong CA instead of exhausting `waitForHealthyServices`'s - * full 30s budget on a TLS verification failure. + * PostgREST's local Kong gateway coordinates. TLS/CA trust for a local https gateway is the + * caller's responsibility when composing the `HttpClient.HttpClient` layer this module requires + * — when `api.tls.enabled`, the caller resolves the local Kong CA and overrides the HTTP client + * around the health-check call, so `checkHttpReady` trusts it instead of exhausting the full + * retry budget on a TLS verification failure. */ export interface HealthCheckPostgrestGateway { readonly containerId: string; @@ -131,7 +90,7 @@ export interface HealthCheckPostgrestGateway { export interface WaitForHealthyServicesOptions { readonly timeoutSeconds?: number; readonly postgrest?: HealthCheckPostgrestGateway; - /** See {@link EDGE_RUNTIME_READY_PATH}'s doc comment for why this reuses the same gateway shape as {@link postgrest}. */ + /** Reuses {@link postgrest}'s gateway shape; see {@link EDGE_RUNTIME_READY_PATH}. */ readonly edgeRuntime?: HealthCheckPostgrestGateway; /** Each watched container's already-resolved image, keyed by container name. */ readonly images?: ReadonlyMap<string, string>; @@ -163,34 +122,21 @@ function makeExecFormatScanner() { } /** - * Recovery advice for a timeout whose logs showed {@link EXEC_FORMAT_ERROR}, - * or `undefined` when no affected image can be named. + * Recovery advice for a timeout whose logs showed {@link EXEC_FORMAT_ERROR}, or `undefined` when + * no affected image can be named. * * `supabase stop` leads the sequence because a bare restart is a no-op on the - * `--ignore-health-check` path: that path leaves the stack up by design, so the - * next `supabase start` takes the already-running short-circuit and never - * recreates the broken container. `stop` without `--no-backup` preserves the - * database volume, and is harmless after the hard-fail path's own rollback. - * - * Both `supabase` steps resolve their own project the way this run did, so the - * sequence names that requirement rather than embedding the resolved workdir: - * a path rendered into a copy-pasteable command needs shell quoting, and the - * correct quoting differs between POSIX shells and `cmd.exe`. - * - * `-f` because the container may still reference the image, and `runtime` - * rather than a hardcoded `docker` because `spawnContainerCli` falls back to - * Podman on hosts without Docker. The closing line covers the case re-pulling - * cannot fix: a pinned version with no build for this host (supabase/cli#3718, - * #4674), where the same image comes straight back. + * `--ignore-health-check` path, which leaves the stack up by design. `runtime` (not a hardcoded + * `docker`) accounts for a Podman-only host. The closing line covers what re-pulling cannot fix: + * a pinned image with no build for this host's architecture. */ function execFormatRecoveryHint( containerIds: ReadonlyArray<string>, images: ReadonlyMap<string, string> | undefined, runtime: ContainerRuntime, ): string | undefined { - // Both names, always: a container and its image can be named after different - // things (`supabase_inbucket_*` runs `mailpit`), so naming only one leaves - // the reader guessing whether this is the same failure as the reason above. + // Both names, always: `supabase_inbucket_*` runs `mailpit`, so naming only the container would + // leave the reader guessing whether this is the same failure as the reason above. const affected = containerIds.flatMap((containerId) => { const image = images?.get(containerId); return image === undefined ? [] : [{ containerId, image }]; @@ -206,11 +152,7 @@ function execFormatRecoveryHint( ].join("\n"); } -/** - * Go's `assertContainerHealthy` (`status.go:147-156`), reused verbatim via - * {@link inspectContainerState} — the same primitive `status.handler.ts` - * already uses for the exact same not-running/not-ready gating. - */ +/** Not-running/not-ready gating, via the same {@link inspectContainerState} primitive `status.handler.ts` uses. */ function checkContainerReady(spawner: Spawner, containerId: string): Effect.Effect<void, string> { return inspectContainerState(spawner, containerId).pipe( Effect.mapError((cause) => cause.message), @@ -226,12 +168,7 @@ function checkContainerReady(spawner: Spawner, containerId: string): Effect.Effe ); } -/** - * Go's `checkHTTPHead` (`status.go:211-229`): an HTTP HEAD through the local - * Kong gateway, expecting exactly 200. Bypasses the Docker healthcheck - * entirely — PostgREST "does not support native health checks" - * (`status.go:159-161`). - */ +/** An HTTP HEAD through the local Kong gateway, expecting exactly 200. */ function checkHttpReady( gateway: HealthCheckPostgrestGateway, path: string, @@ -252,15 +189,11 @@ function checkHttpReady( } /** - * Go's `DockerStreamLogsOnce` (`apps/cli-go/internal/utils/docker.go:593-606`) - * via `docker logs <id>`, teed to this process's stderr — best-effort: a - * failure to stream logs must never mask the timeout error it was printed - * alongside, so every failure here is swallowed. + * `docker logs <id>`, teed to this process's stderr. Best-effort: a failure to stream logs must + * never mask the timeout error it was printed alongside, so every failure here is swallowed. * - * Resolves to whether the logs contained {@link EXEC_FORMAT_ERROR}, - * scanned off the bytes already being teed — no extra Docker call, no buffer — - * and to the runtime that answered, so recovery advice can name the binary the - * user actually has. + * Resolves to whether the logs contained {@link EXEC_FORMAT_ERROR}, scanned off the bytes already + * being teed, and to the runtime that answered, so recovery advice can name the right binary. */ function streamContainerLogsOnce( spawner: Spawner, @@ -312,7 +245,7 @@ interface ExecFormatScanResult { readonly runtime: ContainerRuntime | undefined; } -/** Go's `fmt.Fprintln(os.Stderr, containerId, "container logs:")` (`start.go:218`) + the log dump itself. */ +/** Prints "<containerId> container logs:" then dumps the container's logs. */ function dumpContainerLogs( spawner: Spawner, containerId: string, @@ -326,13 +259,10 @@ function dumpContainerLogs( } /** - * Waits for every container in `containerIds` to become ready, mirroring - * Go's `WaitForHealthyService(ctx, timeout, started...)`. Resolves once all - * are ready; fails with {@link HealthCheckTimeoutError} (carrying every - * still-unhealthy container's last-seen reason) once the retry budget is - * exhausted. The caller (`start.handler.ts`) decides whether - * `--ignore-health-check` turns that failure into a warning instead of a hard - * exit — this module only implements the polling contract. + * Waits for every container in `containerIds` to become ready. Resolves once all are ready; + * fails with {@link HealthCheckTimeoutError} (carrying every still-unhealthy container's + * last-seen reason) once the retry budget is exhausted. The caller decides whether + * `--ignore-health-check` turns that failure into a warning instead of a hard exit. */ export function waitForHealthyServices( spawner: Spawner, @@ -356,10 +286,8 @@ export function waitForHealthyServices( return Effect.gen(function* () { let stillWatching = containerIds; - // Mirrors Go's closure-captured `started` slice - // (`db/start/start.go:200-212`): each round narrows `stillWatching` to - // just the containers that failed, so a container that becomes healthy - // mid-run stops being probed on later rounds. + // Each round narrows `stillWatching` to just the containers that failed, so a container + // that becomes healthy mid-run stops being probed on later rounds. const probe: Effect.Effect<void, HealthCheckProbeError, HttpClient.HttpClient> = Effect.gen( function* () { const outcomes = yield* Effect.forEach( @@ -386,20 +314,15 @@ export function waitForHealthyServices( }, ); - // `backoff.WithMaxRetries(backoff.NewConstantBackOff(time.Second), - // uint64(timeout.Seconds()))` (`db/start/start.go:192-198`): a 1-second - // constant delay, capped at `timeoutSeconds` retries after the initial - // attempt (~`timeoutSeconds` further seconds elapsed on total failure). + // A 1-second constant delay, capped at `timeoutSeconds` retries after the initial attempt. const schedule = Schedule.max([Schedule.spaced("1 seconds"), Schedule.recurs(timeoutSeconds)]); yield* probe.pipe( Effect.retry(schedule), Effect.catch((probeError) => Effect.gen(function* () { - // Go skips this dump on context cancellation (`start.go:215`, - // `!errors.Is(err, context.Canceled)`) — an interrupted fiber never - // reaches this `Effect.catch` handler at all, so no separate check - // is needed here. + // An interrupted fiber never reaches this `Effect.catch` handler, so no separate + // cancellation check is needed here. const scans = yield* Effect.forEach(probeError.failures, (failure) => dumpContainerLogs(spawner, failure.containerId).pipe( Effect.map((scan) => ({ ...scan, containerId: failure.containerId })), @@ -415,10 +338,6 @@ export function waitForHealthyServices( ); return yield* Effect.fail( new HealthCheckTimeoutError({ - // Go's `assertContainerHealthy` embeds the id INSIDE the message - // (`errors.Errorf("%s container is not running: %s", …)`, - // `status.go:150,154`) — a bare space, not an `<id>: ` prefix, so the - // joined `errors.Join` text is `<id> container is not ready: <health>`. message: probeError.failures .map((failure) => `${failure.containerId} ${failure.reason}`) .join("\n"), @@ -436,10 +355,8 @@ export function waitForHealthyServices( const SHADOW_READY_CONNECT_TIMEOUT_SECONDS = 2; /** - * One round's verdict. `fatal` is what makes an exited container fail fast - * instead of burning the remaining budget: nothing about a dead container can - * change on a later round, whereas a refused connect (or a transient `docker - * container inspect` failure) is just "not ready yet". + * One round's verdict. `fatal` makes an exited container fail fast instead of burning the + * remaining budget: nothing about a dead container can change on a later round. */ interface ShadowReadyFailure { readonly reason: string; @@ -452,12 +369,10 @@ const shadowNotReady = (reason: string): ShadowReadyFailure => ({ }); /** - * A single short-lived connect attempt against the shadow, dialled exactly the - * way `connectShadowDatabase` (`shadow-database.ts`) dials it — same - * `isLocal`/`dnsResolver` pair, so a config that authenticates for the probe - * authenticates for the real connection too. `Effect.scoped` closes the session - * the moment the probe resolves: the caller opens (and owns) its own connection - * afterwards through `connectShadowDatabase`. + * A single short-lived connect attempt, dialled the same way `connectShadowDatabase` does (same + * `isLocal`/`dnsResolver` pair), so a config that authenticates for the probe authenticates for + * the real connection too. `Effect.scoped` closes the session the moment the probe resolves; the + * caller opens its own connection afterwards. */ const probeShadowConnect = ( connConfig: PgConnInput, @@ -492,8 +407,8 @@ export function waitForShadowReady( ): Effect.Effect<void, HealthCheckTimeoutError, DbConnection> { const timeoutSeconds = opts.timeoutSeconds ?? HEALTH_CHECK_TIMEOUT_SECONDS; - // Twice the second-counted budget: 500ms spacing would otherwise exhaust - // `timeoutSeconds` retries in half the wall time of the old 1s poll. + // Twice the second-counted budget: 500ms spacing would otherwise exhaust `timeoutSeconds` + // retries in half the wall time of a 1-second poll. const schedule = Schedule.max([ Schedule.spaced("500 millis"), Schedule.recurs(timeoutSeconds * 2), From cd68a8b869df7ec4c4a16c3e89bfe3c553269a8e Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 12:50:01 +0100 Subject: [PATCH 24/56] chore(repo): trim comments, shard 23 (1599 -> 762 comment lines) --- .../src/commands/db/query/query.command.ts | 35 +-- .../cli/src/commands/db/query/query.format.ts | 190 ++++++--------- .../db/query/query.format.unit.test.ts | 48 ++-- .../src/commands/db/query/query.handler.ts | 132 ++++------- .../db/query/query.integration.test.ts | 73 +----- .../cli/src/commands/db/query/query.layers.ts | 21 +- .../src/commands/db/reset/reset.command.ts | 8 +- .../src/commands/db/reset/reset.e2e.test.ts | 8 +- .../src/commands/db/reset/reset.handler.ts | 127 ++++------ .../db/reset/reset.integration.test.ts | 221 ++++-------------- .../cli/src/commands/db/reset/reset.layers.ts | 31 +-- .../schema/declarative/declarative.errors.ts | 71 ++---- .../db/schema/declarative/declarative.flow.ts | 52 ++--- .../declarative/declarative.former-default.ts | 17 +- .../db/schema/declarative/declarative.gate.ts | 31 +-- .../declarative/declarative.orchestrate.ts | 2 +- .../schema/declarative/declarative.shared.ts | 11 +- .../declarative/declarative.smart-target.ts | 71 ++---- .../declarative/generate/generate.command.ts | 42 ++-- .../declarative/generate/generate.handler.ts | 133 ++++------- .../generate/generate.integration.test.ts | 143 ++---------- .../schema/declarative/sync/sync.command.ts | 19 +- .../schema/declarative/sync/sync.e2e.test.ts | 7 +- .../schema/declarative/sync/sync.handler.ts | 174 +++++--------- .../declarative/sync/sync.integration.test.ts | 132 ++--------- .../src/commands/db/shared/debug-bundle.ts | 31 +-- .../db/shared/debug-bundle.unit.test.ts | 7 +- 27 files changed, 499 insertions(+), 1338 deletions(-) diff --git a/apps/cli/src/commands/db/query/query.command.ts b/apps/cli/src/commands/db/query/query.command.ts index 74ca5b0947..37c6683ffb 100644 --- a/apps/cli/src/commands/db/query/query.command.ts +++ b/apps/cli/src/commands/db/query/query.command.ts @@ -8,17 +8,10 @@ import { dbQuery } from "./query.handler.ts"; import { dbQueryRuntimeLayer } from "./query.layers.ts"; /** - * NOTE on `--output` / `-o`: `db query` needs its own command-local - * `--output`/`-o` (`json|table|csv`) that shadows the global one, but the - * Effect CLI extracts global flags from the whole token stream **before** - * the leaf parse and builds one tree-wide registry, so a duplicate - * command-scoped `output` global is impossible (`Parser.createFlagRegistry` - * throws on duplicate names). Instead the global `OutputFlag` choice is - * the UNION of every command's `--output` values - * (`env|pretty|json|toml|yaml|table|csv`); this handler reads the global and - * honors `json`, `table`, and `csv` — `db query`'s own enum — defaulting by - * agent mode (JSON for agents, table for humans) when `-o` is unset. See - * SIDE_EFFECTS.md. + * `db query` needs its own `--output`/`-o` (`json|table|csv`), but Effect CLI keeps one global + * flag registry that can't hold two `output` flags, so the global choice is the union of every + * command's values instead. This handler reads the global flag, honors `json`/`table`/`csv`, and + * defaults by agent mode (JSON for agents, table for humans) when unset. See SIDE_EFFECTS.md. */ const config = { sql: Argument.string("sql").pipe( @@ -31,23 +24,17 @@ const config = { ), Flag.optional, ), - // `db query` defaults `--linked` to false and never reads its value; the - // linked-vs-local decision is driven entirely by explicit presence. Model - // presence (not value) with `Option` — the same way `--db-url` does — so - // `--linked=false` still selects the linked path. + // Selects the linked path by presence, not value, so `--linked=false` still selects it. linked: Flag.boolean("linked").pipe( Flag.withDescription("Queries the linked project's database via Management API."), Flag.optional, ), - // `--local` is in the same mutually-exclusive target group as `--db-url`/ - // `--linked`, keyed off explicit presence, not the value (`--local` even - // defaults to true), so model presence with `Option` so `--local=false` - // still counts as an explicit target in the conflict check. + // In the same mutually-exclusive target group as `--db-url`/`--linked`, keyed off explicit + // presence, so `--local=false` still counts as an explicit target in the conflict check. local: Flag.boolean("local").pipe( Flag.withDescription("Queries the local database."), Flag.optional, ), - // TS-only override of the linked project ref — see push.command.ts. projectRef: Flag.string("project-ref").pipe( Flag.withDescription("Project ref of the Supabase project."), Flag.optional, @@ -74,13 +61,9 @@ export const dbQueryCommand = Command.make("query", config).pipe( "project-ref": flags.projectRef, file: flags.file, }, - // --project-ref has no established telemetry-safety baseline, so it - // stays redacted. - // db query's own enum is `json|table|csv`, not the resource-command set. + // --project-ref isn't in the telemetry safe-flags list, so it stays redacted. outputFormats: QUERY_OUTPUT_FORMATS, - // `--file` registers shorthand `-f`, and telemetry reports changed - // flags by canonical name, so `-f query.sql` must log as `file`. `f` - // is query's only telemetry-relevant shorthand. Mirrors dump.command.ts. + // Telemetry reports changed flags by canonical name, so `-f query.sql` must log as `file`. aliases: { f: "file" }, }), withJsonErrorHandling, diff --git a/apps/cli/src/commands/db/query/query.format.ts b/apps/cli/src/commands/db/query/query.format.ts index 2344084700..a67b2884ea 100644 --- a/apps/cli/src/commands/db/query/query.format.ts +++ b/apps/cli/src/commands/db/query/query.format.ts @@ -3,9 +3,8 @@ import { Option } from "effect"; import { goFormatFloat } from "../../../command-internal/go-float.ts"; import { stringWidth } from "../../../command-internal/rune-width.ts"; -// `JSON.rawJSON` (ES2025, present in Bun) wraps a string so `JSON.stringify` emits it -// verbatim as a number/literal token — used to serialize int8/bigint exactly, beyond -// JS number precision. TypeScript's bundled lib does not yet declare it. +// `JSON.rawJSON` (ES2025, in Bun) wraps a string so `JSON.stringify` emits it verbatim as a +// number token, used for exact int8/bigint precision. TypeScript's bundled lib doesn't declare it. declare global { interface JSON { rawJSON(text: string): unknown; @@ -14,27 +13,22 @@ declare global { } /** - * Pure output formatters for `db query`. No Effect or service dependencies, - * so the tablewriter layout, CSV quoting, and JSON envelope stay unit-testable - * and the established output-contract rules (NULL rendering, key sort order, - * HTML escaping) are explicit. + * Pure output formatters for `db query`: no Effect or service dependencies, so layout, quoting, + * and JSON encoding stay unit-testable. */ /** - * Format a JSON-decoded (`interface{}`) value the established way: objects → - * `map[k:v ...]` with byte-sorted keys, arrays → `[a b ...]` (space-separated, - * recursive), booleans → `true`/`false`, numbers via `%g`-style float - * formatting, and nested `nil` → `<nil>`. + * Formats a decoded JSON value: objects as `map[k:v ...]` with byte-sorted keys, arrays as + * space-separated `[a b ...]`, booleans as `true`/`false`, numbers via `%g`-style formatting, + * and nested `nil` as `<nil>`. */ function goFormatValue(value: unknown): string { if (value === null || value === undefined) return "<nil>"; if (typeof value === "string") return value; if (typeof value === "boolean") return value ? "true" : "false"; if (typeof value === "number") return goFormatFloat(value); - // `bytea` columns render as decimal byte values space-separated in brackets - // (`[222 173]`, established output contract). node-postgres returns a - // `Buffer` (a `Uint8Array`), which would otherwise hit the object branch - // below and render as `map[0:222 1:173 ...]`. + // `bytea` columns render as decimal bytes in brackets (`[222 173]`); node-postgres returns a + // `Buffer` (`Uint8Array`), which would otherwise fall into the object branch below. if (value instanceof Uint8Array) return `[${Array.from(value).join(" ")}]`; if (Array.isArray(value)) return `[${value.map(goFormatValue).join(" ")}]`; if (typeof value === "object") { @@ -46,10 +40,9 @@ function goFormatValue(value: unknown): string { } /** - * `nil` → `"NULL"`, everything else via the established `%v`-style - * formatting. JSON object/array column values (common for JSONB on the linked - * path) render as `map[...]` / `[...]` rather than JS `[object Object]` / - * comma-joined text. + * Formats a value the established way: `null`/`undefined` as `"NULL"`, JSON objects/arrays + * (e.g. JSONB from the linked path) as `map[...]`/`[...]`, everything else via `%v`-style + * formatting. */ export function formatValue(value: unknown): string { if (value === null || value === undefined) return "NULL"; @@ -59,14 +52,9 @@ export function formatValue(value: unknown): string { } /** - * Value formatter for the `--linked` path, where the API response is decoded - * into a generic JSON value so every JSON number is a float. `nil` → `"NULL"`, - * everything else via the established `%v`-style formatting — which prints - * floats with `%g` semantics, so `1000000` renders as `1e+06`. Unlike the - * local path (whose integer columns stay plain via `formatValue`), - * primitive numbers here route through the float formatting. Used for - * `db query --linked` table/CSV cells only; JSON output re-marshals the raw - * values. + * Formats `--linked` table/CSV cell values. The API response decodes every number as a JSON + * float, so numbers render via `%g`-style formatting (`1000000` → `1e+06`) instead of staying + * plain like the local path's `formatValue`; JSON output re-marshals the raw values instead. */ export function formatLinkedValue(value: unknown): string { if (value === null || value === undefined) return "NULL"; @@ -78,10 +66,8 @@ export function formatLinkedValue(value: unknown): string { const PG_FLOAT4_OID = 700; const PG_FLOAT8_OID = 701; -// Postgres `date` / `timestamp` / `timestamptz` type OIDs. The `queryRaw` -// type-parser override keeps these as raw Postgres text (not a JS `Date`), so -// microsecond precision survives — a JS `Date` is millisecond resolution and -// applies the local timezone. +// Postgres `date`/`timestamp`/`timestamptz` type OIDs. The `queryRaw` type-parser override keeps +// these as raw text (not a JS `Date`, which is millisecond-resolution and local-timezone). const PG_DATE_OID = 1082; const PG_TIMESTAMP_OID = 1114; const PG_TIMESTAMPTZ_OID = 1184; @@ -105,22 +91,17 @@ const PG_TIMESTAMP_PATTERN = /^(\d{4,})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?)?(?:([+-])(\d{2})(?::?(\d{2}))?(?::?(\d{2}))?)?$/; /** - * Parse a Postgres date/timestamp/timestamptz text value into its UTC wall-clock - * components plus the trimmed sub-second fraction. A `timestamptz` carries a zone - * offset (`+00`, `-07`, `+05:30`) which is shifted to UTC; a `timestamp` has no - * offset and is taken as UTC; a `date` has neither time nor offset (midnight UTC). - * Returns `undefined` for anything unrecognized (e.g. `infinity`), so the caller - * falls back to the raw text. Whole-minute/second zone offsets never touch the - * sub-second fraction, so the offset shift uses millisecond `Date` math while - * `fraction` carries over verbatim. + * Parses a Postgres date/timestamp/timestamptz text value into UTC wall-clock components plus + * the trimmed sub-second fraction. A `timestamptz` offset is shifted to UTC; `timestamp` has no + * offset (taken as UTC); `date` has neither (midnight UTC). Returns `undefined` for anything + * unrecognized (e.g. `infinity`) so the caller falls back to the raw text. */ function parsePgUtcInstant(raw: string): PgUtcInstant | undefined { const m = PG_TIMESTAMP_PATTERN.exec(raw); if (m === null) return undefined; const [, y, mo, d, hh, mi, ss, frac, sign, oh, om, os] = m; - // `Date.UTC` remaps years 0–99 to 1900–1999, which would corrupt historical dates - // (`0001-01-01` → `1901-...`). `setUTCFullYear` does not remap, so build the instant - // explicitly to preserve the original year. + // `Date.UTC` remaps years 0-99 to 1900-1999 (corrupting `0001-01-01`); `setUTCFullYear` does + // not remap, so build the instant that way instead. const dt = new Date(0); dt.setUTCFullYear(Number(y), Number(mo) - 1, Number(d)); dt.setUTCHours(Number(hh ?? "0"), Number(mi ?? "0"), Number(ss ?? "0"), 0); @@ -146,14 +127,9 @@ const pad2 = (n: number): string => String(n).padStart(2, "0"); const pad4 = (n: number): string => String(n).padStart(4, "0"); /** - * Render a parsed instant as the established `time.Time.String()`-style - * format: `2006-01-02 15:04:05.999999999 -0700 MST`, in UTC, fractional zeros - * trimmed. This matches `timestamp` exactly (decoded as UTC). NOTE: - * `timestamptz` is rendered in the process's LOCAL timezone with its zone - * name in the established output contract, which depends on the host's `TZ` - * (not the data) and is not reconstructable; UTC is the stable, - * correct-instant rendering — the same accepted divergence noted on the JSON - * path. + * Renders a parsed instant as `2006-01-02 15:04:05.999999999 -0700 MST` in UTC, with trailing + * fractional zeros trimmed. `timestamptz` would need the host's local zone name to match + * exactly, which isn't reconstructable from the data, so every timestamp type renders in UTC. */ function formatGoTimestamp(i: PgUtcInstant): string { const frac = i.fraction.length > 0 ? `.${i.fraction}` : ""; @@ -167,11 +143,9 @@ function timestampToRfc3339(i: PgUtcInstant): string { } /** - * Format a JS `Date` in the established `%v`-style timestamp format. - * Defensive fallback only: with the `queryRaw` raw-text override, date/timestamp - * columns arrive as strings (see {@link parsePgUtcInstant}), so a `Date` reaches here - * only if a caller supplies native rows — and then only millisecond precision is - * available. + * Formats a JS `Date` in the established timestamp format. Defensive fallback only: with the + * `queryRaw` raw-text override, date/timestamp columns arrive as strings (see + * {@link parsePgUtcInstant}); a `Date` only reaches here for native rows, at millisecond precision. */ function formatGoTime(d: Date): string { const ms = d.getUTCMilliseconds(); @@ -187,13 +161,9 @@ function formatGoTime(d: Date): string { } /** - * Per-column cell formatter for the local / `--db-url` path. Renders `date`/ - * `timestamp`/`timestamptz` columns via the established timestamp format - * (microseconds preserved from the raw Postgres text) and `float4`/`float8` - * columns with `%g`-style formatting (`select 1000000::float8` → `1e+06`), - * while every other column keeps the plain `formatValue` form (so - * integer columns are not turned into `1e+06`). `fieldTypeIds` is the - * per-column OID list from `queryRaw`. + * Per-column cell formatter for the local/`--db-url` path: `date`/`timestamp`/`timestamptz` + * columns render via the established timestamp format, `float4`/`float8` via `%g`-style + * formatting (`1000000` → `1e+06`), everything else via `formatValue` so integers stay plain. */ export function makeLocalCellFormatter( fieldTypeIds: ReadonlyArray<number>, @@ -205,8 +175,7 @@ export function makeLocalCellFormatter( if (instant !== undefined) return formatGoTimestamp(instant); // Unrecognized (e.g. `infinity`): fall through to the raw-text default. } - // Defensive: native rows may still carry a `Date`; render it in the - // established `%v`-style format. + // Defensive: native rows may still carry a `Date`, rendered via the established format. if (value instanceof Date) return formatGoTime(value); if (typeof value === "number" && (oid === PG_FLOAT4_OID || oid === PG_FLOAT8_OID)) { return goFormatFloat(value); @@ -226,18 +195,10 @@ function bytesToBase64(bytes: Uint8Array): string { } /** - * Coerce local/`--db-url` cells to the established JSON output shape. `int8`/ - * `bigint` columns are established as a bare number, so `db query -o json` - * emits one; node-postgres returns the column as a string, which would emit a - * quoted string. Only coerces when the value round-trips losslessly — JS - * cannot represent `|n| > 2^53` exactly, so those stay strings (preserving - * correctness rather than silently corrupting the value). `bytea` columns - * arrive as a `Buffer`; the established encoding is a standard base64 string, - * so coerce those rather than letting `JSON.stringify` emit - * `{"type":"Buffer","data":[...]}`. `date`/`timestamp`/`timestamptz` columns - * arrive as raw text; the established encoding is RFC3339Nano (microseconds - * preserved), so coerce them to that form rather than emitting the raw - * Postgres text. Other column types pass through unchanged; JSON re-marshals them. + * Coerces local/`--db-url` cells to the established JSON shape: `int8`/`bigint` strings become a + * bare number when the value round-trips losslessly (JS can't represent `|n| > 2^53` exactly, so + * larger values stay strings), `bytea` `Buffer`s become base64, and `date`/`timestamp`/ + * `timestamptz` text becomes RFC3339Nano. Everything else passes through unchanged. */ export function coerceLocalJsonRows( data: ReadonlyArray<ReadonlyArray<unknown>>, @@ -252,9 +213,8 @@ export function coerceLocalJsonRows( return instant !== undefined ? timestampToRfc3339(instant) : cell; } if (oid === PG_INT8_OID && typeof cell === "string" && /^-?\d+$/.test(cell)) { - // int8 is established as a bare number for ANY magnitude. A JS number - // loses precision past 2^53, so emit the exact digits as a raw JSON - // number token (`JSON.rawJSON`) rather than a quoted string. + // int8 is established as a bare number at any magnitude; JS numbers lose precision past + // 2^53, so emit the exact digits as a raw JSON number token instead of a quoted string. const asNumber = Number(cell); return Number.isSafeInteger(asNumber) && String(asNumber) === cell ? asNumber @@ -266,12 +226,9 @@ export function coerceLocalJsonRows( } /** - * The established JSON encoder rejects non-finite floats (`db query -o json` - * then fails with empty stdout and exit 1), whereas `JSON.stringify` silently - * coerces `NaN`/`Infinity` to `null`. Returns the established token - * (`NaN` / `+Inf` / `-Inf`) for the first non-finite number cell so the - * caller can fail the command the same way; `undefined` when every value is - * encodable. + * `JSON.stringify` silently coerces `NaN`/`Infinity` to `null`, but the established encoder + * rejects non-finite floats. Returns the established token (`NaN`/`+Inf`/`-Inf`) for the first + * non-finite cell so the caller can fail the same way, or `undefined` when every value is encodable. */ export function findNonFiniteJsonValue( data: ReadonlyArray<ReadonlyArray<unknown>>, @@ -286,9 +243,8 @@ export function findNonFiniteJsonValue( return undefined; } -// Cell width is measured with East Asian Wide = 2, zero-width/combining = 0, -// so column widths/borders align for CJK/emoji output. Counting JS code -// points would under-measure those cells and misalign the table. +// Width counts East Asian Wide as 2 and zero-width/combining as 0, so CJK/emoji cells still +// align columns and borders; counting JS code points would under-measure them. const displayWidth = (text: string): number => stringWidth(text); /** @@ -303,8 +259,7 @@ export function renderTablewriter( ): string { if (cols.length === 0) return ""; const rows = data.map((row) => row.map((cell, columnIndex) => formatCell(cell, columnIndex))); - // Column width is the widest visual line: a cell may contain newlines, - // which split across stacked lines, so measure each line, not the raw string. + // Column width is the widest visual line, since a multi-line cell splits across stacked lines. const widths = cols.map((col, i) => { let width = displayWidth(col); for (const row of rows) { @@ -363,10 +318,9 @@ export function toCsv( } /** - * The established default JSON HTML escaping (`<`, `>`, `&` and the - * line/paragraph separators) — `db query` never disables it. Safe to run on - * the whole serialized document: these characters only occur inside string - * values, never in JSON structure. + * The established JSON HTML escaping (`<`, `>`, `&`, and the line/paragraph separators); `db + * query` never disables it. Safe to run on the whole document since these characters only occur + * inside string values, never in JSON structure. */ function escapeGoJsonHtml(json: string): string { return json @@ -380,24 +334,18 @@ function escapeGoJsonHtml(json: string): string { const byteLess = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0); /** - * A JSON object whose key order is fixed by the builder (not re-sorted by the - * encoder). The established contract distinguishes a `map` (keys sorted by - * byte) from a `struct` (keys in declaration order); both reach the encoder - * as a `OrderedJson` with the order already decided. JS objects can't - * carry this order — `JSON.stringify` reorders integer-like keys numerically - * (`"2"` before `"10"`), unlike the established lexicographic `map` order — - * so the rows/envelope are encoded from explicit entries instead. + * A JSON object whose key order is fixed by the caller rather than re-sorted by the encoder, + * since JS objects can't carry arbitrary order (`JSON.stringify` reorders integer-like keys + * numerically, e.g. `"2"` before `"10"`). */ class OrderedJson { constructor(readonly entries: ReadonlyArray<readonly [string, unknown]>) {} } /** - * Encode a value in the established 2-space-indent JSON output: arrays in - * order, `OrderedJson` in its fixed order, DB-sourced plain objects - * (e.g. JSONB) as a `map` with byte-sorted keys, and `JSON.rawJSON` (exact - * bigint) / primitives via `JSON.stringify`. HTML escaping is applied by the - * caller as a whole-string pass. + * Encodes a value as the established 2-space-indent JSON: arrays in order, `OrderedJson` in its + * fixed order, plain objects (e.g. JSONB) as a byte-sorted `map`, and everything else via + * `JSON.stringify`. HTML escaping is applied by the caller as a whole-string pass. */ function encodeGoJson(value: unknown, indent: number): string { if (value === null || value === undefined) return "null"; @@ -432,10 +380,9 @@ function encodeGoJson(value: unknown, indent: number): string { } /** - * A row as a `map` (column keys sorted by byte), order carried explicitly. - * Duplicate column names (`select 1 as x, 2 as x`) collapse to a single key - * with the last value — the row is built as a map, so the later assignment - * overwrites the earlier one. (The table/CSV path keeps both columns.) + * Builds a row as a byte-sorted `map`. Duplicate column names (`select 1 as x, 2 as x`) collapse + * to a single key holding the last value, since the row is built as a map; the table/CSV path + * keeps both columns instead. */ function orderedRow(cols: ReadonlyArray<string>, values: ReadonlyArray<unknown>): OrderedJson { const byKey = new Map<string, unknown>(); @@ -455,11 +402,9 @@ export interface Advisory { } /** - * The established JSON output. Human mode emits a plain rows array; agent - * mode wraps it in the untrusted-data envelope `{warning, boundary, rows, - * advisory?}`. The `boundary` is supplied by the caller (random hex). Output - * is 2-space indented with a trailing newline, map keys sorted, and - * HTML-escaped. + * The established JSON output: a plain rows array in human mode, or an untrusted-data envelope + * `{warning, boundary, rows, advisory?}` in agent mode (`boundary` supplied by the caller). Output + * is 2-space indented with a trailing newline, map keys sorted, and HTML-escaped. */ export function renderJson( cols: ReadonlyArray<string>, @@ -477,7 +422,7 @@ export function renderJson( // Envelope keys in the established map sort order: advisory, boundary, rows, warning. const envelope: Array<readonly [string, unknown]> = []; if (Option.isSome(advisory)) { - // The advisory uses its declaration field order (NOT sorted). + // The advisory uses its declaration field order, not sorted. const a = advisory.value; envelope.push([ "advisory", @@ -502,8 +447,8 @@ export function renderJson( return `${escapeGoJsonHtml(encodeGoJson(new OrderedJson(envelope), 0))}\n`; } -// Read a JSON string token starting at `s[start] === '"'`; returns the decoded value -// and the index just past the closing quote (handles `\"`, `\\`, and unicode escapes). +// Reads a JSON string token starting at `s[start] === '"'`, returning the decoded value and +// the index just past the closing quote. function readJsonStringToken( s: string, start: number, @@ -531,12 +476,9 @@ function readJsonStringToken( } /** - * Extract column names from the first object of a JSON array, in source order. JS - * `Object.keys` reorders integer-like keys numerically (`{"10":..,"2":..}` → - * `["2","10"]`), which would swap columns for a linked query like - * `select 1 as "10", 2 as "2"`. Preserving the raw source order requires - * scanning the first object's top-level keys textually rather than via - * `Object.keys`. + * Extracts column names from the first object of a JSON array, in source order. `Object.keys` + * reorders integer-like keys numerically (`{"10":..,"2":..}` → `["2","10"]`), which would swap + * columns for a query like `select 1 as "10", 2 as "2"`, so this scans the raw text instead. */ export function orderedKeys(body: string): ReadonlyArray<string> { let parsed: unknown; diff --git a/apps/cli/src/commands/db/query/query.format.unit.test.ts b/apps/cli/src/commands/db/query/query.format.unit.test.ts index 6ccf3c53eb..44b4f6141e 100644 --- a/apps/cli/src/commands/db/query/query.format.unit.test.ts +++ b/apps/cli/src/commands/db/query/query.format.unit.test.ts @@ -25,7 +25,6 @@ describe("formatValue", () => { }); it("renders JSON objects and arrays like Go's fmt %v (not [object Object])", () => { - // Captured from the established %v-style formatting. expect(formatValue({ k: "v", z: 1, a: true })).toBe("map[a:true k:v z:1]"); expect(formatValue([1, 2, "x"])).toBe("[1 2 x]"); expect(formatValue({ count: 1000000 })).toBe("map[count:1e+06]"); @@ -47,7 +46,6 @@ describe("formatValue", () => { }); it("renders bytea (Buffer/Uint8Array) as Go's []byte %v decimal array, not map[]", () => { - // Established: `[222,173,190,239]` renders as "[222 173 190 239]". expect(formatValue(new Uint8Array([222, 173, 190, 239]))).toBe("[222 173 190 239]"); expect(formatValue(new Uint8Array([]))).toBe("[]"); }); @@ -55,8 +53,6 @@ describe("formatValue", () => { describe("formatLinkedValue", () => { it("renders top-level JSON numbers with Go's float64 %g (interface{} path)", () => { - // Linked rows decode into a generic value, so every number is a float and - // is printed with %g — unlike the local path. expect(formatLinkedValue(1000000)).toBe("1e+06"); expect(formatLinkedValue(1234567)).toBe("1.234567e+06"); expect(formatLinkedValue(999999)).toBe("999999"); @@ -72,8 +68,6 @@ describe("formatLinkedValue", () => { }); it("local formatValue keeps top-level integers plain (no %g)", () => { - // Guards the scoping: the shared formatter (local pgx path) must NOT apply %g - // to a plain integer, or local int columns would regress to 1e+06. expect(formatValue(1000000)).toBe("1000000"); }); }); @@ -82,22 +76,22 @@ describe("makeLocalCellFormatter", () => { // OIDs: int4=23, float4=700, float8=701, text=25. it("renders float4/float8 columns with %g and integer columns plain", () => { const fmt = makeLocalCellFormatter([23, 701, 700]); - expect(fmt(1000000, 0)).toBe("1000000"); // int4 column → plain - expect(fmt(1000000, 1)).toBe("1e+06"); // float8 column → %g - expect(fmt(1000000, 2)).toBe("1e+06"); // float4 column → %g + expect(fmt(1000000, 0)).toBe("1000000"); + expect(fmt(1000000, 1)).toBe("1e+06"); + expect(fmt(1000000, 2)).toBe("1e+06"); }); it("leaves non-number cells (and unknown columns) to the default formatter", () => { const fmt = makeLocalCellFormatter([701, 25]); expect(fmt(null, 0)).toBe("NULL"); expect(fmt("hi", 1)).toBe("hi"); - expect(fmt(42, 99)).toBe("42"); // no OID for the column → plain + expect(fmt(42, 99)).toBe("42"); }); it("preserves negative zero in a float column like Go's %v (-0, not 0)", () => { const fmt = makeLocalCellFormatter([701, 701]); - expect(fmt(-0, 0)).toBe("-0"); // float8 column → the sign is preserved - expect(fmt(0, 1)).toBe("0"); // positive zero stays plain + expect(fmt(-0, 0)).toBe("-0"); + expect(fmt(0, 1)).toBe("0"); }); it("renders Date (timestamp) cells like Go's time.Time %v instead of map[]", () => { @@ -109,8 +103,7 @@ describe("makeLocalCellFormatter", () => { }); it("preserves microseconds for raw timestamp text (OID 1114), trimming zeros", () => { - // node-postgres' Date is millisecond-only; the raw-text override keeps - // the µs that the established `%v`-style formatting prints. + // Raw text preserves microseconds; a JS Date is millisecond-only. const fmt = makeLocalCellFormatter([1114]); expect(fmt("2026-01-01 00:00:00.123456", 0)).toBe("2026-01-01 00:00:00.123456 +0000 UTC"); expect(fmt("2026-01-01 00:00:00.12", 0)).toBe("2026-01-01 00:00:00.12 +0000 UTC"); @@ -120,7 +113,6 @@ describe("makeLocalCellFormatter", () => { it("shifts a timestamptz (OID 1184) to UTC while keeping microseconds", () => { const fmt = makeLocalCellFormatter([1184]); expect(fmt("2026-01-01 00:00:00.123456+00", 0)).toBe("2026-01-01 00:00:00.123456 +0000 UTC"); - // -07:00 zone → add 7h to reach UTC; the sub-second fraction is untouched. expect(fmt("2026-01-01 05:30:00.5-07", 0)).toBe("2026-01-01 12:30:00.5 +0000 UTC"); }); @@ -145,28 +137,25 @@ describe("coerceLocalJsonRows", () => { // OIDs: int8=20, text=25. it("coerces in-range int8 string cells to JSON numbers, leaves others alone", () => { const out = coerceLocalJsonRows([["42", "hi"]], [20, 25]); - expect(out[0]?.[0]).toBe(42); // int8 within safe range → number - expect(out[0]?.[1]).toBe("hi"); // text → unchanged + expect(out[0]?.[0]).toBe(42); + expect(out[0]?.[1]).toBe("hi"); }); it("emits out-of-safe-range int8 as an exact bare JSON number (not a string)", () => { - // int8 is established as the full integer; JS numbers lose precision past - // 2^53, so we coerce to a raw JSON number token instead. const huge = "9223372036854775807"; // > Number.MAX_SAFE_INTEGER const coerced = coerceLocalJsonRows([[huge]], [20]); const out = renderJson(["n"], coerced, false, "", Option.none()); - expect(out).toContain(`"n": ${huge}`); // bare number token, unquoted, exact - expect(out).not.toContain(`"${huge}"`); // not a quoted string + expect(out).toContain(`"n": ${huge}`); + expect(out).not.toContain(`"${huge}"`); }); it("coerces bytea (Buffer/Uint8Array) cells to standard base64 like Go's json.Marshal", () => { - // OID 17 = bytea. The established encoding is a base64 string in JSON output. + // OID 17 = bytea. const out = coerceLocalJsonRows([[new Uint8Array([222, 173, 190, 239])]], [17]); expect(out[0]?.[0]).toBe("3q2+7w=="); }); it("coerces timestamp/timestamptz/date cells to Go's RFC3339Nano (UTC, microseconds)", () => { - // The established encoding is RFC3339Nano; node-postgres' Date would lose the µs. expect(coerceLocalJsonRows([["2026-01-01 00:00:00.123456"]], [1114])[0]?.[0]).toBe( "2026-01-01T00:00:00.123456Z", ); @@ -181,7 +170,6 @@ describe("renderTablewriter", () => { it("applies a custom cell formatter (linked %g) when provided", () => { const out = renderTablewriter(["n"], [[1000000]], formatLinkedValue); expect(out).toContain("1e+06"); - // Default (local) formatter keeps it plain. expect(renderTablewriter(["n"], [[1000000]])).toContain("1000000"); }); @@ -229,7 +217,7 @@ describe("renderTablewriter", () => { }); it("sizes columns by terminal rune width so CJK cells stay aligned (Go runewidth)", () => { - // "日本語" is 6 display columns, not 3 code points; the borders must match its width. + // "日本語" is 6 display columns, not 3 code points. const out = renderTablewriter(["name"], [["日本語"], ["ab"]]); expect(out).toBe( ["┌────────┐", "│ name │", "├────────┤", "│ 日本語 │", "│ ab │", "└────────┘", ""].join( @@ -262,36 +250,28 @@ describe("renderJson", () => { }); it("keeps integer-like column keys in Go's lexicographic order (not JS numeric)", () => { - // `select 1 as "10", 2 as "2"` — established output emits "10" before - // "2"; a plain JS object would reorder them numerically to "2","10". const out = renderJson(["10", "2"], [[1, 2]], false, "", Option.none()); expect(out).toBe('[\n {\n "10": 1,\n "2": 2\n }\n]\n'); }); it("collapses duplicate column names to the last value (Go's map overwrite)", () => { - // `select 1 as x, 2 as x` — the row-as-map encoding keeps a single "x" - // with the last value. const out = renderJson(["x", "x"], [[1, 2]], false, "", Option.none()); expect(out).toBe('[\n {\n "x": 2\n }\n]\n'); }); it("preserves negative zero like Go's json.Encoder (-0, not 0)", () => { - // `select '-0'::float8 as n` — established output emits `-0`; - // JSON.stringify(-0) would collapse to `0`. const out = renderJson(["n"], [[-0]], false, "", Option.none()); expect(out).toBe('[\n {\n "n": -0\n }\n]\n'); }); it("wraps agent results in the untrusted-data envelope with HTML-escaped boundary markers", () => { const out = renderJson(["id"], [[1]], true, "deadbeef", Option.none()); - // Envelope keys in the established map-sort order: boundary, rows, warning (no advisory). const boundaryIdx = out.indexOf('"boundary"'); const rowsIdx = out.indexOf('"rows"'); const warningIdx = out.indexOf('"warning"'); expect(boundaryIdx).toBeGreaterThanOrEqual(0); expect(boundaryIdx).toBeLessThan(rowsIdx); expect(rowsIdx).toBeLessThan(warningIdx); - // HTML-escapes < and > (never disabled). expect(out).toContain("\\u003cdeadbeef\\u003e"); expect(out).not.toContain("<deadbeef>"); expect(out.endsWith("\n")).toBe(true); @@ -310,7 +290,6 @@ describe("renderJson", () => { expect(parsed.advisory.remediation_sql).toBe( "ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;", ); - // Advisory keys keep the established declaration order, not sorted. const advisoryJson = out.slice(out.indexOf('"advisory"')); expect(advisoryJson.indexOf('"id"')).toBeLessThan(advisoryJson.indexOf('"priority"')); expect(advisoryJson.indexOf('"priority"')).toBeLessThan(advisoryJson.indexOf('"level"')); @@ -323,7 +302,6 @@ describe("orderedKeys", () => { }); it("preserves integer-like alias order (Object.keys would reorder them numerically)", () => { - // `select 1 as "10", 2 as "2"` → source order is preserved; JS Object.keys → ["2","10"]. expect(orderedKeys('[{"10":1,"2":2,"name":3}]')).toEqual(["10", "2", "name"]); }); diff --git a/apps/cli/src/commands/db/query/query.handler.ts b/apps/cli/src/commands/db/query/query.handler.ts index fb68086ff7..e98118747a 100644 --- a/apps/cli/src/commands/db/query/query.handler.ts +++ b/apps/cli/src/commands/db/query/query.handler.ts @@ -53,13 +53,8 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { const telemetryState = yield* TelemetryState; const telemetryOutputFormat = yield* TelemetryOutputFormat; const linkedProjectCache = yield* LinkedProjectCache; - // The project ref is resolved during the linked pre-run, before DB - // resolution and before SQL resolution. The linked-project cache is - // refreshed unconditionally afterward, so it must refresh even when a - // later step (DB resolution, missing `--file`, no-stdin SQL) fails. - // Captured in the linked preflight; the finalizer on the whole handler - // body reads it. Declared at handler scope so it is visible to both the - // preflight and the `.pipe` finalizer. + // Set only when the linked preflight resolves a ref, so the `Effect.ensuring` finalizer at the + // end of this handler can refresh the linked-project cache even if a later step fails. let linkedRefForCache: string | undefined; const stdin = yield* Stdin; const fs = yield* FileSystem.FileSystem; @@ -73,24 +68,19 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { const dbConn = yield* DbConnection; const dnsResolver = yield* DnsResolverFlag; - // Emit the resolved payload (json/table/csv) to stdout in every output - // format — there is no `--output-format` for `db query`, so there is no - // machine envelope. The CSV and table writers ignore agent mode / the - // advisory; only JSON carries the agent envelope. + // Writes the resolved payload (json/table/csv) to stdout. There's no `--output-format` for + // `db query`, so CSV/table ignore agent mode and only JSON carries the agent envelope. const emit = ( format: ResolvedFormat, cols: ReadonlyArray<string>, data: ReadonlyArray<ReadonlyArray<unknown>>, agentMode: boolean, advisory: Option.Option<Advisory>, - // The linked path passes `formatLinkedValue` (JSON-decoded float - // cells → `%v`/`%g`-style formatting); the local path passes an OID-aware - // formatter (`float4`/`float8` → `%g`, ints plain). JSON output re-marshals - // the raw values either way. + // `formatLinkedValue` for the linked path, an OID-aware formatter for local; only used for + // table/CSV cells since JSON output re-marshals the raw values. formatCell?: (value: unknown, columnIndex: number) => string, - // Local-path column OIDs: lets JSON output coerce int8/bigint string cells - // to bare numbers (established int64 scan). Omitted on the linked path - // (raw JSON values). + // Local-path column OIDs, used to coerce int8/bigint JSON cells to bare numbers; omitted on + // the linked path (already raw JSON values). fieldTypeIds?: ReadonlyArray<number>, ) => Effect.gen(function* () { @@ -100,9 +90,8 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { if (format === "csv") { return yield* output.raw(toCsv(cols, data, formatCell)); } - // The established JSON encoding fails on NaN/±Inf (empty stdout, exit - // 1); mirror that instead of letting `JSON.stringify` emit `null`. - // Checked before any output. + // The established JSON encoding fails on NaN/±Inf (empty stdout, exit 1); mirror that + // instead of letting `JSON.stringify` silently emit `null`. const nonFinite = findNonFiniteJsonValue(data); if (nonFinite !== undefined) { return yield* Effect.fail( @@ -206,9 +195,8 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { ); } - // The API returns a JSON array of row objects for SELECT, or a plain - // command tag for DDL/DML. Anything that is not a JSON array of objects - // is printed verbatim (the array-of-maps decode fails → raw body). + // The API returns a JSON array of row objects for SELECT, or a plain command tag for + // DDL/DML; anything else is printed verbatim. let parsed: unknown; try { parsed = JSON.parse(body); @@ -234,9 +222,8 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { }); yield* Effect.gen(function* () { - // 0. Mutually-exclusive db-url/linked/local group, checked before - // resolving any SQL. "Set" means explicitly set: an Option is set - // when `Some`, a boolean when explicitly `true`. + // 0. The db-url/linked/local group is mutually exclusive, checked before resolving SQL. + // "Set" means explicitly set (Option `Some`, or boolean literally `true`). const exclusive: Array<string> = []; if (Option.isSome(flags.dbUrl)) exclusive.push("db-url"); if (Option.isSome(flags.linked)) exclusive.push("linked"); @@ -249,9 +236,8 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { ); } - // `--project-ref` never implies `--linked` and must not be silently - // discarded on a non-linked target — see push.handler.ts's identical guard - // for the full TS-only rationale. + // `--project-ref` only applies to the linked target; it must not be silently ignored when + // targeting `--local`/`--db-url`. if (Option.isSome(flags.projectRef) && Option.isNone(flags.linked)) { return yield* Effect.fail( new DbQueryMutuallyExclusiveFlagsError({ @@ -261,52 +247,31 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { ); } - // PreRun parity: for --linked, the access token is checked and the - // project ref is loaded BEFORE SQL is resolved, so a missing `--file` or - // a blocking stdin pipe must not mask the expected login / not-linked - // error. Run that preflight here, before resolving SQL. + // For `--linked`, the access token is checked and the project ref is loaded before SQL is + // resolved, so a missing `--file` or a blocking stdin pipe doesn't mask a login/not-linked + // error. let linkedAuth: { readonly token: Redacted.Redacted<string>; readonly ref: string } | undefined; if (Option.isSome(flags.linked)) { const credentials = yield* CommandCredentials; const projectRef = yield* ProjectRefResolver; - // The DB config is resolved FIRST, and only then is the token checked — - // otherwise an unlinked-project / invalid-config / IPv6 / pooler / - // login-role failure is masked behind a generic "supabase login" error. - // - // 1. `loadProjectRef` (flag → env → ref file): the HARD, non-prompting - // loader `db query --linked`'s preflight uses. It validates the ref - // format and fails when absent — and, crucially, surfaces - // `failed to load project ref` on a real (non-not-exist) ref-file - // read error rather than masking it as not-linked (the soft - // `resolveOptional` swallows that to None). + // Config is resolved before the token check, so an unlinked-project/invalid-config/pooler + // failure isn't masked behind a generic "supabase login" error. `loadProjectRef` is the + // strict loader: a real ref-file read error surfaces instead of being treated as not-linked. const ref = yield* projectRef.loadProjectRef(flags.projectRef); - // Record the ref now, so the linked-project cache finalizer fires even - // if the DB resolution or token check below fails. + // Recorded now so the cache finalizer still fires if a later step fails. linkedRefForCache = ref; - // 2. Loads + validates the remote-merged config and resolves the live - // DB connection (TCP probe, pooler fallback, temp login-role mint), - // any of which can fail early. The token is read lazily here only - // when a login role must be minted, so this stays before the - // token-only check. The linked query itself uses the Management - // API, so the resolved connection is discarded — this runs purely - // for pre-run failures. + // Loads and validates the remote-merged config and resolves the live DB connection (TCP + // probe, pooler fallback, temp login-role mint) purely to surface early failures — the + // linked query itself uses the Management API, so this resolved connection is discarded. yield* resolver.resolve({ dbUrl: Option.none(), connType: "linked", dnsResolver, linkedProjectRef: flags.projectRef, }); - // 3. Token check: a token is still required for the Management API - // query even when config resolved without minting a login role - // (e.g. a direct `DB_PASSWORD` was set), so keep this — but after - // the config/ref resolution above. The RESOLVED token (env → - // keyring → file alike) is validated against `sbp_...` and fails - // before any API request. `credentials.getAccessToken` already - // applies that env-precedence + `sbp_` validation on every source, - // so route through it rather than accepting the env - // `SUPABASE_ACCESS_TOKEN` on presence alone — an invalid env token - // must fail here, not surface an `unexpected status` from - // `/database/query`. + // The Management API still needs a token even when config resolved without minting a login + // role. Route through `credentials.getAccessToken`, which validates it against `sbp_...`, + // rather than trusting env presence alone and surfacing a confusing status error later. const tokenOpt = yield* credentials.getAccessToken; if (Option.isNone(tokenOpt)) { return yield* Effect.fail( @@ -319,18 +284,13 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { linkedAuth = { token: tokenOpt.value, ref }; } - // PreRun parity (non-linked): the `--db-url` connection string and local - // config are resolved BEFORE SQL is resolved. So resolve the direct - // connection target here — before reading `--file`/stdin — so a bad - // `--db-url` or config error surfaces ahead of a missing-file error or a - // blocking stdin read. The actual socket connect still happens later in - // `runLocal`. + // Resolves the `--db-url`/local connection target before reading `--file`/stdin, so a bad + // `--db-url` or config error surfaces ahead of a missing-file error or a blocking stdin read. const localTarget = linkedAuth === undefined ? yield* resolver.resolve({ dbUrl: flags.dbUrl, - // This branch is the non-linked path (linkedAuth handles `--linked`), - // so the target is `--db-url` or local. + // linkedAuth handles `--linked`, so this branch's target is `--db-url` or local. connType: Option.isSome(flags.dbUrl) ? "db-url" : "local", dnsResolver, }) @@ -370,13 +330,9 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { ); }); - // 2. Agent mode + the resolved payload format: an explicit `-o - // json|table|csv` always wins; otherwise default to JSON for agents - // and a table for humans. The global `-o` choice is a union (see - // `query.command.ts`), while TS `--output-format json|stream-json` - // must also resolve to JSON here, so values outside `db query`'s own - // `json|table|csv` enum (`pretty|yaml|toml|env`) fall through to the - // agent/machine default rather than erroring. + // 2. Resolve the payload format: an explicit `-o json|table|csv` always wins; otherwise + // default to JSON for agents, table for humans. Any other `-o` value (from the global + // union, e.g. `pretty|yaml|toml|env`) falls through to that default rather than erroring. const agentMode = resolveAgentMode(agentFlag, aiTool.name); const explicit = Option.getOrUndefined(outputFlag); const format: ResolvedFormat = @@ -390,13 +346,11 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { ? "json" : "table"; - // Mirrors the resolved local `-o` (json|table|csv) onto the global the - // telemetry event reads. Without this the instrumentation reports - // `table`/human-default as `text`. + // Mirrors the resolved format onto the global telemetry reads, or instrumentation would + // report the table/human default as `text`. yield* telemetryOutputFormat.set(format); - // 3. Linked → Management API (raw HTTP); local / --db-url → direct connection. - // The --linked token/ref preflight already ran above. + // 3. Linked queries go through the Management API; local/--db-url connect directly. if (linkedAuth !== undefined) { return yield* runLinked(sql, format, agentMode, linkedAuth.ref, linkedAuth.token); } @@ -406,13 +360,9 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { } return yield* runLocal(localTarget, sql, format, agentMode); }).pipe( - // Once a project ref is resolved, write the linked-project cache - // (`GET /v1/projects/{ref}` → `supabase/.temp/linked-project.json`) - // whether the query succeeds or fails — and even when it fails before - // `runLinked` (DB resolution, missing `--file`, no-stdin SQL). The cache - // layer no-ops when the file already exists, the token is missing, or - // the GET is non-200. Only the linked path sets `linkedRefForCache`, so - // `--local` / `--db-url` never trigger this. + // Writes the linked-project cache whenever a ref was resolved, whether the query succeeds or + // fails — `linkedRefForCache` is only set on the linked path, so `--local`/`--db-url` never + // trigger this. Effect.ensuring( Effect.suspend(() => linkedRefForCache !== undefined ? linkedProjectCache.cache(linkedRefForCache) : Effect.void, diff --git a/apps/cli/src/commands/db/query/query.integration.test.ts b/apps/cli/src/commands/db/query/query.integration.test.ts index ec3ea540e0..f4ca8bdc66 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -116,12 +116,8 @@ function mockTelemetryOutputFormat() { } function mockProjectRef(unlinked = false, refReadFails = false) { - // The linked query preflight uses the hard `loadProjectRef`: it fails with - // ErrNotLinked when absent and surfaces a `failed to load project ref` read error - // (ProjectRefReadError) on an unreadable ref file, rather than masking it. - // An explicit `--project-ref` flag gets top precedence, same as Go's - // `flags.LoadProjectRef` — short-circuiting BEFORE either failure mode, so a - // test can prove the flag resolves a ref even for an "unlinked" workdir. + // `loadProjectRef` mirrors the real preflight: an explicit `--project-ref` flag wins first, + // then ErrNotLinked when absent, or a read error (ProjectRefReadError) for an unreadable file. const loadProjectRef = (flagValue: Option.Option<string>) => Option.isSome(flagValue) && flagValue.value.length > 0 ? Effect.succeed(flagValue.value) @@ -254,9 +250,7 @@ function setup(opts: SetupOpts = {}) { workdir: opts.workdir ?? "/work/project", accessToken: opts.accessToken, }), - // The linked token check routes through `credentials.getAccessToken`, - // which validates the resolved token (env/keyring/file) against `sbp_`. - // `accessTokenInvalid` exercises that via the real validator. + // `accessTokenInvalid` exercises the real `sbp_` token validator via `getAccessToken`. Layer.succeed(CommandCredentials, { getAccessToken: opts.accessTokenInvalid === true @@ -299,7 +293,6 @@ describe("db query integration", () => { expect(out.stderrText).toContain("Connecting to local database..."); expect(out.stdoutText).toContain("│ id │ name │"); expect(out.stdoutText).toContain("│ 1 │ alice │"); - // The local path never resolves a project ref, so no linked-project cache write. expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); @@ -360,8 +353,6 @@ describe("db query integration", () => { }); it.live("resolves a relative --file against the workdir", () => { - // A relative `--file` path resolves against the workdir, not the - // original process cwd. const dir = mkdtempSync(join(tmpdir(), "supabase-query-wd-")); writeFileSync(join(dir, "q.sql"), "select * from users"); const { layer, out } = setup({ result: SELECT_RESULT, workdir: dir }); @@ -496,8 +487,6 @@ describe("db query integration", () => { }); it.live("fails JSON output on a non-finite float (Go's json.Encoder error), no stdout", () => { - // select 'NaN'::float8 -o json — encoding fails and exits non-zero with - // empty stdout, rather than emitting `null` like JSON.stringify. const { layer, out } = setup({ result: { fields: ["f"], fieldTypeIds: [701], rows: [[Number.NaN]], commandTag: "SELECT 1" }, agent: "no", @@ -514,8 +503,6 @@ describe("db query integration", () => { }); it.live("records the resolved -o as the telemetry output_format (Go parity)", () => { - // db query's resolved local -o mirrors onto the telemetry global: table - // for humans, json for agents, and the explicit -o otherwise. const human = setup({ result: SELECT_RESULT, agent: "no" }); const agent = setup({ result: SELECT_RESULT, agent: "yes" }); const csv = setup({ result: SELECT_RESULT, agent: "no", goOutput: "csv" }); @@ -581,9 +568,6 @@ describe("db query integration", () => { }); it.live("resolves the --db-url/config before reading SQL (Go root PreRun order)", () => { - // db query --db-url 'bad' -f missing.sql: the connection string is parsed - // before SQL is resolved, so the connection-string error wins over the - // missing-file error. const { layer } = setup({ resolveFails: true }); return Effect.gen(function* () { const exit = yield* dbQuery( @@ -605,7 +589,6 @@ describe("db query integration", () => { }); it.live("rejects conflicting targets (--linked --local) before running any SQL", () => { - // Mutually-exclusive db-url/linked/local group fails before the query runs. const { layer, cache } = setup(); return Effect.gen(function* () { const exit = yield* dbQuery( @@ -619,14 +602,11 @@ describe("db query integration", () => { expect(failMessage(exit)).toBe( "if any flags in the group [db-url linked local] are set none of the others can be; [linked local] were all set", ); - // Failure precedes target resolution, so no linked-project cache write. expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); it.live("rejects --local=false --linked=false as a target conflict (Go flag.Changed)", () => { - // The mutex keys off explicit presence, so the explicit-false forms - // still count as set and conflict — even though both values are false. const { layer } = setup(); return Effect.gen(function* () { const exit = yield* dbQuery( @@ -644,7 +624,6 @@ describe("db query integration", () => { }); it.live("fails an unlinked --linked query without prompting for a project", () => { - // The --linked preflight loads the ref or fails; it never prompts. const { layer } = setup({ unlinked: true }); return Effect.gen(function* () { const exit = yield* dbQuery( @@ -656,9 +635,6 @@ describe("db query integration", () => { }); it.live("surfaces a project-ref read failure instead of reporting not-linked", () => { - // The --linked preflight uses the hard `loadProjectRef`, which returns - // `failed to load project ref` on an unreadable .temp/project-ref rather - // than the not-linked message. The handler must surface that, not mask it. const { layer } = setup({ refReadFails: true }); return Effect.gen(function* () { const exit = yield* dbQuery( @@ -670,8 +646,6 @@ describe("db query integration", () => { }).pipe(Effect.provide(layer)); }); - // ---- linked path ------------------------------------------------------- - it.live("queries the linked project over HTTP and writes the linked-project cache", () => { const { layer, out, cache } = setup({ linkedStatus: 201, @@ -680,14 +654,11 @@ describe("db query integration", () => { return Effect.gen(function* () { yield* dbQuery(flags({ sql: Option.some("select 1"), linked: Option.some(true) })); expect(out.stdoutText).toContain("│ name │ id │"); - // The linked-project cache is refreshed after a --linked run. expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); it.live("queries the project given via --project-ref without a linked workdir", () => { - // The fake resolver would otherwise fail as "unlinked" (`ErrNotLinked`) — - // only the flag can resolve a ref here. const FLAG_REF = "flagflagflagflagflag"; const { layer, out, cache, httpClient } = setup({ linkedStatus: 201, @@ -703,9 +674,6 @@ describe("db query integration", () => { }), ); expect(out.stdoutText).toContain("│ name │ id │"); - // The request path itself must be scoped to the FLAG ref, not merely - // any successful query — proving the flag (not a fallback) drove the - // API call the same way it drove the cache below. expect( httpClient.requests.some((url) => url.includes(`/v1/projects/${FLAG_REF}/database/query`)), ).toBe(true); @@ -716,8 +684,6 @@ describe("db query integration", () => { it.live("--project-ref overrides an already-linked workdir's project ref", () => { const FLAG_REF = "flagflagflagflagflag"; - // The workdir already resolves to REF (e.g. via .temp/project-ref) — the - // flag must win over it. const { layer, cache, httpClient } = setup({ linkedStatus: 201, linkedBody: '[{"name":"alice","id":1}]', @@ -757,9 +723,6 @@ describe("db query integration", () => { }); it.live("treats --linked=false as an explicit linked target (Go gates on flag.Changed)", () => { - // `--linked=false` counts as explicitly set, and the preflight/handler - // gate the linked path on that (not the value), so this still runs the - // linked HTTP path rather than falling through to local. const { layer, out, cache } = setup({ linkedStatus: 201, linkedBody: '[{"name":"alice","id":1}]', @@ -772,12 +735,6 @@ describe("db query integration", () => { }); it.live("resolves the linked DB config before the API call (Go root PreRun order)", () => { - // For --linked, the DB config is resolved before SQL is resolved / the - // Management API call: it loads+validates the remote-merged config AND - // resolves the live DB connection (TCP probe / pooler / temp login-role), - // any of which can fail early. A resolver failure must stop the query - // before the API. (The config-validation-before-network behavior is - // covered at the resolver level in db-config.integration.test.ts.) const { layer, out, cache } = setup({ resolveFails: true, linkedStatus: 201, @@ -789,20 +746,12 @@ describe("db query integration", () => { ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toContain("failed to parse connection string"); - expect(out.stdoutText).toBe(""); // failed before emitting any query result - // The ref is loaded before the DB config, and the cache refresh runs on - // failure too, so a resolve-step failure still refreshes the - // linked-project cache. + expect(out.stdoutText).toBe(""); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); it.live("caches the linked project even when SQL resolution fails (Go PostRun)", () => { - // The ref resolves and the DB config validates, but no SQL is provided on - // a TTY (no --file / no stdin), so the query fails while resolving SQL — - // before runLinked. The ref is recorded in the pre-run and the cache - // refresh runs after the command returns even on a failure, so the - // linked-project cache must still refresh. const { layer, cache } = setup({ stdinTTY: true }); return Effect.gen(function* () { const exit = yield* dbQuery(flags({ linked: Option.some(true) })).pipe(Effect.exit); @@ -824,7 +773,6 @@ describe("db query integration", () => { flags({ sql: Option.some("bad"), linked: Option.some(true) }), ).pipe(Effect.exit); expect(failMessage(exit)).toContain("unexpected status 400"); - // The cache write runs unconditionally, so it fires on failure too. expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }, @@ -872,8 +820,6 @@ describe("db query integration", () => { }); it.live("falls back to map keys when the first linked row has no orderable keys", () => { - // A leading null row makes `orderedKeys` return [] → the handler falls back to - // the first row's own keys (here also empty), rendering an empty table. const { layer, out } = setup({ linkedStatus: 201, linkedBody: "[null]" }); return Effect.gen(function* () { yield* dbQuery(flags({ sql: Option.some("select 1"), linked: Option.some(true) })); @@ -913,10 +859,6 @@ describe("db query integration", () => { it.live( "rejects an invalid env access token before the linked query (Go LoadAccessTokenFS)", () => { - // The linked preflight validates the resolved token (env/keyring/file) - // against `sbp_...` and fails before any API request. So an invalid - // env token must fail with the invalid-token error, not make the - // query and surface unexpected status. const { layer, out } = setup({ accessTokenInvalid: true, linkedStatus: 201 }); return Effect.gen(function* () { const exit = yield* dbQuery( @@ -924,16 +866,12 @@ describe("db query integration", () => { ).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(failMessage(exit)).toContain("Invalid access token format"); - // Failed at the token check → no query result emitted. expect(out.stdoutText).toBe(""); }).pipe(Effect.provide(layer)); }, ); it.live("runs the --linked login preflight before reading --file (Go PreRun order)", () => { - // `db query --linked -f missing.sql` without a token must surface the - // login error, not a file-read failure — the token is checked in the - // preflight, before SQL is resolved. const { layer } = setup({ accessToken: Option.none() }); return Effect.gen(function* () { const exit = yield* dbQuery( @@ -945,9 +883,6 @@ describe("db query integration", () => { }); it.live("surfaces a linked config/connection failure before the missing-token error", () => { - // The linked config (config + ref + DB config) is resolved before the - // query command's token check, so an unresolvable linked config must - // surface ahead of the generic "supabase login" error — not be masked by it. const { layer } = setup({ accessToken: Option.none(), resolveFails: true }); return Effect.gen(function* () { const exit = yield* dbQuery( diff --git a/apps/cli/src/commands/db/query/query.layers.ts b/apps/cli/src/commands/db/query/query.layers.ts index 024fdd67b9..84654f2f51 100644 --- a/apps/cli/src/commands/db/query/query.layers.ts +++ b/apps/cli/src/commands/db/query/query.layers.ts @@ -14,17 +14,10 @@ import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; /** * Runtime layer for `supabase db query`. * - * The `--local` / `--db-url` paths go through `DbConfigResolver` + - * `DbConnection` (auth-free). The `--linked` path POSTs to the Management - * API over raw HTTP, so it needs `CommandCredentials` / `HttpClient` / - * `ProjectRefResolver` / `CommandSettings` (plus `TelemetryState` / - * `CommandRuntime` / `LinkedProjectCache`) — supplied by - * `linkedDbResolverRuntimeLayer`. That runtime exposes the access token - * **lazily** via `CommandPlatformApiFactory` rather than the eager `CommandPlatformApi` - * stack, so building the runtime resolves no token: `db query --local` / - * `--db-url` run without a login (the handler's `--linked` branch checks - * `getAccessToken` itself), matching the token requirement only kicking in - * on the `--linked` path. + * The `--local`/`--db-url` paths go through `DbConfigResolver` + `DbConnection` (auth-free). The + * `--linked` path POSTs to the Management API, supplied by `linkedDbResolverRuntimeLayer`, which + * exposes the access token lazily so building the runtime resolves no token — `--local`/`--db-url` + * run without a login, since only the handler's `--linked` branch checks `getAccessToken`. */ const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); @@ -32,10 +25,8 @@ const dbConfig = dbConfigLayer.pipe( Layer.provide(cliSettings), Layer.provide(dbConnectionLayer), Layer.provide(debugLoggerLayer), - // The linked db-config resolver + the linked-resolver runtime both snapshot - // the single `IdentityStitch`; provide the SAME layer reference to - // each so Effect memoises one shared instance. Without it the bundled - // binary panics with a missing-service error (CLAUDE.md invariant 5). + // The linked db-config resolver and the linked-resolver runtime both need the same + // `IdentityStitch` instance; provide the same layer reference to each so Effect memoizes it. Layer.provide(identityStitchLayer), ); diff --git a/apps/cli/src/commands/db/reset/reset.command.ts b/apps/cli/src/commands/db/reset/reset.command.ts index 841b65b604..0f695b2bcc 100644 --- a/apps/cli/src/commands/db/reset/reset.command.ts +++ b/apps/cli/src/commands/db/reset/reset.command.ts @@ -23,7 +23,6 @@ const config = { Flag.withDescription("Resets the local database with local migrations."), Flag.withDefault(false), ), - // TS-only override of the linked project ref — see push.command.ts. projectRef: Flag.string("project-ref").pipe( Flag.withDescription("Project ref of the Supabase project."), Flag.optional, @@ -67,11 +66,8 @@ export const dbResetCommand = Command.make("reset", config).pipe( version: flags.version, last: flags.last, }, - // NO safeFlags: telemetry-safe marking is per flag INSTANCE — migration - // squash's `--version` is marked safe, but db reset's `--version` is - // unmarked, so it stays redacted here. `--project-ref` has no - // established telemetry-safety baseline either, so it stays redacted - // as well. + // No safeFlags: telemetry-safe marking is per flag instance, and neither `--version` nor + // `--project-ref` is marked safe on this command, so both stay redacted. }), withJsonErrorHandling, ), diff --git a/apps/cli/src/commands/db/reset/reset.e2e.test.ts b/apps/cli/src/commands/db/reset/reset.e2e.test.ts index c8d2d3ac1a..892a1389f7 100644 --- a/apps/cli/src/commands/db/reset/reset.e2e.test.ts +++ b/apps/cli/src/commands/db/reset/reset.e2e.test.ts @@ -18,11 +18,7 @@ describe("supabase db reset", () => { rmSync(workdir, { recursive: true, force: true }); }); - // Docker-free: the destructive remote-reset confirmation fires after the config - // load and BEFORE any connection is dialed, so a piped decline exits without a - // database. Declining prints an established output contract: a single - // `context canceled` line on stderr and exit 1, with NO `--debug` - // troubleshooting hint. + // Docker-free: the confirmation fires after config load and before any connection is dialed. test( "declining the remote reset prompt prints only context canceled, no --debug hint", { timeout: E2E_TIMEOUT_MS }, @@ -32,8 +28,6 @@ describe("supabase db reset", () => { { cwd: workdir, stdin: "n\n" }, ); expect(exitCode).toBe(1); - // The destructive confirmation (default No → `[y/N]`) actually rendered and - // was answered — the cancellation didn't come from some other failure path. expect(stripAnsi(stderr)).toContain("[y/N]"); const lines = stripAnsi(stderr).trimEnd().split("\n"); expect(lines.at(-1)).toBe("context canceled"); diff --git a/apps/cli/src/commands/db/reset/reset.handler.ts b/apps/cli/src/commands/db/reset/reset.handler.ts index 6d8daa3637..9b53b41c18 100644 --- a/apps/cli/src/commands/db/reset/reset.handler.ts +++ b/apps/cli/src/commands/db/reset/reset.handler.ts @@ -53,21 +53,10 @@ const toLogMessage = (version: string): string => version.length > 0 ? ` to version: ${version}` : "..."; /** - * `supabase db reset` — reinitialise a database from local migrations (+ seed). - * - * Fully native — no remaining Go delegation on either target. The local - * path's container-recreate primitives are native, and the local-reset - * composition itself is hoisted into `resetLocalDatabase` - * (`command-internal/db-bootstrap/reset-local-database.ts`) so `db schema - * declarative`'s smart-target/sync recovery reset can call it in-process too, - * instead of shelling out to a second `supabase-go` child. The remote target's - * `--experimental` schema-files path — the last remaining Go delegation on - * this command — is now also native (`applySchemaFiles`): a versionless - * `--experimental`/`SUPABASE_EXPERIMENTAL` remote reset with pg-delta NOT - * enabled takes the EXPERIMENTAL declarative schema-files branch instead of - * replaying timestamped migrations, mirroring `migrateAndSeed`'s - * already-native local-side implementation of the exact same branch (reused - * by both the PG14 and PG15 recreate paths). + * `supabase db reset` — reinitializes a database from local migrations (+ seed). The local + * composition is hoisted into `resetLocalDatabase` so `db schema declarative`'s recovery reset + * can reuse it; a versionless `--experimental` remote reset with pg-delta disabled takes the + * declarative schema-files branch instead of replaying migrations, matching the local path. */ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { const output = yield* Output; @@ -83,25 +72,17 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { const workdir = cliSettings.workdir; const migrationsDir = path.join(workdir, "supabase", "migrations"); - // The project `.env` is applied before the `yes`/`experimental` gates are - // read, so a `SUPABASE_YES` / `SUPABASE_EXPERIMENTAL` set only in - // `supabase/.env` is honored. Load the project env first and resolve both - // gates against it, as `db pull` does for `yes`. + // The project `.env` is applied before the `yes`/`experimental` gates are read, so a + // `SUPABASE_YES`/`SUPABASE_EXPERIMENTAL` set only in `supabase/.env` is honored. const projectEnv = yield* loadProjectEnv(fs, path, workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); const experimental = yield* resolveExperimentalWithProjectEnv(projectEnv); let linkedRefForCache: string | undefined; const body = Effect.gen(function* () { - // The project `.env` is applied to make every key visible to the WHOLE - // reset run, not just the flag-gate reads above — in particular - // `getRegistryImageUrl` reads - // `SUPABASE_INTERNAL_IMAGE_REGISTRY` straight from - // `process.env` for the container image resolution below (review CLI-1958). `db push` - // (`push.handler.ts`) scopes this the same way, as the first statement of its own - // `body` — mirror that exactly so a private/air-gapped registry configured only in - // `supabase/.env` reaches image resolution instead of silently falling back to the - // default registries. + // The project `.env` is applied to make every key visible to the whole reset run, not just + // the flag-gate reads above — `getRegistryImageUrl` reads `SUPABASE_INTERNAL_IMAGE_REGISTRY` + // straight from `process.env` for the container image resolution below. yield* applyProjectEnv(projectEnv); const target = resolveDbTargetFlags(cliArgs.args); // Mutually-exclusive db-url/linked/local group. @@ -112,9 +93,8 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { }), ); } - // `--last` is an unsigned flag, so a negative value is rejected at parse - // time (`Flag.integer` here accepts it). Reject it the same way rather - // than silently treating it as "no --last" and resetting the full history. + // `--last` is an unsigned flag, so a negative value should be rejected; `Flag.integer` here + // accepts it, so reject it explicitly rather than silently resetting the full history. if (Option.isSome(flags.last) && flags.last.value < 0) { return yield* Effect.fail( new DbResetLastFlagError({ @@ -122,7 +102,7 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { }), ); } - // Mutually-exclusive version/last group — alphabetical group. + // Mutually-exclusive version/last group. if (Option.isSome(flags.version) && Option.isSome(flags.last)) { return yield* Effect.fail( new DbResetVersionFlagsError({ @@ -169,9 +149,8 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { // version were given at all. if (Option.isSome(flags.version) && flags.version.value.length > 0) { const v = flags.version.value; - // Rejects non-numeric text AND values outside the int64 range; - // `parseMigrationVersion` mirrors that exactly (`migration repair` - // uses the same helper for its own version parse). + // Rejects non-numeric text and values outside the int64 range; shared with `migration + // repair`'s own version parse via `parseMigrationVersion`. if (parseMigrationVersion(v) === undefined) { // The bare "invalid version number" is returned unwrapped; the // `failed to parse <v>:` wrapper belongs to `migration repair` only. @@ -181,11 +160,8 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { }), ); } - // The version is validated by globbing `supabase/migrations/<version>_*.sql` - // DIRECTLY with no filtering — so a deprecated first migration (e.g. - // `20200101000000_init.sql`) that `listLocalMigrations` excludes is - // still accepted. Mirror that with a raw directory read + glob match - // instead of the filtered migration listing. + // Validated by globbing `supabase/migrations/<version>_*.sql` directly, with no filtering, + // so a deprecated first migration that `listLocalMigrations` excludes is still accepted. const entries = yield* fs .readDirectory(migrationsDir) .pipe(Effect.orElseSucceed(() => [] as ReadonlyArray<string>)); @@ -211,9 +187,8 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { const connType = target.connType ?? "local"; - // `--project-ref` never implies `--linked` and must not be silently - // discarded on a non-linked target — see push.handler.ts's identical guard - // for the full TS-only rationale. + // `--project-ref` only applies to the linked target; it must not be silently ignored when + // targeting `--local`/`--db-url`. if (Option.isSome(flags.projectRef) && connType !== "linked") { return yield* Effect.fail( new DbResetTargetFlagsError({ @@ -223,11 +198,8 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { ); } - // The project ref is loaded BEFORE the fallible linked resolution, and - // the linked-project cache is written even when a later step errors. - // Pre-load the ref so the post-run cache finalizer still fires when - // resolve fails mid-way (merged config, temp-role mint, connection) — - // mirrors push.handler. + // Loaded before the fallible linked resolution, so the post-run cache finalizer still fires + // even when `resolver.resolve` fails mid-way (merged config, temp-role mint, connection). if (connType === "linked") { const refResolver = yield* ProjectRefResolver; linkedRefForCache = yield* refResolver.loadProjectRef(flags.projectRef); @@ -240,13 +212,9 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { linkedProjectRef: flags.projectRef, }); - // Local target → native local reset. The actual composition (running - // check, container recreate, storage-health gate, bucket seeding, - // git-branch line) is hoisted into `resetLocalDatabase` — shared - // with `db schema declarative`'s in-process recovery reset — so this - // call site stays a thin wrapper around it, keeping only the version/ - // seed-flags plumbing and the JSON envelope, which belong to this - // top-level command alone (see that function's own header for why). + // Local target: the composition (container recreate, storage-health gate, bucket seeding, + // git-branch line) is hoisted into `resetLocalDatabase`, shared with `db schema declarative`'s + // recovery reset; this call site keeps only version/seed-flags plumbing and the JSON envelope. if (cfg.isLocal) { yield* resetLocalDatabase({ version: resolvedVersion, @@ -261,21 +229,14 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { return; } - // Re-confirm `linkedRefForCache` from the now-resolved `cfg.ref` for the native - // remote path below. A `connType === "db-url"` target leaves `linkedRefForCache` - // as whatever the pre-load block set (nothing, for `db-url`), since this - // assignment only fires when linked. + // Re-confirms `linkedRefForCache` from the now-resolved `cfg.ref`; a `db-url` target leaves + // it as whatever the pre-load block set (nothing), since this only fires when linked. const linkedRef = Option.getOrUndefined(cfg.ref ?? Option.none()); if (connType === "linked" && linkedRef !== undefined) linkedRefForCache = linkedRef; - // Single config load: decodes the whole config with env-expansion + - // weak-typed boolean parsing (so `enabled = "env(SEED_ENABLED)"` etc. - // load), applies `SUPABASE_*` env overrides, merges a matching - // `[remotes.<ref>]` block, and decrypts every `encrypted:` secret with - // the shell AND project-`.env` `DOTENV_PRIVATE_KEY*` keys — aborting here - // (before the destructive prompt / `dropUserSchemas`) on any - // undecryptable/invalid config, exactly like the established behavior - // before the reset runs. + // Decodes the whole config with env-expansion and weak-typed boolean parsing, applies + // `SUPABASE_*` overrides, merges a matching `[remotes.<ref>]` block, and decrypts every + // `encrypted:` secret — aborting here, before the destructive prompt, on any invalid config. const configRef = connType === "linked" && linkedRef !== undefined ? linkedRef : undefined; const toml = yield* checkDbToml(fs, path, workdir, configRef); if (toml.appliedRemote !== undefined) { @@ -295,8 +256,7 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { } yield* output.raw(`Resetting remote database${toLogMessage(resolvedVersion)}\n`, "stderr"); - // Established output contract: NO "Connecting to ... database..." line - // is printed here. + // Established output contract: no "Connecting to ... database..." line is printed here. yield* Effect.scoped( Effect.gen(function* () { const session = yield* dbConn.connect(cfg.conn, { isLocal: false, dnsResolver }); @@ -304,18 +264,13 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { yield* dropUserSchemas(session, applyError); yield* upsertVaultSecrets(session, vaultSecrets); - // Three-conjunct EXPERIMENTAL gate: `--experimental`/`SUPABASE_EXPERIMENTAL` - // + no resolved version + pg-delta NOT enabled. A hard if/else-if — - // taking the schema-files branch means timestamped migrations never - // run at all, even when the glob matches nothing (an empty - // `schema_paths = []` default silently applies NOTHING rather than - // falling back to migrations — CLI-1958). + // Takes the schema-files branch when `--experimental`/`SUPABASE_EXPERIMENTAL` is set, no + // version is resolved, and pg-delta is disabled — a hard if/else-if, so an empty + // `schema_paths = []` applies nothing rather than falling back to migrations. const useSchemaFiles = experimental && resolvedVersion === "" && !toml.pgDelta.enabled; if (useSchemaFiles) { - // `projectEnv` (loaded above, before `experimental`/`yes` resolve) is - // threaded through so a `SUPABASE_SCANNER_BUFFER_SIZE` set only in - // `supabase/.env` is honored here (see `checkScannerBufferSize`'s - // doc comment, `migration-apply.ts`). + // Threaded through so a `SUPABASE_SCANNER_BUFFER_SIZE` set only in `supabase/.env` is + // honored here (see `checkScannerBufferSize` in `migration-apply.ts`). yield* applySchemaFiles( session, fs, @@ -327,7 +282,7 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { ); } else if (toml.migrationsEnabled) { const locals = yield* listLocalMigrations(fs, path, migrationsDir); - // LoadPartialMigrations filter: version === "" || v <= version. + // Selects every migration when no version is set, otherwise only versions <= the target. const pending = locals.filter((p) => { if (resolvedVersion === "") return true; const m = MIGRATE_FILE_PATTERN.exec(path.basename(p)); @@ -336,12 +291,9 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { yield* applyMigrations(session, fs, path, pending, applyError); } - // `--no-seed` disables seeding; `--sql-paths` overrides [db.seed].sql_paths - // and force-enables it. The two are mutually exclusive (validated - // above). Same single home as the local path's identical override - // (`resolveResetSeedConfig`, `db-setup.ts`) — one implementation - // of this seed-flags override for both targets, per "Hoist Before You - // Duplicate" (`apps/cli/CLAUDE.md`). + // `--no-seed` disables seeding; `--sql-paths` overrides `[db.seed].sql_paths` and + // force-enables it (mutually exclusive, validated above). Shares `resolveResetSeedConfig` + // with the local path's identical override. const resolvedSeed = resolveResetSeedConfig( toml.seed, { noSeed: flags.noSeed, sqlPaths: flags.sqlPaths }, @@ -371,8 +323,7 @@ export const dbReset = Effect.fn("db.reset")(function* (flags: DbResetFlags) { ), ), Effect.ensuring(telemetryState.flush), - // Closes the `Scope` `applyProjectEnv` (above) acquires its `process.env` - // reverts against — mirrors `push.handler.ts`'s own `body.pipe(..., Effect.scoped)`. + // Closes the `Scope` that `applyProjectEnv` acquires its `process.env` revert against. Effect.scoped, ); }); diff --git a/apps/cli/src/commands/db/reset/reset.integration.test.ts b/apps/cli/src/commands/db/reset/reset.integration.test.ts index c3dd5515db..4bdfe65350 100644 --- a/apps/cli/src/commands/db/reset/reset.integration.test.ts +++ b/apps/cli/src/commands/db/reset/reset.integration.test.ts @@ -79,9 +79,8 @@ const DEFAULT_FLAGS: DbResetFlags = { }; /** - * Tracks every `resolve`/`resolvePoolerFallback` invocation so tests can prove a - * connection was resolved exactly once per reset — `resolve()` mints/verifies a - * temporary Postgres login role over the Management API for a `--linked` target. + * Tracks every `resolve`/`resolvePoolerFallback` call so tests can prove a connection was + * resolved exactly once per reset. */ function mockResolver(opts: { isLocal: boolean; @@ -93,9 +92,8 @@ function mockResolver(opts: { const layer = Layer.succeed(DbConfigResolver, { resolve: (flags: DbConfigFlags) => { calls++; - // A threaded `--project-ref` flag takes the same top precedence a real - // resolver would give it, so a test can prove the flag (not just the - // fixed `opts.ref`) drives the resolved (and later cached) ref. + // A threaded `--project-ref` flag takes the same precedence a real resolver gives it, so + // a test can prove the flag (not just `opts.ref`) drives the resolved ref. const linkedProjectRef = flags.linkedProjectRef ?? Option.none(); const resolvedRef = Option.isSome(linkedProjectRef) && linkedProjectRef.value.length > 0 @@ -131,12 +129,9 @@ function mockResolver(opts: { } /** - * A single `DbConnection` mock shared by BOTH the remote path (tracks - * `execs`/`queries` for the drop-schema/migrate/seed assertions) and the native - * local recreate path (the PG14 branch's `session.exec`/`.query` calls) — - * `dbReset` composes exactly one `DbConnection` layer, so tests must - * not register two competing ones (the second would silently shadow the first - * in `Layer.mergeAll`). + * A single `DbConnection` mock shared by both the remote path (tracks `execs`/`queries` for + * drop-schema/migrate/seed assertions) and the native local recreate path (the PG14 branch's + * `session.exec`/`.query` calls) — `dbReset` composes exactly one `DbConnection` layer. */ function mockConnection( opts: { @@ -220,13 +215,6 @@ function mockConnection( }; } -// --------------------------------------------------------------------------- -// Native local-reset harness — mirrors `db/start/start.integration.test.ts`'s own -// `mockContainerCliSpawner`/`defaultRoute`/`fakeDbSession`, adapted for reset's -// container-REMOVE-then-recreate flow (rather than start's volume-existence probe) -// and its post-recreate satellite-restart + Kong-reload step. -// --------------------------------------------------------------------------- - const PROJECT_ID = "test"; const DB_ID = `supabase_db_${PROJECT_ID}`; const KONG_ID = `supabase_kong_${PROJECT_ID}`; @@ -312,8 +300,7 @@ function fakeContainerId(name: string): string { const createArgs = (spawned: ReadonlyArray<SpawnRecord>): ReadonlyArray<string> | undefined => spawned.find((s) => s.args[0] === "create")?.args; -// `docker container rm -f <id>` / `docker volume rm -f <name>` — the target is -// argv[3] (after the `-f` flag at argv[2]), not argv[2] itself. +// `docker ... rm -f <id>` puts the target at argv[3] (after the `-f` flag at argv[2]). const removedContainers = (spawned: ReadonlyArray<SpawnRecord>): ReadonlyArray<string> => spawned .filter((s) => s.args[0] === "container" && s.args[1] === "rm") @@ -378,10 +365,8 @@ function defaultLocalResetRoute(opts: DefaultRouteOpts = {}) { if (id === STORAGE_ID) { if (opts.storageMissing === true) return { exitCode: 1, stderr: [`Error: No such container: ${id}`] }; - // A present-but-unhealthy storage container's wait-then-timeout-fails-the-reset - // behavior is pinned precisely (exact 30s boundary) by - // `await-storage-ready.unit.test.ts`'s own fake-clock tests — no route knob for - // it here (review CLI-1958). + // A present-but-unhealthy storage container's wait-then-timeout behavior (exact 30s + // boundary) is covered by `await-storage-ready.unit.test.ts`'s fake-clock tests instead. return { stdout: [HEALTHY_STATE] }; } if (opts.running === false) @@ -427,9 +412,8 @@ function setup( replicationSlotCounts?: ReadonlyArray<number>; replicationSlotQueryFails?: boolean; failStatement?: { readonly sql: string; readonly code?: string; readonly message: string }; - // Simulates a genuinely unlinked workdir: `loadProjectRef` fails with - // `ProjectRefNotLinkedError` absent an explicit `--project-ref` flag, - // instead of silently falling back to `opts.ref ?? VALID_REF`. + // Simulates an unlinked workdir: `loadProjectRef` fails with `ProjectRefNotLinkedError` + // absent an explicit `--project-ref` flag, instead of falling back to `opts.ref`. linkedFails?: boolean; }, ) { @@ -447,8 +431,8 @@ function setup( const conn = mockConnection(opts); const telemetry = mockTelemetryStateTracked(); const linkedCache = mockLinkedProjectCacheTracked(); - // The local-reset bucket-seed core statically requires the (lazy) Management-API - // factory; never invoked on `--local` (projectRef === ""). + // The local-reset bucket-seed core statically requires the (lazy) Management-API factory, + // though `--local` never invokes it. const platformApi = mockCommandPlatformApiService({}); const resolver = mockResolver({ isLocal: opts.isLocal ?? false, @@ -470,16 +454,12 @@ function setup( alwaysReadyHttpClientLayer, dockerRunLayer.pipe(Layer.provide(child.layer), Layer.provide(mockProcessControl().layer)), Layer.succeed(NetworkIdFlag, Option.none()), - // The remote-reset confirmation is answered through mockOutput's - // `promptConfirmResponses` (the TTY/clack path), so mark stdin a TTY. Stdin is - // only referenced by promptYesNo's non-TTY branch (unreached here) but must - // be present to satisfy the effect's requirements. + // The remote-reset confirmation is answered through mockOutput's `promptConfirmResponses` + // (the TTY/clack path); stdin is only required to satisfy the effect's service dependency. mockTty({ stdinIsTty: true }), mockStdin(true), - // The linked ref is pre-loaded (for the post-run cache) before the DB - // config is resolved. `loadProjectRef` gives an explicit `--project-ref` - // flag top precedence — mirror that so a test can prove the flag (not just - // `opts.ref`) drives the linked ref. + // `loadProjectRef` gives an explicit `--project-ref` flag top precedence, mirrored here so a + // test can prove the flag (not just `opts.ref`) drives the linked ref. Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(opts.ref ?? VALID_REF), resolveForLink: () => Effect.succeed(opts.ref ?? VALID_REF), @@ -553,9 +533,7 @@ describe("db reset", () => { expect(kongReloadCalls(child.spawned)).toHaveLength(1); expect(out.stderrText).toContain("Finished "); expect(out.stderrText).toContain("on branch "); - // The local-reset composition now lives in the shared - // `resetLocalDatabase` (CLI-2062) — confirm this handler's own - // single `Effect.ensuring` finalizer still fires exactly once through it. + // Confirms the single `Effect.ensuring` finalizer still fires exactly once. expect(telemetry.flushCount).toBe(1); }); }); @@ -578,13 +556,9 @@ describe("db reset", () => { local: true, version: Option.some("20240101000000"), }).pipe(Effect.provide(layer)); - // The migration up to (and including) the resolved version IS re-applied through - // the recreated database's own session (positive assertion — proves MigrateAndSeed - // actually ran, not just that the cutoff excluded something)... expect(conn.execs.some((sql) => sql.includes("create table version_one_marker ()"))).toBe( true, ); - // ...but the second migration must not be applied at all. expect(conn.execs.some((sql) => sql.includes("create table version_two_marker ()"))).toBe( false, ); @@ -689,9 +663,8 @@ describe("db reset", () => { isLocal: true, }); return Effect.gen(function* () { - // No buckets configured -> the seed-buckets core short-circuits, but - // the storage gate is still consulted (storage is inspected before - // buckets are seeded). + // No buckets configured, so the seed-buckets core short-circuits, but storage is still + // inspected first. yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); expect( child.spawned.some( @@ -869,8 +842,8 @@ describe("db reset", () => { // initDatabase: schema SQL execs directly over the session — no PG15+ one-shot jobs. expect(dbSetupJobCalls(child.spawned)).toHaveLength(0); expect(conn.execs.length).toBeGreaterThan(4); - // RestartDatabase: "Restarting containers..." then a real `docker restart` of `db`, - // THEN the satellite restarts + Kong reload (RestartDatabase-then-restartServices). + // Restarting containers logs first, then a real `docker restart` of `db`, then the + // satellite restarts + Kong reload. expect(out.stderrText).toContain("Restarting containers...\n"); const dbRestartIndex = child.spawned.findIndex( (s) => s.args[0] === "restart" && s.args[1] === DB_ID, @@ -887,10 +860,8 @@ describe("db reset", () => { it.live( "attaches Go's ExecBatch error context to a failed DROP/CREATE DATABASE statement", () => { - // These four statements are built as a migration file and run through - // a batch executor, so a failure gets the same rich context - // (`At statement: <index>` + the statement text) a real migration - // file failure would — not the bare driver error (review CLI-1958). + // Built as a migration file and run through a batch executor, so a failure gets the same + // rich context (`At statement: <index>` + statement text) a real migration failure would. const { layer } = setup(tmp.current, { toml: PG14_TOML, args: ["db", "reset", "--local"], @@ -926,7 +897,6 @@ describe("db reset", () => { }); return Effect.gen(function* () { yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - // The reset still completes: the swallowed failure does not abort the recreate. expect( conn.execs.some((sql) => sql === "CREATE DATABASE postgres WITH OWNER postgres"), ).toBe(true); @@ -954,8 +924,6 @@ describe("db reset", () => { }); it.live("swallows a disconnect-clients failure that is not a PgError at all", () => { - // A non-PgError failure (network blip) is swallowed too — only a genuine PgError - // whose code differs from 3D000 surfaces. const { layer, conn } = setup(tmp.current, { toml: PG14_TOML, args: ["db", "reset", "--local"], @@ -967,7 +935,6 @@ describe("db reset", () => { }); return Effect.gen(function* () { yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - // Swallowed: no PgError code at all -> the reset still completes. expect( conn.execs.some((sql) => sql === "CREATE DATABASE postgres WITH OWNER postgres"), ).toBe(true); @@ -977,12 +944,9 @@ describe("db reset", () => { it.live( "swallows a disconnect-clients failure carrying a node system errno, not a real SQLSTATE", () => { - // `toExecError`'s fallback (`db-connection.sql-pg.layer.ts`) sets `code` - // from `extractSqlState`, which returns ANY string `code` found in the cause - // chain — including a bare node system errno like `ECONNRESET`/`ETIMEDOUT`, which is - // NOT a Postgres SQLSTATE. A socket error should never match as a real - // SQLSTATE either, so the discriminator must check `isSqlState(code)` - // before comparing against `3D000`, not just `code !== undefined`. + // `extractSqlState` returns any string `code` found in the cause chain, including a bare + // node errno like `ECONNRESET` — not a real SQLSTATE. The discriminator must check + // `isSqlState(code)` before comparing against `3D000`, not just `code !== undefined`. const { layer, conn } = setup(tmp.current, { toml: PG14_TOML, args: ["db", "reset", "--local"], @@ -995,7 +959,6 @@ describe("db reset", () => { }); return Effect.gen(function* () { yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - // Swallowed: a node errno is not a SQLSTATE -> the reset still completes. expect( conn.execs.some((sql) => sql === "CREATE DATABASE postgres WITH OWNER postgres"), ).toBe(true); @@ -1074,10 +1037,6 @@ describe("db reset", () => { }); it.live("reapplies migrations and seeds after a default local reset (PG14)", () => { - // Positive assertion: proves the final MigrateAndSeed step actually runs and - // re-applies the user's migrations/seed — this step is currently deletable with - // every OTHER PG14 assertion (DROP/CREATE statements, restart ordering, - // disconnect/replication-slot behavior) staying green. const { layer, conn } = setup(tmp.current, { toml: PG14_TOML, files: { @@ -1114,9 +1073,8 @@ describe("db reset", () => { const migrationIndex = conn.execs.findIndex((sql) => sql.includes("https://example.com")); expect(pgNetIndex).toBeGreaterThanOrEqual(0); expect(migrationIndex).toBeGreaterThan(pgNetIndex); - // Same drop-then-recreate order as fresh setup: the PG14 dump installs pg_net - // unconditionally, so it is dropped first and only recreated because webhooks - // are enabled. + // The PG14 dump installs pg_net unconditionally, so it's dropped first and only + // recreated because webhooks are enabled. const dropIndex = conn.execs.findIndex((sql) => sql.includes("drop extension if exists pg_net"), ); @@ -1126,9 +1084,8 @@ describe("db reset", () => { }); it.live("drops the PG14 dump's implicit pg_net when Database Webhooks is disabled", () => { - // Fresh setup already removed it here; without the same drop on the reset path a - // PG14 `db reset` left pg_net installed and diverged from `supabase start`, - // surfacing as pg_net drift in the next engine's shadow baseline. + // Without this drop, a PG14 `db reset` would leave pg_net installed and diverge from + // `supabase start`, surfacing as drift in the next engine's shadow baseline. const { layer, conn } = setup(tmp.current, { toml: PG14_TOML, args: ["db", "reset", "--local"], @@ -1165,11 +1122,9 @@ describe("db reset", () => { local: true, version: Option.some("20240101000000"), }).pipe(Effect.provide(layer)); - // Positive: the migration up to (and including) the resolved version IS re-applied. expect(conn.execs.some((sql) => sql.includes("create table version_one_marker ()"))).toBe( true, ); - // The second migration must not be applied at all. expect(conn.execs.some((sql) => sql.includes("create table version_two_marker ()"))).toBe( false, ); @@ -1187,10 +1142,7 @@ describe("db reset", () => { }); return Effect.gen(function* () { yield* dbReset(DEFAULT_FLAGS).pipe(Effect.provide(layer)); - // PG14 reset's own init calls the schema-init step directly — unlike - // `db start`'s own PG14 path, which execs globals.sql first. A fingerprint unique - // to `START_DB_GLOBALS_SQL` (see `templates/db-globals.sql.ts`) must never - // appear in this reset's execs. + // A fingerprint unique to `START_DB_GLOBALS_SQL` must never appear in this reset's execs. expect(conn.execs.some((sql) => sql.includes("CREATE ROLE anon"))).toBe(false); }); }, @@ -1199,12 +1151,9 @@ describe("db reset", () => { it.live( "resolves db.migrations.schema_paths against supabase/ before applying it on an experimental PG14 reset", () => { - // `recreateLocalDatabase14` must pass the NORMALIZED `toml.schemaPaths` - // (`supabase/`-prefix-resolved by `checkDbToml`) into the final - // `migrateAndSeed` call, not the raw, unresolved config value — the raw - // `["schema.sql"]` pattern would glob-match against the WORKDIR root (where no - // such file exists), failing the whole reset, instead of `supabase/schema.sql` - // (where this test actually places the file). + // `recreateLocalDatabase14` must pass the normalized `toml.schemaPaths` (resolved by + // `checkDbToml`) into the final `migrateAndSeed` call — the raw `["schema.sql"]` pattern + // would glob-match against the workdir root instead of `supabase/schema.sql`. const { layer, conn } = setup(tmp.current, { toml: 'project_id = "test"\n[db]\nmajor_version = 14\n[db.migrations]\nschema_paths = ["schema.sql"]\n', files: { "supabase/schema.sql": "create table schema_paths_marker ();" }, @@ -1247,9 +1196,6 @@ describe("db reset", () => { ); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - // Config loads through the established reader (`checkDbToml`), - // so a malformed config aborts with `failed to load config`, same - // as the other db commands (diff/dump/pull/migration). expect(JSON.stringify(exit.cause)).toContain("failed to load config"); } }); @@ -1316,8 +1262,6 @@ describe("db reset", () => { if (Exit.isFailure(exit)) { const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure) && failure.value._tag).toBe("DbResetInvalidVersionError"); - // The bare "invalid version number" is returned unwrapped — no - // `failed to parse <v>:` wrapper (that belongs to `migration repair`). expect(Option.isSome(failure) && failure.value.message).toBe("invalid version number"); } }); @@ -1341,9 +1285,6 @@ describe("db reset", () => { }); it.live("rejects an out-of-int64-range --version", () => { - // Rejects magnitudes outside the int64 range even though the text is - // all digits. `INTEGER_PATTERN` alone would have accepted this and - // fallen through to the glob check instead. const { layer } = setup(tmp.current, { toml: 'project_id = "test"\n' }); return Effect.gen(function* () { const exit = yield* dbReset({ @@ -1361,9 +1302,6 @@ describe("db reset", () => { }); it.live("treats an empty --version like no version at all", () => { - // An empty --version skips validation entirely, so it must fall through - // to a full reset rather than glob-checking "" or rejecting it as an - // invalid version. const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', confirm: [true], @@ -1407,9 +1345,7 @@ describe("db reset", () => { return Effect.gen(function* () { yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); expect(out.stderrText).toContain("Resetting remote database..."); - // No "Connecting to ... database..." line (established output contract). expect(out.stderrText).not.toContain("Connecting to"); - // Drop block ran, then the migration applied. expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); expect(out.stderrText).toContain("Applying migration 20240101000000_test.sql..."); expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); @@ -1418,11 +1354,8 @@ describe("db reset", () => { }); it.live("fails a remote reset before dropping schemas on an undecryptable secret", () => { - // Regression: the old point-of-use vault decryption ran AFTER `dropUserSchemas`, - // so an undecryptable `encrypted:` secret dropped the schemas before failing. - // Every secret is decrypted while loading config before the reset runs, - // so the reset must abort before any destructive work — matched here by - // `checkDbToml` at load time. + // Every secret is decrypted while loading config, before the reset runs, so an + // undecryptable secret must abort before any destructive work. const { layer, conn } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.vault]\nmy_secret = "encrypted:anything"\n', confirm: [true], @@ -1438,15 +1371,11 @@ describe("db reset", () => { "failed to parse config: missing private key", ); } - // Config load failed before ResetAll → schemas were never dropped. expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(false); }); }); it.live("fails a remote reset before dropping schemas on an empty project_id", () => { - // Config validation rejects an explicit `project_id = ""` before the - // reset prompt, so the native remote reset must abort before - // `dropUserSchemas`. const { layer, conn } = setup(tmp.current, { toml: 'project_id = ""\n', confirm: [true], @@ -1467,13 +1396,10 @@ describe("db reset", () => { }); it.live("auto-confirms a remote reset via SUPABASE_YES set only in the project .env", () => { - // The project `.env` is applied before the reset prompt reads `yes`, - // so a `SUPABASE_YES` in supabase/.env auto-confirms the destructive - // prompt (default false). const { layer, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', files: { "supabase/.env": "SUPABASE_YES=true\n" }, - // Deliberately no `confirm` responses — the prompt must be auto-confirmed. + // No `confirm` responses: the prompt must auto-confirm. }); return Effect.gen(function* () { yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); @@ -1482,10 +1408,6 @@ describe("db reset", () => { }); it.live("still caches the linked ref when DB-config resolution fails", () => { - // The linked-project cache is refreshed unconditionally after the - // command returns even on error, and the project ref is loaded BEFORE - // the fallible temp-role/connection step — so a failed linked resolve - // must not skip the post-run linked-project cache write. const { layer, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n', resolveFails: true, @@ -1502,8 +1424,6 @@ describe("db reset", () => { }); it.live("resets the project given via --project-ref without a linked workdir", () => { - // The fake resolver fails as "unlinked" (`ProjectRefNotLinkedError`) - // absent the flag — only the flag can resolve a ref here. const FLAG_REF = "flagflagflagflagflag"; const { layer, conn, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n', @@ -1524,8 +1444,6 @@ describe("db reset", () => { it.live("--project-ref overrides an already-linked workdir's project ref", () => { const FLAG_REF = "flagflagflagflagflag"; - // The workdir already resolves to VALID_REF (e.g. via - // .temp/project-ref) — the flag must win over it. const { layer, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n', ref: VALID_REF, @@ -1544,8 +1462,6 @@ describe("db reset", () => { }); it.live("rejects --project-ref on the default local target", () => { - // reset defaults to local when no target flag is set — the guard must - // fire from the flag alone, with no explicit --local/--db-url needed. const FLAG_REF = "flagflagflagflagflag"; const { layer, conn, resolver, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n', @@ -1643,8 +1559,8 @@ describe("db reset", () => { it.live( "applies configured schema files instead of replaying migrations on an experimental remote reset", () => { - // `--linked=false` still selects the linked/remote target (Cobra `Changed` - // semantics) — exercised here alongside the schema-files branch itself. + // `--linked=false` still selects the linked/remote target, exercised here alongside the + // schema-files branch itself. const { layer, out, conn, resolver, linkedCache } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n\n[experimental.pgdelta]\nenabled = false\n', files: { @@ -1659,17 +1575,10 @@ describe("db reset", () => { }); return Effect.gen(function* () { yield* dbReset({ ...DEFAULT_FLAGS, linked: false }).pipe(Effect.provide(layer)); - // The configured schema file ran... expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(true); - // ...but the timestamped migration did NOT — the if/else-if is - // mutually exclusive; taking the schema-files branch means - // migrations never run at all. expect(conn.execs.some((s) => s.includes("create table migrated_table"))).toBe(false); expect(out.stderrText).not.toContain("Applying migration"); - // Seeding still runs afterward — it sits outside the if/else-if. expect(out.stderrText).toContain("Seeding data from supabase/seed.sql..."); - // A real connection is resolved now — this is a fully native path, not a - // delegated one that discarded the resolve (CLI-1958 removed the delegate). expect(resolver.calls).toBe(1); expect(linkedCache.cached).toBe(true); expect(linkedCache.cachedRef).toBe(VALID_REF); @@ -1680,9 +1589,6 @@ describe("db reset", () => { it.live( "applies schema files across multiple schema_paths patterns in declaration order, sorted within each pattern", () => { - // Matches are sorted WITHIN each pattern but preserve DECLARATION - // order ACROSS patterns (no global re-sort) — `zz/*.sql`'s files - // must all run before `aa/*.sql`'s, even though "aa" sorts before "zz". const { layer, conn } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["zz/*.sql", "aa/*.sql"]\n', files: { @@ -1707,9 +1613,6 @@ describe("db reset", () => { it.live( "expands a schema_paths directory entry to its nested .sql files on an experimental remote reset", () => { - // `[db.migrations].schema_paths` resolves through the SQL-files glob - // (not a plain-files glob), which expands a directory match to its - // regular `.sql` files, recursively — unlike a plain glob pattern. const { layer, conn } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["some-dir"]\n', files: { @@ -1730,9 +1633,6 @@ describe("db reset", () => { it.live( "silently applies nothing when schema_paths is unset on an experimental remote reset (Go's undocumented default-config behavior)", () => { - // `schema_paths` defaults to `[]`. With no patterns to glob, the - // schema-files apply is a silent no-op — it does NOT fall back to - // replaying migrations (a hard if/else-if). const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n', files: migrationFile("20240101000000", "create table migrated_table ();"), @@ -1741,9 +1641,7 @@ describe("db reset", () => { }); return Effect.gen(function* () { yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - // Schemas are still dropped (ResetAll drops before MigrateAndSeed)... expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); - // ...but the local migration is silently skipped, not applied. expect(conn.execs.some((s) => s.includes("create table migrated_table"))).toBe(false); expect(out.stderrText).not.toContain("Applying migration"); }); @@ -1764,8 +1662,6 @@ describe("db reset", () => { }); return Effect.gen(function* () { yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - // pg-delta being enabled disables the schema-files branch even - // though `--experimental` and `schema_paths` are both set. expect(conn.execs.some((s) => s.includes("create table migrated_table"))).toBe(true); expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(false); expect(out.stderrText).toContain("Applying migration"); @@ -1791,8 +1687,6 @@ describe("db reset", () => { linked: true, version: Option.some("20240101000000"), }).pipe(Effect.provide(layer)); - // A resolved --version disables the schema-files branch (it - // requires an empty version), even with `--experimental` set. expect(conn.execs.some((s) => s.includes("create table migrated_table"))).toBe(true); expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(false); }); @@ -1816,24 +1710,19 @@ describe("db reset", () => { if (Exit.isFailure(exit)) { const cause = JSON.stringify(exit.cause); expect(cause).toContain("no files matched pattern: supabase/nomatch/*.sql"); - // No CmdSuggestion on this failure mode — only a per-file exec failure sets one. expect(cause).not.toContain("See schema file"); } - // Schemas were already dropped before the failed apply step (drop-then-apply order). expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); }); }, ); it.live("ignores a partial schema_paths glob failure once at least one pattern matches", () => { - // The joined glob error only surfaces when NO pattern matched anything - // at all; a partial failure is silently dropped. const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql", "typo/*.sql"]\n', files: { "supabase/schemas/01_users.sql": "create table schema_users ();", - // Present so the (unrelated) seed glob's own "no files matched" WARN line - // doesn't show up and get confused with the schema-files warning below. + // Present so the seed glob's own "no files matched" warning doesn't show up here too. "supabase/seed.sql": "insert into t values (1);", }, experimental: true, @@ -1866,7 +1755,6 @@ describe("db reset", () => { if (Exit.isFailure(exit)) { const cause = JSON.stringify(exit.cause); expect(cause).toContain("syntax error at or near"); - // The suggestion is `"See schema file: <Bold(fp)>"` (established output contract). expect(cause).toContain("See schema file:"); expect(cause).toContain("supabase/schemas/01_users.sql"); } @@ -1879,10 +1767,6 @@ describe("db reset", () => { it.live.skipIf(isRoot)( "does not attach the schema-file suggestion when a schema file cannot be READ on an experimental remote reset", () => { - // The file-read/parse step returns BEFORE the suggestion is ever set — - // only a later statement-execution failure attaches it. A file that - // glob-matches but can't be read (permissions changed after the glob) - // must fail WITHOUT the suggestion, unlike the exec-failure case above. const schemaFile = join(tmp.current, "supabase", "schemas", "01_users.sql"); const { layer, conn } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n', @@ -1901,7 +1785,6 @@ describe("db reset", () => { const cause = JSON.stringify(exit.cause); expect(cause).not.toContain("See schema file"); } - // The statement was never reached, so it was never executed. expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(false); }).pipe(Effect.ensuring(Effect.sync(() => chmodSync(schemaFile, 0o644)))); }, @@ -1910,11 +1793,6 @@ describe("db reset", () => { it.live.skipIf(isRoot)( "fails an experimental remote reset (without silently succeeding) when a matched schema_paths directory cannot be walked", () => { - // Directory walking stops on the first read failure and only silently - // drops that error when at least one OTHER file was still found; with - // a single pattern matching only the unreadable directory, `declared` - // stays empty and the command aborts — it must not report success - // having applied nothing. const schemasDir = join(tmp.current, "supabase", "schemas"); const { layer, conn } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas"]\n', @@ -1934,7 +1812,6 @@ describe("db reset", () => { expect(cause).toContain("failed to walk matched directory"); expect(cause).not.toContain("See schema file"); } - // Schemas were already dropped before the failed apply step (drop-then-apply order). expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(false); }).pipe(Effect.ensuring(Effect.sync(() => chmodSync(schemasDir, 0o755)))); @@ -1944,9 +1821,6 @@ describe("db reset", () => { it.live( "takes the native experimental schema-files path via SUPABASE_EXPERIMENTAL in the project .env", () => { - // The project `.env` is applied before EXPERIMENTAL is read, so a - // `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` reaches the - // native three-conjunct gate the same way an explicit `--experimental` does. const previous = process.env["SUPABASE_EXPERIMENTAL"]; delete process.env["SUPABASE_EXPERIMENTAL"]; const { layer, out, conn } = setup(tmp.current, { @@ -1986,7 +1860,6 @@ describe("db reset", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(JSON.stringify(exit.cause)).toContain("--no-seed cannot be used with --sql-paths"); - // The established suggestion, rendered as a Suggestion: line. expect(JSON.stringify(exit.cause)).toContain("Use either"); } }); @@ -2010,8 +1883,6 @@ describe("db reset", () => { }).pipe(Effect.provide(layer)); expect(conn.execs.some((s) => s.includes("create table schema_users"))).toBe(true); expect(conn.execs.some((s) => s.includes("insert into"))).toBe(false); - // A `--db-url` target always resolves a real connection — this is no longer - // delegated at all (CLI-1958). expect(resolver.calls).toBe(1); }); }, @@ -2077,7 +1948,6 @@ describe("db reset", () => { }); return Effect.gen(function* () { yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe(Effect.provide(layer)); - // Schemas are still dropped, but nothing is applied or seeded. expect(conn.execs.some((s) => s.includes("drop schema if exists"))).toBe(true); expect(out.stderrText).not.toContain("Applying migration"); expect(out.stderrText).not.toContain("Seeding data from"); @@ -2105,12 +1975,11 @@ describe("db reset", () => { format: "json", }); return Effect.gen(function* () { - // json mode is non-interactive → prompt takes the default (false) → cancel. + // json mode's default-false prompt has no confirm response, so it declines and cancels. const exit = yield* dbReset({ ...DEFAULT_FLAGS, linked: true }).pipe( Effect.provide(layer), Effect.exit, ); - // default-false prompt in non-text mode declines → context canceled. expect(Exit.isFailure(exit)).toBe(true); expect(out).toBeDefined(); }); @@ -2180,7 +2049,6 @@ describe("db reset", () => { linked: true, sqlPaths: [absSeed], }).pipe(Effect.provide(layer)); - // Absolute paths are preserved (not prefixed with supabase/) and seeded. expect(out.stderrText).toContain(`Seeding data from ${absSeed}...`); }); }); @@ -2209,9 +2077,6 @@ describe("db reset", () => { it.live( "seeds from --sql-paths on an experimental remote reset, independently of the schema-files apply", () => { - // `--sql-paths` overrides `[db.seed].sql_paths` regardless of which - // branch of the migrate-and-seed step ran — seeding sits outside the - // if/else-if, and the seed override is resolved entirely upstream of it. const { layer, out, conn } = setup(tmp.current, { toml: 'project_id = "test"\n\n[db.migrations]\nschema_paths = ["schemas/*.sql"]\n', files: { diff --git a/apps/cli/src/commands/db/reset/reset.layers.ts b/apps/cli/src/commands/db/reset/reset.layers.ts index 28d1f12400..789da48678 100644 --- a/apps/cli/src/commands/db/reset/reset.layers.ts +++ b/apps/cli/src/commands/db/reset/reset.layers.ts @@ -16,19 +16,13 @@ import { linkedProjectCacheLayer } from "../../../telemetry/linked-project-cache import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts"; /** - * Runtime layer for `supabase db reset`. Same composition as `db push` / `db lint`: - * the Postgres connection, the db-config resolver, project-ref resolution, and the - * linked-project cache, all over the lazy management-API factory so the local / - * `--db-url` paths never resolve an access token at layer-build time. Both targets - * are fully native (the local container-recreate primitives, and the remote - * `--experimental` schema-files apply) — no Go delegation remains on this - * command, so `GoProxy` is not composed here. + * Runtime layer for `supabase db reset`: the Postgres connection, the db-config resolver, + * project-ref resolution, and the linked-project cache, all over the lazy management-API factory + * so the local/`--db-url` paths never resolve an access token at layer-build time. * - * `dockerRunLayer` backs the native local recreate's PG15+ one-shot migrate - * jobs (`startSetupLocalDatabase`, reused via `recreateLocalDatabase`) - * — same reasoning as `db start`'s own `start.layers.ts`. - * `CommandSettings`/`ChildProcessSpawner`/`FileSystem`/`Path`/`RuntimeInfo` are - * ambient from the root runtime (`shared/cli/run.ts`). + * `dockerRunLayer` backs the native local recreate's PG15+ one-shot migrate jobs. + * `CommandSettings`/`ChildProcessSpawner`/`FileSystem`/`Path`/`RuntimeInfo` are ambient from the + * root runtime (`shared/cli/run.ts`). */ const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); const httpClient = httpClientLayer.pipe(Layer.provide(debugLoggerLayer)); @@ -70,19 +64,16 @@ export const dbResetRuntimeLayer = Layer.mergeAll( httpClient, credentials, projectRef, - // Exposed (not just provided to `projectRef`) because the local reset path reuses - // the seed-buckets core, whose `resolveStorageCredentials` requires the - // (lazy) Management-API factory for the linked branch — never hit on `--local`, - // but a static service requirement of the shared core. + // Exposed because the shared seed-buckets core's `resolveStorageCredentials` statically + // requires the (lazy) Management-API factory for the linked branch, even though `--local` + // never hits it. platformApiFactory, linkedProjectCache, identityStitchLayer, telemetryStateLayer, - // `promptYesNo`'s non-TTY branch reads the piped answer via `Stdin`; - // without it a CI/piped remote `db reset` that reaches the confirmation - // prompt fails with a missing-service defect instead of the default. + // `promptYesNo`'s non-TTY branch reads the piped answer via `Stdin`; without it a CI/piped + // remote `db reset` that reaches the confirmation prompt fails with a missing-service defect. stdinLayer, - // Backs the native local recreate's PG15+ one-shot migrate jobs. dockerRunLayer, commandRuntimeLayer(["db", "reset"]), ); diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts b/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts index 69151e5f4f..7ba8e65309 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts @@ -9,11 +9,8 @@ import type { DeclarativeLoadCompatibilityFinding } from "./declarative.flow.ts" /** * Declarative commands were invoked without `--experimental` and without - * `[experimental.pgdelta] enabled = true`. Byte-matches Go's gate error - * `"declarative commands require --experimental flag or pg-delta enabled in config"` - * plus the `utils.CmdSuggestion` - * (`apps/cli-go/cmd/db_schema_declarative.go:63-69`, deleted in CLI-1970; - * last present at commit 7b469f5b3). + * `[experimental.pgdelta] enabled = true`; message text and suggestion are an established + * output contract. */ export class DeclarativeNotEnabledError extends Data.TaggedError("DeclarativeNotEnabledError")<{ readonly message: string; @@ -25,10 +22,8 @@ export class DeclarativeNotEnabledError extends Data.TaggedError("DeclarativeNot } /** - * A target could not be resolved in non-interactive mode. Byte-matches Go's - * `"in non-interactive mode, specify a target: --local, --linked, or --db-url"` - * (generate, `:200`) and the sync variants that require `db schema declarative - * generate` first (`:311`, `:318`). + * A target could not be resolved in non-interactive mode; message text is an established output + * contract, shared by `generate` and the `sync` variants requiring `generate` first. */ export class DeclarativeNonInteractiveError extends Data.TaggedError( "DeclarativeNonInteractiveError", @@ -41,12 +36,9 @@ export class DeclarativeNonInteractiveError extends Data.TaggedError( } /** - * A mutually-exclusive flag group was violated. Reproduces cobra's - * `MarkFlagsMutuallyExclusive` `ValidateFlagGroups` error byte-for-byte: - * - `generate`: `db-url`/`linked`/`local` (`apps/cli-go/cmd/db_schema_declarative.go:570`) - * - `sync`: `apply`/`no-apply` (`apps/cli-go/cmd/db_schema_declarative.go:561`) - * (both deleted in CLI-1970; last present at commit 7b469f5b3). Both fail - * before any side effects run, matching cobra's pre-RunE validation. + * A mutually-exclusive flag group was violated: `generate`'s `db-url`/`linked`/`local`, or + * `sync`'s `apply`/`no-apply`. Message text is an established output contract; both fail before + * any side effects run. */ export class DeclarativeMutuallyExclusiveFlagsError extends Data.TaggedError( "DeclarativeMutuallyExclusiveFlagsError", @@ -59,9 +51,8 @@ export class DeclarativeMutuallyExclusiveFlagsError extends Data.TaggedError( } /** - * The interactive custom-database-URL prompt was empty or unparseable. Byte-matches - * Go's `"database URL cannot be empty"` (`:281`) and - * `"failed to parse connection string: " + err` (`:285`). + * The interactive custom-database-URL prompt was empty or unparseable; message text is an + * established output contract. */ export class DeclarativeInvalidDbUrlError extends Data.TaggedError("DeclarativeInvalidDbUrlError")<{ readonly message: string; @@ -72,9 +63,8 @@ export class DeclarativeInvalidDbUrlError extends Data.TaggedError("DeclarativeI } /** - * `db schema declarative generate` ran but produced no declarative files (sync's - * post-generate guard). Byte-matches Go's - * `"declarative schema generation did not produce any files"` (`:326`). + * `db schema declarative generate` ran but produced no declarative files (sync's post-generate + * guard); message text is an established output contract. */ export class DeclarativeNoFilesGeneratedError extends Data.TaggedError( "DeclarativeNoFilesGeneratedError", @@ -86,12 +76,7 @@ export class DeclarativeNoFilesGeneratedError extends Data.TaggedError( } } -/** - * Diffing declarative schema to migrations failed. Wraps - * `declarative.DiffDeclarativeToMigrations` errors - * (`apps/cli-go/internal/db/declarative/declarative.go`). A debug bundle is - * written before this surfaces. - */ +/** Diffing declarative schema to migrations failed. A debug bundle is written before this surfaces. */ export class DeclarativeDiffError extends Data.TaggedError("DeclarativeDiffError")<{ readonly message: string; }> { @@ -106,10 +91,8 @@ export class DeclarativeCompatibilityError extends Data.TaggedError( )<{ readonly message: string; /** - * Recovery commands, printed bare on stderr by `Output.fail` INSTEAD of the - * generic "Try rerunning the command with --debug" footer. A compatibility - * gate is a deliberate refusal, not a crash, so it must never suggest - * troubleshooting flags (same mechanism as {@link DeclarativeApplyError}). + * Recovery commands, printed bare on stderr by `Output.fail` instead of the generic + * "Try rerunning the command with --debug" footer, since this is a refusal, not a crash. */ readonly suggestion?: string; /** Structured only for a known implicit-extension failure during shadow load. */ @@ -121,11 +104,8 @@ export class DeclarativeCompatibilityError extends Data.TaggedError( } /** - * Applying the generated migration to the local database failed. Wraps Go's - * `applyMigrationToLocal` error; in interactive mode the handler offers a - * reset+reapply before this surfaces - * (`apps/cli-go/cmd/db_schema_declarative.go:397-435`, deleted in CLI-1970; - * last present at commit 7b469f5b3). + * Applying the generated migration to the local database failed; in interactive mode the handler + * offers a reset+reapply before this surfaces. */ export class DeclarativeApplyError extends Data.TaggedError("DeclarativeApplyError")<{ readonly message: string; @@ -135,10 +115,8 @@ export class DeclarativeApplyError extends Data.TaggedError("DeclarativeApplyErr */ readonly connect?: boolean; /** - * Forwarded from the underlying typed failure this wraps (e.g. a - * `KongReloadError`'s recovery hint, or a health-timeout architecture - * hint) when the local-reset recovery path fails — the wrap must not drop it - * (review CLI-1958). + * Forwarded from the underlying typed failure this wraps (e.g. a `KongReloadError`'s recovery + * hint) when the local-reset recovery path fails, so the wrap doesn't drop it. */ readonly suggestion?: string; }> { @@ -151,10 +129,9 @@ export class DeclarativeApplyError extends Data.TaggedError("DeclarativeApplyErr } /** - * Duck-types an optional `suggestion: string` off an arbitrary typed failure — - * used when wrapping a lower-level error (e.g. `resetLocalDatabase`'s - * `KongReloadError`) into a {@link DeclarativeApplyError} so its - * recovery hint isn't silently dropped by the wrap. + * Duck-types an optional `suggestion: string` off an arbitrary typed failure, used when wrapping + * a lower-level error (e.g. `resetLocalDatabase`'s `KongReloadError`) into a + * {@link DeclarativeApplyError} so its recovery hint isn't silently dropped by the wrap. */ export function readErrorSuggestion(error: unknown): string | undefined { if (typeof error !== "object" || error === null || !("suggestion" in error)) return undefined; @@ -163,8 +140,6 @@ export function readErrorSuggestion(error: unknown): string | undefined { } /** - * Materializing the declarative export on disk failed. Byte-matches Go's - * `WriteDeclarativeSchemas` errors (`declarative.go:239`): - * `"failed to clean declarative schema directory: " + err` and - * `"unsafe declarative export path: " + path`. + * Materializing the declarative export on disk failed: `"failed to clean declarative schema + * directory: " + err` or `"unsafe declarative export path: " + path`. */ diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts b/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts index 4c49b6b2f3..e3b9301104 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.flow.ts @@ -39,11 +39,7 @@ export interface DeclarativeCompatibilityGap { readonly recommendedAction: DeclarativeCompatibilityAction; } -/** - * Pure control-flow helpers ported from the legacy Go implementation (deleted - * in CLI-1970; last present at commit 7b469f5b3) and kept free of - * Effect/services so handler decisions remain unit-testable. - */ +/** Pure control-flow helpers, kept free of Effect/services so handler decisions stay unit-testable. */ export function resolveDeclarativeMigrationName(name: string, file: string): string { return name.length > 0 ? name : file; @@ -86,10 +82,9 @@ export function classifyDeclarativeCompatibilityGap(opts: { const ambiguousRemovals = extensions.filter( (extension) => !IMPLICIT_EXTENSIONS.some((implicit) => implicit === extension), ); - // Removing a pg_cron job or pgmq queue declaration is an ordinary delete or - // rename on a maintained tree, not legacy-export evidence: only a dropped - // extension trips the gate (CLI-2282). Their removals are kept as evidence - // solely to enumerate the objects a dropped owning extension takes with it. + // Removing a pg_cron job or pgmq queue declaration is an ordinary delete/rename, not + // legacy-export evidence — only a dropped extension trips the gate. Their removals are kept + // solely to enumerate what a dropped owning extension takes with it. const extensionIntents = opts.removals.extensionIntents.filter((intent) => extensions.includes(intent.extension), ); @@ -232,11 +227,9 @@ export const extensionDeclaration = (extension: string): string => `CREATE EXTENSION IF NOT EXISTS "${extension}" WITH SCHEMA "extensions";`; /** - * Shell family the recovery commands are rendered for. The staged-upgrade - * recipe contains destructive filesystem operations, so it must be runnable as - * printed: POSIX shells get `rm -rf`/`mv` with `&&` and backslash - * continuations; Windows gets single-line PowerShell (`Remove-Item`/`Move-Item` - * with `;`), which also runs unmodified in Windows Terminal's default shell. + * Shell family the recovery commands are rendered for: POSIX gets `rm -rf`/`mv` with `&&` and + * backslash continuations; Windows gets single-line PowerShell (`Remove-Item`/`Move-Item` with + * `;`), since the staged-upgrade recipe must be runnable exactly as printed. */ export type ShellPlatform = "posix" | "windows"; @@ -250,12 +243,10 @@ export interface StagedExportContext { } /** - * Derives the staging directory as a sibling of the declarative directory by - * suffixing its last path segment. Trailing separators (and `/.` segments) in - * the configured `declarative_schema_path` are stripped first — appending to - * `supabase/schemas/` verbatim would nest the staging directory *inside* the - * active tree, so a later sync would load the staged export recursively and - * the printed `rm -rf <dir> && mv` adoption command would destroy both copies. + * Derives the staging directory as a sibling of the declarative directory by suffixing its last + * path segment (trailing separators and `/.` segments stripped first). Appending verbatim would + * nest the staging directory inside the active tree, so a later sync would load it recursively + * and the printed `rm -rf <dir> && mv` adoption command would destroy both copies. */ export const resolveStagedDeclarativeDir = (declarativeDir: string): string => { const isSeparator = (ch: string | undefined) => ch === "/" || ch === "\\"; @@ -346,9 +337,8 @@ function stagedExportCommands(context: StagedExportContext): ReadonlyArray<strin } /** - * Evidence lines for a plan that succeeded but whose removals reveal the tree is - * a legacy export (the plan-refuse gate). The load-fail gate builds its own - * evidence from the shadow-load diagnostics instead. + * Evidence lines for a plan that succeeded but whose removals reveal the tree is a legacy export + * (the plan-refuse gate); the load-fail gate builds its own evidence from shadow-load diagnostics. */ export function formatDeclarativeGapEvidence( gap: DeclarativeCompatibilityGap, @@ -376,17 +366,11 @@ export interface DeclarativeUpgradeGateText { } /** - * The single template both compatibility gates render. Both mean the same thing - * ("this declarative tree is a legacy pg-delta export"), so they must read the - * same; only the evidence block differs. The recovery commands live in - * `suggestion` so `Output.fail` prints them instead of the generic - * "rerun with --debug" footer — a deliberate gate is not a crash. - * - * Deliberately offers exactly ONE non-interactive recovery: the staged - * regenerate. Telling a non-interactive user to hand-add an extension - * declaration is a false trail — on a real CLI tree each declaration only - * unlocks the next refusal. Interactive flows still offer the repair as an - * advanced choice. + * The single template both compatibility gates render ("this declarative tree is a legacy + * pg-delta export"); only the evidence block differs. Recovery commands live in `suggestion` so + * `Output.fail` prints them instead of the "rerun with --debug" footer. Offers only the staged + * regenerate for non-interactive recovery, since hand-adding one extension only unlocks the next + * refusal; interactive flows offer that repair separately. */ export function formatDeclarativeUpgradeGate(opts: { readonly evidence: ReadonlyArray<string>; diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.former-default.ts b/apps/cli/src/commands/db/schema/declarative/declarative.former-default.ts index 57a27c304f..c97ba22492 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.former-default.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.former-default.ts @@ -17,18 +17,13 @@ const formerDeclarativeDefaultWarning = (formerDirRel: string, defaultDirRel: st )}\n`; /** - * Warns when a project still has a declarative tree at the former default - * `supabase/database` while relying on the implicit default, which now resolves - * to `supabase/schemas`. Without this, an upgraded project silently stops - * reading its existing tree: non-interactive sync reports "no declarative - * schema found" with no hint why, and `--yes` regenerates a fresh tree that - * ignores edits present only under the old path. TS-only guidance (no Go - * counterpart); recorded in docs/go-cli-divergences.md. + * Warns when a project still has a declarative tree at the former default `supabase/database` + * while relying on the implicit default, which now resolves to `supabase/schemas` — otherwise an + * upgraded project silently stops reading its existing tree. See docs/go-cli-divergences.md. * - * Fires only when all three hold: `declarative_schema_path` is unset, the new - * default directory has no entries, and the former default contains `.sql` - * files or an export manifest. Probe failures read as "absent" so the warning - * can never turn an unreadable directory into a command failure. + * Fires only when `declarative_schema_path` is unset, the new default directory has no entries, + * and the former default contains `.sql` files or an export manifest; probe failures read as + * "absent" so the warning can never turn an unreadable directory into a command failure. */ export const warnFormerDeclarativeDefault = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.gate.ts b/apps/cli/src/commands/db/schema/declarative/declarative.gate.ts index c4699eea7d..aad2b660b6 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.gate.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.gate.ts @@ -4,23 +4,17 @@ import { aqua, bold } from "../../../../command-internal/colors.ts"; import { DeclarativeNotEnabledError } from "./declarative.errors.ts"; /** - * Whether the declarative (pg-delta) code paths are enabled. Mirrors Go's - * `dbDeclarativeCmd.PersistentPreRunE` net effect - * (`apps/cli-go/cmd/db_schema_declarative.go:49-77`, deleted in CLI-1970; - * last present at commit 7b469f5b3): passing `--experimental` - * force-enables pg-delta, so the gate is open when either the global - * `--experimental` flag is set **or** `[experimental.pgdelta] enabled = true` - * is present in `config.toml` (Go's `utils.IsPgDeltaEnabled`). + * Whether the declarative (pg-delta) code paths are enabled: the gate is open when either the + * global `--experimental` flag is set or `[experimental.pgdelta] enabled = true` is present in + * `config.toml`. */ export function isPgDeltaEnabled(experimental: boolean, pgDeltaEnabled: boolean): boolean { return experimental || pgDeltaEnabled; } /** - * The `utils.CmdSuggestion` shown when the gate is closed, byte-matching Go's - * `fmt.Sprintf(...)` (`:64-68`). `configPath` is `supabase/config.toml` - * (`utils.ConfigPath`). `aqua`/`bold` render plain when stderr is - * not a TTY, matching Go's lipgloss profile detection. + * The suggestion shown when the gate is closed. `configPath` is `supabase/config.toml`; + * `aqua`/`bold` render plain when stderr is not a TTY. */ export function pgDeltaSuggestion(configPath: string): string { return `Either pass ${aqua("--experimental")} or add ${aqua( @@ -29,18 +23,9 @@ export function pgDeltaSuggestion(configPath: string): string { } /** - * The Effect-CLI replacement for Go's `dbDeclarativeCmd.PersistentPreRunE` gate - * (`apps/cli-go/cmd/db_schema_declarative.go:49-99`, deleted in CLI-1970; - * last present at commit 7b469f5b3). Cobra runs - * `PersistentPreRunE` BEFORE `ValidateFlagGroups()` (mutual-exclusivity checks) - * and `RunE` (`cobra@v1.10.2/command.go:985,1010,1014`), so this gate must run - * before the `MarkFlagsMutuallyExclusive` check in the same command — `db-url`/ - * `linked`/`local` on `generate` (`:570`), `apply`/`no-apply` on `sync` (`:561`) - * — a closed gate must win over a flag-group conflict, not the other way - * around. Invoke at the top of each declarative leaf handler's body, before - * that handler's mutex check. Fails with `DeclarativeNotEnabledError` - * (carrying the byte-exact message + suggestion) when neither `--experimental` - * nor `[experimental.pgdelta]` enables pg-delta. + * The pg-delta gate. Must run at the top of each declarative leaf handler's body, before that + * handler's own mutually-exclusive-flags check — a closed gate wins over a flag conflict, not + * the other way around. */ export const requirePgDelta = Effect.fnUntraced(function* (opts: { readonly experimental: boolean; diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts b/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts index 289808267e..a0277b4aaf 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.orchestrate.ts @@ -34,7 +34,7 @@ export interface DeclarativeRunContext { readonly linkedProjectRef?: string; } -/** The output of a declarative-to-migrations diff. Mirrors Go's `SyncResult`. */ +/** The output of a declarative-to-migrations diff. */ export interface DeclarativeSyncResult { readonly diffSQL: string; readonly files: ReadonlyArray<PgDeltaRenderedFile>; diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.shared.ts b/apps/cli/src/commands/db/schema/declarative/declarative.shared.ts index e80654d4c2..9758479f09 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.shared.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.shared.ts @@ -1,14 +1,9 @@ import { Command, Flag } from "effect/unstable/cli"; /** - * Base `db schema declarative` group command carrying the shared `--no-cache` - * flag. Go registers `--no-cache` as a persistent flag on the group - * (`apps/cli-go/cmd/db_schema_declarative.go:480-481`, deleted in CLI-1970; - * last present at commit 7b469f5b3), so it is accepted both - * before and after the `generate`/`sync` subcommand name. Subcommand handlers read - * the resolved value via `yield* dbSchemaDeclarativeSharedBase` — its context - * tag is stable across `withSubcommands`, so this base (defined without subcommands - * to avoid an import cycle) is the one the leaves import. + * Base `db schema declarative` group command carrying the shared `--no-cache`/`--strict-coverage` + * flags, accepted both before and after the `generate`/`sync` subcommand name. Defined without + * subcommands to avoid an import cycle; leaf handlers import this base directly. */ export const dbSchemaDeclarativeSharedBase = Command.make("declarative").pipe( Command.withDescription("Manage declarative database schemas."), diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts b/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts index 0371b77cb7..034a172138 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.smart-target.ts @@ -25,33 +25,28 @@ import { import type { DeclarativeShadowDbError } from "../../shared/pgdelta.errors.ts"; import { DeclarativeSeam } from "../../shared/pgdelta.seam.service.ts"; -/** - * The local connection bits the smart-target resolver needs (Go reads these from - * the merged config's `[db]`). - */ +/** The local connection bits the smart-target resolver needs. */ export interface LocalConn { readonly port: number; readonly password: string; } /** - * The flag surface the smart-target resolver reads. Both `generate` (passing its - * full flags) and `sync` (constructing a target-less value for its bootstrap) - * satisfy this, mirroring Go passing the same `cmd` into `runDeclarativeGenerate`. + * The flag surface the smart-target resolver reads. Both `generate` (passing its full flags) and + * `sync` (constructing a target-less value for its bootstrap) satisfy this. */ export interface SmartTargetFlags { readonly dbUrl: Option.Option<string>; - // Presence-modelled (Go's `flag.Changed`), like `--db-url`. The resolver only - // reads `dbUrl` to pick db-url vs linked, so this is carried for type-compat. + // Presence-modelled like `--db-url`. The resolver only reads `dbUrl` to pick db-url vs linked, + // so this is carried for type-compat. readonly linked: Option.Option<boolean>; readonly password: Option.Option<string>; readonly reset: boolean; } const localConnection = (local: LocalConn) => ({ - // Go derives the local host from `utils.Config.Hostname` (`GetHostname()`: - // SUPABASE_SERVICES_HOSTNAME → tcp DOCKER_HOST → 127.0.0.1), not a hardcoded - // loopback (`apps/cli-go/internal/utils/misc.go:298-312`). + // Host resolution order: SUPABASE_SERVICES_HOSTNAME → tcp DOCKER_HOST → 127.0.0.1, not a + // hardcoded loopback. host: getHostname(), port: local.port, user: "postgres", @@ -91,11 +86,9 @@ export const resolveRemoteEndpoint = Effect.fnUntraced(function* (flags: SmartTa }); /** - * Smart-mode (no explicit target) interactive target resolution — Go's - * `runDeclarativeGenerate` smart branch (`apps/cli-go/cmd/db_schema_declarative.go:198-298`, - * deleted in CLI-1970; last present at commit 7b469f5b3). - * Shared by `generate` (smart mode) and `sync` (no-declarative-files bootstrap) so - * both offer the same local / linked / custom choice and local-reset prompt. + * Smart-mode (no explicit target) interactive target resolution, shared by `generate` (smart + * mode) and `sync` (no-declarative-files bootstrap) so both offer the same local/linked/custom + * choice and local-reset prompt. */ export const resolveSmartTargetEndpoint = Effect.fnUntraced(function* ( flags: SmartTargetFlags, @@ -108,23 +101,19 @@ export const resolveSmartTargetEndpoint = Effect.fnUntraced(function* ( beforeLocalTarget: Effect.Effect<void, DeclarativeShadowDbError> = Effect.void, ) { if (!hasMigrations) { - // No migrations → generate from local. Go runs ensureLocalDatabaseStarted first - // (db_schema_declarative.go:291), starting a stopped stack. + // No migrations: generate from local, starting a stopped stack first. yield* beforeLocalTarget; yield* (yield* DeclarativeSeam).ensureLocalDatabaseStarted(); return localEndpoint(local, yield* DnsResolverFlag); } const output = yield* Output; - // Go's prompts below read `viper.GetBool("YES")` after `loadNestedEnv` - // (`pkg/config/config.go:789`), so `SUPABASE_YES` — from the shell env or the - // project `.env` — must auto-confirm too, not just the flag (CLI-1974). + // `SUPABASE_YES` — from the shell env or the project `.env` — must auto-confirm the prompts + // below too, not just the `--yes` flag. const projectEnv = yield* loadProjectEnv(fs, path, workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); - // Insert "Linked project" between local and custom (Go's choice order) when the - // workdir is linked with a valid ref. Go gates this on `LoadProjectRef`, which - // validates the ref (`project_ref.go:75`), so an invalid on-disk ref hides the - // choice rather than showing it and failing later. + // Inserts "Linked project" between local and custom when the workdir is linked with a valid + // ref; an invalid on-disk ref hides the choice rather than showing it and failing later. const showLinked = Option.isSome(linkedRef) && PROJECT_REF_PATTERN.test(linkedRef.value); const choice = yield* output.promptSelect("Generate declarative schema from:", [ { value: "local", label: "Local database", hint: "generate from local Postgres" }, @@ -141,8 +130,7 @@ export const resolveSmartTargetEndpoint = Effect.fnUntraced(function* ( ]); if (choice === "linked") { - // Same path as an explicit `--linked` (Go calls `NewDbConfigWithPassword`): - // login-role mint + pooler fallback, then `ToPostgresURL`. + // Same path as an explicit `--linked`: login-role mint + pooler fallback, then the resolved URL. return yield* resolveRemoteEndpoint({ ...flags, linked: Option.some(true) }); } @@ -153,12 +141,9 @@ export const resolveSmartTargetEndpoint = Effect.fnUntraced(function* ( new DeclarativeInvalidDbUrlError({ message: "database URL cannot be empty" }), ); } - // Go parses the entry with pgconn.ParseConfig then feeds pg-delta a normalized - // ToPostgresURL (`apps/cli-go/cmd/db_schema_declarative.go:283-287`, deleted - // in CLI-1970; last present at commit 7b469f5b3). Layer the - // project env (loaded once above) under the shell env like the --db-url path so - // libpq PG* fallbacks resolve, and reject malformed input with Go's "failed to - // parse connection string" error (password redacted, CWE-209). + // Layers the project env (loaded once above) under the shell env like the --db-url path so + // libpq PG* fallbacks resolve; malformed input fails with a redacted connection string + // (CWE-209). const conn = parseConnectionString(dbURL, (name) => process.env[name] ?? projectEnv[name]); if (conn === undefined) { return yield* Effect.fail( @@ -175,17 +160,14 @@ export const resolveSmartTargetEndpoint = Effect.fnUntraced(function* ( } satisfies PgDeltaDatabaseEndpoint; } - // "Local database" choice: Go runs ensureLocalDatabaseStarted before the reset - // prompt (db_schema_declarative.go:249), starting a stopped stack. + // "Local database" choice: starts a stopped stack before the reset prompt. yield* beforeLocalTarget; yield* (yield* DeclarativeSeam).ensureLocalDatabaseStarted(); let shouldReset = flags.reset; if (!shouldReset) { - // Go asks via Console.PromptYesNo (db_schema_declarative.go:320-322, default - // false): --yes/SUPABASE_YES auto-resets WITH the `<label> [y/N] y` stderr - // echo (console.go:70-72) — routed through `promptYesNo` so the echo - // is not skipped (CLI-1974). + // `--yes`/`SUPABASE_YES` auto-resets, but still echoes the `<label> [y/N] y` stderr line via + // `promptYesNo` rather than skipping it. shouldReset = yield* promptYesNo( output, yes, @@ -194,12 +176,9 @@ export const resolveSmartTargetEndpoint = Effect.fnUntraced(function* ( ); } if (shouldReset) { - // Go runs reset in-process and returns the error (`cmd/db_schema_declarative.go:262-267`). - // `resetLocalDatabase` now runs the same way — in-process, sharing this - // command's own context — rather than shelling out to a second `supabase-go` child - // (CLI-2062): it resolves `NetworkIdFlag` itself, so no argv-forwarding is - // needed to stay on a custom Docker network, and a real failure propagates through - // the effect's own failure channel instead of a synthesized exit code. + // `resetLocalDatabase` runs in-process, sharing this command's own context: it resolves + // `NetworkIdFlag` itself, so no argv-forwarding is needed to stay on a custom Docker network, + // and a real failure propagates through the effect's own failure channel. yield* resetLocalDatabase().pipe( Effect.mapError( (error) => diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.command.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.command.ts index 7aaed96f32..23bf7fe998 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.command.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.command.ts @@ -16,13 +16,9 @@ const config = { Flag.withDescription("Overwrite declarative schema files without confirmation."), Flag.withDefault(false), ), - // Deliberately NOT named `--output`/`-o`: the legacy root reserves those for - // the global machine-format flag (`OutputFlag`, `json|yaml|toml|env|…`), - // and a leaf string flag would shadow it — `generate -o json` would write a - // directory named `json` instead of being a (Go-ignored) format request. - // `db diff`'s local `--output`/`-o` shadowing is different: Go itself - // registers it (`cmd/db.go:222`), so parity fixes that name; this flag is - // TS-only and free to avoid the collision. + // Not named `--output`/`-o`: that's reserved for the global machine-format flag + // (`OutputFlag`), and a leaf string flag would shadow it — `generate -o json` would write a + // directory literally named `json` instead of requesting JSON output. outputDir: Flag.string("output-dir").pipe( Flag.withDescription( "Write the generated declarative schema to this directory without changing the configured declarative schema path.", @@ -37,11 +33,8 @@ const config = { Flag.withAlias("s"), Flag.withDescription("Comma separated list of schema to include."), Flag.atLeast(0), - // Go registers `--schema` as a cobra `StringSliceVarP` - // (`apps/cli-go/cmd/db_schema_declarative.go:495`, deleted in CLI-1970; - // last present at commit 7b469f5b3), which CSV-splits each - // occurrence so `-s public,auth` includes the two schemas separately. Mirror - // the `gen types` / `db lint` parsing so quoted commas are handled the same way. + // CSV-splits each occurrence so `-s public,auth` includes the two schemas separately, same + // as `gen types`/`db lint`'s quoted-comma parsing. Flag.mapTryCatch( (rawValues) => parseSchemaFlags(rawValues), (err) => (err instanceof Error ? err.message : String(err)), @@ -53,12 +46,8 @@ const config = { ), Flag.optional, ), - // Go gates explicit-target selection on `flag.Changed` (presence), not the bool - // value — `hasExplicitTargetFlag` is `Changed("local")||Changed("linked")|| - // Changed("db-url")` (`apps/cli-go/cmd/db_schema_declarative.go:139-141`, - // deleted in CLI-1970; last present at commit 7b469f5b3). Model - // `--linked`/`--local` as `Option` (like `--db-url`) so `--linked=false` still - // takes the explicit linked path, matching Go (and the `db query` fix). + // Explicit-target selection keys off presence, not the bool value, so model `--linked`/ + // `--local` as `Option` (like `--db-url`) so `--linked=false` still takes the explicit path. linked: Flag.boolean("linked").pipe( Flag.withDescription("Generates declarative schema from the linked project."), Flag.optional, @@ -96,10 +85,8 @@ export const dbSchemaDeclarativeGenerateCommand = Command.make("generate", confi strictCoverage: shared.strictCoverage, }; return yield* dbSchemaDeclarativeGenerate(merged).pipe( - // Go's PostRun prints this on success via `fmt.Println` → stdout - // (`cmd/db_schema_declarative.go:93`), so keep it on stdout in text mode. In - // json / stream-json the bare human line would corrupt the payload, so emit a - // structured result instead (machine stdout is payload-only — CLI-1546). + // Printed on stdout in text mode; in json/stream-json the bare human line would corrupt + // the payload, so emit a structured result instead (machine stdout is payload-only). Effect.tap(() => Effect.gen(function* () { const output = yield* Output; @@ -121,15 +108,12 @@ export const dbSchemaDeclarativeGenerateCommand = Command.make("generate", confi "db-url": merged.dbUrl, linked: merged.linked, local: merged.local, - // `password` must never be added to `safeFlags` — it is a credential and - // must always reach telemetry as `<redacted>` (matches Go, which never - // marks `--password` telemetry-safe). + // `password` must never be added to `safeFlags`: it's a credential and must always + // reach telemetry as `<redacted>`. password: merged.password, }, - // Go registers `--schema`/`-s` (StringSliceVarP) and `--password`/`-p` - // (StringVarP) (`cmd/db_schema_declarative.go:495,500`); telemetry reports - // changed flags by canonical `flag.Name` via `pflag.Visit`, so map the - // shorthands so `generate -s public -p secret` logs `schema`/`password`. + // Telemetry reports changed flags by canonical name, so map the shorthands: `generate + // -s public -p secret` must log `schema`/`password`. aliases: { s: "schema", p: "password" }, }), withJsonErrorHandling, diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts index 7def784435..249cd9df18 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.handler.ts @@ -60,17 +60,11 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene const telemetryState = yield* TelemetryState; const linkedProjectCache = yield* LinkedProjectCache; const dnsResolver = yield* DnsResolverFlag; - // Go's `dbDeclarativeCmd.PersistentPreRunE` calls `flags.LoadConfig` — which runs - // `loadNestedEnv` and `os.Setenv`s each project-.env key — BEFORE reading - // `viper.GetBool("EXPERIMENTAL")` for the gate below (`apps/cli-go/cmd/ - // db_schema_declarative.go:73-78`, `pkg/config/config.go:789`). Load the project env - // first and resolve against it, as `db reset` does for its own experimental gate, so a - // `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` opens the gate too. + // The project env is loaded and resolved before the gate below, so a `SUPABASE_EXPERIMENTAL` + // set only in `supabase/.env` opens the gate too. const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); const experimental = yield* resolveExperimentalWithProjectEnv(projectEnv); - // `--yes` OR `SUPABASE_YES` (shell env or project `.env`): Go's prompts here - // read `viper.GetBool("YES")` after `loadNestedEnv`, so the env var must - // auto-confirm too, not just the flag (CLI-1974). + // `--yes` or `SUPABASE_YES` (shell env or project `.env`) must auto-confirm the prompts below. const yes = yield* resolveYesWithProjectEnv(projectEnv); // The resolved linked ref (explicit `--linked` only), hoisted so the post-run @@ -79,24 +73,18 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene yield* Effect.gen(function* () { const baseToml = yield* readDbToml(fs, path, cliSettings.workdir); - // Gate before the mutex check below — order matters; see - // requirePgDelta's doc comment for why. The pg-delta gate also runs on - // the BASE config: Go's declarative `PersistentPreRunE` gates before the root - // `ParseDatabaseConfig` reloads any `[remotes.<ref>]` block, so a remote - // `experimental.pgdelta.enabled = true` must NOT enable a base-disabled - // command without `--experimental`. + // Gate before the mutex check below (see `requirePgDelta`'s doc comment for why), and on the + // base config: a remote `experimental.pgdelta.enabled = true` must not enable a + // base-disabled command without `--experimental`. yield* requirePgDelta({ experimental, pgDeltaEnabled: baseToml.pgDelta.enabled, configPath: path.join("supabase", "config.toml"), }); - // cobra `MarkFlagsMutuallyExclusive("db-url", "linked", "local")` - // (`apps/cli-go/cmd/db_schema_declarative.go:570`, deleted in CLI-1970; - // last present at commit 7b469f5b3) runs via - // `ValidateFlagGroups()`, which cobra invokes AFTER `PersistentPreRunE` (the - // gate above) — see requirePgDelta's doc comment for the full ordering. - // "Set" follows cobra's `Changed`: Option set when `Some`, boolean when `true`. + // Mutually-exclusive db-url/linked/local group, checked after the gate above (see + // `requirePgDelta`'s doc comment for the ordering). "Set" means explicitly set: Option + // `Some`, or boolean `true`. const exclusive: Array<string> = []; if (Option.isSome(flags.dbUrl)) exclusive.push("db-url"); if (Option.isSome(flags.linked)) exclusive.push("linked"); @@ -109,15 +97,12 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene ); } - // Explicit `--linked`: Go re-loads config with the resolved ref (root - // `ParseDatabaseConfig` linked branch), so a matching `[remotes.<ref>]` block - // overrides `experimental.pgdelta.*` (declarative_schema_path / format_options) - // for the downstream path/format settings only — NOT the gate above. (Smart-mode - // "Linked project" does NOT re-load in Go, so it is excluded — only `flags.linked`.) + // Explicit `--linked` re-loads config with the resolved ref, so a matching `[remotes.<ref>]` + // block overrides `experimental.pgdelta.*` downstream only, not the gate above. Smart-mode's + // "Linked project" choice does not re-load, so only `flags.linked` triggers this. let toml = baseToml; - // The resolved linked ref (explicit `--linked` only) is threaded into the - // native raw-shadow export source (so its platform setup uses the - // remote-merged config, matching Go's `Generate`) and into the post-run + // The resolved linked ref (explicit `--linked` only) is threaded into the raw-shadow export + // source, so its platform setup uses the remote-merged config, and into the post-run // linked-project cache finalizer below. if (Option.isSome(flags.linked)) { const linkedRef = Option.isSome(cliSettings.projectId) @@ -129,10 +114,9 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene } } - // Preserve the selected value for user-facing output: invocation-local - // `--output` wins, otherwise use the configured declarative path. File I/O - // resolves relative values from the project workdir while keeping absolute - // values unchanged. + // Preserve the selected value for user-facing output: invocation-local `--output` wins, + // otherwise the configured declarative path. File I/O resolves relative values from the + // project workdir while keeping absolute values unchanged. const declarativeDirRel = Option.getOrElse(flags.outputDir, () => resolveDeclarativeDir(path, toml.pgDelta), ); @@ -158,13 +142,10 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene const run: DeclarativeRunContext = { pgDelta: { - // `resolvePgDeltaProjectId` mirrors Go's `Config.ProjectId` singleton - // (`SUPABASE_PROJECT_ID` env → config.toml's `project_id` → sanitized workdir - // basename) — NOT `cliSettings.projectId` alone, which is env-only and resolves to - // `""` for a project relying on config.toml's `project_id` or the workdir-basename - // default, mounting the WRONG `supabase_edge_runtime_` Deno-cache volume. `toml` - // reflects any `--linked` remote merge above, so its own `appliedRemote`/`projectId` - // suppress a conflicting ambient env var the same way `db diff`/`db pull` do. + // `resolvePgDeltaProjectId` resolves `SUPABASE_PROJECT_ID` env → config.toml's + // `project_id` → sanitized workdir basename — not `cliSettings.projectId` alone, which + // is env-only and would mount the wrong `supabase_edge_runtime_` Deno-cache volume for a + // project relying on config or the workdir-basename default. projectId: resolvePgDeltaProjectId(cliSettings.projectId, toml, cliSettings.workdir), cwd: cliSettings.workdir, // Merged config's deno_version (re-loaded with the linked ref above on @@ -191,11 +172,8 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene if (hasExplicitTarget) { const seam = yield* DeclarativeSeam; if (Option.isSome(flags.local)) { - // Target selection keys off flag presence (Go's `Changed`), but the - // auto-start gates on the boolean VALUE: Go passes `declarativeLocal` to - // `ensureLocalDatabaseStarted` (`db_schema_declarative.go:190`), which - // short-circuits `if !local { return nil }` (`:127-128`). So `--local=false` - // selects the local target but must NOT start a stopped stack. + // Target selection keys off flag presence, but auto-start gates on the boolean value, so + // `--local=false` selects the local target but must not start a stopped stack. yield* seam.ensureLocalPostgresImageCurrent(); if (Option.getOrElse(flags.local, () => false)) { yield* seam.ensureLocalDatabaseStarted(); @@ -214,10 +192,8 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene ); } if ((yield* hasDeclarativeFiles(fs, declarativeDir)) && !flags.overwrite) { - // Go asks via Console.PromptYesNo (db_schema_declarative.go:268-270, - // default false): --yes/SUPABASE_YES auto-confirms WITH the - // `<label> [y/N] y` stderr echo (console.go:70-72) — routed through - // `promptYesNo` so the echo is not skipped (CLI-1974). + // `--yes`/`SUPABASE_YES` auto-confirms, but still echoes the `<label> [y/N] y` stderr + // line via `promptYesNo` rather than skipping it. const ok = yield* promptYesNo( output, yes, @@ -232,23 +208,14 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene } } const hasMigrations = yield* hasMigrationFiles(fs, path, migrationsDir); - // Go's `runDeclarativeGenerate` calls `flags.LoadProjectRef` ONLY inside the - // `hasMigrationFiles` branch (`db_schema_declarative.go:219-224`): it offers a - // "Linked project" choice when the workdir is linked, and that `LoadProjectRef` - // sets the global `flags.ProjectRef`, so root `ensureProjectGroupsCached` writes - // the linked-project cache/groups regardless of which target the user then picks - // (`cmd/root.go:176,214-218`). Resolve the ref the same way the resolver's - // `--linked` branch does (config `project_id` → `.temp/project-ref`) — only when - // migrations exist (matching Go's placement; no read in the no-migrations path) — - // and record it for the post-run cache finalizer so smart generate in a linked - // workdir caches like Go even when the user chooses local/custom. + // Only when migrations exist, resolve the ref (config `project_id` → `.temp/project-ref`) + // and record it for the post-run cache finalizer, so smart generate in a linked workdir + // caches the project regardless of which target the user picks. let linkedRef = Option.none<string>(); if (hasMigrations) { - // Smart prompt only decides whether to OFFER the linked choice — Go guards - // this `LoadProjectRef` with `if err == nil` (`db_schema_declarative.go:222-224`), - // ignoring read/validation errors and proceeding with local/custom. So swallow - // a broken `.temp/project-ref` here (omit the linked choice) rather than - // aborting; the explicit `--linked` branch above keeps propagating (hard path). + // Only decides whether to offer the linked choice, so swallow a broken + // `.temp/project-ref` here (omit the choice) rather than aborting; the explicit + // `--linked` branch above still propagates a real failure. linkedRef = Option.isSome(cliSettings.projectId) ? cliSettings.projectId : yield* readProjectRefFile(fs, path, cliSettings.workdir).pipe( @@ -274,11 +241,8 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene const result = yield* generateDeclarativeOutput(run, target); if (!overwrite && (yield* confirmOverwriteHasFiles(fs, declarativeDir))) { - // Go's confirmOverwrite goes through Console.PromptYesNo (`internal/db/ - // declarative/declarative.go:234`, default false): --yes/SUPABASE_YES - // auto-confirms WITH the `<label> [y/N] y` stderr echo (console.go:70-72) - // — routed through `promptYesNo` so the echo is not skipped - // (CLI-1974). + // `--yes`/`SUPABASE_YES` auto-confirms, but still echoes the `<label> [y/N] y` stderr line + // via `promptYesNo` rather than skipping it. const ok = yield* promptYesNo( output, yes, @@ -298,13 +262,9 @@ export const dbSchemaDeclarativeGenerate = Effect.fn("db.schema.declarative.gene yield* warnPreservedUnmanagedDeclarativeFiles(declarativeDirRel, written); yield* output.raw(declarativeSchemaWrittenLine(declarativeDirRel), "stderr"); }).pipe( - // Go's `ensureProjectGroupsCached` PersistentPostRun (`cmd/root.go:176,214-234`) - // writes the linked-project cache (`GET /v1/projects/{ref}` → - // `supabase/.temp/linked-project.json`) for any resolved ref, on success and - // failure. Only explicit `--linked` resolves a ref here (Go gates on - // `flags.ProjectRef != ""`); the cache layer no-ops when the file exists, the - // token is missing, or the GET is non-200. Read the ref lazily — it is assigned - // inside the body above. + // Writes the linked-project cache for any resolved ref, on success and failure. Only + // explicit `--linked` resolves a ref here; the cache layer no-ops when the file exists, the + // token is missing, or the GET is non-200. Effect.ensuring( Effect.suspend(() => linkedProjectRef !== undefined ? linkedProjectCache.cache(linkedProjectRef) : Effect.void, @@ -321,15 +281,10 @@ const hasDeclarativeFiles = Effect.fnUntraced(function* (fs: FileSystem.FileSyst return entries.length > 0; }); -// The overwrite-confirmation guard, mirroring Go's `confirmOverwrite` -// (`apps/cli-go/internal/db/declarative/declarative.go:220-235`). Unlike the -// smart-mode `hasDeclarativeFiles` above (which matches `cmd.hasDeclarativeFiles` -// and swallows read errors), `confirmOverwrite` returns the `ReadDir` error and -// `Generate` aborts on it (`declarative.go:123-127`). So an unreadable-but-existing -// declarative dir must abort here rather than read as "empty" and get silently -// overwritten by `writeDeclarativeSchemas`. Only a not-exist directory means -// "no confirmation needed"; Go returns the raw error, so let the `PlatformError` -// propagate unwrapped. +// The overwrite-confirmation guard. Unlike the smart-mode `hasDeclarativeFiles` above (which +// swallows read errors), an unreadable-but-existing declarative dir must abort here rather than +// read as "empty" and get silently overwritten by `writeDeclarativeSchemas`; only a not-exist +// directory means "no confirmation needed", so let any other `PlatformError` propagate unwrapped. const confirmOverwriteHasFiles = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, dir: string, @@ -351,11 +306,9 @@ const hasMigrationFiles = Effect.fnUntraced(function* ( path: Path.Path, migrationsDir: string, ) { - // Smart-mode presence/prompt probe only: mirror Go's `cmd.hasMigrationFiles` - // (`db_schema_declarative.go:164-169`), which wraps `migration.ListLocalMigrations` - // and returns `false` on EVERY error (unreadable dir, path-is-a-file, …), not just - // not-exist — so generate continues into the no-migrations local flow. The real diff - // path keeps `listLocalMigrations`' hard error behavior (Go `declarative.go:369`). + // Smart-mode presence/prompt probe only: returns `false` on any error (unreadable dir, + // path-is-a-file, not-exist, …), so generate continues into the no-migrations local flow. The + // real diff path keeps `listLocalMigrations`'s hard error behavior instead. const migrations = yield* listLocalMigrations(fs, path, migrationsDir).pipe( Effect.orElseSucceed(() => [] as ReadonlyArray<string>), ); diff --git a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts index 8877e5035a..4dab3d6373 100644 --- a/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/generate/generate.integration.test.ts @@ -90,9 +90,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { const localPostgresImageChecks: Array<true> = []; let ensureStartedCalls = 0; const platformApi = mockCommandPlatformApiService({}); - // Backs `resetLocalDatabase`'s real, native container-recreate — reached - // when the smart-target local-reset prompt is confirmed (CLI-2062: it now runs - // in-process instead of shelling out to a second `supabase-go` child). + // Backs `resetLocalDatabase`'s real, native container-recreate, reached when the smart-target + // local-reset prompt is confirmed. const child = mockContainerCliSpawner( defaultLocalResetRoute("test", { running: opts.resetShouldFail !== true }), ); @@ -225,9 +224,9 @@ function setup(workdir: string, opts: SetupOpts = {}) { make: CommandPlatformApi.pipe(Effect.provide(platformApi.layer)), }), BunServices.layer, - // `child.layer` must be listed AFTER `BunServices.layer` — `Layer.mergeAll` - // resolves a duplicate service tag to whichever layer is listed LAST, so this - // mock overrides Bun's real `ChildProcessSpawner` instead of the reverse. + // `child.layer` must be listed after `BunServices.layer` — `Layer.mergeAll` resolves a + // duplicate service tag to whichever layer is listed last, so this mock overrides Bun's + // real `ChildProcessSpawner` instead of the reverse. child.layer, runtimeInfo, processControl.layer, @@ -284,9 +283,6 @@ describe("db schema declarative generate integration", () => { }); it.effect("--local --linked with --experimental fails with the mutex error", () => { - // Go's declarative PersistentPreRunE gate (db_schema_declarative.go:49-99) runs - // BEFORE cobra's ValidateFlagGroups() mutex check (cobra@v1.10.2/command.go:985, - // 1010), so the mutex error only surfaces once the gate is open. const { layer } = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { const exit = yield* Effect.exit( @@ -304,9 +300,6 @@ describe("db schema declarative generate integration", () => { it.effect( "--local --linked without --experimental fails with the gate error, not the mutex error", () => { - // Mirrors storage's experimental-gate-vs-mutex ordering fix (CLI-1855 / CLI-1876): - // the pg-delta gate runs before the mutex check, so an unopened gate wins even - // when the flags would also violate mutual exclusivity. const { layer } = setup(tmp.current, { experimental: false }); return Effect.gen(function* () { const exit = yield* Effect.exit( @@ -323,11 +316,6 @@ describe("db schema declarative generate integration", () => { it.effect( "--local --linked with SUPABASE_EXPERIMENTAL env (no --experimental flag) fails with the mutex error", () => { - // Go's gate reads viper.GetBool("EXPERIMENTAL") (db_schema_declarative.go:78), - // which picks up SUPABASE_EXPERIMENTAL via viper.AutomaticEnv (root.go:318-334), - // so an env-only experimental session still opens the gate and lets the mutex - // check fire. resolveExperimental (not the raw ExperimentalFlag) is - // what makes the TS gate honor the env var the same way. const { layer } = setup(tmp.current, { experimental: false }); const ENV = "SUPABASE_EXPERIMENTAL"; return Effect.gen(function* () { @@ -353,10 +341,6 @@ describe("db schema declarative generate integration", () => { it.effect( "an explicit --experimental=false closes the gate even when SUPABASE_EXPERIMENTAL is set", () => { - // viper's bound-pflag lookup returns the flag value whenever Changed is true — - // BEFORE falling back to AutomaticEnv (viper@v1.21.0/viper.go:1176-1178) — so an - // explicit --experimental=false must win over SUPABASE_EXPERIMENTAL=1, closing the - // gate instead of letting the env value override it. const { layer } = setup(tmp.current, { experimental: false, args: ["db", "schema", "declarative", "generate", "--experimental=false"], @@ -379,12 +363,6 @@ describe("db schema declarative generate integration", () => { it.effect( "--local --linked with SUPABASE_EXPERIMENTAL set only in the project .env fails with the mutex error", () => { - // Go's flags.LoadConfig runs loadNestedEnv (which os.Setenv's each project-.env key) - // before dbDeclarativeCmd.PersistentPreRunE reads viper.GetBool("EXPERIMENTAL") - // (apps/cli-go/cmd/db_schema_declarative.go:73-78, deleted in CLI-1970; last - // present at commit 7b469f5b3; pkg/config/config.go:789), so a - // SUPABASE_EXPERIMENTAL set only in supabase/.env opens the gate and lets the mutex - // check fire, same as the shell-env case above. const saved = process.env["SUPABASE_EXPERIMENTAL"]; delete process.env["SUPABASE_EXPERIMENTAL"]; mkdirSync(join(tmp.current, "supabase"), { recursive: true }); @@ -434,7 +412,6 @@ describe("db schema declarative generate integration", () => { const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { yield* dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); - // The engine receives the local database endpoint as the export target. expect(s.engineExportCalls[0]!.targetRef).toContain( "postgresql://postgres:postgres@127.0.0.1:54322", ); @@ -445,8 +422,6 @@ describe("db schema declarative generate integration", () => { ), ); expect(written).toBe("create table players ();"); - // Go prints the relative `utils.GetDeclarativeDir()` verbatim - // (`declarative.go:156`) — never the resolved absolute dir. expect( s.out.rawChunks.map((c) => ({ text: stripAnsi(c.text), stream: c.stream })), ).toContainEqual({ @@ -454,7 +429,6 @@ describe("db schema declarative generate integration", () => { stream: "stderr", }); expect(s.out.rawChunks.some((c) => c.text.includes(tmp.current))).toBe(false); - // Go runs ensureLocalDatabaseStarted before generating from local. expect(s.ensureStartedCalls).toBe(1); }).pipe(Effect.provide(s.layer)); }); @@ -575,10 +549,9 @@ describe("db schema declarative generate integration", () => { }); it.effect("honors --yes to overwrite existing declarative files without prompting", () => { - // Pre-seed the declarative dir so the overwrite branch is reached. With --yes, - // Go's confirmOverwrite returns true immediately (Console.PromptYesNo); the - // handler must skip the prompt and overwrite. No promptConfirmResponses are - // queued, so reaching the prompt would error — success proves --yes bypassed it. + // Pre-seeds the declarative dir so the overwrite branch is reached. No + // `promptConfirmResponses` are queued, so reaching the prompt would error — success proves + // `--yes` bypassed it. mkdirSync(join(tmp.current, "supabase", "schemas"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "schemas", "existing.sql"), "create table x ();"); const s = setup(tmp.current, { experimental: true, yes: true }); @@ -595,11 +568,7 @@ describe("db schema declarative generate integration", () => { }); it.effect("aborts (does not overwrite) when the declarative dir cannot be read", () => { - // Go's confirmOverwrite returns the ReadDir error and Generate aborts on it - // (declarative.go:123-127, 226-229), rather than treating an unreadable existing - // dir as empty and letting WriteDeclarativeSchemas wipe/recreate the path. - // Seeding supabase/schemas as a FILE makes readDirectory fail with ENOTDIR (a - // non-NotFound PlatformError), so the command must fail without writing. + // Seeding `supabase/schemas` as a file makes `readDirectory` fail with ENOTDIR. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "schemas"), "not a directory"); const s = setup(tmp.current, { experimental: true }); @@ -608,8 +577,6 @@ describe("db schema declarative generate integration", () => { dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })), ); expect(Exit.isFailure(exit)).toBe(true); - // The declarative path is untouched — still our seeded file, never wiped and - // rewritten as a directory of schema files. expect(readFileSync(join(tmp.current, "supabase", "schemas"), "utf8")).toBe( "not a directory", ); @@ -625,14 +592,11 @@ describe("db schema declarative generate integration", () => { yield* dbSchemaDeclarativeGenerate(flags({ dbUrl: Option.some("postgres://remote/db") })); expect(s.resolverCalls.length).toBe(1); expect(s.engineExportCalls[0]!.targetRef).toContain("@db.remote:5432"); - // Remote target → the local stack is never started. expect(s.ensureStartedCalls).toBe(0); }).pipe(Effect.provide(s.layer)); }); it.effect("writes to an absolute declarative_schema_path as-is (no workdir prefix)", () => { - // Go's config resolver leaves an absolute declarative_schema_path unchanged; path.join - // would mangle /repo + /abs into /repo/abs. const absSchema = mkdtempSync(join(tmpdir(), "decl-abs-")); mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( @@ -647,12 +611,10 @@ describe("db schema declarative generate integration", () => { const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { yield* dbSchemaDeclarativeGenerate(flags({ local: Option.some(true) })); - // File lands under the absolute path, NOT tmp.current/<absSchema>. expect(existsSync(join(absSchema, "public", "tables", "players.sql"))).toBe(true); expect(readFileSync(join(absSchema, "public", "tables", "players.sql"), "utf8")).toBe( "create table players ();", ); - // Go prints the configured value verbatim — absolute here, never workdir-prefixed. expect( s.out.rawChunks.map((c) => ({ text: stripAnsi(c.text), stream: c.stream })), ).toContainEqual({ @@ -664,9 +626,6 @@ describe("db schema declarative generate integration", () => { }); it.effect("explicit --linked applies a matching [remotes.<ref>] schema-path override", () => { - // Go re-loads config with the linked ref (root ParseDatabaseConfig), so a matching - // [remotes.<ref>] block overrides experimental.pgdelta.declarative_schema_path — - // the declarative files must land under the remote-overridden path. const ref = "abcdefghijklmnopqrst"; mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( @@ -692,23 +651,19 @@ describe("db schema declarative generate integration", () => { ), ); expect(written).toBe("create table players ();"); - // The resolved linked ref is threaded into the engine export as projectRef, so - // the export's platform setup uses the [remotes.<ref>]-merged config. expect(s.engineExportCalls[0]!.projectRef).toBe(ref); }).pipe(Effect.provide(s.layer)); }); it.effect("--linked=false is an explicit linked target (Go gates on flag.Changed)", () => { - // pflag marks `--linked=false` as Changed, so Go takes the explicit linked path - // rather than smart mode. Non-interactive (no TTY, no --yes) so a smart-mode - // fall-through would fail with "specify a target" — assert it does NOT. + // Non-interactive (no TTY, no --yes), so a smart-mode fall-through would fail with "specify + // a target" instead. const s = setup(tmp.current, { experimental: true, stdinIsTty: false }); return Effect.gen(function* () { const exit = yield* Effect.exit( dbSchemaDeclarativeGenerate(flags({ linked: Option.some(false) })), ); expect(Exit.isSuccess(exit)).toBe(true); - // Took the explicit linked path: the resolver was called with connType "linked". expect(s.resolverCalls).toContainEqual(expect.objectContaining({ connType: "linked" })); }).pipe(Effect.provide(s.layer)); }); @@ -723,14 +678,10 @@ describe("db schema declarative generate integration", () => { }); it.effect("--local=false selects the local target but does NOT auto-start the stack", () => { - // Go selects local on flag.Changed but gates ensureLocalDatabaseStarted on the - // bool value (declarativeLocal), so `--local=false` must not start a stopped stack. const s = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { yield* dbSchemaDeclarativeGenerate(flags({ local: Option.some(false) })); - // Took the explicit local target and ran the export ... expect(s.engineExportCalls).toHaveLength(1); - // ... but did NOT auto-start (value is false). expect(s.ensureStartedCalls).toBe(0); expect(s.localPostgresImageChecks).toHaveLength(1); }).pipe(Effect.provide(s.layer)); @@ -739,9 +690,6 @@ describe("db schema declarative generate integration", () => { it.effect( "explicit --linked gates pg-delta on base config, not a remote enabled override", () => { - // Go gates pg-delta on the base LoadConfig (declarative PersistentPreRunE) before the - // root ParseDatabaseConfig reloads the remote block, so a remote enabled=true must NOT - // enable a base-disabled command without --experimental. const ref = "abcdefghijklmnopqrst"; mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( @@ -796,10 +744,8 @@ describe("db schema declarative generate integration", () => { }); it.effect("smart mode: --yes regenerates over existing files without prompting", () => { - // Go's overwrite question goes through Console.PromptYesNo, which auto-accepts - // under --yes, so existing declarative files are regenerated (not skipped) and - // no prompt is shown. No migrations → the smart target resolves to local without - // a further prompt. No promptConfirmResponses are queued, so a prompt would throw. + // No migrations, so the smart target resolves to local without a further prompt. No + // `promptConfirmResponses` are queued, so a prompt would throw. const declDir = join(tmp.current, "supabase", "schemas"); mkdirSync(declDir, { recursive: true }); writeFileSync(join(declDir, "existing.sql"), "-- existing"); @@ -807,9 +753,6 @@ describe("db schema declarative generate integration", () => { return Effect.gen(function* () { yield* dbSchemaDeclarativeGenerate(flags()); expect(s.engineExportCalls).toHaveLength(1); - // Go's PromptYesNo echoes the auto-accepted question to stderr under the - // global YES flag (`console.go:70-72`) — the echo must not be skipped, and - // the prompt renders the relative dir (`db_schema_declarative.go:268`). expect(stripAnsi(s.out.stderrText)).toContain( `Declarative schema already exists at ${join("supabase", "schemas")}. Regenerate from database? This will overwrite existing files. [y/N] y\n`, ); @@ -820,8 +763,6 @@ describe("db schema declarative generate integration", () => { }); it.effect("smart mode: SUPABASE_YES=1 regenerates over existing files like --yes", () => { - // Go reads `viper.GetBool("YES")`, which `AutomaticEnv` also binds to the - // SUPABASE_YES env var — the flag alone is not the whole surface (CLI-1974). const declDir = join(tmp.current, "supabase", "schemas"); mkdirSync(declDir, { recursive: true }); writeFileSync(join(declDir, "existing.sql"), "-- existing"); @@ -873,9 +814,6 @@ describe("db schema declarative generate integration", () => { }); it.effect("smart mode: propagates a reset failure instead of exiting the process", () => { - // Go runs reset in-process and returns the error; `resetLocalDatabase` now - // runs the same way (CLI-2062), so its real failure must fail the effect (so - // telemetry flush / error handling run) rather than process.exit via GoProxy. mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { @@ -890,15 +828,12 @@ describe("db schema declarative generate integration", () => { expect(failError(exit)).toMatchObject({ message: "database reset failed: supabase start is not running.", }); - // Failed before any destructive container work. expect(localResetRemovedContainers(s.child.spawned)).toEqual([]); }).pipe(Effect.provide(s.layer)); }); it.effect("smart mode: offers and resolves the linked project when the workdir is linked", () => { - // Go's runDeclarativeGenerate adds a "Linked project" choice when LoadProjectRef - // succeeds; selecting it builds the URL via NewDbConfigWithPassword (the --linked - // path). Use a valid 20-char ref so the choice is shown. + // A valid 20-char ref is required for the linked choice to show. mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { @@ -909,8 +844,6 @@ describe("db schema declarative generate integration", () => { }); return Effect.gen(function* () { yield* dbSchemaDeclarativeGenerate(flags()); - // The prompt offered the linked choice, and selecting it routed through the - // resolver's --linked branch. const options = s.out.promptSelectCalls[0]?.options ?? []; expect(options.map((o) => o.value)).toEqual(["local", "linked", "custom"]); expect(s.resolverCalls).toContainEqual(expect.objectContaining({ connType: "linked" })); @@ -941,11 +874,6 @@ describe("db schema declarative generate integration", () => { it.effect( "smart mode: caches the linked project even when the user picks local (Go PostRun)", () => { - // Go's runDeclarativeGenerate calls LoadProjectRef inside the hasMigrationFiles - // branch to offer the linked choice, which sets the global flags.ProjectRef; root - // ensureProjectGroupsCached then writes the linked-project cache regardless of - // which target the user picks (cmd/root.go:176,214-218). So a linked workdir + - // smart mode + "Local database" choice must still cache. mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { @@ -958,12 +886,9 @@ describe("db schema declarative generate integration", () => { return Effect.gen(function* () { yield* dbSchemaDeclarativeGenerate(flags()); expect(s.cache.cached).toBe(true); - // This scenario also runs a real in-process local reset - // (`resetLocalDatabase`, CLI-2062) — its own body never touches the - // linked-project cache or telemetry, so the outer command's single - // `Effect.ensuring` finalizer must still fire EXACTLY once each, not - // twice, matching Go's single-process `reset.Run` (no second - // `PersistentPostRun` from a separate child process). + // The in-process local reset's own body never touches the linked-project cache or + // telemetry, so the outer command's single `Effect.ensuring` finalizer must still fire + // exactly once each, not twice. expect(s.cache.cacheCount).toBe(1); expect(s.telemetry.flushCount).toBe(1); }).pipe(Effect.provide(s.layer)); @@ -971,17 +896,12 @@ describe("db schema declarative generate integration", () => { ); it.effect("smart mode: does not cache when no migrations exist (Go skips LoadProjectRef)", () => { - // With no migrations, Go never enters the hasMigrationFiles branch, so it never - // calls LoadProjectRef and flags.ProjectRef stays empty — no cache, even though - // the workdir has a project_id. const s = setup(tmp.current, { experimental: true, yes: true, projectId: Option.some("abcdefghijklmnopqrst"), }); return Effect.gen(function* () { - // No migrations dir → smart target resolves to local without offering linked - // (--yes satisfies the non-interactive gate). yield* dbSchemaDeclarativeGenerate(flags()); expect(s.cache.cached).toBe(false); }).pipe(Effect.provide(s.layer)); @@ -1004,26 +924,19 @@ describe("db schema declarative generate integration", () => { }); it.effect("smart mode: an unreadable migrations path is treated as no migrations", () => { - // Go's cmd.hasMigrationFiles returns false on ANY ListLocalMigrations error - // (db_schema_declarative.go:164-169), flowing into the no-migrations local generate. - // Seeding supabase/migrations as a FILE makes the list fail with ENOTDIR — the smart - // probe must swallow it and proceed, not abort. + // Seeding `supabase/migrations` as a file makes the list fail with ENOTDIR. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "migrations"), "not a directory"); const s = setup(tmp.current, { experimental: true, yes: true }); return Effect.gen(function* () { const exit = yield* Effect.exit(dbSchemaDeclarativeGenerate(flags())); expect(Exit.isSuccess(exit)).toBe(true); - // No migrations → local generate path started the stack (not aborted on the read). expect(s.ensureStartedCalls).toBe(1); }).pipe(Effect.provide(s.layer)); }); it.effect("smart mode: an unreadable ref file just omits the linked choice", () => { - // Go guards the smart-prompt LoadProjectRef with `if err == nil` - // (db_schema_declarative.go:222-224): a broken .temp/project-ref omits the linked - // choice and local/custom generation proceeds. Seeding project-ref as a DIRECTORY - // makes the read fail; the smart read must swallow it, not abort. + // Seeding `.temp/project-ref` as a directory makes the read fail. mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); mkdirSync(join(tmp.current, "supabase", ".temp", "project-ref"), { recursive: true }); @@ -1037,7 +950,6 @@ describe("db schema declarative generate integration", () => { return Effect.gen(function* () { const exit = yield* Effect.exit(dbSchemaDeclarativeGenerate(flags())); expect(Exit.isSuccess(exit)).toBe(true); - // Linked choice omitted (ref unreadable), and nothing cached as linked. expect((s.out.promptSelectCalls[0]?.options ?? []).map((o) => o.value)).toEqual([ "local", "custom", @@ -1047,15 +959,12 @@ describe("db schema declarative generate integration", () => { }); it.effect("smart mode: --yes auto-resets the local database without prompting", () => { - // Go's Console.PromptYesNo auto-returns true under the global --yes flag, so the - // "Reset local database to match migrations first?" prompt must be skipped and the - // reset must run. No promptConfirmResponses are supplied, so a prompt would throw. + // No `promptConfirmResponses` are supplied, so a prompt would throw. mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); // `resetLocalDatabase`'s container-recreate resolves its own project id from - // `@supabase/config` (config.toml / real env), independently of the mocked - // `CommandSettings.projectId` — pin it to "test" so the recreated container name - // matches the spawner route's assumption. + // `@supabase/config`, independently of the mocked `CommandSettings.projectId` — pin it to + // "test" so the recreated container name matches the spawner route's assumption. writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); const s = setup(tmp.current, { experimental: true, @@ -1065,8 +974,6 @@ describe("db schema declarative generate integration", () => { }); return Effect.gen(function* () { yield* dbSchemaDeclarativeGenerate(flags()); - // The reset actually ran — recreated the local `db` container in-process - // (CLI-2062: no `supabase-go` child) — proving it's a real effect. expect(localResetRemovedContainers(s.child.spawned)).toContain("supabase_db_test"); expect(localResetCreateArgs(s.child.spawned)).not.toBeUndefined(); expect(s.out.rawChunks.some((c) => c.text.includes("Resetting local database"))).toBe(true); @@ -1074,9 +981,6 @@ describe("db schema declarative generate integration", () => { }); it.effect("smart mode: forwards --network-id to the local reset", () => { - // `resetLocalDatabase` resolves `NetworkIdFlag` itself from the - // shared context (CLI-2062) — no argv-forwarding needed — so the recreated - // container must land on the custom network directly. mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); @@ -1097,8 +1001,6 @@ describe("db schema declarative generate integration", () => { }); it.effect("smart mode: rejects a malformed custom database URL", () => { - // Go parses the custom URL with pgconn.ParseConfig and fails with - // "failed to parse connection string: ..." rather than passing it to pg-delta. mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { @@ -1128,7 +1030,6 @@ describe("db schema declarative generate integration", () => { }); return Effect.gen(function* () { yield* dbSchemaDeclarativeGenerate(flags()); - // Normalized via ToPostgresURL → connect_timeout appended, like Go. expect(s.engineExportCalls[0]!.targetRef).toContain( "@db.example.com:5432/app?connect_timeout=", ); diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.command.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.command.ts index 2bb511c109..c196f92522 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.command.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.command.ts @@ -14,11 +14,8 @@ const config = { Flag.withAlias("s"), Flag.withDescription("Comma separated list of schema to include."), Flag.atLeast(0), - // Go registers `--schema` as a cobra `StringSliceVarP` - // (`apps/cli-go/cmd/db_schema_declarative.go:484`, deleted in CLI-1970; - // last present at commit 7b469f5b3), which CSV-splits each - // occurrence so `-s public,auth` includes the two schemas separately. Mirror - // the `gen types` / `db lint` parsing so quoted commas are handled the same way. + // CSV-splits each occurrence so `-s public,auth` includes the two schemas separately, same + // as `gen types`/`db lint`'s quoted-comma parsing. Flag.mapTryCatch( (rawValues) => parseSchemaFlags(rawValues), (err) => (err instanceof Error ? err.message : String(err)), @@ -33,10 +30,8 @@ const config = { Flag.withDescription("Name for the generated migration file."), Flag.optional, ), - // cobra's `MarkFlagsMutuallyExclusive("apply", "no-apply")` keys off `flag.Changed`, - // not the value (`cmd/db_schema_declarative.go:561`), so model presence with `Option` - // so `--apply=false --no-apply` still trips the conflict. The apply decision below - // reads the resolved value via `Option.getOrElse`. + // Mutually exclusive with `--no-apply`, keyed off presence not value, so model with `Option` + // so `--apply=false --no-apply` still trips the conflict. apply: Flag.boolean("apply").pipe( Flag.withDescription("Apply the generated migration to the local database without prompting."), Flag.optional, @@ -81,10 +76,8 @@ export const dbSchemaDeclarativeSyncCommand = Command.make("sync", config).pipe( apply: merged.apply, "no-apply": merged.noApply, }, - // Go registers `--schema`/`-s` (StringSliceVarP) and `--file`/`-f` - // (StringVarP) (`cmd/db_schema_declarative.go:484-485`); telemetry reports - // changed flags by canonical `flag.Name` via `pflag.Visit`, so map the - // shorthands so `sync -s public -f out.sql` logs `schema`/`file`. + // Telemetry reports changed flags by canonical name, so map the shorthands: `sync + // -s public -f out.sql` must log `schema`/`file`. aliases: { s: "schema", f: "file" }, }), withJsonErrorHandling, diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.e2e.test.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.e2e.test.ts index 6269054bdd..532ad6bce9 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.e2e.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.e2e.test.ts @@ -170,9 +170,8 @@ describe("db schema declarative sync (e2e)", () => { expect(converged.exitCode, commandFailure(converged)).toBe(0); expect(`${converged.stdout}${converged.stderr}`).toContain("No schema changes found"); - // Extension-managed objects on the converged tree (CLI-2282). The global - // e2e afterEach tears the stack project down after every test, so this - // continues in the same test rather than a second one. + // Extension-managed objects on the converged tree. The stack teardown after every test + // means this continues in the same test rather than a second one. const jobsPath = path.join(projectDir, "supabase", "schemas", "jobs.sql"); const runSync = (name: string) => runSupabase( @@ -213,7 +212,7 @@ describe("db schema declarative sync (e2e)", () => { expect(added.sql).toMatch(/cron\.schedule(?:_in_database)?\('nightly_cleanup'/); expect(added.sql).toContain("pgmq.create('emails')"); - // Rename the job and drop the queue: previously refused as a legacy export. + // Rename the job and drop the queue — this must not be refused as a legacy export. writeFileSync( jobsPath, "select cron.schedule('weekly_cleanup', '0 3 * * 0', $$delete from public.disposable_note$$);\n", diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts index 7faf3976f0..f061a8e813 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.handler.ts @@ -76,7 +76,7 @@ import type { DbSchemaDeclarativeSyncFlags } from "./sync.command.ts"; const DEFAULT_SYNC_NAME = "declarative_sync"; -/** Go's `GetCurrentTimestamp`: UTC `YYYYMMDDHHmmss`. */ +/** UTC timestamp format `YYYYMMDDHHmmss`. */ const formatTimestamp = (millis: number): string => new Date(millis).toISOString().replace(/\D/g, "").slice(0, 14); @@ -89,29 +89,18 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f const path = yield* Path.Path; const cliSettings = yield* CommandSettings; const telemetryState = yield* TelemetryState; - // Go's `dbDeclarativeCmd.PersistentPreRunE` calls `flags.LoadConfig` — which runs - // `loadNestedEnv` and `os.Setenv`s each project-.env key — BEFORE reading - // `viper.GetBool("EXPERIMENTAL")` for the gate below (`apps/cli-go/cmd/ - // db_schema_declarative.go:73-78`, `pkg/config/config.go:789`). Load the project env - // first and resolve against it, as `db reset` does for its own experimental gate, so a - // `SUPABASE_EXPERIMENTAL` set only in `supabase/.env` opens the gate too. + // The project env is loaded and resolved before the gate below, so a `SUPABASE_EXPERIMENTAL` + // set only in `supabase/.env` opens the gate too. const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); const experimental = yield* resolveExperimentalWithProjectEnv(projectEnv); - // `--yes` OR `SUPABASE_YES` (shell env or project `.env`): Go's prompts here - // read `viper.GetBool("YES")` after `loadNestedEnv`, so the env var must - // auto-confirm too, not just the flag (CLI-1974). + // `--yes` or `SUPABASE_YES` (shell env or project `.env`) must auto-confirm the prompts below. const yes = yield* resolveYesWithProjectEnv(projectEnv); const dnsResolver = yield* DnsResolverFlag; const seam = yield* DeclarativeSeam; const linkedProjectCache = yield* LinkedProjectCache; - // Go's sync bootstrap delegates to `runDeclarativeGenerate`, whose - // `flags.LoadProjectRef` (called inside the `hasMigrationFiles` branch) sets the - // global `flags.ProjectRef`; root `ensureProjectGroupsCached` then writes the - // linked-project cache/groups on success or failure (`cmd/root.go:176,214-218`). - // Captured in the bootstrap branch below; the finalizer on the whole handler body - // reads it. Declared at handler scope so it is visible to both the body and the - // `.pipe` finalizer. + // Set when the bootstrap branch below resolves a linked ref, so the `Effect.ensuring` + // finalizer at the end of this handler can refresh the linked-project cache. let linkedProjectRef: string | undefined; yield* Effect.gen(function* () { @@ -124,13 +113,8 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f configPath: path.join("supabase", "config.toml"), }); - // cobra `MarkFlagsMutuallyExclusive("apply", "no-apply")` - // (`apps/cli-go/cmd/db_schema_declarative.go:561`, deleted in CLI-1970; - // last present at commit 7b469f5b3) runs via - // `ValidateFlagGroups()`, which cobra invokes AFTER `PersistentPreRunE` (the - // gate above) — see requirePgDelta's doc comment for the full ordering. - // Reject the conflict here rather than letting `--no-apply` silently win in - // the apply-decision helper. + // Mutually-exclusive apply/no-apply group, checked after the gate above. Reject the conflict + // here rather than letting `--no-apply` silently win in the apply-decision helper. const exclusive: Array<string> = []; if (Option.isSome(flags.apply)) exclusive.push("apply"); if (Option.isSome(flags.noApply)) exclusive.push("no-apply"); @@ -142,14 +126,11 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f ); } - // Go's `utils.GetDeclarativeDir()` — the config value verbatim (already - // `supabase/`-prefixed when relative) or the relative `supabase/schemas` - // default. Printed verbatim in the bootstrap's written-to line below, exactly - // as Go prints it (Go chdirs into the workdir, so its paths stay relative). + // The config value verbatim (already `supabase/`-prefixed when relative) or the relative + // `supabase/schemas` default; printed verbatim in the bootstrap's written-to line below. const declarativeDirRel = resolveDeclarativeDir(path, toml.pgDelta); - // `path.resolve` (not `path.join`) so an absolute `declarative_schema_path` is - // used as-is, matching Go's `config.resolve` (which only prefixes the workdir onto - // a relative path). `path.join(workdir, abs)` would mangle the absolute path. + // `path.resolve` (not `path.join`) so an absolute `declarative_schema_path` is used as-is; + // `path.join(workdir, abs)` would mangle an absolute path. const declarativeDir = path.resolve(cliSettings.workdir, declarativeDirRel); const stagedDirRel = resolveStagedDeclarativeDir(declarativeDirRel); // Repair prompts name the file they would edit by its full configured path — @@ -159,11 +140,10 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f const tempDir = pgDeltaTempPath(path, cliSettings.workdir); const run: DeclarativeRunContext = { pgDelta: { - // `resolvePgDeltaProjectId` mirrors Go's `Config.ProjectId` singleton - // (`SUPABASE_PROJECT_ID` env → config.toml's `project_id` → sanitized workdir - // basename) — NOT `cliSettings.projectId` alone, which is env-only and resolves to - // `""` for a project relying on config.toml's `project_id` or the workdir-basename - // default, mounting the WRONG `supabase_edge_runtime_` Deno-cache volume. + // `resolvePgDeltaProjectId` resolves `SUPABASE_PROJECT_ID` env → config.toml's + // `project_id` → sanitized workdir basename — not `cliSettings.projectId` alone, which + // is env-only and would mount the wrong `supabase_edge_runtime_` Deno-cache volume for a + // project relying on config or the workdir-basename default. projectId: resolvePgDeltaProjectId(cliSettings.projectId, toml, cliSettings.workdir), cwd: cliSettings.workdir, denoVersion: toml.denoVersion, @@ -182,11 +162,9 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f yield* warnFormerDeclarativeDefault(fs, path, cliSettings.workdir, toml.pgDelta); const declarativeFilesExist = yield* declarativeDirHasFiles(fs, declarativeDir); - // Go's `saveApplyDebugBundle`: warn (rather than masking the apply error) and - // treat the bundle path as empty when the debug directory cannot be created, so - // an apply failure still surfaces without claiming a bundle was saved - // (`apps/cli-go/cmd/db_schema_declarative.go:447-461`, deleted in - // CLI-1970; last present at commit 7b469f5b3). + // Warns (rather than masking the apply error) and treats the bundle path as empty when the + // debug directory cannot be created, so an apply failure still surfaces without claiming a + // bundle was saved. const saveApplyDebugBundle = (bundle: DebugBundle) => saveDebugBundle(fs, path, cliSettings.workdir, tempDir, migrationsDir, bundle).pipe( Effect.matchEffect({ @@ -204,10 +182,8 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f message: "no declarative schema found. Run supabase db schema declarative generate first", }); if (!tty.stdinIsTty && !yes) return yield* Effect.fail(noFiles); - // Go asks via Console.PromptYesNo (db_schema_declarative.go:381, default - // true): --yes/SUPABASE_YES auto-confirms WITH the `<label> [Y/n] y` - // stderr echo (console.go:70-72) — routed through `promptYesNo` - // so the echo is not skipped (CLI-1974). + // `--yes`/`SUPABASE_YES` auto-confirms, but still echoes the `<label> [Y/n] y` stderr line + // via `promptYesNo` rather than skipping it. const ok = yield* promptYesNo( output, yes, @@ -215,32 +191,22 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f true, ); if (!ok) return yield* Effect.fail(noFiles); - // Go delegates to the full smart-generate flow (`runDeclarativeGenerate`, - // db_schema_declarative.go:321): with migrations present it offers the - // local / linked / custom target choice + local-reset prompt, so a linked - // workdir can bootstrap from the remote rather than silently using local. - // Smart-mode presence probe only: Go's delegated `runDeclarativeGenerate` uses - // `hasMigrationFiles`, which returns `false` on ANY `ListLocalMigrations` error - // (`db_schema_declarative.go:164-169`), flowing into the no-migrations local - // generate. Swallow read errors here so an unreadable/file migrations path - // doesn't abort the bootstrap; the diff path below keeps the hard list behavior. + // Delegates to the full smart-generate flow: with migrations present it offers the + // local/linked/custom target choice plus a local-reset prompt. The presence probe below + // swallows read errors rather than aborting the bootstrap; the diff path further down + // keeps the hard list behavior. const hasMigrations = (yield* listLocalMigrations(fs, path, migrationsDir).pipe( Effect.orElseSucceed(() => [] as ReadonlyArray<string>), )).length > 0; - // Go calls `flags.LoadProjectRef` only inside `runDeclarativeGenerate`'s - // `hasMigrationFiles` branch (`db_schema_declarative.go:219-224`), which sets - // the global `flags.ProjectRef` so the post-run cache fires regardless of the - // chosen target. Resolve the ref the same way (config `project_id` → - // `.temp/project-ref`), only when migrations exist, and record it for the - // finalizer so a linked-workdir bootstrap caches like Go. + // Only when migrations exist, resolve the ref (config `project_id` → `.temp/project-ref`) + // and record it for the finalizer, so a linked-workdir bootstrap caches regardless of the + // chosen target. let linkedRef = Option.none<string>(); if (hasMigrations) { - // Smart prompt only decides whether to OFFER the linked choice — Go guards - // `LoadProjectRef` with `if err == nil` (`db_schema_declarative.go:222-224`), - // ignoring read errors and continuing with local/custom. Swallow a broken - // `.temp/project-ref` here; `linkedProjectRef` then stays unset so the post-run - // cache correctly does not fire (Go leaves `flags.ProjectRef` empty on error). + // Only decides whether to offer the linked choice, so swallow a broken + // `.temp/project-ref` here; `linkedProjectRef` then stays unset so the post-run cache + // correctly does not fire. linkedRef = Option.isSome(cliSettings.projectId) ? cliSettings.projectId : yield* readProjectRefFile(fs, path, cliSettings.workdir).pipe( @@ -250,8 +216,8 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f linkedProjectRef = linkedRef.value; } } - // sync has no target flags (Go passes its target-less `cmd` into generate), - // so reset stays interactive (the prompt fires under the local choice). + // sync has no target flags, so reset stays interactive (the prompt fires under the local + // choice). const target = yield* resolveSmartTargetEndpoint( { dbUrl: Option.none(), linked: Option.none(), password: Option.none(), reset: false }, { port: toml.port, password: toml.password }, @@ -274,12 +240,9 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f }), ); } - // Go's delegated `declarative.Generate` prints the written-to line to stderr - // after the write and the catalog warm (`declarative.go:133→138-155→156`), on - // both the interactive-accept and --yes/SUPABASE_YES bootstrap paths, and - // regardless of --no-cache (the warm is skipped, the line is not). It prints - // `utils.GetDeclarativeDir()` — the relative dir above, never a resolved - // absolute path, because Go chdirs into the workdir (CLI-1980). + // Printed on both the interactive-accept and --yes/SUPABASE_YES bootstrap paths, and + // regardless of `--no-cache` (only the catalog warm is skipped). Uses the relative dir + // above, never a resolved absolute path. yield* output.raw(declarativeSchemaWrittenLine(declarativeDirRel), "stderr"); } @@ -317,12 +280,9 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f } yield* ensureLocalPostgresImageCurrent; yield* seam.ensureLocalDatabaseStarted(); - // The staged export snapshots the RUNNING local database verbatim — not a - // shadow built from migrations, which is what the failed plan compared. Say - // so, and offer the same reset the smart-target local path offers, so stale - // Studio-made drift does not silently become the staged declarative tree. - // This path is only reachable interactively (both prompts above gate on a - // TTY without --yes), so the prompt always really asks. + // The staged export snapshots the running local database verbatim, not a shadow built + // from migrations (what the failed plan compared) — offer the same reset the smart-target + // local path offers, so stale Studio-made drift doesn't silently become the staged tree. yield* output.raw( `Exporting from the running local database (not the migrations state). Review ${stagedDirRel} before adopting it.\n`, "stderr", @@ -377,8 +337,7 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f migrations, }).pipe( Effect.matchEffect({ - // Go prints nothing when SaveDebugBundle errors on the diff path - // (`db_schema_declarative.go:337-340`: `if saveErr == nil`). + // Prints nothing when the debug bundle itself fails to save. onFailure: () => Effect.void, onSuccess: (debugDir) => output.raw(debugBundleMessage(debugDir), "stderr"), }), @@ -595,8 +554,8 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f // Step 7: apply decision. const decision = resolveDeclarativeSyncApplyDecision({ - // The mutex check above gates on presence (Go `flag.Changed`); the decision - // itself reads the resolved boolean value (Go's `BoolVar` default is false). + // The mutex check above gates on presence; the decision itself reads the resolved boolean + // value (default false). apply: Option.getOrElse(flags.apply, () => false), noApply: Option.getOrElse(flags.noApply, () => false), yes, @@ -624,9 +583,8 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f return; } - // A Ctrl-C or defect during the apply is not a migration-apply failure — - // propagate it unchanged instead of synthesizing a fake - // `DeclarativeApplyError` (review CLI-1958). + // A Ctrl-C or defect during the apply is not a migration-apply failure — propagate it + // unchanged instead of synthesizing a fake `DeclarativeApplyError`. const applyFailure = Cause.findFail(applyExit.cause); if (Result.isFailure(applyFailure)) { return yield* Effect.failCause(applyFailure.failure); @@ -652,25 +610,19 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f { defaultValue: false }, ); if (shouldReset) { - // Go runs reset in-process (`cmd/db_schema_declarative.go:414-423`). - // `resetLocalDatabase` now runs the same way — in-process, sharing this - // command's own context — rather than shelling out to a second `supabase-go` - // child (CLI-2062): it resolves `NetworkIdFlag` itself, so no - // argv-forwarding is needed to stay on a custom network. + // `resetLocalDatabase` runs in-process, sharing this command's own context: it resolves + // `NetworkIdFlag` itself, so no argv-forwarding is needed to stay on a custom network. const resetExit = yield* resetLocalDatabase().pipe(Effect.exit); if (Exit.isFailure(resetExit)) { - // A Ctrl-C or defect during the recovery reset must cancel the command, - // not get rewritten into a synthetic "unknown error" apply failure — - // propagate it unchanged (review CLI-1958). + // A Ctrl-C or defect during the recovery reset must cancel the command, not get + // rewritten into a synthetic "unknown error" apply failure. const resetFailure = Cause.findFail(resetExit.cause); if (Result.isFailure(resetFailure)) { return yield* Effect.failCause(resetFailure.failure); } - // Go returns `resetErr` here, surfacing the failure that actually blocked - // recovery — not the original apply error — and prints it exactly once (no - // extra "database reset failed:" wrapper). Build the reset error from the - // real typed failure and use that one value for the message, suggestion, - // debug bundle, and return. + // Surfaces the failure that actually blocked recovery, not the original apply error, + // printed exactly once (no extra "database reset failed:" wrapper) — build it from the + // real typed failure and reuse that one value for message, suggestion, and bundle. const rawResetFailure = resetFailure.success.error; const resetError = new DeclarativeApplyError({ message: rawResetFailure.message, @@ -688,9 +640,8 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f error: resetError.message, migrations, }); - // Go guards each saved-path line with `len(debugDir) > 0` - // (`db_schema_declarative.go:413-419`), so a bundle that failed to save - // does not print a path that does not exist. + // Guards each saved-path line so a bundle that failed to save doesn't print a path + // that doesn't exist. if (debugDir.length > 0) { yield* output.raw(`\nDebug information saved to ${bold(debugDir)}\n`, "stderr"); } @@ -704,19 +655,14 @@ export const dbSchemaDeclarativeSync = Effect.fn("db.schema.declarative.sync")(f return; } } - // Go: `if len(debugDir) > 0 { PrintDebugBundleMessage(debugDir) }` - // (`db_schema_declarative.go:428-431`). if (debugDir.length > 0) { yield* output.raw(debugBundleMessage(debugDir), "stderr"); } return yield* Effect.fail(applyError); }).pipe( - // Mirror Go's `ensureProjectGroupsCached` PersistentPostRun (`cmd/root.go:176, - // 214-218`): when the bootstrap path resolved a linked ref, write the - // linked-project cache (`GET /v1/projects/{ref}` → `supabase/.temp/ - // linked-project.json`) whether sync succeeds or fails. The cache layer no-ops - // when the file exists / no token / non-200. Only the linked bootstrap sets - // `linkedProjectRef`, so non-linked syncs never trigger this. + // Writes the linked-project cache whenever the bootstrap path resolved a linked ref, whether + // sync succeeds or fails; only the linked bootstrap sets `linkedProjectRef`, so non-linked + // syncs never trigger this. Effect.ensuring( Effect.suspend(() => linkedProjectRef !== undefined ? linkedProjectCache.cache(linkedProjectRef) : Effect.void, @@ -736,7 +682,7 @@ const declarativeDirHasFiles = Effect.fnUntraced(function* ( return entries.length > 0; }); -/** Connects once and applies the ordered migration files (Go's `applyMigrationToLocal`). */ +/** Connects once and applies the ordered migration files. */ const applyMigrationToLocal = ( local: { port: number; password: string; dnsResolver: "native" | "https" }, migrationPaths: ReadonlyArray<string>, @@ -748,10 +694,8 @@ const applyMigrationToLocal = ( const session = yield* dbConnection .connect( { - // Go's applyMigrationToLocal connects with utils.Config.Hostname - // (`apps/cli-go/cmd/db_schema_declarative.go:463`, deleted in - // CLI-1970; last present at commit 7b469f5b3), honoring - // SUPABASE_SERVICES_HOSTNAME / tcp DOCKER_HOST — not a hardcoded loopback. + // Host resolution order: SUPABASE_SERVICES_HOSTNAME → tcp DOCKER_HOST → 127.0.0.1, not + // a hardcoded loopback. host: getHostname(), port: local.port, user: "postgres", diff --git a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts index 95b10245c9..326ed1d387 100644 --- a/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts +++ b/apps/cli/src/commands/db/schema/declarative/sync/sync.integration.test.ts @@ -90,9 +90,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { const cache = mockLinkedProjectCacheTracked(); const localPostgresImageChecks: Array<true> = []; const platformApi = mockCommandPlatformApiService({}); - // Backs `resetLocalDatabase`'s real, native container-recreate — reached - // when the recovery-reset offer is accepted (CLI-2062: it now runs in-process - // instead of shelling out to a second `supabase-go` child). + // Backs `resetLocalDatabase`'s real, native container-recreate, reached when the + // recovery-reset offer is accepted. const child = mockContainerCliSpawner( defaultLocalResetRoute("test", { running: opts.resetShouldFail !== true }), ); @@ -115,14 +114,9 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const dbExec: string[] = []; const dbBatches: Array<ReadonlyArray<string>> = []; - // Go's default `[db] shadow_port` (`db-config.toml-read.ts`'s - // `DEFAULT_SHADOW_PORT`) — none of these tests override it. The migrations- - // catalog resolution's shadow (CLI-1956) now ALSO connects through this same - // fake `DbConnection` for its own platform-baseline setup/migration - // replay, so its SQL (BEGIN/REVOKE.../CREATE DATABASE contrib_regression) must - // be excluded from `dbExec`, which every "not yet applied" assertion below - // expects to stay empty until the REAL local-apply connection - // (`applyMigrationToLocal`, `toml.port`) runs. + // The default `[db] shadow_port` (none of these tests override it). The migrations-catalog + // shadow also connects through this fake `DbConnection`, so its SQL must be excluded from + // `dbExec`, which every "not yet applied" assertion expects to stay empty until real apply. const SHADOW_PORT = 54320; const dbConn = Layer.succeed(DbConnection, { connect: (cfg: PgConnInput) => @@ -255,15 +249,15 @@ function setup(workdir: string, opts: SetupOpts = {}) { networkIdFlag, Layer.succeed(DnsResolverFlag, "native"), debugFlag, - // The local-reset bucket-seed core statically requires the (lazy) Management-API - // factory; never invoked on the local recovery reset (projectRef === ""). + // The local-reset bucket-seed core statically requires the (lazy) Management-API factory, + // though it's never invoked on the local recovery reset. Layer.succeed(CommandPlatformApiFactory, { make: CommandPlatformApi.pipe(Effect.provide(platformApi.layer)), }), BunServices.layer, - // `child.layer` must be listed AFTER `BunServices.layer` — `Layer.mergeAll` - // resolves a duplicate service tag to whichever layer is listed LAST, so this - // mock overrides Bun's real `ChildProcessSpawner` instead of the reverse. + // `child.layer` must be listed after `BunServices.layer` — `Layer.mergeAll` resolves a + // duplicate service tag to whichever layer is listed last, so this mock overrides Bun's + // real `ChildProcessSpawner` instead of the reverse. child.layer, runtimeInfo, processControl.layer, @@ -358,9 +352,6 @@ describe("db schema declarative sync integration", () => { }); it.effect("--apply and --no-apply together with --experimental fail with the mutex error", () => { - // Go's declarative PersistentPreRunE gate (db_schema_declarative.go:49-99) runs - // BEFORE cobra's ValidateFlagGroups() mutex check (cobra@v1.10.2/command.go:985, - // 1010), so the mutex error only surfaces once the gate is open. const { layer } = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { const exit = yield* Effect.exit( @@ -378,9 +369,6 @@ describe("db schema declarative sync integration", () => { it.effect( "--apply and --no-apply together without --experimental fail with the gate error, not the mutex error", () => { - // Mirrors storage's experimental-gate-vs-mutex ordering fix (CLI-1855 / CLI-1876): - // the pg-delta gate runs before the mutex check, so an unopened gate wins even - // when the flags would also violate mutual exclusivity. const { layer } = setup(tmp.current, { experimental: false }); return Effect.gen(function* () { const exit = yield* Effect.exit( @@ -395,11 +383,6 @@ describe("db schema declarative sync integration", () => { it.effect( "--apply and --no-apply together with SUPABASE_EXPERIMENTAL env (no --experimental flag) fail with the mutex error", () => { - // Go's gate reads viper.GetBool("EXPERIMENTAL") (db_schema_declarative.go:78), - // which picks up SUPABASE_EXPERIMENTAL via viper.AutomaticEnv (root.go:318-334), - // so an env-only experimental session still opens the gate and lets the mutex - // check fire. resolveExperimental (not the raw ExperimentalFlag) is - // what makes the TS gate honor the env var the same way. const { layer } = setup(tmp.current, { experimental: false }); const ENV = "SUPABASE_EXPERIMENTAL"; return Effect.gen(function* () { @@ -423,10 +406,6 @@ describe("db schema declarative sync integration", () => { it.effect( "an explicit --experimental=false closes the gate even when SUPABASE_EXPERIMENTAL is set", () => { - // viper's bound-pflag lookup returns the flag value whenever Changed is true — - // BEFORE falling back to AutomaticEnv (viper@v1.21.0/viper.go:1176-1178) — so an - // explicit --experimental=false must win over SUPABASE_EXPERIMENTAL=1, closing the - // gate instead of letting the env value override it. const { layer } = setup(tmp.current, { experimental: false, args: ["db", "schema", "declarative", "sync", "--experimental=false"], @@ -447,12 +426,6 @@ describe("db schema declarative sync integration", () => { it.effect( "--apply and --no-apply together with SUPABASE_EXPERIMENTAL set only in the project .env fail with the mutex error", () => { - // Go's flags.LoadConfig runs loadNestedEnv (which os.Setenv's each project-.env key) - // before dbDeclarativeCmd.PersistentPreRunE reads viper.GetBool("EXPERIMENTAL") - // (apps/cli-go/cmd/db_schema_declarative.go:73-78, deleted in CLI-1970; last - // present at commit 7b469f5b3; pkg/config/config.go:789), so a - // SUPABASE_EXPERIMENTAL set only in supabase/.env opens the gate and lets the mutex - // check fire, same as the shell-env case above. const saved = process.env["SUPABASE_EXPERIMENTAL"]; delete process.env["SUPABASE_EXPERIMENTAL"]; mkdirSync(join(tmp.current, "supabase"), { recursive: true }); @@ -481,10 +454,7 @@ describe("db schema declarative sync integration", () => { ); it.effect("rejects --apply=false --no-apply as a conflict (Go flag.Changed)", () => { - // cobra keys the mutex off flag.Changed, so an explicit `--apply=false` still - // counts as set and conflicts with `--no-apply`, even though its value is false. - // The gate runs first (see requirePgDelta's doc comment), so --experimental - // is required here for the mutex error to be the one that surfaces. + // The gate runs first, so `--experimental` is required here for the mutex error to surface. const { layer } = setup(tmp.current, { experimental: true }); return Effect.gen(function* () { const exit = yield* Effect.exit( @@ -505,16 +475,11 @@ describe("db schema declarative sync integration", () => { expect((failError(exit) as { message: string }).message).toContain( "no declarative schema found", ); - // No tree under the former default either — nothing to point at. expect(stripAnsi(s.out.stderrText)).not.toContain("WARNING: found declarative schema files"); }).pipe(Effect.provide(s.layer)); }); it.effect("warns when the tree still lives under the former supabase/database default", () => { - // Upgrade path: the implicit default moved from supabase/database to - // supabase/schemas. A project that generated under the old default and never - // set declarative_schema_path must get an explanation, not a bare - // "no declarative schema found". const formerDir = join(tmp.current, "supabase", "database"); mkdirSync(formerDir, { recursive: true }); writeFileSync(join(formerDir, "public.sql"), "create table a();"); @@ -581,10 +546,7 @@ describe("db schema declarative sync integration", () => { }); it.effect("--yes bypasses the bootstrap prompt when no declarative files exist", () => { - // Without --yes + non-TTY this fails at the "no declarative schema found" gate - // (prior test). With --yes, Go's PromptYesNo auto-confirms, so the bootstrap is - // attempted instead — it must NOT fail at that gate. No promptConfirm is queued, - // so reaching the prompt would also error. + // No `promptConfirmResponses` are queued, so reaching the prompt would also error. const s = setup(tmp.current, { experimental: true, stdinIsTty: false, yes: true, diffSql: "" }); return Effect.gen(function* () { const exit = yield* Effect.exit( @@ -595,10 +557,6 @@ describe("db schema declarative sync integration", () => { }); it.effect("bootstrap prints the declarative-schema-written line after generating", () => { - // The bootstrap prints `Declarative schema written to <dir>` to stderr after - // writing the generated files, before sync's own diff (step 2). It prints the - // relative `supabase/schemas` default — never the absolute resolved dir - // (CLI-1980). const s = setup(tmp.current, { experimental: true, stdinIsTty: true, @@ -613,7 +571,6 @@ describe("db schema declarative sync integration", () => { .filter((c) => c.text === line); expect(written).toHaveLength(1); expect(written[0]?.stream).toBe("stderr"); - // The generated files actually landed in the printed (resolved) dir. expect( existsSync(join(tmp.current, "supabase", "schemas", "public", "tables", "players.sql")), ).toBe(true); @@ -622,8 +579,6 @@ describe("db schema declarative sync integration", () => { }); it.effect("--yes bootstrap prints the declarative-schema-written line too", () => { - // The auto-confirmed (--yes / SUPABASE_YES) bootstrap reaches the same - // written-to print as the interactive accept. const s = setup(tmp.current, { experimental: true, stdinIsTty: false, @@ -642,9 +597,7 @@ describe("db schema declarative sync integration", () => { }); it.effect("bootstrap with migrations offers the smart target choice (not local-only)", () => { - // Go delegates the no-files bootstrap to runDeclarativeGenerate; with migrations - // present it offers local/linked/custom rather than silently generating from - // local. projectId "test" is an invalid ref so the linked choice is hidden. + // `projectId: "test"` is an invalid ref, so the linked choice is hidden. mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { @@ -662,9 +615,6 @@ describe("db schema declarative sync integration", () => { }); it.effect("bootstrap linked target does not run the local Postgres image check", () => { - // The stale-image guard only matters once bootstrap chooses a local source. A - // linked/custom bootstrap can build fresh catalogs and skip local apply, so it - // must reach the target prompt before any local-container inspection. mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { @@ -745,10 +695,7 @@ describe("db schema declarative sync integration", () => { }); it.effect("bootstrap: an unreadable migrations path is treated as no migrations", () => { - // Go's delegated hasMigrationFiles returns false on ANY ListLocalMigrations error - // (db_schema_declarative.go:164-169), flowing into the no-migrations local generate. - // Seeding supabase/migrations as a FILE makes the probe's list fail with ENOTDIR; it - // must be swallowed so the bootstrap reaches generation, not abort on the read. + // Seeding `supabase/migrations` as a file makes the probe's list fail with ENOTDIR. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "migrations"), "not a directory"); const s = setup(tmp.current, { @@ -761,8 +708,6 @@ describe("db schema declarative sync integration", () => { const exit = yield* Effect.exit( dbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })), ); - // The probe was softened: it reached generation (files written, sync - // completed on the empty diff), NOT an abort on the migrations directory read. expect(JSON.stringify(exit)).not.toContain("failed to read directory"); expect(Exit.isSuccess(exit)).toBe(true); expect( @@ -772,10 +717,7 @@ describe("db schema declarative sync integration", () => { }); it.effect("bootstrap: an unreadable ref file just omits the linked choice", () => { - // Go ignores smart-prompt LoadProjectRef errors (`if err == nil`, - // db_schema_declarative.go:222-224): a broken .temp/project-ref omits the linked - // choice and bootstrap continues. Seeding project-ref as a DIRECTORY makes the read - // fail; the bootstrap smart read must swallow it, not abort. + // Seeding `.temp/project-ref` as a directory makes the read fail. mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); mkdirSync(join(tmp.current, "supabase", ".temp", "project-ref"), { recursive: true }); @@ -791,7 +733,6 @@ describe("db schema declarative sync integration", () => { const exit = yield* Effect.exit( dbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })), ); - // Reached the smart prompt (didn't abort on the ref read); linked choice omitted. expect((s.out.promptSelectCalls[0]?.options ?? []).map((o) => o.value)).toEqual([ "local", "custom", @@ -801,10 +742,6 @@ describe("db schema declarative sync integration", () => { }); it.effect("bootstrap caches the linked project after resolving the ref", () => { - // The bootstrap resolves the linked ref (config project_id → .temp/project-ref) - // when migrations exist, and the handler's finalizer writes the linked-project - // cache whether sync succeeds or fails. Here it resolves the ref, bootstraps - // from local, and completes on the empty diff — the cache must be written. mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { @@ -822,8 +759,6 @@ describe("db schema declarative sync integration", () => { }); it.effect("does not cache when the workdir is not linked", () => { - // No project_id and no .temp/project-ref file → no ref resolves in the bootstrap, - // so flags.ProjectRef stays empty in Go and nothing is cached. mkdirSync(join(tmp.current, "supabase", "migrations"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "migrations", "0001_init.sql"), "select 1;"); const s = setup(tmp.current, { @@ -883,7 +818,6 @@ describe("db schema declarative sync integration", () => { "ALTER TABLE a ADD COLUMN b int", expect.stringContaining("supabase_migrations.schema_migrations"), ]); - // No reset on success — the recovery reset's container-remove never ran. expect(localResetRemovedContainers(s.child.spawned)).toEqual([]); expect(s.out.rawChunks.some((c) => c.text.includes("Migration applied successfully"))).toBe( true, @@ -906,14 +840,10 @@ describe("db schema declarative sync integration", () => { const error = failError(exit); expect(error).toMatchObject({ message: expect.stringContaining("uuid-ossp"), - // Recovery commands ride on `suggestion` so `Output.fail` prints them - // instead of the generic "rerun with --debug" footer. suggestion: expect.stringContaining( "supabase db schema declarative generate --local --overwrite", ), }); - // Hand-editing extension.sql is a false trail non-interactively: each - // declaration only unlocks the next refusal. expect(JSON.stringify(error)).not.toContain("extension.sql"); expect(existsSync(join(tmp.current, "supabase", "migrations"))).toBe(false); }).pipe(Effect.provide(s.layer)); @@ -1033,7 +963,6 @@ describe("db schema declarative sync integration", () => { const exit = yield* dbSchemaDeclarativeSync(flags()).pipe(Effect.exit); expect(failError(exit)).toMatchObject({ _tag: "DeclarativeCompatibilityError", - // Same unified template as the load-fail gate — only the evidence differs. message: expect.stringContaining( "This supabase/schemas tree looks like a legacy pg-delta export.", ), @@ -1151,9 +1080,8 @@ describe("db schema declarative sync integration", () => { it.effect("staged export names its live-database source and honors the reset offer", () => { seedDeclarative(tmp.current); - // `resetLocalDatabase`'s container-recreate resolves its own project id - // from `@supabase/config` — pin it so the recreated container name matches - // the spawner route's assumption (same as the apply-failure reset test). + // `resetLocalDatabase` resolves its own project id from `@supabase/config`; pin it so the + // recreated container name matches the spawner route's assumption. writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); const s = setup(tmp.current, { stdinIsTty: true, @@ -1164,12 +1092,9 @@ describe("db schema declarative sync integration", () => { }); return Effect.gen(function* () { yield* dbSchemaDeclarativeSync(flags({ noApply: Option.some(true) })); - // The export source is stated before the snapshot, so stale local drift - // cannot silently become the staged declarative tree. expect(stripAnsi(s.out.stderrText)).toContain( "Exporting from the running local database (not the migrations state).", ); - // Accepting the offer really reset the local database before the export. expect(localResetRemovedContainers(s.child.spawned)).toContain("supabase_db_test"); expect( existsSync(join(tmp.current, "supabase", "schemas-next", ".pgdelta-export.json")), @@ -1230,10 +1155,9 @@ describe("db schema declarative sync integration", () => { "apply failure in a TTY offers reset+reapply and runs the reset natively in-process", () => { seedDeclarative(tmp.current); - // `resetLocalDatabase`'s container-recreate resolves its own project id - // from `@supabase/config` (config.toml / real env), independently of the - // mocked `CommandSettings.projectId` — pin it to "test" so the recreated - // container name matches the spawner route's assumption. + // `resetLocalDatabase` resolves its own project id from `@supabase/config`, independently + // of the mocked `CommandSettings.projectId`; pin it so the recreated container name + // matches the spawner route's assumption. writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); const s = setup(tmp.current, { experimental: true, @@ -1247,9 +1171,6 @@ describe("db schema declarative sync integration", () => { expect(s.out.rawChunks.some((c) => c.text.includes("Migration failed to apply"))).toBe( true, ); - // The recovery reset actually ran — recreated the local `db` container - // (CLI-2062: in-process, not a `supabase-go` child) — proving it's a real - // effect, not just a tracked call. expect(localResetRemovedContainers(s.child.spawned)).toContain("supabase_db_test"); expect(localResetCreateArgs(s.child.spawned)).not.toBeUndefined(); expect(s.out.rawChunks.some((c) => c.text.includes("Resetting local database"))).toBe(true); @@ -1259,18 +1180,14 @@ describe("db schema declarative sync integration", () => { ), ).toBe(true); expect(existsSync(join(tmp.current, "supabase", ".temp", "pgdelta", "debug"))).toBe(true); - // `resetLocalDatabase`'s own body never touches telemetry — the outer - // `sync` command's single `Effect.ensuring` finalizer must still fire - // EXACTLY once, not twice, matching Go's single-process `reset.Run` (no - // second `PersistentPostRun` from a separate child process) (CLI-2062). + // `resetLocalDatabase`'s own body never touches telemetry, so the outer command's single + // `Effect.ensuring` finalizer must still fire exactly once, not twice. expect(s.telemetry.flushCount).toBe(1); }).pipe(Effect.provide(s.layer)); }, ); it.effect("surfaces the reset failure (not the apply error) when reset also fails", () => { - // Go returns resetErr here (`cmd/db_schema_declarative.go:414-423`), so the failure - // that actually blocked recovery is reported, not the original apply error ("boom"). seedDeclarative(tmp.current); const s = setup(tmp.current, { experimental: true, @@ -1286,21 +1203,16 @@ describe("db schema declarative sync integration", () => { expect(failError(exit)).toMatchObject({ message: "supabase start is not running.", }); - // Printed exactly once — no "database reset failed:" double-wrap (review CLI-1958). expect( s.out.rawChunks.some((c) => c.text.includes("Database reset also failed: supabase start is not running."), ), ).toBe(true); - // A real failure, before any destructive container work. expect(localResetRemovedContainers(s.child.spawned)).toEqual([]); }).pipe(Effect.provide(s.layer)); }); it.effect("forwards --network-id to the recovery reset", () => { - // `resetLocalDatabase` resolves `NetworkIdFlag` itself from the - // shared context (CLI-2062) — no argv-forwarding needed — so the recreated - // container must land on the custom network directly. seedDeclarative(tmp.current); writeFileSync(join(tmp.current, "supabase", "config.toml"), 'project_id = "test"\n'); const s = setup(tmp.current, { diff --git a/apps/cli/src/commands/db/shared/debug-bundle.ts b/apps/cli/src/commands/db/shared/debug-bundle.ts index 873c1d15bd..449e0b7b06 100644 --- a/apps/cli/src/commands/db/shared/debug-bundle.ts +++ b/apps/cli/src/commands/db/shared/debug-bundle.ts @@ -19,7 +19,7 @@ export interface DebugBundle { readonly migrations?: ReadonlyArray<string>; } -/** Go's debug-bundle id layout `20060102-150405` (UTC). */ +/** The debug-bundle id layout `20060102-150405` (UTC). */ export function formatDebugId(millis: number): string { const digits = new Date(millis).toISOString().replace(/\D/gu, "").slice(0, 14); return `${digits.slice(0, 8)}-${digits.slice(8)}`; @@ -38,11 +38,9 @@ const copyBestEffort = (fs: FileSystem.FileSystem, from: string, to: string): Ef ); /** - * Writes a debug bundle to `<tempDir>/debug/<id>/` and returns the directory. - * Mirrors Go's `SaveDebugBundle`: creating the top-level directory is fatal (the - * effect fails so callers don't claim a bundle was saved), while every individual - * artifact write and the nested `migrations/` dir are best-effort (a failed copy - * must not mask the original error). + * Writes a debug bundle to `<tempDir>/debug/<id>/` and returns the directory. Creating the + * top-level directory is fatal (so callers don't claim a bundle was saved), while every + * individual artifact write is best-effort (a failed copy must not mask the original error). */ export const saveDebugBundle = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, @@ -53,12 +51,6 @@ export const saveDebugBundle = Effect.fnUntraced(function* ( bundle: DebugBundle, ) { const debugDir = path.join(tempDir, "debug", bundle.id); - // Go's `SaveDebugBundle` returns an error when the top-level debug directory - // cannot be created (`apps/cli-go/internal/db/declarative/debug.go:40-42`); only - // the individual artifact writes (and the nested `migrations/` dir) are - // best-effort once the directory exists. Propagating this failure lets callers - // suppress the "Debug information saved" message instead of pointing at a - // directory that was never created. yield* fs.makeDirectory(debugDir, { recursive: true }); // The catalog refs are workdir-relative paths (`supabase/.temp/pgdelta/...`), so @@ -94,18 +86,15 @@ export const saveDebugBundle = Effect.fnUntraced(function* ( return debugDir; }); -/** Collects local migration *filenames* for a debug bundle (Go's `CollectMigrationsList`). */ +/** Collects local migration filenames for a debug bundle. */ export const collectMigrationsList = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, path: Path.Path, migrationsDir: string, ) { - // Go's `CollectMigrationsList` swallows a `ListLocalMigrations` read error and - // returns nil (`internal/db/declarative/debug.go:118-128`): the debug bundle is - // collected while a primary diff/apply error is already in flight, so an - // unreadable `supabase/migrations` must only omit migration copies, never replace - // the actionable original error. (The main generate/sync path keeps failing on an - // unreadable dir — that fail-on-read lives at the direct callers.) + // The debug bundle is collected while a primary diff/apply error is already in flight, so an + // unreadable `supabase/migrations` must only omit migration copies, never replace the + // actionable original error. The main generate/sync path still fails on an unreadable dir. const migrations = yield* listLocalMigrations(fs, path, migrationsDir).pipe( Effect.orElseSucceed(() => [] as ReadonlyArray<string>), ); @@ -113,8 +102,8 @@ export const collectMigrationsList = Effect.fnUntraced(function* ( }); /** - * Builds the issue-reporting message printed after a debug bundle is saved. - * Byte-matches Go's `PrintDebugBundleMessage` (leading blank line included). + * Builds the issue-reporting message printed after a debug bundle is saved (leading blank line + * included); message text is an established output contract. */ export function debugBundleMessage(debugDir: string): string { const lines = [""]; diff --git a/apps/cli/src/commands/db/shared/debug-bundle.unit.test.ts b/apps/cli/src/commands/db/shared/debug-bundle.unit.test.ts index d26a98cad4..772f97dc60 100644 --- a/apps/cli/src/commands/db/shared/debug-bundle.unit.test.ts +++ b/apps/cli/src/commands/db/shared/debug-bundle.unit.test.ts @@ -36,9 +36,8 @@ describe("saveDebugBundle", () => { }); it.effect("fails (does not return a path) when the debug directory cannot be created", () => { - // Plant a regular file where the `debug` directory needs to be, so the recursive - // makeDirectory fails — Go's SaveDebugBundle returns an error here rather than - // claiming a bundle was saved. + // Plants a regular file where the `debug` directory needs to be, so the recursive + // makeDirectory fails. const root = mkdtempSync(join(tmpdir(), "debug-fail-")); const tempDir = join(root, "pgdelta"); writeFileSync(join(root, "pgdelta"), "not a directory"); @@ -80,8 +79,6 @@ describe("collectMigrationsList", () => { it.effect( "swallows an unreadable migrations dir (returns []) so it never masks the primary error", () => { - // Go's CollectMigrationsList returns nil on a read error; the debug bundle just - // omits migration copies rather than replacing the in-flight diff/apply error. const root = mkdtempSync(join(tmpdir(), "collect-fail-")); const migrationsPath = join(root, "migrations"); writeFileSync(migrationsPath, "not a directory"); From ee11166de6de34252b23f1a9d786f63c021fe18e Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 12:53:07 +0100 Subject: [PATCH 25/56] chore(repo): trim comments, shard 36 (455 -> 241 comment lines) --- .../storage/mv/mv.integration.test.ts | 4 +- .../cli/src/commands/storage/rm/rm.command.ts | 7 +-- .../cli/src/commands/storage/rm/rm.handler.ts | 32 +++------- .../storage/rm/rm.integration.test.ts | 33 ++-------- .../src/commands/storage/storage.e2e.test.ts | 22 +++---- .../src/commands/storage/storage.errors.ts | 48 +++------------ ...rage.experimental-gate.integration.test.ts | 13 +--- .../cli/src/commands/storage/storage.flags.ts | 31 +++------- .../storage/storage.flags.unit.test.ts | 3 - .../cli/src/commands/storage/storage.frame.ts | 61 ++++++------------- .../src/commands/storage/storage.iterate.ts | 26 +++----- .../telemetry/disable/disable.command.ts | 12 +--- .../telemetry/enable/enable.command.ts | 8 +-- .../telemetry/telemetry.integration.test.ts | 27 ++------ apps/cli/src/commands/test/db/db.command.ts | 8 +-- apps/cli/src/commands/test/new/new.command.ts | 12 ++-- apps/cli/src/commands/test/new/new.errors.ts | 5 +- apps/cli/src/commands/test/new/new.handler.ts | 7 +-- .../commands/test/new/new.integration.test.ts | 5 +- .../cli/src/commands/unlink/unlink.command.ts | 10 ++- apps/cli/src/commands/unlink/unlink.errors.ts | 5 +- .../cli/src/commands/unlink/unlink.handler.ts | 21 +++---- .../unlink/unlink.integration.test.ts | 11 ++-- .../activate/activate.command.ts | 25 +++----- .../activate/activate.handler.ts | 12 ++-- .../check-availability.command.ts | 25 +++----- .../check-availability.handler.ts | 16 ++--- .../delete/delete.command.ts | 16 ++--- .../vanity-subdomains/get/get.command.ts | 16 ++--- 29 files changed, 153 insertions(+), 368 deletions(-) diff --git a/apps/cli/src/commands/storage/mv/mv.integration.test.ts b/apps/cli/src/commands/storage/mv/mv.integration.test.ts index 25bb9f96ca..d3f625b4fe 100644 --- a/apps/cli/src/commands/storage/mv/mv.integration.test.ts +++ b/apps/cli/src/commands/storage/mv/mv.integration.test.ts @@ -170,7 +170,6 @@ describe("storage mv", () => { mvFlags({ src: "ss:///private", dst: "ss:///private/docs", recursive: true }), ).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); - // Nested file moved with the sub/ prefix rewritten under docs/. const nested = requests.find( (r) => r.url.includes(MOVE) && (r.body as { sourceKey?: string }).sourceKey === "sub/b.txt", ); @@ -255,8 +254,7 @@ describe("storage mv", () => { }); it.live("moves within the project given via --project-ref, overriding VALID_REF", () => { - // `opts.projectRef` (the fake's own fallback) is left at its default - // (VALID_REF) — the flag must win over it and drive the gateway host. + // The fake's default projectRef is VALID_REF; the flag must win over it. const FLAG_REF = "flagflagflagflagflag"; const { layer, requests, linkedCache } = setupStorage(tmp.current, { routes: [{ method: "POST", match: MOVE, body: { message: "Successfully moved" } }], diff --git a/apps/cli/src/commands/storage/rm/rm.command.ts b/apps/cli/src/commands/storage/rm/rm.command.ts index 3eccf6dc99..d37bfed444 100644 --- a/apps/cli/src/commands/storage/rm/rm.command.ts +++ b/apps/cli/src/commands/storage/rm/rm.command.ts @@ -45,8 +45,7 @@ export const storageRmCommand = Command.make("rm", config).pipe( ]), Command.withHandler((flags) => Effect.gen(function* () { - // Gate before the mutex check below — order matters; see - // requireExperimental's doc comment for why. + // Gate before the mutex check below; see requireExperimental's doc comment for why. yield* requireExperimental; const cliArgs = yield* CliArgs; yield* assertStorageTargetsExclusive(cliArgs.args); @@ -63,9 +62,7 @@ export const storageRmCommand = Command.make("rm", config).pipe( local: flags.local, projectRef: flags.projectRef, }).pipe( - // TS-only flag with no Go telemetry-safety baseline; Go's nearest - // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most - // others) are unmarked, so it stays redacted. + // project-ref isn't on the safe-flags allowlist, so it stays redacted in telemetry. withCommandTelemetry({ flags: telemetryFlags }), ); }).pipe(withJsonErrorHandling), diff --git a/apps/cli/src/commands/storage/rm/rm.handler.ts b/apps/cli/src/commands/storage/rm/rm.handler.ts index 35b94746bf..774296734f 100644 --- a/apps/cli/src/commands/storage/rm/rm.handler.ts +++ b/apps/cli/src/commands/storage/rm/rm.handler.ts @@ -32,11 +32,10 @@ import { listStoragePaths } from "../storage.iterate.ts"; export interface StorageRmFlags { readonly files: ReadonlyArray<string>; readonly recursive: boolean; - // `linked` is carried for parity with the ls/cp/mv handler signatures; routing - // reads only `local` (Go `storage.go:21-32` reads `GetBool("local")`). + // Routing reads only `local`; `linked` is unused. readonly linked: boolean; readonly local: boolean; - // TS-only override of the linked project ref — see push.command.ts (db push). + // Overrides the linked project's ref; only valid when targeting --linked, not --local. readonly projectRef: Option.Option<string>; } @@ -65,14 +64,8 @@ export const storageRm = Effect.fn("storage.rm")(function* (flags: StorageRmFlag yield* Effect.gen(function* () { yield* assertStorageWorkdir(cliSettings.workdir); - // Resolve the project ref BEFORE reading the project `.env`: the - // linked-project ref must be resolved strictly before the config load - // (the `.env` work). An unlinked workdir must fail fast with the - // not-linked guidance before a malformed/unreadable `supabase/.env` gets - // a chance to mask it with an env-parse error. - // `--project-ref` never implies `--linked` and must not be silently - // discarded on the local target — see push.handler.ts's identical guard - // (db push) for the full TS-only rationale. + // Resolving the project ref before loading the project `.env` ensures an unlinked + // workdir fails with the not-linked message instead of an env-parse error. if (Option.isSome(flags.projectRef) && flags.local) { return yield* Effect.fail( new StorageMutuallyExclusiveFlagsError({ @@ -84,10 +77,8 @@ export const storageRm = Effect.fn("storage.rm")(function* (flags: StorageRmFlag const projectRef = flags.local ? "" : yield* resolver.loadProjectRef(flags.projectRef); linkedRef = projectRef; - // `--yes` OR `SUPABASE_YES`. Both the `--local` and (default) `--linked` - // branches load the project `.env` files before the confirmation - // prompt, so a `SUPABASE_YES` set only in `supabase/.env` must - // auto-confirm here too. + // `.env` loads before the confirmation prompt, so a `SUPABASE_YES` set only in + // `supabase/.env` also auto-confirms. const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); const loaded = yield* loadStorageConfig(cliSettings, projectRef); @@ -95,7 +86,6 @@ export const storageRm = Effect.fn("storage.rm")(function* (flags: StorageRmFlag yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); } - // Group paths by bucket, validating BEFORE building the client (Go `rm.go:31-47`). const groups = new Map<string, Array<string>>(); for (const objectPath of flags.files) { const remotePath = yield* parseStorageUrlEffect(objectPath); @@ -117,8 +107,7 @@ export const storageRm = Effect.fn("storage.rm")(function* (flags: StorageRmFlag { projectRef, config: loaded.config, userAgent: cliSettings.userAgent }, (gateway) => Effect.gen(function* () { - // No paths: `-r` deletes every bucket, otherwise it's a missing-flag - // error (Go `rm.go:52-63`, after the client is built). + // No paths given: `-r` deletes every bucket, otherwise it's a missing-flag error. if (groups.size === 0) { if (!flags.recursive) { return yield* new StorageMissingFlagError(); @@ -168,7 +157,7 @@ export const storageRm = Effect.fn("storage.rm")(function* (flags: StorageRmFlag ); }); -/** Go `rm.deleteObjects` (`rm.go:145-156`): DELETE in chunks of DELETE_OBJECTS_LIMIT. */ +/** Deletes objects in chunks of DELETE_OBJECTS_LIMIT. */ const deleteObjects = ( gateway: StorageGateway, bucket: string, @@ -187,9 +176,8 @@ const deleteObjects = ( }); /** - * Go `RemoveStoragePathAll` (`rm.go:102-143`): BFS over the prefix tree (LIFO), - * deleting files per directory, then deleting the bucket itself when the prefix - * is empty. `prefix` is terminated by `/` or empty. + * Walks the prefix tree with a stack, deleting files per directory, then the bucket + * itself once the prefix is empty. `prefix` ends with `/` or is empty. */ const removeStoragePathAll = ( gateway: StorageGateway, diff --git a/apps/cli/src/commands/storage/rm/rm.integration.test.ts b/apps/cli/src/commands/storage/rm/rm.integration.test.ts index 3f66b7e96d..05cd8b600b 100644 --- a/apps/cli/src/commands/storage/rm/rm.integration.test.ts +++ b/apps/cli/src/commands/storage/rm/rm.integration.test.ts @@ -86,8 +86,7 @@ describe("storage rm", () => { }); it.live("auto-confirms via SUPABASE_YES even without the --yes flag", () => { - // viper AutomaticEnv (root.go:318-320) means `SUPABASE_YES` is equivalent to - // `--yes`; the flag layer is left at its default `false` to prove the env path. + // The --yes flag itself stays false here, to isolate the env-var path. process.env["SUPABASE_YES"] = "1"; const { layer, out, requests } = setupStorage(tmp.current, { toml: 'project_id = "test"\n', @@ -109,10 +108,7 @@ describe("storage rm", () => { }); it.live("auto-confirms from SUPABASE_YES in the project .env (Go loadNestedEnv)", () => { - // SUPABASE_YES lives only in supabase/.env, not the shell — both the - // `--local` and (default) `--linked` branches load the project `.env` - // files before the confirmation prompt, so the deletion auto-confirms - // with no --yes flag and no env var set in the shell. + // SUPABASE_YES here lives only in supabase/.env, not the shell. const { layer, out, requests } = setupStorage(tmp.current, { toml: 'project_id = "test"\n', local: true, @@ -136,10 +132,7 @@ describe("storage rm", () => { it.live( "surfaces not-linked guidance before a malformed project .env (Go LoadProjectRef-before-LoadConfig)", () => { - // The linked-project ref is resolved strictly before the config load - // that reads the project `.env` files, so an unlinked workdir must - // fail with the not-linked guidance even when `supabase/.env` is - // malformed — the malformed file must never be reached. + // The malformed supabase/.env must never be read; ref resolution fails first. const { layer, requests } = setupStorage(tmp.current, { toml: 'project_id = "test"\n', linkedFails: true, @@ -182,8 +175,6 @@ describe("storage rm", () => { }); it.live("honors a piped 'y' on non-TTY stdin and deletes", () => { - // Go scans piped stdin before defaulting (`console.go:74-82`); a piped `y` - // overrides the `n` default and deletes, even on a non-terminal. const { layer, requests, out } = setupStorage(tmp.current, { toml: 'project_id = "test"\n', local: true, @@ -201,14 +192,11 @@ describe("storage rm", () => { }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests.some((r) => r.method === "DELETE")).toBe(true); - // The consumed answer is echoed after the label on non-TTY stdin. expect(out.stderrText).toContain("[y/N] y"); }); }); it.live("falls back to the default (no) on an unparseable piped answer", () => { - // Unrecognized input is treated as unanswered, so the confirmation - // prompt keeps the `n` default and the deletion is skipped. const { layer, requests } = setupStorage(tmp.current, { toml: 'project_id = "test"\n', local: true, @@ -615,8 +603,7 @@ describe("storage rm", () => { }); it.live("deletes from the project given via --project-ref, overriding VALID_REF", () => { - // `opts.projectRef` (the fake's own fallback) is left at its default - // (VALID_REF) — the flag must win over it and drive the gateway host. + // The fake's default projectRef is VALID_REF; the flag must win over it. const FLAG_REF = "flagflagflagflagflag"; const { layer, requests, linkedCache } = setupStorage(tmp.current, { yes: true, @@ -665,12 +652,8 @@ describe("storage rm", () => { it.live( "does not delete anything when --workdir names a config-less subdirectory of a real ancestor project", () => { - // CLI-2285 regression, destructive-command variant: the ancestor - // project's config.toml declares a non-default [api] port — if this - // silently climbed to it, `storage rm -r` would target whatever - // (possibly running) local stack that ancestor points at. An EXPLICIT - // --workdir must hard-fail before the gateway is ever built, so no - // DELETE is ever issued. + // An explicit --workdir must hard-fail rather than climb to an ancestor's + // config.toml, which could point at a different (possibly running) local stack. writeAncestorConfig(tmp.current, 'project_id = "test"\n[api]\nport = 65432\n'); const sub = join(tmp.current, "nested", "dir"); mkdirSync(sub, { recursive: true }); @@ -699,10 +682,6 @@ describe("storage rm", () => { it.live( "a defaulted workdir with no project anywhere still proceeds using the embedded default config", () => { - // Mirrors the regression above with explicitWorkdir flipped: a - // DEFAULTED workdir must keep its established tolerant fallback - // (`loadStorageConfig`'s `decodeDefaultCliConfig({})` branch) - // rather than hard-failing — the deletion still proceeds. const { layer, requests } = setupStorage(tmp.current, { local: true, yes: true, diff --git a/apps/cli/src/commands/storage/storage.e2e.test.ts b/apps/cli/src/commands/storage/storage.e2e.test.ts index d2b9adb566..677b4568de 100644 --- a/apps/cli/src/commands/storage/storage.e2e.test.ts +++ b/apps/cli/src/commands/storage/storage.e2e.test.ts @@ -8,11 +8,9 @@ import { runSupabase } from "../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; /** - * Golden-path e2e for the `storage` group: the real compiled-binary surface and - * the parser boundary for the persistent `--linked`/`--local` flags. Object - * list/copy/move/remove parity is covered by the integration + unit suites - * (they don't need a live local stack); these only exercise what the in-process - * suites bypass. + * Golden-path e2e for `storage`: the compiled binary and `--linked`/`--local` flag + * parsing. Object list/copy/move/remove behavior is covered by the integration and + * unit suites, which don't need a live local stack. */ describe("supabase storage", () => { let projectDir: string; @@ -38,9 +36,8 @@ describe("supabase storage", () => { }); test("rejects passing both --local and --linked", { timeout: E2E_TIMEOUT_MS }, async () => { - // The experimental gate runs BEFORE the mutex check, so --experimental - // must be set here to reach the mutex check at all — otherwise the - // experimental-gate error wins (see the next test). + // The experimental gate runs before the mutex check, so --experimental must be + // set here to reach the mutex check at all. const { exitCode, stdout, stderr } = await runSupabase( ["storage", "ls", "--local", "--linked", "ss:///", "--experimental"], { cwd: projectDir }, @@ -55,8 +52,6 @@ describe("supabase storage", () => { "rejects storage subcommands without --experimental", { timeout: E2E_TIMEOUT_MS }, async () => { - // `storage` is an experimental command group; running it without - // --experimental is rejected by the experimental gate. const { exitCode, stdout, stderr } = await runSupabase( ["storage", "ls", "ss:///", "--local"], { @@ -71,11 +66,8 @@ describe("supabase storage", () => { ); test("accepts --local after the subcommand token", { timeout: E2E_TIMEOUT_MS }, async () => { - // `--linked`/`--local` are per-leaf flags (Effect CLI requires unique - // global-flag names tree-wide and `seed` owns them), so they follow the - // subcommand. With --experimental it parses and passes the gate; there's no - // live local stack so it fails to connect — but it must PARSE (no - // "Unrecognized flag") and must NOT be blocked by the experimental gate. + // --linked/--local are per-leaf flags, not global ones — Effect CLI requires + // unique global-flag names tree-wide and `seed` already owns those names. const { stdout, stderr } = await runSupabase( ["storage", "ls", "ss:///", "--local", "--experimental"], { cwd: projectDir }, diff --git a/apps/cli/src/commands/storage/storage.errors.ts b/apps/cli/src/commands/storage/storage.errors.ts index 7ecd040de8..1b63a54e8c 100644 --- a/apps/cli/src/commands/storage/storage.errors.ts +++ b/apps/cli/src/commands/storage/storage.errors.ts @@ -12,14 +12,12 @@ import { goQuote } from "../../command-internal/go-quote.ts"; * Domain errors for `supabase storage ls/cp/mv/rm`. Each `message` is an * established stderr text. * - * The Storage gateway errors (`StorageGateway{Network,Status}Error`) and - * credential-derivation errors live in the shared modules + * Storage gateway errors and credential-derivation errors live in * `command-internal/storage-gateway.errors.ts` and - * `command-internal/storage-credentials.errors.ts`; the url-parse failures - * are thrown by `command-internal/storage-url.ts` and mapped here. + * `command-internal/storage-credentials.errors.ts`; url-parse failures come from + * `command-internal/storage-url.ts` and are mapped here. */ -/** `client.ErrInvalidURL` (`internal/storage/client/scheme.go:12`). */ export class StorageInvalidUrlError extends Data.TaggedError("StorageInvalidUrlError")<{ readonly message: string; }> { @@ -32,11 +30,7 @@ export class StorageInvalidUrlError extends Data.TaggedError("StorageInvalidUrlE } } -/** - * A `url.Parse` failure, wrapped like Go's - * `errors.Errorf("failed to parse … url: %w", err)`. The `message` already - * contains the full `failed to parse storage url: parse "…": …` text. - */ +/** `message` already contains the full `failed to parse storage url: parse "…": …` text. */ export class StorageUrlParseError extends Data.TaggedError("StorageUrlParseError")<{ readonly message: string; }> { @@ -45,11 +39,7 @@ export class StorageUrlParseError extends Data.TaggedError("StorageUrlParseError } } -/** - * `cp`'s local→local branch (`internal/storage/cp/cp.go:59-60`). Go sets - * `utils.CmdSuggestion` to the aqua `cp -r` hint, printed verbatim after the - * error — the legacy text error renderer prints `suggestion` the same way. - */ +/** Local→local copy is unsupported; `suggestion` renders as an aqua hint after the error. */ export class StorageUnsupportedOperationError extends Data.TaggedError( "StorageUnsupportedOperationError", )<{ @@ -69,25 +59,15 @@ export class StorageUnsupportedOperationError extends Data.TaggedError( } /** - * `cp`'s `--jobs` is a pflag uint: a non-uint token fails - * `strconv.ParseUint(s, 0, 64)` at flag-parse time. Established message - * format `invalid argument %q for %q flag: %v` with the shorthand-prefixed - * flag name, carrying the RAW token (so `--jobs=-01` reports `"-01"`, not a - * normalized `"-1"`) and strconv's cause (`invalid syntax` / `value out of - * range`). Both token occurrences are `%q`-quoted — pflag applies `%q` to - * the value and strconv's `NumError.Error()` wraps `e.Num` in - * `strconv.Quote` — so an escapable token stays one escaped line (go1.26: - * `--jobs 'a"b'` → `… "a\"b" …`, not a raw quote/newline). - * Thrown from the flag's own `Flag.mapTryCatch` in `cp.command.ts` so the - * rejection happens during command parsing — `formatInvalidValueMessage` - * surfaces the resulting `CliError.InvalidValue`'s message verbatim. + * Formats an invalid `-j, --jobs` value using the established + * `invalid argument %q for %q flag: %v` message, with the raw (unnormalized) token + * quoted and escaped so it never breaks onto a new line. */ export function storageInvalidJobsMessage(token: string, cause: string): string { const quoted = goQuote(new TextEncoder().encode(token)); return `invalid argument ${quoted} for "-j, --jobs" flag: strconv.ParseUint: parsing ${quoted}: ${cause}`; } -/** `cp`'s remote→remote branch (`internal/storage/cp/cp.go:57`). */ export class StorageCopyBetweenBucketsError extends Data.TaggedError( "StorageCopyBetweenBucketsError", )<{ @@ -102,7 +82,6 @@ export class StorageCopyBetweenBucketsError extends Data.TaggedError( } } -/** `mv`'s cross-bucket branch (`internal/storage/mv/mv.go:19,38`). */ export class StorageUnsupportedMoveError extends Data.TaggedError("StorageUnsupportedMoveError")<{ readonly message: string; }> { @@ -115,7 +94,6 @@ export class StorageUnsupportedMoveError extends Data.TaggedError("StorageUnsupp } } -/** `mv`'s both-root branch (`internal/storage/mv/mv.go:20,35`). */ export class StorageMissingPathError extends Data.TaggedError("StorageMissingPathError")<{ readonly message: string; }> { @@ -128,7 +106,6 @@ export class StorageMissingPathError extends Data.TaggedError("StorageMissingPat } } -/** `rm`'s root-arg branch (`internal/storage/rm/rm.go:21,41`). */ export class StorageMissingBucketError extends Data.TaggedError("StorageMissingBucketError")<{ readonly message: string; }> { @@ -141,7 +118,6 @@ export class StorageMissingBucketError extends Data.TaggedError("StorageMissingB } } -/** `rm`'s directory-without-`-r` branch (`internal/storage/rm/rm.go:22,44,53`). */ export class StorageMissingFlagError extends Data.TaggedError("StorageMissingFlagError")<{ readonly message: string; }> { @@ -154,11 +130,7 @@ export class StorageMissingFlagError extends Data.TaggedError("StorageMissingFla } } -/** - * `Object not found: <path>` — `cp` recursive download with no objects - * (`cp.go:94`), `mv` recursive with no objects (`mv.go:85`), `rm` recursive on - * an empty prefix (`rm.go:114`). - */ +/** Raised by recursive `cp`/`mv`/`rm` when no objects match the given path. */ export class StorageObjectNotFoundError extends Data.TaggedError("StorageObjectNotFoundError")<{ readonly message: string; }> { @@ -171,7 +143,7 @@ export class StorageObjectNotFoundError extends Data.TaggedError("StorageObjectN } } -/** `failed to read file:` / `failed to create file:` (`pkg/storage/objects.go`). */ +/** Raised with an established "failed to read file: …" or "failed to create file: …" message. */ export class StorageFileError extends Data.TaggedError("StorageFileError")<{ readonly message: string; }> { diff --git a/apps/cli/src/commands/storage/storage.experimental-gate.integration.test.ts b/apps/cli/src/commands/storage/storage.experimental-gate.integration.test.ts index ff4f5a8271..78ea602091 100644 --- a/apps/cli/src/commands/storage/storage.experimental-gate.integration.test.ts +++ b/apps/cli/src/commands/storage/storage.experimental-gate.integration.test.ts @@ -18,12 +18,6 @@ import { ExperimentalRequiredError } from "../../command-internal/experimental-g import { storageCommand } from "./storage.command.ts"; import { StorageMutuallyExclusiveFlagsError } from "./storage.errors.ts"; -// The experimental gate runs before mutual-exclusivity checks. So -// `supabase storage ls --linked --local` without `--experimental` must -// surface the experimental-gate error, not the mutex error — this suite -// proves that ordering is wired into the actual `.command.ts` handler -// pipeline for all four leaves, not just the shared helper in isolation. - const tempRoot = useTempWorkdir("supabase-storage-experimental-int-"); const testRoot = Command.make("supabase").pipe( @@ -38,11 +32,8 @@ function setup(args: ReadonlyArray<string>) { CliOutput.layer(textCliOutputFormatter()), out.layer, Layer.succeed(CliArgs, { args }), - // `storageGatewayRuntimeLayer`'s cliSettings/credentials layers read - // real env/files when built. Neither check under test ever reaches that - // lazy factory, but isolate ambient env and homeDir defensively anyway — - // same rationale as the sibling experimental-gate tests (ssl-enforcement, - // postgres-config, network-bans). + // Isolates ambient env/homeDir defensively even though neither check here reaches + // storageGatewayRuntimeLayer's lazy cliSettings/credentials factory. isolatedHomeLayer(tempRoot.current, { SUPABASE_NO_KEYRING: "1" }), mockProcessControl().layer, mockTty({ stdinIsTty: false, stdoutIsTty: false }), diff --git a/apps/cli/src/commands/storage/storage.flags.ts b/apps/cli/src/commands/storage/storage.flags.ts index 4b5e575de9..124ec77b90 100644 --- a/apps/cli/src/commands/storage/storage.flags.ts +++ b/apps/cli/src/commands/storage/storage.flags.ts @@ -5,19 +5,11 @@ import { changedLinkedLocalFlags } from "../../command-internal/db-target-flags. import { StorageMutuallyExclusiveFlagsError } from "./storage.errors.ts"; /** - * `--linked` / `--local`: `--linked` defaults to `true`, `--local` to - * `false`, and the two are mutually exclusive. The routing reads the - * **value** of `--local`: when true the project ref is cleared (local - * stack), otherwise the linked path resolves it. - * - * These are declared **per-leaf** rather than as `storage`-group scoped globals - * because Effect CLI requires global-flag names to be unique across the whole - * command tree (`Command.runWith` builds one registry from every declared - * global), and `seed` already owns scoped globals named `linked`/`local` with - * different defaults/descriptions. The only behavioural cost is that - * `--linked`/`--local` must follow the subcommand token (`storage ls - * --local`, not `storage --local ls`) — the same shape the `db` family uses - * for its per-leaf `--linked`/`--local`. + * `--local`'s value decides local vs linked: true clears the project ref, false + * resolves it via the linked project. Declared per-leaf rather than as a + * `storage`-scoped global because Effect CLI requires unique global-flag names + * tree-wide and `seed` already owns `linked`/`local`, so these must follow the + * subcommand token (`storage ls --local`, not `storage --local ls`). */ export const StorageLinkedFlagDef = Flag.boolean("linked").pipe( Flag.withDescription("Connects to Storage API of the linked project."), @@ -29,24 +21,21 @@ export const StorageLocalFlagDef = Flag.boolean("local").pipe( Flag.withDefault(false), ); -// TS-only override of the linked project ref — see push.command.ts (db push). -// No Go equivalent: `storage.go` never registers `--project-ref` on this -// command family. Declared once here (not per-leaf) since all four -// `storage ls/cp/mv/rm` leaves share the identical declaration. +// Overrides the project ref for the linked target; declared once since all four +// storage leaves share the identical flag. export const StorageProjectRefFlagDef = Flag.string("project-ref").pipe( Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ); -/** Changed `--linked`/`--local` set (cobra `pflag.Changed`), for the exclusivity check. */ +/** Names of `--linked`/`--local` flags that were explicitly passed, for the exclusivity check. */ export function storageChangedTargetFlags(args: ReadonlyArray<string>): ReadonlyArray<string> { return changedLinkedLocalFlags(args); } /** - * Mutual-exclusion check for `--linked`/`--local`. Rejected at flag - * validation — before the handler body/telemetry flush — so it must NOT - * emit `cli_command_executed`; each leaf calls this BEFORE + * Mutual-exclusion check for `--linked`/`--local`, rejected before the handler body + * so it never fires `cli_command_executed`. Each leaf must call this before * `withCommandTelemetry`. */ export const assertStorageTargetsExclusive = Effect.fnUntraced(function* ( diff --git a/apps/cli/src/commands/storage/storage.flags.unit.test.ts b/apps/cli/src/commands/storage/storage.flags.unit.test.ts index ab5f8fa674..7e0373c298 100644 --- a/apps/cli/src/commands/storage/storage.flags.unit.test.ts +++ b/apps/cli/src/commands/storage/storage.flags.unit.test.ts @@ -25,9 +25,6 @@ describe("storageChangedTargetFlags", () => { }); it("skips the value of `storage cp` value-consuming flags (content-type/cache-control/jobs)", () => { - // The value following a bare `--content-type`/`--cache-control`/`--jobs`/`-j` - // is consumed as that flag's argument, so a `--local`/`--linked` token there - // must NOT be detected as a selector. expect( storageChangedTargetFlags(["storage", "cp", "--content-type", "--local", "a", "b"]), ).toEqual([]); diff --git a/apps/cli/src/commands/storage/storage.frame.ts b/apps/cli/src/commands/storage/storage.frame.ts index 43ff9c6e91..6deb47bdc0 100644 --- a/apps/cli/src/commands/storage/storage.frame.ts +++ b/apps/cli/src/commands/storage/storage.frame.ts @@ -25,10 +25,8 @@ import { } from "./storage.errors.ts"; /** - * Shared plumbing for the four `storage` subcommands. Each handler resolves the - * project ref (the value of `--local` decides local vs linked, mirroring Go's - * `storage.go:21-32`), then uses these helpers for the parts Go shares via - * `utils.Config` + `client.NewStorageAPI`. + * Shared plumbing for the four `storage` subcommands: resolving the project ref + * (`--local`'s value decides local vs linked) and building the gateway client. */ const decodeDefaultCliConfig = Schema.decodeUnknownSync(CliConfigSchema); @@ -40,21 +38,12 @@ interface LoadedStorageConfig { } /** - * Load `supabase/config.toml`: a parse failure aborts - * (`StorageConfigError`); a missing file falls back to the embedded - * defaults — EXCEPT for a LOCAL target (`projectRef === ""`) with an - * explicitly-set `--workdir`/`SUPABASE_WORKDIR`, where it hard-fails instead - * (`StorageMissingProjectConfigError`): the embedded default `api.port` - * could otherwise retarget a local `storage rm -r` (or any other operation) - * at a different, possibly running, local stack. A REMOTE target - * (`--project-ref`/`--linked`) never hard-fails on this, explicit workdir or - * not: `resolveStorageCredentials` doesn't read `config` at all on that - * path (Management API credentials only), so a config-less workdir poses no - * such risk there — it would only cost the (cosmetic) `[remotes.*]` override - * line. A DEFAULTED workdir keeps the established tolerant fallback either - * way. When a `[remotes.<name>]` block matches the linked ref, `appliedRemote` - * carries its name so the caller can print the `Loading config override:` - * line. + * Loads `supabase/config.toml`, falling back to the embedded defaults when it's + * missing — except for a local target with an explicit `--workdir`, which raises + * `StorageMissingProjectConfigError` instead (see that error's doc for why). A + * remote target never hard-fails this way, since credential resolution doesn't + * read `config` at all. `appliedRemote` is set when a `[remotes.<name>]` block + * matches the linked ref. */ export const loadStorageConfig = Effect.fnUntraced(function* ( cliSettings: { readonly workdir: string; readonly explicitWorkdir: boolean }, @@ -92,13 +81,7 @@ export const loadStorageConfig = Effect.fnUntraced(function* ( } satisfies LoadedStorageConfig; }); -/** - * Validates the resolved `--workdir`/`SUPABASE_WORKDIR` exists and is a - * directory (`validateWorkdirIsDirectory`), mapping into the shared - * `StorageWorkdirError` — hoisted here (rather than duplicated across - * `ls`/`mv`/`rm`/`cp`) since `ls`/`mv` don't otherwise need `FileSystem` in - * scope. - */ +/** Validates the resolved `--workdir`/`SUPABASE_WORKDIR` exists and is a directory. */ export const assertStorageWorkdir = Effect.fnUntraced(function* (workdir: string) { const fs = yield* FileSystem.FileSystem; yield* validateWorkdirIsDirectory(workdir, fs).pipe( @@ -107,18 +90,11 @@ export const assertStorageWorkdir = Effect.fnUntraced(function* (workdir: string }); /** - * Resolve Storage credentials and run `body` against a freshly-built gateway, - * with the `FetchHttpClient.Fetch` override applied to the gateway calls only - * (CA-trusting for a local https gateway, plain `globalThis.fetch` otherwise). - * - * The credential lookup (the `--linked` api-keys call) runs BEFORE the override - * scope, so it still honors `--dns-resolver https` through the Management API - * client — mirroring Go, where Storage uses `status.NewKongClient` / - * `http.DefaultClient` while `tenant.GetApiKeys` uses the DoH-wrapped client. - * - * `makeStorageGateway` only constructs the client object (no network), so - * building it inside the override scope is fine; the override is read per request - * from the fiber context when a gateway call executes. + * Resolves Storage credentials and runs `body` against a freshly-built gateway, with + * `FetchHttpClient.Fetch` overridden for gateway calls only (CA-trusting for a local + * https gateway, otherwise plain `fetch`). Credential lookup runs before that + * override scope, so it still honors `--dns-resolver https` through the Management + * API client. */ export const connectStorageGateway = <E, R>( opts: { readonly projectRef: string; readonly config: CliConfig; readonly userAgent: string }, @@ -143,12 +119,9 @@ export const connectStorageGateway = <E, R>( }); /** - * Go `client.ParseStorageURL` as an Effect: returns the object path or fails - * with the tagged `StorageInvalidUrlError` (pattern mismatch) / - * `StorageUrlParseError` (url-parse failure, wrapped like Go's - * `failed to parse storage url: %w`). Used by `ls`, `mv`, and `rm`; `cp` parses - * `src`/`dst` with `goUrlParse` directly (it branches on the scheme and - * wraps as `failed to parse src url` / `failed to parse dst url`). + * Parses a storage URL into its object path, failing with `StorageInvalidUrlError` + * on a pattern mismatch or `StorageUrlParseError` on a URL-parse failure. Used by + * `ls`, `mv`, and `rm`; `cp` parses `src`/`dst` directly. */ export const parseStorageUrlEffect = (objectUrl: string) => Effect.try({ diff --git a/apps/cli/src/commands/storage/storage.iterate.ts b/apps/cli/src/commands/storage/storage.iterate.ts index 1984c3c951..cb19c1342a 100644 --- a/apps/cli/src/commands/storage/storage.iterate.ts +++ b/apps/cli/src/commands/storage/storage.iterate.ts @@ -6,19 +6,14 @@ import { PAGE_LIMIT, type StorageGateway } from "../../command-internal/storage- import { goPathSplit, splitBucketPrefix } from "../../command-internal/storage-url.ts"; /** - * Pagination + BFS traversal shared by `storage ls/cp/mv/rm`. The `callback` - * receives each entry name (or full path, for the recursive variant); a - * directory entry has a trailing `/`. Errors from the gateway or the - * callback short-circuit the loop, returning the first error. - * - * The `Loading page:` notice is emitted only in text mode — json/stream-json - * consumers don't want the pagination noise on stderr. + * Pagination + traversal helpers shared by `storage ls/cp/mv/rm`. `callback` receives + * each entry name (or full path for the recursive variant); a directory entry has a + * trailing `/`. The `Loading page:` notice is text-mode only. */ /** - * Go `ls.IterateStoragePaths` (`ls.go:44-82`): when the path resolves to the - * bucket root, list buckets filtered by the (possibly empty) bucket prefix; - * otherwise page through objects under the prefix. + * Lists buckets filtered by prefix when `remotePath` resolves to the bucket root; + * otherwise pages through objects under the prefix. */ export const iterateStoragePaths = <E>( gateway: StorageGateway, @@ -52,10 +47,7 @@ export const iterateStoragePaths = <E>( } }); -/** - * Go `ls.ListStoragePaths` (`ls.go:35-42`): collect every entry name under the - * path into an array. - */ +/** Collects every entry name under `remotePath` into an array. */ export const listStoragePaths = ( gateway: StorageGateway, output: typeof Output.Service, @@ -72,9 +64,8 @@ export const listStoragePaths = ( }); /** - * Go `ls.IterateStoragePathsAll` (`ls.go:94-136`): BFS over the directory tree - * (LIFO queue), invoking `callback` with each object's full path. An empty - * bucket is reported as `<bucket>/`. + * Walks the directory tree with a stack, invoking `callback` with each object's + * full path. An empty bucket is reported as `<bucket>/`. */ export const iterateStoragePathsAll = <E>( gateway: StorageGateway, @@ -112,7 +103,6 @@ export const iterateStoragePathsAll = <E>( yield* callback(objectPath); }), ); - // Also report empty buckets (Go: a top-level empty bucket → `<bucket>/`). const [bucket, prefix] = splitBucketPrefix(dirPath); if (empty && prefix.length === 0) { yield* callback(`${bucket}/`); diff --git a/apps/cli/src/commands/telemetry/disable/disable.command.ts b/apps/cli/src/commands/telemetry/disable/disable.command.ts index c91147167f..4061573795 100644 --- a/apps/cli/src/commands/telemetry/disable/disable.command.ts +++ b/apps/cli/src/commands/telemetry/disable/disable.command.ts @@ -12,15 +12,9 @@ export const telemetryDisableCommand = Command.make("disable", config).pipe( Command.withDescription("Disable CLI telemetry."), Command.withShortDescription("Disable telemetry"), Command.withHandler((flags) => - // Go parity (`cmd/root.go:131-138,171-181`): `cli_command_executed` fires - // gated on the CONSENT SNAPSHOT TAKEN BEFORE this command's handler runs - // (Go's PersistentPreRunE reads the on-disk state before `disable`'s RunE - // mutates it), not on the just-written `false`. `analyticsLayer` - // reproduces that naturally: it reads `TelemetryRuntime.consent` once, at - // layer-construction time, before the handler below ever executes, so - // leaving analytics enabled here fires the event exactly when telemetry - // was enabled prior to this invocation, and stays silent when it was - // already disabled — see `enable.command.ts` for the mirror-image case. + // `cli_command_executed` fires based on the consent value read at layer-construction + // time, before this handler mutates it — so `disable` still fires when telemetry was + // enabled going into the call, and stays silent when it was already disabled. telemetryDisable(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(commandRuntimeLayer(["telemetry", "disable"])), diff --git a/apps/cli/src/commands/telemetry/enable/enable.command.ts b/apps/cli/src/commands/telemetry/enable/enable.command.ts index 5c0f7194aa..0f07e91bba 100644 --- a/apps/cli/src/commands/telemetry/enable/enable.command.ts +++ b/apps/cli/src/commands/telemetry/enable/enable.command.ts @@ -12,12 +12,8 @@ export const telemetryEnableCommand = Command.make("enable", config).pipe( Command.withDescription("Enable CLI telemetry."), Command.withShortDescription("Enable telemetry"), Command.withHandler((flags) => - // Go parity (`cmd/root.go:131-138,171-181`): `cli_command_executed` fires - // gated on the pre-toggle consent snapshot, same as `disable` — see that - // command's comment. In the common case (enabling from a disabled state) - // the snapshot is `false`, so the event stays silent; running `enable` - // when telemetry is ALREADY enabled fires it, matching Go's uniform, - // state-based (not command-based) gate. + // `cli_command_executed` fires based on the consent value read at layer-construction + // time: silent when enabling from a disabled state, since the snapshot is `false`. telemetryEnable(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), Command.provide(commandRuntimeLayer(["telemetry", "enable"])), diff --git a/apps/cli/src/commands/telemetry/telemetry.integration.test.ts b/apps/cli/src/commands/telemetry/telemetry.integration.test.ts index 9b1ad5f060..da7ee7d576 100644 --- a/apps/cli/src/commands/telemetry/telemetry.integration.test.ts +++ b/apps/cli/src/commands/telemetry/telemetry.integration.test.ts @@ -45,16 +45,9 @@ function setup(dir: string) { return { out, analytics, layer }; } -// Wires the REAL `analyticsLayer` (consent-gated, backed by -// `telemetryRuntimeLayer` reading `dir`'s telemetry.json) instead of -// `mockAnalytics()` — the un-mocked boundary `Analytics.capture` calls -// actually pass through. No PostHog key is set in the test env, so -// `analyticsLayer` resolves to its no-op branch regardless of consent -// (real-network PostHog delivery has no test double anywhere in this repo); -// this proves the command runs the real consent-gated layer end-to-end -// without crashing, not the exact PostHog call count. The snapshot-timing -// mechanism itself (pre-toggle consent surviving the handler's own disk -// write) is proven directly in `runtime.layer.unit.test.ts`. +// Uses the real analyticsLayer (no PostHog key set, so it always resolves to its +// no-op branch) to prove the command runs the consent-gated layer end-to-end without +// crashing. The snapshot-timing mechanism itself is proven in runtime.layer.unit.test.ts. function setupWithRealAnalytics(dir: string) { const out = mockOutput(); const runtimeInfoLayer = mockRuntimeInfo({ homeDir: dir }); @@ -181,17 +174,9 @@ describe("telemetry integration", () => { ) as Effect.Effect<void>; }); - // Go parity (`cmd/root.go:131-138,171-181`): `cli_command_executed` is gated on - // the consent SNAPSHOT taken before the handler runs, not the value the handler - // just wrote. These two assert the narrower wiring fix using `mockAnalytics()` - // (which unconditionally records every capture, bypassing consent entirely): - // `disable`/`enable` no longer force-suppress analytics via `analytics: false`, - // so the shared instrumentation wrapper actually reaches `Analytics.capture`. - // The snapshot-timing mechanism itself — that the pre-toggle value survives the - // handler's own on-disk write — is proven directly against `telemetryRuntimeLayer` - // in `shared/telemetry/runtime.layer.unit.test.ts`. The two tests further below - // run the same commands through the REAL, consent-gated `analyticsLayer` - // (not this mock) to prove the production wiring doesn't crash end-to-end. + // mockAnalytics() unconditionally records every capture, bypassing consent, so these + // assert only that disable/enable stopped force-suppressing analytics via + // `analytics: false`. See runtime.layer.unit.test.ts for the snapshot-timing proof. it.live("disable no longer force-suppresses cli_command_executed", () => { const dir = makeTempDir(); const { analytics, layer } = setup(dir); diff --git a/apps/cli/src/commands/test/db/db.command.ts b/apps/cli/src/commands/test/db/db.command.ts index b3fd03baf2..efc05ecf9b 100644 --- a/apps/cli/src/commands/test/db/db.command.ts +++ b/apps/cli/src/commands/test/db/db.command.ts @@ -9,12 +9,8 @@ import { import { testDbRuntimeLayer } from "../../../command-internal/test-db.layers.ts"; /** - * `test db` — the visible entry point. Its hidden alias `db test` - * (`../../db/test/test.command.ts`) reuses `testDbConfig` and - * `runTestDbCommand` verbatim — the same implementation function - * reused across two `Command` registrations. The implementation itself - * lives in `command-internal/legacy-test-db.*` — see that module's doc - * comment for why. + * `test db` — the visible entry point. Its hidden alias `db test` reuses the same + * `testDbConfig`/`runTestDbCommand` implementation across two `Command` registrations. */ export const testDbCommand = Command.make("db", testDbConfig).pipe( Command.withDescription(TEST_DB_DESCRIPTION), diff --git a/apps/cli/src/commands/test/new/new.command.ts b/apps/cli/src/commands/test/new/new.command.ts index 2bbfa1edce..8dfcfba7af 100644 --- a/apps/cli/src/commands/test/new/new.command.ts +++ b/apps/cli/src/commands/test/new/new.command.ts @@ -24,9 +24,8 @@ const config = { export type TestNewFlags = CliCommand.Command.Config.Infer<typeof config>; // `test new` writes a local file and makes no Management API calls, so it avoids -// `managementApiRuntimeLayer` (which eagerly resolves an access token). -// `commandSettingsLayer` provides the resolved `workdir`; `Layer.provide` does not -// share to siblings inside a merge, so it is exposed at the top level too. +// `managementApiRuntimeLayer`. `commandSettingsLayer` (providing `workdir`) is +// exposed at the top level too, since `Layer.provide` doesn't share to merge siblings. const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); const testNewRuntimeLayer = Layer.mergeAll( @@ -43,11 +42,8 @@ export const testNewCommand = Command.make("new", config).pipe( withCommandTelemetry({ flags, config, - // `--template` registers `-t` (Flag.withAlias above); without this, - // `-t pgtap` never resolves to the canonical `template` name in - // extractChangedFlagNames, so it wouldn't appear in telemetry at all - // (Go's pflag.Visit reports the canonical name regardless of shorthand — - // cmd/root_analytics.go:53-76). + // Without this, `-t pgtap` never resolves to the canonical `template` name in + // extractChangedFlagNames, so it wouldn't appear in telemetry at all. aliases: { t: "template" }, }), withJsonErrorHandling, diff --git a/apps/cli/src/commands/test/new/new.errors.ts b/apps/cli/src/commands/test/new/new.errors.ts index 3b144733e9..407b387acb 100644 --- a/apps/cli/src/commands/test/new/new.errors.ts +++ b/apps/cli/src/commands/test/new/new.errors.ts @@ -19,10 +19,7 @@ export class TestNewFileExistsError extends Data.TaggedError("TestNewFileExistsE } } -/** - * Writing the test file failed (e.g. permission denied). Mirrors Go's - * `utils.WriteFile` error (`new.go:28`). - */ +/** Writing the test file failed (e.g. permission denied). */ export class TestNewWriteError extends Data.TaggedError("TestNewWriteError")<{ readonly path: string; readonly message: string; diff --git a/apps/cli/src/commands/test/new/new.handler.ts b/apps/cli/src/commands/test/new/new.handler.ts index 11fb321ac6..8129e43591 100644 --- a/apps/cli/src/commands/test/new/new.handler.ts +++ b/apps/cli/src/commands/test/new/new.handler.ts @@ -22,9 +22,8 @@ export const testNew = Effect.fn("test.new")(function* (flags: TestNewFlags) { const template = Option.getOrElse(flags.template, () => "pgtap" as const); yield* Effect.gen(function* () { - // Path is relative to the project root (`utils.DbTestsDir` = - // "supabase/tests") and that relative path is what gets printed; FS ops - // are rooted at the resolved workdir. + // The printed path is relative to the project root ("supabase/tests"); FS ops are + // rooted at the resolved workdir. const relPath = path.join("supabase", "tests", `${flags.name}_test.sql`); const target = path.join(cliSettings.workdir, relPath); @@ -35,8 +34,6 @@ export const testNew = Effect.fn("test.new")(function* (flags: TestNewFlags) { ); } - // `utils.WriteFile` pins the dir to 0755 and the test file to 0644 - // (`internal/utils/misc.go:281,284`). yield* fs .makeDirectory(path.dirname(target), { recursive: true, mode: 0o755 }) .pipe( diff --git a/apps/cli/src/commands/test/new/new.integration.test.ts b/apps/cli/src/commands/test/new/new.integration.test.ts index 4bc3d98dce..b23cd95697 100644 --- a/apps/cli/src/commands/test/new/new.integration.test.ts +++ b/apps/cli/src/commands/test/new/new.integration.test.ts @@ -56,9 +56,8 @@ function setup(opts: SetupOpts = {}) { out.layer, cliSettings, telemetry.layer, - // BunServices provides FileSystem + Path; when forcing a failure the failing - // layer is appended last so it overrides FileSystem (Path still comes from - // BunServices — duplicate-tag mergeAll is last-wins). + // The failing layer is appended last so it overrides BunServices' FileSystem + // (duplicate-tag mergeAll is last-wins); Path still comes from BunServices. BunServices.layer, ...(opts.writeFails === true ? [failingFsLayer("writeFileString")] : []), ...(opts.mkdirFails === true ? [failingFsLayer("makeDirectory")] : []), diff --git a/apps/cli/src/commands/unlink/unlink.command.ts b/apps/cli/src/commands/unlink/unlink.command.ts index 6ef40f460c..caf9e5e1df 100644 --- a/apps/cli/src/commands/unlink/unlink.command.ts +++ b/apps/cli/src/commands/unlink/unlink.command.ts @@ -10,12 +10,10 @@ import { withJsonErrorHandling } from "../../shared/output/json-error-handling.t import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { unlink } from "./unlink.handler.ts"; -// `unlink` makes no Management API calls (no access token is needed), so it -// deliberately avoids `managementApiRuntimeLayer` — that layer eagerly resolves -// an access token and would fail with "Access token not provided" for a token-less -// `unlink`. It provides only the services the handler + instrumentation consume. -// `commandSettingsLayer` is provided to credentials AND exposed at the top level -// (Layer.provide does not share to siblings inside a merge — CLAUDE.md invariant 5). +// `unlink` makes no Management API calls, so it avoids `managementApiRuntimeLayer`, +// which eagerly resolves an access token and would fail for a token-less `unlink`. +// `commandSettingsLayer` is exposed at the top level too, since `Layer.provide` +// doesn't share to merge siblings. const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); const credentials = commandCredentialsLayer.pipe( Layer.provide(cliSettings), diff --git a/apps/cli/src/commands/unlink/unlink.errors.ts b/apps/cli/src/commands/unlink/unlink.errors.ts index cd6904d42f..8c997ad4f1 100644 --- a/apps/cli/src/commands/unlink/unlink.errors.ts +++ b/apps/cli/src/commands/unlink/unlink.errors.ts @@ -19,10 +19,7 @@ export class UnlinkRefReadError extends Data.TaggedError("UnlinkRefReadError")<{ } } -/** - * Removing the `supabase/.temp` directory failed. Byte-matches Go's - * `"failed to remove temp directory: " + err` (`unlink.go:32`). - */ +/** Removing the `supabase/.temp` directory failed. */ export class UnlinkTempRemovalError extends Data.TaggedError("UnlinkTempRemovalError")<{ readonly message: string; }> { diff --git a/apps/cli/src/commands/unlink/unlink.handler.ts b/apps/cli/src/commands/unlink/unlink.handler.ts index f63d946a33..cb460df274 100644 --- a/apps/cli/src/commands/unlink/unlink.handler.ts +++ b/apps/cli/src/commands/unlink/unlink.handler.ts @@ -21,17 +21,16 @@ export const unlink = Effect.fn("unlink")(function* () { const paths = tempPaths(path, cliSettings.workdir); yield* Effect.gen(function* () { - // 1. Load the linked project ref. An absent file is `ErrNotLinked`; any other - // read failure surfaces verbatim (unlink.go:16-19). + // 1. Load the linked project ref. An absent file means not-linked; any other + // read failure surfaces verbatim. const exists = yield* fs.exists(paths.projectRef).pipe(Effect.orElseSucceed(() => false)); if (!exists) { return yield* Effect.fail( new ProjectRefNotLinkedError({ message: PROJECT_NOT_LINKED_MESSAGE }), ); } - // Go reads the raw bytes without trimming — `link` writes the ref with no - // trailing newline, so the value round-trips exactly (used for both the - // stderr message and the keyring key). + // No trimming needed: `link` writes the ref with no trailing newline, so the raw + // bytes round-trip exactly for both the stderr message and the keyring key. const projectRef = yield* fs.readFileString(paths.projectRef).pipe( Effect.mapError( (cause) => @@ -43,8 +42,8 @@ export const unlink = Effect.fn("unlink")(function* () { yield* output.raw(`Unlinking project: ${projectRef}\n`, "stderr"); - // 2. Best-effort: remove the temp dir and delete the stored db-password - // credential. Both are attempted; non-ignored errors are joined (unlink.go:29-41). + // 2. Best-effort: both the temp-dir removal and the credential delete are + // attempted regardless of either failing; their errors are joined below. const collected: Array<UnlinkTempRemovalError | CredentialDeleteError> = []; const removed = yield* fs.remove(paths.tempDir, { recursive: true, force: true }).pipe( @@ -63,9 +62,8 @@ export const unlink = Effect.fn("unlink")(function* () { const [first, ...rest] = collected; if (first !== undefined) { - // Mirror `errors.Join(allErrors...)` (unlink.go:41): surface every - // collected message, not just the first. Keep the leading failure's tag - // (temp removal precedes the credential delete, matching that order). + // Surfaces every collected message, not just the first, while keeping the + // leading failure's tag (temp removal is attempted before the credential delete). if (rest.length === 0) { return yield* Effect.fail(first); } @@ -77,8 +75,7 @@ export const unlink = Effect.fn("unlink")(function* () { ); } - // 3. PostRun: `Finished supabase unlink.` to stdout (text), structured success - // otherwise. + // 3. Print "Finished supabase unlink." in text mode, or a structured success otherwise. if (output.format === "text") { yield* output.raw("Finished supabase unlink.\n"); } else { diff --git a/apps/cli/src/commands/unlink/unlink.integration.test.ts b/apps/cli/src/commands/unlink/unlink.integration.test.ts index 0e72b942a9..d5fc6de7c1 100644 --- a/apps/cli/src/commands/unlink/unlink.integration.test.ts +++ b/apps/cli/src/commands/unlink/unlink.integration.test.ts @@ -32,9 +32,8 @@ interface SetupOpts { removeFails?: boolean; } -// Wraps the real Bun FileSystem but forces `remove` to fail, so the -// temp-dir-removal error branch can be exercised deterministically (cross-platform, -// independent of filesystem permissions). +// Wraps the real Bun FileSystem but forces `remove` to fail, so the temp-dir-removal +// error branch is exercised deterministically, independent of filesystem permissions. const failingRemoveFsLayer = Layer.effect( FileSystem.FileSystem, Effect.gen(function* () { @@ -103,8 +102,8 @@ describe("unlink integration", () => { }); it.live("succeeds when no credential is stored (keyring not-found ignored)", () => { - // The tracked credentials mock returns `true`; a real not-found returns - // `false` without erroring — either way unlink succeeds. + // The mock returns true here; a real not-found returns false without erroring — + // either way unlink succeeds. const { layer, out, workdir } = setup(); seedProjectRef(workdir, VALID_REF); return Effect.gen(function* () { @@ -135,7 +134,6 @@ describe("unlink integration", () => { if (Exit.isFailure(exit)) { expect(JSON.stringify(exit.cause)).toContain("CredentialDeleteError"); } - // The temp dir is still removed before the credential delete is attempted. expect(existsSync(join(workdir, "supabase", ".temp"))).toBe(false); }).pipe(Effect.provide(layer)); }); @@ -162,7 +160,6 @@ describe("unlink integration", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const json = JSON.stringify(exit.cause); - // errors.Join parity — both failure messages are surfaced, not just the first. expect(json).toContain("failed to remove temp directory"); expect(json).toContain("failed to delete project credential"); } diff --git a/apps/cli/src/commands/vanity-subdomains/activate/activate.command.ts b/apps/cli/src/commands/vanity-subdomains/activate/activate.command.ts index fc0eff0de7..77b2c3cc4a 100644 --- a/apps/cli/src/commands/vanity-subdomains/activate/activate.command.ts +++ b/apps/cli/src/commands/vanity-subdomains/activate/activate.command.ts @@ -17,12 +17,9 @@ const config = { Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), - // Go marks this flag required (`cmd/vanitySubdomains.go:67`), but cobra - // validates required flags only AFTER `PersistentPreRunE` - // (`cobra@v1.10.2/command.go:985,1005`) — so the `--experimental` gate, - // login check, and project-ref resolution must all win over a missing - // `--desired-subdomain`. Optional at parse time; presence is enforced in - // the handler (after ref resolution) instead. + // Optional at parse time so the --experimental gate, login check, and project-ref + // resolution all run first; required-ness is enforced in the handler after ref + // resolution. desiredSubdomain: Flag.string("desired-subdomain").pipe( Flag.withDescription("The desired vanity subdomain to use for your Supabase project."), Flag.optional, @@ -38,17 +35,13 @@ export const vanitySubdomainsActivateCommand = Command.make("activate", config). Command.withShortDescription("Activate a vanity subdomain"), Command.withHandler((flags) => Effect.gen(function* () { - // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) - // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an - // invalid `-o` value must win over a missing `--experimental` flag. + // An invalid `-o` value must win over a missing `--experimental` flag, so this + // validation runs before the gate. yield* validateOutputFormat(RESOURCE_OUTPUT_FORMATS); - // Go gates `vanityCmd` (vanity-subdomains) behind `--experimental` in PersistentPreRunE - // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). - // `managementApiRuntimeLayer` eagerly resolves an access token as part - // of building its `CommandPlatformApi` layer, so it must be provided AFTER - // the gate (inline here) rather than via `Command.provide` on the whole - // command — `Command.provide` would build the layer, and fail on a missing - // token, before this generator's first `yield*` ever runs. + // `managementApiRuntimeLayer` eagerly resolves an access token, so it's provided + // inline after the experimental gate rather than via `Command.provide` on the + // whole command — `Command.provide` would build the layer, and fail on a missing + // token, before the gate ever runs. yield* requireExperimental; return yield* vanitySubdomainsActivate(flags).pipe( withCommandTelemetry({ flags }), diff --git a/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts b/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts index 5dde147a42..787b5b8e93 100644 --- a/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/activate/activate.handler.ts @@ -22,7 +22,7 @@ import { } from "../vanity-subdomains.errors.ts"; import type { VanitySubdomainsActivateFlags } from "./activate.command.ts"; -/** Type shape for `api.ActivateVanitySubdomainResponse` (`types.gen.go`). */ +/** Struct shape for encoding the activate response as YAML/TOML. */ const GO_ACTIVATE_VANITY_RESPONSE = goStruct([["custom_domain", goString]]); const mapActivateError = mapHttpError({ @@ -46,13 +46,9 @@ export const vanitySubdomainsActivate = Effect.fn("vanity-subdomains.activate")( const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { - // Go validates the required `--desired-subdomain` only after - // `PersistentPreRunE` completes (gate → login → ref resolution, - // `cmd/root.go:93-117`; `cobra@v1.10.2/command.go:985,1005`), and - // `PersistentPostRun` still fires telemetry + the linked-project cache - // on that failure — hence this check sits inside both `Effect.ensuring` - // wrappers, after ref resolution. Cobra checks the flag was *changed*, - // not non-empty, so `--desired-subdomain ""` passes and reaches the API. + // This check sits inside both `Effect.ensuring` wrappers so telemetry and the + // linked-project cache still fire on this failure. Only absence is checked, not + // emptiness, so an explicit `--desired-subdomain ""` passes through to the API. if (Option.isNone(flags.desiredSubdomain)) { return yield* Effect.fail( new DesiredSubdomainRequiredError({ diff --git a/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.command.ts b/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.command.ts index df5e3ae86d..d96a370410 100644 --- a/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.command.ts +++ b/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.command.ts @@ -17,12 +17,9 @@ const config = { Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), - // Go marks this flag required (`cmd/vanitySubdomains.go:69`), but cobra - // validates required flags only AFTER `PersistentPreRunE` - // (`cobra@v1.10.2/command.go:985,1005`) — so the `--experimental` gate, - // login check, and project-ref resolution must all win over a missing - // `--desired-subdomain`. Optional at parse time; presence is enforced in - // the handler (after ref resolution) instead. + // Optional at parse time so the --experimental gate, login check, and project-ref + // resolution all run first; required-ness is enforced in the handler after ref + // resolution. desiredSubdomain: Flag.string("desired-subdomain").pipe( Flag.withDescription("The desired vanity subdomain to use for your Supabase project."), Flag.optional, @@ -39,17 +36,13 @@ export const vanitySubdomainsCheckAvailabilityCommand = Command.make( Command.withShortDescription("Check subdomain availability"), Command.withHandler((flags) => Effect.gen(function* () { - // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) - // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an - // invalid `-o` value must win over a missing `--experimental` flag. + // An invalid `-o` value must win over a missing `--experimental` flag, so this + // validation runs before the gate. yield* validateOutputFormat(RESOURCE_OUTPUT_FORMATS); - // Go gates `vanityCmd` (vanity-subdomains) behind `--experimental` in PersistentPreRunE - // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). - // `managementApiRuntimeLayer` eagerly resolves an access token as part - // of building its `CommandPlatformApi` layer, so it must be provided AFTER - // the gate (inline here) rather than via `Command.provide` on the whole - // command — `Command.provide` would build the layer, and fail on a missing - // token, before this generator's first `yield*` ever runs. + // `managementApiRuntimeLayer` eagerly resolves an access token, so it's provided + // inline after the experimental gate rather than via `Command.provide` on the + // whole command — `Command.provide` would build the layer, and fail on a missing + // token, before the gate ever runs. yield* requireExperimental; return yield* vanitySubdomainsCheckAvailability(flags).pipe( withCommandTelemetry({ flags }), diff --git a/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts b/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts index f3371f78c9..556453c55e 100644 --- a/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts +++ b/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.handler.ts @@ -22,7 +22,7 @@ import { } from "../vanity-subdomains.errors.ts"; import type { VanitySubdomainsCheckAvailabilityFlags } from "./check-availability.command.ts"; -/** Type shape for `api.SubdomainAvailabilityResponse` (`types.gen.go`). */ +/** Struct shape for encoding the availability response as YAML/TOML. */ const GO_AVAILABILITY_RESPONSE = goStruct([["available", goBool]]); const mapCheckError = mapHttpError({ @@ -45,13 +45,9 @@ export const vanitySubdomainsCheckAvailability = Effect.fn("vanity-subdomains.ch const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { - // The required `--desired-subdomain` is validated only after the - // gate → login → ref resolution sequence completes (`cmd/root.go:93-117`; - // `cobra@v1.10.2/command.go:985,1005`), and telemetry + the - // linked-project cache still fire on that failure — hence this check - // sits inside both `Effect.ensuring` wrappers, after ref resolution. - // Cobra checks the flag was *changed*, not non-empty, so - // `--desired-subdomain ""` passes and reaches the API. + // This check sits inside both `Effect.ensuring` wrappers so telemetry and the + // linked-project cache still fire on this failure. Only absence is checked, not + // emptiness, so an explicit `--desired-subdomain ""` passes through to the API. if (Option.isNone(flags.desiredSubdomain)) { return yield* Effect.fail( new DesiredSubdomainRequiredError({ @@ -77,8 +73,8 @@ export const vanitySubdomainsCheckAvailability = Effect.fn("vanity-subdomains.ch // tagged error before deciding whether to suggest an upgrade, then re-fail. const mapped = yield* Effect.flip(mapCheckError(cause)); if (mapped._tag === "VanitySubdomainsCheckUnexpectedStatusError") { - // The check command calls SuggestUpgradeOnError without a following - // TrackUpgradeSuggested, so the analytics event is suppressed here too. + // Unlike `activate`, this command suppresses the upgrade-suggestion + // analytics event. const upgradeSuggested = yield* suggestUpgrade({ projectRef: ref, featureKey: "vanity_subdomain", diff --git a/apps/cli/src/commands/vanity-subdomains/delete/delete.command.ts b/apps/cli/src/commands/vanity-subdomains/delete/delete.command.ts index f902b5cd1a..dd84cd7ec5 100644 --- a/apps/cli/src/commands/vanity-subdomains/delete/delete.command.ts +++ b/apps/cli/src/commands/vanity-subdomains/delete/delete.command.ts @@ -28,17 +28,13 @@ export const vanitySubdomainsDeleteCommand = Command.make("delete", config).pipe Command.withShortDescription("Delete the vanity subdomain"), Command.withHandler((flags) => Effect.gen(function* () { - // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) - // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an - // invalid `-o` value must win over a missing `--experimental` flag. + // An invalid `-o` value must win over a missing `--experimental` flag, so this + // validation runs before the gate. yield* validateOutputFormat(RESOURCE_OUTPUT_FORMATS); - // Go gates `vanityCmd` (vanity-subdomains) behind `--experimental` in PersistentPreRunE - // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). - // `managementApiRuntimeLayer` eagerly resolves an access token as part - // of building its `CommandPlatformApi` layer, so it must be provided AFTER - // the gate (inline here) rather than via `Command.provide` on the whole - // command — `Command.provide` would build the layer, and fail on a missing - // token, before this generator's first `yield*` ever runs. + // `managementApiRuntimeLayer` eagerly resolves an access token, so it's provided + // inline after the experimental gate rather than via `Command.provide` on the + // whole command — `Command.provide` would build the layer, and fail on a missing + // token, before the gate ever runs. yield* requireExperimental; return yield* vanitySubdomainsDelete(flags).pipe( withCommandTelemetry({ flags }), diff --git a/apps/cli/src/commands/vanity-subdomains/get/get.command.ts b/apps/cli/src/commands/vanity-subdomains/get/get.command.ts index 0bade3d5c9..b8043ec7f5 100644 --- a/apps/cli/src/commands/vanity-subdomains/get/get.command.ts +++ b/apps/cli/src/commands/vanity-subdomains/get/get.command.ts @@ -26,17 +26,13 @@ export const vanitySubdomainsGetCommand = Command.make("get", config).pipe( Command.withShortDescription("Get the current vanity subdomain"), Command.withHandler((flags) => Effect.gen(function* () { - // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) - // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an - // invalid `-o` value must win over a missing `--experimental` flag. + // An invalid `-o` value must win over a missing `--experimental` flag, so this + // validation runs before the gate. yield* validateOutputFormat(RESOURCE_OUTPUT_FORMATS); - // Go gates `vanityCmd` (vanity-subdomains) behind `--experimental` in PersistentPreRunE - // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). - // `managementApiRuntimeLayer` eagerly resolves an access token as part - // of building its `CommandPlatformApi` layer, so it must be provided AFTER - // the gate (inline here) rather than via `Command.provide` on the whole - // command — `Command.provide` would build the layer, and fail on a missing - // token, before this generator's first `yield*` ever runs. + // `managementApiRuntimeLayer` eagerly resolves an access token, so it's provided + // inline after the experimental gate rather than via `Command.provide` on the + // whole command — `Command.provide` would build the layer, and fail on a missing + // token, before the gate ever runs. yield* requireExperimental; return yield* vanitySubdomainsGet(flags).pipe( withCommandTelemetry({ flags }), From b14974badf8f53994c53e44be9e5343758fa3278 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 12:55:07 +0100 Subject: [PATCH 26/56] chore(repo): trim comments, shard 29 (693 -> 399 comment lines) --- .../commands/migration/down/down.handler.ts | 33 +-- .../migration/down/down.integration.test.ts | 30 +-- .../migration/fetch/fetch.e2e.test.ts | 12 +- .../commands/migration/fetch/fetch.errors.ts | 12 +- .../migration/fetch/fetch.integration.test.ts | 66 ++---- .../migration/fetch/fetch.live.test.ts | 30 +-- .../commands/migration/list/list.command.ts | 4 +- .../commands/migration/list/list.format.ts | 17 +- .../migration/list/list.format.unit.test.ts | 9 +- .../commands/migration/list/list.handler.ts | 16 +- .../migration/list/list.integration.test.ts | 18 +- .../migration/migration.integration.test.ts | 12 +- .../commands/migration/migration.layers.ts | 30 +-- .../commands/migration/migration.prompt.ts | 26 +-- .../src/commands/migration/new/new.handler.ts | 28 +-- .../migration/new/new.integration.test.ts | 10 +- .../migration/repair/repair.command.ts | 5 +- .../migration/repair/repair.handler.ts | 36 ++-- .../repair/repair.integration.test.ts | 53 ++--- .../commands/migration/squash/squash.diff.ts | 41 +--- .../migration/squash/squash.diff.unit.test.ts | 18 +- .../commands/migration/squash/squash.dump.ts | 2 +- .../migration/squash/squash.e2e.test.ts | 17 +- .../migration/squash/squash.errors.ts | 6 +- .../migration/squash/squash.handler.ts | 118 ++++------- .../squash/squash.integration.test.ts | 200 +++++++----------- .../src/commands/migration/up/up.handler.ts | 14 +- 27 files changed, 281 insertions(+), 582 deletions(-) diff --git a/apps/cli/src/commands/migration/down/down.handler.ts b/apps/cli/src/commands/migration/down/down.handler.ts index e4fbf093fe..5339036fb3 100644 --- a/apps/cli/src/commands/migration/down/down.handler.ts +++ b/apps/cli/src/commands/migration/down/down.handler.ts @@ -44,8 +44,7 @@ const runDown = Effect.fnUntraced(function* ( const path = yield* Path.Path; const dnsResolver = yield* DnsResolverFlag; - // Flag-group mutual-exclusion first: validated at - // parse time, ahead of the root pre-run. + // Checked here, ahead of the root pre-run. if (target.setFlags.length > 1) { return yield* Effect.fail( new MigrationTargetFlagsError({ @@ -54,11 +53,10 @@ const runDown = Effect.fnUntraced(function* ( ); } - const connType = target.connType ?? "local"; // down defaults to `--local`. + const connType = target.connType ?? "local"; // `--project-ref` never implies `--linked` and must not be silently - // discarded on a non-linked target — see push.handler.ts's identical guard - // (db push) for the full TS-only rationale. + // discarded on a non-linked target; see push.handler.ts's identical guard. if (Option.isSome(flags.projectRef) && connType !== "linked") { return yield* Effect.fail( new MigrationTargetFlagsError({ @@ -68,8 +66,8 @@ const runDown = Effect.fnUntraced(function* ( ); } - // Resolve the DB config BEFORE the `--last` validation, so an unlinked/invalid - // target surfaces before the `--last must be greater than 0` error. + // Resolves before `--last` validation, so an unlinked/invalid target error + // surfaces before the `--last must be greater than 0` error. const cfg = yield* resolver.resolve({ dbUrl: flags.dbUrl, connType, @@ -77,17 +75,13 @@ const runDown = Effect.fnUntraced(function* ( linkedProjectRef: flags.projectRef, }); - // The project .env loads after the parse-time flag-group validation above — so a - // SUPABASE_YES set only in supabase/.env auto-confirms, but a flag conflict still - // surfaces before any .env read. Resolve --yes against the project env here, not - // just process.env. + // Loads after the flag-group check above, so a flag conflict surfaces before + // any .env read; a SUPABASE_YES set only in supabase/.env still auto-confirms. const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); - // Linked down caches the project ref, gated on the ref loaded in pre-run, NOT - // on the handler's own failure. Load it now and attach the - // cache to the whole flow via `Effect.ensuring`, so it runs even on the `--last`/cancel - // failure paths. + // Attached to the whole flow via `Effect.ensuring` so the cache write still + // runs on the `--last`/cancel failure paths. const cacheLinkedRef = connType === "linked" ? yield* Effect.gen(function* () { @@ -99,7 +93,6 @@ const runDown = Effect.fnUntraced(function* ( : undefined; const downFlow = Effect.gen(function* () { - // `--last` zero-value validation runs after DB-config resolution. if (flags.last === 0) { return yield* Effect.fail( new MigrationLastZeroError({ message: "--last must be greater than 0" }), @@ -111,8 +104,6 @@ const runDown = Effect.fnUntraced(function* ( yield* Effect.scoped( Effect.gen(function* () { - // The connect diagnostic prints to stderr before dialing, - // local/remote per the resolved connection. yield* output.raw( `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, "stderr", @@ -153,10 +144,8 @@ const runDown = Effect.fnUntraced(function* ( yield* migrateAndSeed(session, fs, path, cliSettings.workdir, version, { migrationsEnabled: toml.migrationsEnabled, seed: toml.seed, - // `version` is always non-empty here (`migration down` reverts to a concrete - // target) — the empty-version half of `migrateAndSeed`'s declarative - // branch gate is therefore always false on this call site regardless of these - // three values, matching the file's own doc comment. + // `version` is always non-empty here, so `migrateAndSeed`'s empty-version + // branch never triggers regardless of these three flags. experimental: false, pgDeltaEnabled: false, schemaPaths: [], diff --git a/apps/cli/src/commands/migration/down/down.integration.test.ts b/apps/cli/src/commands/migration/down/down.integration.test.ts index 59a8c5daa2..1e0277708e 100644 --- a/apps/cli/src/commands/migration/down/down.integration.test.ts +++ b/apps/cli/src/commands/migration/down/down.integration.test.ts @@ -109,17 +109,15 @@ function setup(workdir: string, opts: SetupOpts = {}) { extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), - // A migration file's statements arrive as one batch; replay them through - // `exec`/`query` so this suite's recordings and failure injection still apply. + // Replays each statement through exec/query so recordings and failure injection apply. execBatch: (statements) => sequentialExecBatch(session)(statements), }; return Effect.succeed(session); }, }); - // `loadProjectRef` gives an explicit `--project-ref` flag top precedence, same - // as Go's `flags.LoadProjectRef` — mirror that so a test can prove the flag - // (not just the hardcoded `VALID_REF` fallback) drives the linked ref. + // Gives an explicit --project-ref flag precedence over the VALID_REF fallback, so a + // test can prove the flag drives the linked ref. const projectRef = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(VALID_REF), resolveForLink: () => Effect.succeed(VALID_REF), @@ -145,8 +143,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { mockTty({ stdinIsTty: opts.isTTY ?? true }), mockStdin( opts.isTTY ?? true, - // Migration prompts read stdin directly, so a confirm answer is - // supplied via piped stdin rather than the Output prompt mock. + // Migration prompts read stdin directly, so the confirm answer is piped in. opts.pipedInput ?? (opts.confirm === undefined ? undefined : opts.confirm ? "y\n" : "n\n"), ), BunServices.layer, @@ -184,15 +181,12 @@ describe("migration down", () => { }); it.live("resolves the DB target before rejecting --last 0", () => { - // The DB config resolves before the `--last === 0` - // check, so an unlinked/invalid target error wins over --last 0. const { layer } = setup(tmp.current, { failResolve: true }); return Effect.gen(function* () { const exit = yield* migrationDown(flags({ last: 0 })).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const failure = Cause.findErrorOption(exit.cause); - // The target/config error surfaces first, NOT the --last 0 error. expect(Option.isSome(failure) && failure.value._tag).toBe("ProjectRefNotLinkedError"); } }).pipe(Effect.provide(layer)); @@ -218,10 +212,8 @@ describe("migration down", () => { }); return Effect.gen(function* () { yield* migrationDown(flags({ last: 1 })); - // The connection banner prints to stderr before dialing. expect(stripAnsi(out.stderrText)).toContain("Connecting to local database..."); expect(stripAnsi(out.stderrText)).toContain("Resetting database to version: 20240101000000"); - // dropped user schemas, then re-applied the migration <= target version. expect(execs.some((sql) => sql.startsWith("do $$"))).toBe(true); expect( queries.some( @@ -235,9 +227,8 @@ describe("migration down", () => { it.live( "reverts on the project given via --project-ref --linked, overriding the linked ref", () => { - // down defaults to local; only with --linked does the flag's ref get - // cached. The fake resolver's own fallback (VALID_REF) represents - // whatever the workdir would resolve to absent the flag. + // VALID_REF is the fake resolver's fallback, representing whatever the + // workdir would resolve to without the flag override. const FLAG_REF = "flagflagflagflagflag"; seed(tmp.current, "20240101000000_a.sql"); const { layer, cache } = setup(tmp.current, { @@ -257,8 +248,6 @@ describe("migration down", () => { ); it.live("rejects --project-ref on the default local target", () => { - // down defaults to local when no target flag is set — the guard must fire - // from the flag alone, with no explicit --local/--db-url needed. const FLAG_REF = "flagflagflagflagflag"; const { layer, execs, queries, cache } = setup(tmp.current, { remote: ["20240101000000", "20240102000000"], @@ -299,8 +288,7 @@ describe("migration down", () => { }); it.live("falls back to NO (cancels) without a TTY and no piped answer", () => { - // Stdin is read regardless of TTY (isTTY only changes the timeout); with no piped - // answer the empty read falls back to the default (NO) → cancel. + // isTTY only changes the read timeout; stdin is still read either way. const { layer, out } = setup(tmp.current, { isTTY: false, remote: ["20240101000000", "20240102000000"], @@ -337,8 +325,7 @@ describe("migration down", () => { it.live("auto-confirms from SUPABASE_YES in the project .env (Go loadNestedEnv)", () => { seed(tmp.current, "20240101000000_a.sql"); - // SUPABASE_YES lives only in supabase/.env, not the shell — the project env loads it - // before the prompt, so the revert auto-confirms with no --yes flag and no stdin answer. + // SUPABASE_YES lives only in supabase/.env; the project env loads it before the prompt. writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_YES=true\n"); const { layer, out } = setup(tmp.current, { format: "json", @@ -436,7 +423,6 @@ describe("migration down", () => { }); return Effect.gen(function* () { yield* migrationDown(flags({ last: 1 })); - // Dirty seed → "Updating seed hash" + hash UPSERT, but the seed SQL is NOT re-run. expect(stripAnsi(out.stderrText)).toContain("Updating seed hash to supabase/seed.sql..."); expect( queries.some((q) => q.sql.includes("INSERT INTO supabase_migrations.seed_files")), diff --git a/apps/cli/src/commands/migration/fetch/fetch.e2e.test.ts b/apps/cli/src/commands/migration/fetch/fetch.e2e.test.ts index adf035d93e..1014a88d22 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.e2e.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.e2e.test.ts @@ -22,11 +22,8 @@ describe("supabase migration fetch", () => { rmSync(workdir, { recursive: true, force: true }); }); - // Real-subprocess guard for the production Stdin wiring + confirm prompt: a piped - // answer to the overwrite prompt must actually be read, not auto-defaulted. A declined - // `n` cancels before connecting, so no DB is required. This is the boundary in-process - // tests cannot cover — they inject a mock Stdin, which masked a missing-service bug - // where the migration DB runtime never provided the real stdin layer. + // Exercises the real Stdin wiring; in-process tests inject a mock Stdin and can't + // catch a missing real-stdin layer. test( "reads a piped 'n' answer to the overwrite prompt and cancels", { timeout: E2E_TIMEOUT_MS }, @@ -36,15 +33,12 @@ describe("supabase migration fetch", () => { stdin: "n\n", }); - // Declined → cancelled (exit 1), and the prompt label reached stderr. expect(exitCode).toBe(1); expect(stripAnsi(stderr)).toContain("[Y/n]"); - // A declined prompt renders a lone `context canceled` line, with NO - // `SuggestDebugFlag` troubleshooting hint appended. CLI-1973. + // A declined prompt exits with a lone "context canceled" line and no --debug hint. const lines = stripAnsi(stderr).trimEnd().split("\n"); expect(lines.at(-1)).toBe("context canceled"); expect(stderr).not.toContain("Try rerunning the command with --debug"); - // The existing file was NOT overwritten — the piped answer was honored. expect(readdirSync(join(workdir, "supabase", "migrations"))).toEqual([ "20240101000000_existing.sql", ]); diff --git a/apps/cli/src/commands/migration/fetch/fetch.errors.ts b/apps/cli/src/commands/migration/fetch/fetch.errors.ts index 2516f7bb6b..5d8807f2ff 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.errors.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.errors.ts @@ -13,15 +13,9 @@ import { export class MigrationFetchWriteError extends Data.TaggedError("MigrationFetchWriteError")<{ readonly message: string; /** - * Absolute paths of migration files this fetch had already written, in remote-history - * order, before THIS failure — populated whenever a later row in the same fetch fails - * validation or the write itself (a tampered/malformed remote row, or a mid-loop write - * failure), after earlier rows already wrote successfully. `undefined`/omitted when - * nothing had been written yet (e.g. the very first row failed, or the failure happened - * before the write loop even started). Read by `pull.aggregate.ts`'s - * `pullFailedStepResult` (via `hasWrittenSoFar`) so `supabase pull`'s - * `migration_history` step can report partial progress instead of always claiming - * `written: []` on a failed fetch. + * Absolute paths of migration files already written before this failure, in + * remote-history order; omitted if nothing had been written yet. Read by + * `pull.aggregate.ts` to report partial progress instead of an empty list. */ readonly writtenSoFar?: ReadonlyArray<string>; }> { diff --git a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts index 0625ea9985..840da7649f 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.integration.test.ts @@ -91,9 +91,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), }); - // `loadProjectRef` gives an explicit `--project-ref` flag top precedence, same - // as Go's `flags.LoadProjectRef` — mirror that so a test can prove the flag - // (not just the hardcoded `VALID_REF` fallback) drives the linked ref. + // Gives an explicit --project-ref flag precedence over the VALID_REF fallback, so a + // test can prove the flag drives the linked ref. const projectRef = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(VALID_REF), resolveForLink: () => Effect.succeed(VALID_REF), @@ -119,8 +118,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { mockTty({ stdinIsTty: opts.isTTY ?? true }), mockStdin( opts.isTTY ?? true, - // Migration prompts read stdin directly, so a confirm answer is - // supplied via piped stdin rather than the Output prompt mock. + // Migration prompts read stdin directly, so the confirm answer is piped in. opts.pipedInput ?? (opts.confirm === undefined ? undefined : opts.confirm ? "y\n" : "n\n"), ), BunServices.layer, @@ -151,7 +149,6 @@ describe("migration fetch", () => { }); return Effect.gen(function* () { yield* migrationFetch(flags()); - // The connection banner prints to stderr before dialing. expect(out.stderrText).toContain("Connecting to remote database..."); const dir = migrationsDir(tmp.current); const files = readdirSync(dir); @@ -161,11 +158,8 @@ describe("migration fetch", () => { }); it.live("writes a lone separator for a row with no statements (Go parity)", () => { - // A `schema_migrations` row can legally have a NULL/empty `statements` array - // (older projects, manually-inserted rows). Joining statements with ";\n" - // plus a trailing ";\n" means an empty array yields exactly ";\n" — a file with a stray - // semicolon, not an empty file. This port keeps these bytes; lock it - // so a future "emit an empty file instead" refactor is a conscious divergence. + // A schema_migrations row can have a NULL/empty statements array; joining still + // yields exactly ";\n", not an empty file. This locks that byte behavior. const { layer } = setup(tmp.current, { rows: [{ version: "20240101000000", name: "empty", statements: [] }], }); @@ -208,9 +202,7 @@ describe("migration fetch", () => { }); it.live("honors a piped 'n' answer without a TTY (cancels the overwrite)", () => { - // The overwrite prompt defaults to YES; piped stdin is read even when non-interactive, - // so a piped `n` overrides the default and cancels. Proves the - // non-TTY path reads the answer instead of blindly taking the default. + // The overwrite prompt defaults to YES; piped stdin still overrides it without a TTY. mkdirSync(migrationsDir(tmp.current), { recursive: true }); writeFileSync(join(migrationsDir(tmp.current), "existing.sql"), "select 1;\n"); const { layer } = setup(tmp.current, { @@ -246,9 +238,8 @@ describe("migration fetch", () => { it.live( "auto-confirms the overwrite prompt from SUPABASE_YES in the project .env (Go loadNestedEnv)", () => { - // SUPABASE_YES lives only in supabase/.env, not the shell — `fetch` defaults to - // `--linked`, and the project `.env` files load before the overwrite prompt, so the - // overwrite auto-confirms with no --yes flag and no piped stdin answer (CLI-1878). + // SUPABASE_YES lives only in supabase/.env; the project env loads before the + // overwrite prompt. mkdirSync(migrationsDir(tmp.current), { recursive: true }); writeFileSync(join(migrationsDir(tmp.current), "existing.sql"), "select 1;\n"); writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_YES=true\n"); @@ -264,9 +255,8 @@ describe("migration fetch", () => { ); it.live("still prompts on stderr in json mode and proceeds on a piped yes", () => { - // The prompt writes to stderr and reads stdin regardless of --output, - // so --output-format json must NOT silently auto-accept: the overwrite prompt fires on - // stderr and a piped `y` proceeds, while the json result still goes to stdout. + // The overwrite prompt still writes to stderr and reads stdin in json mode; it must + // not silently auto-accept. mkdirSync(migrationsDir(tmp.current), { recursive: true }); writeFileSync(join(migrationsDir(tmp.current), "existing.sql"), "select 1;\n"); const { layer, out } = setup(tmp.current, { @@ -276,7 +266,6 @@ describe("migration fetch", () => { }); return Effect.gen(function* () { yield* migrationFetch(flags()); - // The prompt label reached stderr (it was NOT format-gated into a silent default). expect(out.stderrText).toContain("[Y/n]"); expect(out.messages).toContainEqual( expect.objectContaining({ @@ -289,8 +278,6 @@ describe("migration fetch", () => { }); it.live("honors a piped no in json mode (cancels the overwrite, no auto-accept)", () => { - // Regression guard: before the fix, json mode routed through the non-interactive Output - // prompt and auto-accepted (default YES), overwriting. Now a piped `n` is honored. mkdirSync(migrationsDir(tmp.current), { recursive: true }); writeFileSync(join(migrationsDir(tmp.current), "existing.sql"), "select 1;\n"); const { layer } = setup(tmp.current, { @@ -322,15 +309,13 @@ describe("migration fetch", () => { const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationFetchWriteError"); } - // Nothing is written when the guard fires. expect(readdirSync(migrationsDir(tmp.current))).toEqual([]); }).pipe(Effect.provide(layer)); }); it.live("writes a Go-valid signed version verbatim (no all-digits requirement)", () => { - // The raw `version` column writes into `<version>_<name>.sql` with no digit check, - // so a malformed-but-safe value like `-1` - // (listable/repairable) must fetch, not abort the whole run. + // The raw version column writes verbatim into <version>_<name>.sql with no digit + // check, so a value like "-1" still fetches instead of aborting the run. const { layer } = setup(tmp.current, { rows: [{ version: "-1", name: "legacy", statements: ["select 1"] }], }); @@ -341,8 +326,7 @@ describe("migration fetch", () => { }); it.live("rejects a hostile version from the history table (traversal guard on version)", () => { - // The traversal hardening covers the `version` field too: a separator/`..` there is - // rejected even though it is no longer required to be all-digits. + // Traversal hardening covers the version field too, not just name. const { layer } = setup(tmp.current, { rows: [{ version: "../../etc", name: "x", statements: [] }], }); @@ -358,10 +342,9 @@ describe("migration fetch", () => { }); it.live("reports a write failure", () => { - // A file at <workdir>/supabase/migrations makes `makeDirectory` fail. `supabase` itself - // must stay a real directory here: the handler's project-env load (CLI-1878) - // reads `<workdir>/supabase/.env*` before this mkdir, and a plain - // file at `<workdir>/supabase` would make that read fail first (ENOTDIR) instead. + // A file at .../migrations makes makeDirectory fail; supabase itself must stay a + // real directory, since the handler's project-env load reads supabase/.env* before + // this mkdir and would hit ENOTDIR first otherwise. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "migrations"), "not a directory"); const { layer } = setup(tmp.current, { rows: [] }); @@ -376,9 +359,6 @@ describe("migration fetch", () => { }); it.live("resolves DB config before creating the migrations dir or prompting", () => { - // The DB config resolves before any filesystem/prompt side effect, - // so an invalid target fails first. With the resolver - // failing, the supabase/migrations dir must NOT be created and no prompt is shown. const { layer, out } = setup(tmp.current, { resolveFails: true }); return Effect.gen(function* () { const exit = yield* migrationFetch(flags()).pipe(Effect.exit); @@ -387,7 +367,6 @@ describe("migration fetch", () => { const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure) && failure.value._tag).toBe("DbConfigLoadError"); } - // The config failed before any side effect: no migrations dir, no overwrite prompt. expect(existsSync(migrationsDir(tmp.current))).toBe(false); expect(out.promptConfirmCalls.length).toBe(0); }).pipe(Effect.provide(layer)); @@ -396,12 +375,8 @@ describe("migration fetch", () => { it.live( "rejects --db-url combined with --linked before reading the project .env (CLI-1878)", () => { - // Cobra's `MarkFlagsMutuallyExclusive` validates at parse time, ahead of the root - // `PersistentPreRunE` that runs `ParseDatabaseConfig`/`loadNestedEnv` — so a flag - // conflict must surface even when `supabase/.env` is malformed (which would abort a - // project-env load with a DIFFERENT error, `DbConfigLoadError`, if the env load - // ran first). Locks in the fix that reordered the project-env load in `fetch.handler.ts` - // to run after this flag-group check. + // A flag conflict must surface even when supabase/.env is malformed, which would + // otherwise abort with a different error (DbConfigLoadError) if the env load ran first. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", ".env"), "!=broken\n"); const { layer } = setup(tmp.current, { @@ -423,9 +398,8 @@ describe("migration fetch", () => { it.live( "fetches from the project given via --project-ref, overriding the default linked ref", () => { - // The fake resolver's own fallback (VALID_REF) represents whatever - // the workdir would resolve to absent the flag — the flag must win over it - // and drive the cached ref. + // VALID_REF is the fake resolver's fallback; the flag must win over it and drive + // the cached ref. const FLAG_REF = "flagflagflagflagflag"; const { layer, cache } = setup(tmp.current, { rows: [] }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/migration/fetch/fetch.live.test.ts b/apps/cli/src/commands/migration/fetch/fetch.live.test.ts index ba0d6853bb..7f861e08ee 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.live.test.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.live.test.ts @@ -7,29 +7,19 @@ import { requireLiveSuccess, test, throwWithCleanup } from "../../../../tests/he const LIVE_TIMEOUT_MS = 120_000; -// A uniquely named migration to seed into the remote history and fetch back. const NAME = "cli_live_fetch"; function liveMigrationVersion(): string { return new Date().toISOString().replace(/\D/gu, "").slice(0, 14); } -// Destructive data-plane scenario (Postgres over the pooler) — the setup repairs -// remote migration history and the teardown reverts that exact row. The fixture -// provisions one ACTIVE_HEALTHY project for the serial live suite. +// Destructive: repairs remote migration history in setup and reverts that row in +// teardown. // -// Golden path: `migration fetch` reads the remote `schema_migrations` history and -// writes each row to `supabase/migrations/<version>_<name>.sql`. -// -// Unlike `migration list`, `migration fetch` does NOT tolerate a missing history -// table: reading the migration table has no undefined-table fallback (only -// the list path does), so against a freshly provisioned -// project with no `supabase_migrations.schema_migrations` table it exits non-zero -// (`relation … does not exist`). So we first SEED one migration into the remote -// history via `migration repair --status applied` (which creates the migration -// table then upserts the version from the local file), establishing -// the table + a row for `fetch` to read back. The shared fixture's pooler URL is -// passed explicitly so the test does not fall back to a direct IPv6 host. +// `fetch` has no undefined-table fallback (unlike `list`), so it fails against a fresh +// project with no schema_migrations table. The setup seeds one row via +// `migration repair --status applied` first, which creates the table. The pooler URL +// is passed explicitly to avoid falling back to a direct IPv6 host. test( "fetches a seeded remote migration into the local migrations directory", { timeout: LIVE_TIMEOUT_MS }, @@ -42,8 +32,8 @@ test( let targetError: unknown; const cleanupErrors: Array<unknown> = []; try { - // Seed: record one migration in the remote history. `repair --status applied` - // reads the local file for the version's name/statements, so write it first. + // repair --status applied reads the local file for name/statements, so write it + // before running repair. await mkdir(path.join(seedDir, "supabase", "migrations"), { recursive: true }); await writeFile( path.join(seedDir, "supabase", "migrations", migrationFile), @@ -55,12 +45,10 @@ test( ); requireLiveSuccess(repairResult, "migration repair setup"); - // Fetch into a fresh (empty) dir so no overwrite prompt fires; it reads the - // remote history and writes <version>_<name>.sql. + // A fresh, empty dir avoids the overwrite prompt. const fetched = await cli(["migration", "fetch", ...targetArgs], { cwd: fetchDir }); expect(fetched.exitCode, `stdout:\n${fetched.stdout}\nstderr:\n${fetched.stderr}`).toBe(0); - // fetch wrote the seeded migration back, under its established filename format. const files = await readdir(path.join(fetchDir, "supabase", "migrations")); expect(files).toContain(migrationFile); } catch (error) { diff --git a/apps/cli/src/commands/migration/list/list.command.ts b/apps/cli/src/commands/migration/list/list.command.ts index b7cac85131..ccedb29cbf 100644 --- a/apps/cli/src/commands/migration/list/list.command.ts +++ b/apps/cli/src/commands/migration/list/list.command.ts @@ -49,9 +49,7 @@ export const migrationListCommand = Command.make("list", config).pipe( // `password` is a credential — always reaches telemetry as `<redacted>`. password: flags.password, }, - // TS-only flag with no Go telemetry-safety baseline; Go's nearest - // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most - // others) are unmarked, so it stays redacted. + // `p` maps to the already-redacted `password` flag. aliases: { p: "password" }, }), withJsonErrorHandling, diff --git a/apps/cli/src/commands/migration/list/list.format.ts b/apps/cli/src/commands/migration/list/list.format.ts index ffed0aeb20..40ebdcbb50 100644 --- a/apps/cli/src/commands/migration/list/list.format.ts +++ b/apps/cli/src/commands/migration/list/list.format.ts @@ -14,16 +14,16 @@ export interface MigrationListRow { /** * Two-pointer merge of remote + local migration versions into chronological - * rows, minus the markdown framing: non-numeric versions are skipped, and the time - * column uses `FormatTimestampVersion`. + * rows: non-numeric versions are skipped, and the time column uses + * `formatTimestampVersion`. */ export function makeMigrationListRows( remote: ReadonlyArray<string>, local: ReadonlyArray<string>, ): ReadonlyArray<MigrationListRow> { - // `loadLocalVersions` yields versions in file-name order, which reverses - // `ORDER BY version` whenever one version is a prefix of another - // (supabase/cli#6036), desynchronising the walk into duplicate half-empty rows. + // Local versions arrive in file-name order, which can invert `ORDER BY version` + // order when one version is a prefix of another (supabase/cli#6036); sorted here + // to keep the merge in sync. const sortedLocal = sortMigrationVersions(local); const rows: Array<MigrationListRow> = []; let i = 0; @@ -71,10 +71,9 @@ export function makeMigrationListRows( } /** - * Renders the merged rows as backtick-wrapped Glamour markdown cells - * (`|`<v>`|` `|`<time>`|`): present cells are inline code spans, absent - * cells are a single space inside backticks. AsciiStyle preserves the backticks - * (`code.block_prefix`/`block_suffix` = "`"), so the rendered table includes them. + * Renders merged rows as backtick-wrapped Glamour markdown cells: present cells are + * inline code spans, and absent cells are a lone space inside backticks so AsciiStyle + * keeps the code-span formatting. */ export function migrationListTableCells( rows: ReadonlyArray<MigrationListRow>, diff --git a/apps/cli/src/commands/migration/list/list.format.unit.test.ts b/apps/cli/src/commands/migration/list/list.format.unit.test.ts index 1c592f9601..c2b10f461b 100644 --- a/apps/cli/src/commands/migration/list/list.format.unit.test.ts +++ b/apps/cli/src/commands/migration/list/list.format.unit.test.ts @@ -5,7 +5,6 @@ import { makeMigrationListRows, migrationListTableCells } from "./list.format.ts describe("makeMigrationListRows", () => { it("tabulates short numeric versions in chronological order (Go TestMakeTable)", () => { - // makeTable(["0","2"], ["0","1"]) — passthrough time for non-timestamp versions. expect(makeMigrationListRows(["0", "2"], ["0", "1"])).toEqual([ { local: "0", remote: "0", time: "0" }, { local: "1", remote: "", time: "1" }, @@ -27,10 +26,9 @@ describe("makeMigrationListRows", () => { }); it("pairs an 8-digit and a 14-digit version that share a prefix (#6036)", () => { - // Local versions arrive in file-name order, where `20260420010000_b.sql` - // precedes `20260420_a.sql` ('0' < '_') — the reverse of the `ORDER BY - // version` order `schema_migrations` is read back in. Unsorted, the walk - // desynchronises and reports `20260420` as both remote-only and local-only. + // File-name order puts `20260420010000_b.sql` before `20260420_a.sql`, the + // reverse of `ORDER BY version`; unsorted, the walk reports `20260420` as both + // remote-only and local-only. expect( makeMigrationListRows(["20260420", "20260420010000"], ["20260420010000", "20260420"]), ).toEqual([ @@ -67,7 +65,6 @@ describe("migrationListTableCells", () => { ["Local", "Remote", "Time (UTC)"], migrationListTableCells(rows), ); - // Backtick-wrapped inline code is preserved by AsciiStyle. expect(out).toContain("` `"); expect(out).toContain("`20220727064247`"); expect(out).toContain("`20220727064248`"); diff --git a/apps/cli/src/commands/migration/list/list.handler.ts b/apps/cli/src/commands/migration/list/list.handler.ts index 813531c124..937add8997 100644 --- a/apps/cli/src/commands/migration/list/list.handler.ts +++ b/apps/cli/src/commands/migration/list/list.handler.ts @@ -33,9 +33,8 @@ const runList = Effect.fnUntraced(function* ( const path = yield* Path.Path; const dnsResolver = yield* DnsResolverFlag; - // Mutually-exclusive flag groups, in registration order: the target group - // first, then {db-url, password}. `setFlags` is already - // alphabetically sorted, matching the established group-error formatting. + // Mutually-exclusive flag groups, checked target group first, then {db-url, + // password}; `setFlags` is already sorted, matching the established error format. if (target.setFlags.length > 1) { return yield* Effect.fail( new MigrationTargetFlagsError({ @@ -53,8 +52,7 @@ const runList = Effect.fnUntraced(function* ( } // `--project-ref` never implies `--linked` and must not be silently - // discarded on a non-linked target — see push.handler.ts's identical guard - // (db push) for the full TS-only rationale. + // discarded on a non-linked target; see push.handler.ts's identical guard. if (Option.isSome(flags.projectRef) && (target.connType ?? "linked") !== "linked") { return yield* Effect.fail( new MigrationTargetFlagsError({ @@ -65,7 +63,6 @@ const runList = Effect.fnUntraced(function* ( } const listBody = Effect.gen(function* () { - // list defaults to `--linked`. const cfg = yield* resolver.resolve({ dbUrl: flags.dbUrl, connType: target.connType ?? "linked", @@ -76,8 +73,6 @@ const runList = Effect.fnUntraced(function* ( const remote = yield* Effect.scoped( Effect.gen(function* () { - // The connect diagnostic prints to stderr before dialing, - // local/remote per the resolved connection. yield* output.raw( `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, "stderr", @@ -104,9 +99,8 @@ const runList = Effect.fnUntraced(function* ( } }); - // `--linked` resolves the project ref and writes the linked-project cache so - // telemetry carries the org/project grouping. `--local` / `--db-url` leave the - // ref empty. + // `--linked` resolves the project ref and writes the linked-project cache; `--local` + // / `--db-url` leave the ref empty. if ((target.connType ?? "linked") === "linked") { const projectRef = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; diff --git a/apps/cli/src/commands/migration/list/list.integration.test.ts b/apps/cli/src/commands/migration/list/list.integration.test.ts index 9ed4a20c0e..c1bc93f0cb 100644 --- a/apps/cli/src/commands/migration/list/list.integration.test.ts +++ b/apps/cli/src/commands/migration/list/list.integration.test.ts @@ -78,9 +78,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { }), }); - // `loadProjectRef` gives an explicit `--project-ref` flag top precedence, same - // as Go's `flags.LoadProjectRef` — mirror that so a test can prove the flag - // (not just the hardcoded `VALID_REF` fallback) drives the linked ref. + // Gives an explicit --project-ref flag precedence over the VALID_REF fallback, so a + // test can prove the flag drives the linked ref. const projectRef = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(VALID_REF), resolveForLink: () => Effect.succeed(VALID_REF), @@ -137,15 +136,13 @@ describe("migration list", () => { }); return Effect.gen(function* () { yield* migrationList(flags()); - // The connection banner prints to stderr before dialing. expect(stripAnsi(ctx.out.stderrText)).toContain("Connecting to remote database..."); const stdout = stripAnsi(ctx.out.stdoutText); expect(stdout).toContain("Local"); expect(stdout).toContain("Time (UTC)"); - expect(stdout).toContain("`20240101000000`"); // in sync (both) - expect(stdout).toContain("`20240102000000`"); // remote only - expect(stdout).toContain("`20240103000000`"); // local only - // linked by default → resolver receives connType "linked" + cache written. + expect(stdout).toContain("`20240101000000`"); + expect(stdout).toContain("`20240102000000`"); + expect(stdout).toContain("`20240103000000`"); expect(ctx.resolverCalls[0]?.connType).toBe("linked"); expect(ctx.cache.cachedRef).toBe(VALID_REF); }).pipe(Effect.provide(ctx.layer)); @@ -183,9 +180,8 @@ describe("migration list", () => { }); it.live("lists the project given via --project-ref, overriding the default linked ref", () => { - // The fake resolver's own fallback (VALID_REF) represents whatever - // the workdir would resolve to absent the flag — the flag must win over it - // and drive the cached ref. + // VALID_REF is the fake resolver's fallback; the flag must win over it and drive + // the cached ref. const FLAG_REF = "flagflagflagflagflag"; seedMigrations(tmp.current, ["20240101000000_a.sql"]); const ctx = setup(tmp.current, { remote: ["20240101000000"] }); diff --git a/apps/cli/src/commands/migration/migration.integration.test.ts b/apps/cli/src/commands/migration/migration.integration.test.ts index 43b7e7a502..ca961827dd 100644 --- a/apps/cli/src/commands/migration/migration.integration.test.ts +++ b/apps/cli/src/commands/migration/migration.integration.test.ts @@ -6,7 +6,7 @@ import { textCliOutputFormatter } from "../../shared/output/text-formatter.ts"; import { GLOBAL_FLAGS } from "../../command-internal/global-flags.ts"; import { migrationCommand } from "./migration.command.ts"; -// `withGlobalFlags` must come AFTER `withSubcommands` — see +// `withGlobalFlags` must come after `withSubcommands` — see // `start.string-slice-flags.integration.test.ts`'s identical comment. const testRoot = Command.make("supabase").pipe( Command.withSubcommands([migrationCommand]), @@ -15,10 +15,9 @@ const testRoot = Command.make("supabase").pipe( describe("migration command integration", () => { it.live("accepts the Go-compatible plural migrations alias", () => { - // After CLI-1969, `squash` is native and no `migration` subcommand is proxied - // any more — so the plural alias is now proven at the PARSER instead: a + // No subcommand is proxied, so the plural alias is proven at the parser: // `migrations squash --nope` must fail with squash's own unknown-flag error, - // which never builds the command's `Command.provide` runtime layer. + // before the command's runtime layer ever builds. const run = Effect.gen(function* () { const exit = yield* Command.runWith(testRoot, { version: "0.0.0-test" })([ "migrations", @@ -28,14 +27,13 @@ describe("migration command integration", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const causeJson = JSON.stringify(exit.cause); - // The alias resolved: the parse error is scoped to the squash LEAF, not the root. + // The alias resolved: the parse error is scoped to the squash subcommand, not the root. expect(causeJson).toContain('"commandPath":["supabase","migration","squash"]'); expect(causeJson).not.toContain('"subcommand":"migrations"'); } }).pipe(Effect.provide(CliOutput.layer(textCliOutputFormatter()))); - // Command.runWith's Environment type is retained even though this path only needs CliOutput - // at runtime. + // Command.runWith's Environment type is retained even though only CliOutput is needed. return run as Effect.Effect<void>; }); }); diff --git a/apps/cli/src/commands/migration/migration.layers.ts b/apps/cli/src/commands/migration/migration.layers.ts index 5b24123a0d..e9366c12cc 100644 --- a/apps/cli/src/commands/migration/migration.layers.ts +++ b/apps/cli/src/commands/migration/migration.layers.ts @@ -15,11 +15,9 @@ import { telemetryStateLayer } from "../../telemetry/telemetry-state.layer.ts"; const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); /** - * Runtime layer for `supabase migration new`. The leanest of the migration - * runtimes: no DB connection, no Management API, no Docker. Just the resolved CLI - * config (for `--workdir`), telemetry-state flush, piped stdin, and the command - * runtime span. `Output`, `Analytics`, `Stdio`, `FileSystem`, `Path`, `Clock`, - * and `Tty` come from the root layer. + * Runtime layer for `supabase migration new`: the resolved CLI config, telemetry + * flush, and piped stdin, with no DB connection, Management API, or Docker. + * `Output`, `Analytics`, `FileSystem`, `Path`, `Clock`, and `Tty` come from the root. */ export const migrationNewRuntimeLayer = Layer.mergeAll( cliSettings, @@ -37,17 +35,10 @@ const dbConfig = dbConfigLayer.pipe( /** * Runtime layer for the DB-touching migration subcommands (`list` / `fetch` / - * `repair` / `up` / `down`). Mirrors `pull.layers.ts` minus the - * pg-delta / migra stack (no Docker, edge-runtime, SSL probe, or shadow seam): - * the db-config resolver + connection, the lazy linked-resolver auth stack - * (project-ref + linked-project cache), the shared identity stitcher, telemetry - * flush, piped stdin (for the migration confirm prompt, which reads - * stdin), and the command runtime span. `Output`, `Analytics`, `Stdio`, - * `FileSystem`, `Path`, `Clock`, `Tty`, and `YesFlag` come from the root. + * `repair` / `up` / `down`). * - * `identityStitchLayer` is provided by the SAME reference to `dbConfig` and - * the linked resolver so Effect memoises one shared identity-stitch attempt - * (CLAUDE.md invariant 5). + * `identityStitchLayer` is provided by the same reference to `dbConfig` and the + * linked resolver so Effect memoizes one shared identity-stitch attempt. */ export const migrationDbRuntimeLayer = (commandPath: ReadonlyArray<string>) => Layer.mergeAll( @@ -64,12 +55,9 @@ export const migrationDbRuntimeLayer = (commandPath: ReadonlyArray<string>) => const httpClient = httpClientLayer.pipe(Layer.provide(debugLoggerLayer)); /** - * Runtime layer for `supabase migration squash` — `migrationDbRuntimeLayer`'s bundle - * plus the three services only squash needs: `DockerRun` (the `pg_dump` one-shot - * container + the shadow's PG15+ one-shot setup jobs), `HttpClient` (the native shadow's - * health-check wait), and `DebugLogger` (used on the - * `LoadLocalVersions` fallback). `ChildProcessSpawner`/`RuntimeInfo`/`Tty`/`FileSystem`/ - * `Path` come from the root layer, same as `db diff`. + * Runtime layer for `supabase migration squash`: `migrationDbRuntimeLayer`'s bundle + * plus `DockerRun` (pg_dump and the shadow's setup jobs), `HttpClient` (the shadow's + * health-check wait), and `DebugLogger` (the `loadLocalVersions` fallback). */ export const migrationSquashRuntimeLayer = Layer.mergeAll( migrationDbRuntimeLayer(["migration", "squash"]), diff --git a/apps/cli/src/commands/migration/migration.prompt.ts b/apps/cli/src/commands/migration/migration.prompt.ts index c99cfbd8a9..ab4bf3e628 100644 --- a/apps/cli/src/commands/migration/migration.prompt.ts +++ b/apps/cli/src/commands/migration/migration.prompt.ts @@ -9,23 +9,11 @@ const TTY_TIMEOUT_MILLIS = 10 * 60 * 1000; const NON_TTY_TIMEOUT_MILLIS = 100; /** - * The console yes/no prompt shared by the prompting migration subcommands - * (fetch / repair / down / squash). - * - * This deliberately diverges from the general `promptYesNo` - * (`command-internal/prompt-yes-no.ts`) on two axes — do NOT "unify" them - * without deciding both behaviors (CLI-1974 review): - * - json/stream-json: the label is written to STDERR and one line of STDIN is - * read regardless of the `--output` format (which only shapes stdout), so this prompts - * independently of `output.format` and does NOT route through `output.promptConfirm` - * — the json/stream-json Output layers make that non-interactive, which would - * silently auto-default a real TTY run under `--output-format json` (fetch - * auto-overwriting, down/repair-all auto-cancelling). `promptYesNo` instead - * silently takes the default in machine modes. - * - real TTY: this reads a raw stdin line with a 10-minute timeout; - * `promptYesNo` renders a clack confirm UI. - * - * `--yes` short-circuits to `true`, echoing `<label> y`. + * The console yes/no prompt shared by fetch/repair/down/squash. Diverges from the + * general `promptYesNo`: it writes the label to stderr and reads one stdin line + * regardless of `--output` format (rather than auto-defaulting in json/stream-json), + * and on a real TTY it reads a raw stdin line with a 10-minute timeout instead of a + * clack confirm UI. `--yes` short-circuits to `true`, echoing `<label> y`. */ export const migrationConfirm = ( title: string, @@ -41,9 +29,7 @@ export const migrationConfirm = ( return true; } - // Print the label to stderr, read one line of stdin (TTY 10 min / - // non-TTY 100 ms). A non-TTY run echoes the input back to stderr; a - // TTY echoes via the terminal. An empty / unparseable answer falls back to the default. + // TTY input echoes via the terminal; a non-TTY run needs a manual stderr echo. yield* output.raw(label, "stderr"); const line = yield* stdin.readLine(stdin.isTTY ? TTY_TIMEOUT_MILLIS : NON_TTY_TIMEOUT_MILLIS); const input = Option.getOrElse(line, () => ""); diff --git a/apps/cli/src/commands/migration/new/new.handler.ts b/apps/cli/src/commands/migration/new/new.handler.ts index 71960e8fb6..0273e63685 100644 --- a/apps/cli/src/commands/migration/new/new.handler.ts +++ b/apps/cli/src/commands/migration/new/new.handler.ts @@ -34,13 +34,9 @@ export const migrationNew = Effect.fn("migration.new")(function* (flags: Migrati flags.migrationName, ); - // The name is a positional CLI arg; `path.join` collapses `..` segments, so a - // name like `../../../foo` resolves OUTSIDE the migrations directory and lets - // `migration new` write an arbitrary file (CWE-22) — reachable when the name - // comes from an agent/CI template rather than a human. Real names are simple - // identifiers, so containing the write to `supabase/migrations` is - // parity-neutral for legitimate input while closing the arbitrary-write - // vector — the same TS-only hardening `migration fetch` applies to remote rows. + // `path.join` collapses ".." segments, so a name like "../../../foo" would + // resolve outside the migrations dir (CWE-22); reject it, since real names are + // simple identifiers. const migrationsDir = path.join(cliSettings.workdir, "supabase", "migrations"); if (!migrationPath.startsWith(migrationsDir + path.sep)) { return yield* Effect.fail( @@ -54,24 +50,22 @@ export const migrationNew = Effect.fn("migration.new")(function* (flags: Migrati .makeDirectory(path.dirname(migrationPath), { recursive: true }) .pipe(Effect.mapError((cause) => new MigrationNewWriteError({ message: cause.message }))); - // The RELATIVE path prints: `supabase/migrations` - // is workdir-independent regardless of the invoking cwd. Reproduce that exactly - // while still writing to the absolute `migrationPath`. + // The printed path is workdir-relative, independent of the invoking cwd, while + // the write itself uses the absolute `migrationPath`. const relativePath = path.join( "supabase", "migrations", `${timestamp}_${flags.migrationName}.sql`, ); - // stdout-bound line, so the colour TTY gate must check stdout (see - // `colors.ts`'s doc comment — the CLI-1546 bug class). + // This line prints to stdout, so the color gate must check stdout; see + // `colors.ts`'s doc comment. const printCreated = output.format === "text" ? output.raw(`Created new migration at ${bold(relativePath, process.stdout)}\n`) : Effect.void; - // Materialize the empty migration before reporting success instead of relying on an - // otherwise-unused open handle. This is the same create-then-append pattern used by - // db dump and db pull. + // Materializes the empty migration up front rather than relying on an otherwise-unused + // open handle; the same create-then-append pattern used by db dump and db pull. yield* fs.writeFile(migrationPath, new Uint8Array(0), { mode: 0o644 }).pipe( Effect.mapError( (cause) => @@ -107,8 +101,8 @@ export const migrationNew = Effect.fn("migration.new")(function* (flags: Migrati ); } - // Do not emit success solely because a runtime write reported success. This command's - // contract is that the returned path exists when it exits zero. + // The command's contract is that the returned path exists when it exits zero, not + // merely that the write call reported success. yield* fs.stat(migrationPath).pipe( Effect.mapError( (cause) => diff --git a/apps/cli/src/commands/migration/new/new.integration.test.ts b/apps/cli/src/commands/migration/new/new.integration.test.ts index ecbfa1752e..651800f289 100644 --- a/apps/cli/src/commands/migration/new/new.integration.test.ts +++ b/apps/cli/src/commands/migration/new/new.integration.test.ts @@ -78,9 +78,7 @@ describe("migration new", () => { const file = onlyMigration(tmp.current); expect(file).toMatch(/^\d{14}_create_widgets\.sql$/u); - // Empty file when stdin is a TTY (nothing is written). expect(readFileSync(join(migrationsDir(tmp.current), file), "utf8")).toBe(""); - // The workdir-relative path prints, not the absolute write path. expect(stripAnsi(out.stdoutText)).toBe( `Created new migration at supabase/migrations/${file}\n`, ); @@ -95,7 +93,6 @@ describe("migration new", () => { yield* migrationNew({ migrationName: "from_stdin" }); const file = onlyMigration(tmp.current); - // Byte-exact: the trailing newline is preserved (raw stdin bytes are copied verbatim). expect(readFileSync(join(migrationsDir(tmp.current), file), "utf8")).toBe(script); expect(stripAnsi(out.stdoutText)).toContain(`Created new migration at supabase/migrations/`); }).pipe(Effect.provide(layer)); @@ -211,11 +208,8 @@ describe("migration new", () => { it.live( "fails the command when piped stdin errors mid-copy (Go: failed to copy from stdin)", () => { - // The streaming copy returns "failed to copy from stdin" and exits non-zero on a stdin - // read error; it must surface that, not leave a truncated file. - // The Created-line print is already - // scheduled by the time the copy fails, so the Created line still prints to stdout - // BEFORE the copy error propagates to stderr / exit 1 — assert BOTH here. + // The Created line is already scheduled by the time the copy fails, so it still + // prints to stdout before the error propagates to stderr. const failingStdin = Layer.succeed(Stdin, { isTTY: false, readPipedBytes: Effect.succeed(Option.none()), diff --git a/apps/cli/src/commands/migration/repair/repair.command.ts b/apps/cli/src/commands/migration/repair/repair.command.ts index 216f6febb9..4d4473d17c 100644 --- a/apps/cli/src/commands/migration/repair/repair.command.ts +++ b/apps/cli/src/commands/migration/repair/repair.command.ts @@ -62,9 +62,8 @@ export const migrationRepairCommand = Command.make("repair", config).pipe( // `password` is a credential — always reaches telemetry as `<redacted>`. password: flags.password, }, - // --status is Flag.choice and is auto-detected as safe via `config` - // below; password stays redacted. --project-ref has no established - // telemetry-safety baseline either, so it stays redacted too. + // --status is auto-detected as safe via config below; password and + // --project-ref stay redacted. config, aliases: { p: "password" }, }), diff --git a/apps/cli/src/commands/migration/repair/repair.handler.ts b/apps/cli/src/commands/migration/repair/repair.handler.ts index 414f2cca38..aafb26a7e8 100644 --- a/apps/cli/src/commands/migration/repair/repair.handler.ts +++ b/apps/cli/src/commands/migration/repair/repair.handler.ts @@ -100,7 +100,7 @@ const updateMigrationTable = Effect.fnUntraced(function* ( ), ); - // Printed only when NOT repairing the whole table. + // Printed only when not repairing the whole table. if (!repairAll) { yield* output.raw( `Repaired migration history: [${versions.join(" ")}] => ${status}\n`, @@ -139,11 +139,10 @@ const runRepair = Effect.fnUntraced(function* ( const migrationsDir = path.join(cliSettings.workdir, "supabase", "migrations"); const repairAll = input.versions.length === 0; - const connType = target.connType ?? "linked"; // repair defaults to `--linked`. + const connType = target.connType ?? "linked"; // `--project-ref` never implies `--linked` and must not be silently - // discarded on a non-linked target — see push.handler.ts's identical guard - // (db push) for the full TS-only rationale. + // discarded on a non-linked target; see push.handler.ts's identical guard. if (Option.isSome(input.projectRef) && connType !== "linked") { return yield* Effect.fail( new MigrationTargetFlagsError({ @@ -153,10 +152,8 @@ const runRepair = Effect.fnUntraced(function* ( ); } - // Resolve the DB config (and, for the linked default, the project ref) BEFORE the - // version parse and any prompt, so an - // unlinked / invalid-config / malformed-`--db-url` run surfaces that error before an - // invalid positional version or a prompt. + // Resolves the DB config (and, for the linked default, the project ref) before the + // version parse and any prompt, so an invalid target surfaces first. const cfg = yield* resolver.resolve({ dbUrl: input.dbUrl, connType, @@ -165,18 +162,14 @@ const runRepair = Effect.fnUntraced(function* ( linkedProjectRef: input.projectRef, }); - // The project .env loads after the parse-time flag-group validation above — so a - // SUPABASE_YES set only in supabase/.env auto-confirms the repair-all prompt, but a - // flag conflict still surfaces before any .env read. Resolve --yes against the - // project env here, not just process.env. + // Loads after the flag-group check above, so a flag conflict surfaces before any + // .env read; a SUPABASE_YES set only in supabase/.env still auto-confirms the + // repair-all prompt. const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); - // Linked repair caches the project ref + identifies project groups, gated on the - // command having executed, NOT on the handler's own failure. The ref is loaded now - // (pre-run), and the cache is attached to the whole - // repair flow via `Effect.ensuring` below — so it runs even when the version parse fails - // or the repair-all prompt is declined (caches on cancellation too). + // Attached to the whole flow via `Effect.ensuring` below so the cache write still + // runs even when the version parse fails or the repair-all prompt is declined. const cacheLinkedRef = connType === "linked" ? yield* Effect.gen(function* () { @@ -188,8 +181,7 @@ const runRepair = Effect.fnUntraced(function* ( : undefined; const repairFlow = Effect.gen(function* () { - // Version validation runs after DB-config resolution. Rejects non-numeric AND - // out-of-int64-range values; `parseMigrationVersion` mirrors that exactly. + // Rejects non-numeric and out-of-int64-range values. for (const version of input.versions) { if (parseMigrationVersion(version) === undefined) { return yield* Effect.fail( @@ -200,7 +192,7 @@ const runRepair = Effect.fnUntraced(function* ( } } - // repair-all confirmation (default NO). Then load every local version. + // repair-all confirmation defaults to declining; then loads every local version. let versions = input.versions; if (repairAll) { const confirmed = yield* migrationConfirm( @@ -217,8 +209,6 @@ const runRepair = Effect.fnUntraced(function* ( yield* Effect.scoped( Effect.gen(function* () { - // The connect diagnostic prints to stderr before dialing, - // local/remote per the resolved connection. yield* output.raw( `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, "stderr", @@ -240,7 +230,7 @@ const runRepair = Effect.fnUntraced(function* ( ); if (output.format === "text") { - // The success banner (stdout) + follow-up suggestion (stderr), both on success. + // Success banner to stdout; follow-up suggestion to stderr. yield* output.raw(`Finished ${aqua("supabase migration repair")}.\n`); yield* emitSuccessTrailer( `Run ${aqua("supabase migration list")} to show the updated migration history.\n`, diff --git a/apps/cli/src/commands/migration/repair/repair.integration.test.ts b/apps/cli/src/commands/migration/repair/repair.integration.test.ts index ff2df841b4..af99e5696e 100644 --- a/apps/cli/src/commands/migration/repair/repair.integration.test.ts +++ b/apps/cli/src/commands/migration/repair/repair.integration.test.ts @@ -89,17 +89,15 @@ function setup(workdir: string, opts: SetupOpts = {}) { extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), - // A migration file's statements arrive as one batch; replay them through - // `exec`/`query` so this suite's recordings and failure injection still apply. + // Replays each statement through exec/query so recordings and failure injection apply. execBatch: (statements) => sequentialExecBatch(session)(statements), }; return Effect.succeed(session); }, }); - // `loadProjectRef` gives an explicit `--project-ref` flag top precedence, same - // as Go's `flags.LoadProjectRef` — mirror that so a test can prove the flag - // (not just the hardcoded `VALID_REF` fallback) drives the linked ref. + // Gives an explicit --project-ref flag precedence over the VALID_REF fallback, so a + // test can prove the flag drives the linked ref. const projectRef = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(VALID_REF), resolveForLink: () => Effect.succeed(VALID_REF), @@ -125,8 +123,7 @@ function setup(workdir: string, opts: SetupOpts = {}) { mockTty({ stdinIsTty: opts.isTTY ?? true }), mockStdin( opts.isTTY ?? true, - // Migration prompts read stdin directly, so a confirm answer is - // supplied via piped stdin rather than the Output prompt mock. + // Migration prompts read stdin directly, so the confirm answer is piped in. opts.pipedInput ?? (opts.confirm === undefined ? undefined : opts.confirm ? "y\n" : "n\n"), ), BunServices.layer, @@ -158,9 +155,7 @@ describe("migration repair", () => { const { layer, execs, queries, out } = setup(tmp.current); return Effect.gen(function* () { yield* migrationRepair(input({ versions: ["20240101000000"], status: "applied" })); - // The connection banner prints to stderr before dialing. expect(stripAnsi(out.stderrText)).toContain("Connecting to remote database..."); - // One transaction: BEGIN ... COMMIT, no ROLLBACK. expect(execs).toContain("BEGIN"); expect(execs).toContain("COMMIT"); expect(execs).not.toContain("ROLLBACK"); @@ -170,8 +165,6 @@ describe("migration repair", () => { }); it.live("resolves the DB target before parsing positional versions", () => { - // The DB config resolves before - // version parsing, so an unlinked target error wins over a bad version. const { layer } = setup(tmp.current, { failResolve: true }); return Effect.gen(function* () { const exit = yield* migrationRepair( @@ -180,15 +173,12 @@ describe("migration repair", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const failure = Cause.findErrorOption(exit.cause); - // The config/target error surfaces first, NOT the invalid-version error. expect(Option.isSome(failure) && failure.value._tag).toBe("ProjectRefNotLinkedError"); } }).pipe(Effect.provide(layer)); }); it.live("caches the linked project even when the repair-all prompt is declined", () => { - // The project cache runs regardless of the - // handler's own failure, so a declined repair-all (cancellation) still caches the ref. const { layer, cache } = setup(tmp.current, { confirm: false }); return Effect.gen(function* () { const exit = yield* migrationRepair(input({ versions: [], status: "applied" })).pipe( @@ -225,8 +215,7 @@ describe("migration repair", () => { if (Exit.isFailure(exit)) { const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationInvalidVersionError"); - // Guard: unlike `db reset` (bare `invalid version number`), - // `migration repair` keeps the established `failed to parse <v>:` wrapper. + // Unlike db reset's bare message, migration repair keeps the "failed to parse <v>:" wrapper. expect(Option.isSome(failure) && failure.value.message).toBe( "failed to parse not-a-number: invalid version number", ); @@ -237,9 +226,6 @@ describe("migration repair", () => { it.live("rejects a version outside Go's int range before any DB mutation", () => { const { layer, execs, queries } = setup(tmp.current); return Effect.gen(function* () { - // Explicit versions validate with a strict integer parse, which - // rejects values above the int64 range; a 20-digit version must fail - // `invalid version number` before any glob/upsert/delete. const exit = yield* migrationRepair( input({ versions: ["99999999999999999999"], status: "applied" }), ).pipe(Effect.exit); @@ -248,7 +234,6 @@ describe("migration repair", () => { const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure) && failure.value._tag).toBe("MigrationInvalidVersionError"); } - // Validation runs before connecting, so no transaction or upsert occurred. expect(execs).not.toContain("BEGIN"); expect(queries.some((q) => q.sql.includes("ON CONFLICT"))).toBe(false); }).pipe(Effect.provide(layer)); @@ -267,9 +252,8 @@ describe("migration repair", () => { it.live( "repair-all with --status reverted wipes the whole history (no upserts, no deletes)", () => { - // repair-all + reverted queues ONLY TRUNCATE: the per-version DELETE is - // the non-repair-all path and the UPSERT is the applied path, so the net - // effect is wiping the entire history table. + // repair-all + reverted only queues TRUNCATE; DELETE is the non-repair-all path + // and UPSERT is the applied path. seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); const { layer, execs, queries } = setup(tmp.current, { confirm: true }); return Effect.gen(function* () { @@ -298,8 +282,7 @@ describe("migration repair", () => { }); it.live("repair-all without a TTY and no piped answer falls back to NO (cancel)", () => { - // Stdin is read regardless of TTY (isTTY only changes the timeout); - // with no piped answer the empty read falls back to the default (NO) → cancel. + // isTTY only changes the read timeout; stdin is still read either way. const { layer, out } = setup(tmp.current, { isTTY: false }); return Effect.gen(function* () { const exit = yield* migrationRepair(input({ versions: [], status: "applied" })).pipe( @@ -315,20 +298,17 @@ describe("migration repair", () => { }); it.live("repair-all honors a piped 'y' answer without a TTY (proceeds)", () => { - // Piped stdin is read even when non-interactive; a piped `y` overrides - // the default NO, so repair-all truncates and reapplies. + // Piped stdin is read even without a TTY, overriding the default no. seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); const { layer, execs, queries } = setup(tmp.current, { isTTY: false, pipedInput: "y\n" }); return Effect.gen(function* () { yield* migrationRepair(input({ versions: [], status: "applied" })); - // Proceeded: repair-all truncates then upserts the local file. expect(execs).toContain("TRUNCATE supabase_migrations.schema_migrations"); expect(queries.some((q) => q.sql.includes("ON CONFLICT"))).toBe(true); }).pipe(Effect.provide(layer)); }); it.live("auto-confirms repair-all via SUPABASE_YES (no --yes flag)", () => { - // SUPABASE_YES=1 auto-confirms without --yes. const previous = process.env["SUPABASE_YES"]; process.env["SUPABASE_YES"] = "1"; seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); @@ -351,8 +331,7 @@ describe("migration repair", () => { it.live( "auto-confirms repair-all via SUPABASE_YES in the project .env (Go loadNestedEnv)", () => { - // SUPABASE_YES set only in supabase/.env (not the shell) — the project env loads it - // before the repair-all prompt, so it auto-confirms with no --yes flag and no stdin answer. + // SUPABASE_YES lives only in supabase/.env; the project env loads it before the prompt. seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); writeFileSync(join(tmp.current, "supabase", ".env"), "SUPABASE_YES=true\n"); const { layer, execs, queries } = setup(tmp.current); @@ -375,8 +354,6 @@ describe("migration repair", () => { const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure) && failure.value._tag).toBe("ProjectRefNotLinkedError"); } - // The DB config resolves before any prompt, so the - // config error surfaces immediately and the repair-all confirmation is never shown. expect(out.promptConfirmCalls.length).toBe(0); }).pipe(Effect.provide(layer)); }); @@ -397,9 +374,8 @@ describe("migration repair", () => { }); it.live("prints multiple repaired versions using Go's %v slice format", () => { - // The established slice format prints - // space-separated, bracketed, NO commas. A `.join(", ")` "cleanup" reads more - // natural in TS but would silently break byte parity, so lock the format here. + // The established format is space-separated and bracketed, with no commas; a + // `.join(", ")` cleanup would silently change established output. seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); seedMigration(tmp.current, "20240102000000_more.sql", "create table b;\n"); const { layer, out } = setup(tmp.current); @@ -461,9 +437,8 @@ describe("migration repair", () => { }); it.live("repairs the project given via --project-ref, overriding the default linked ref", () => { - // The fake resolver's own fallback (VALID_REF) represents whatever - // the workdir would resolve to absent the flag — the flag must win over it - // and drive the cached ref. + // VALID_REF is the fake resolver's fallback; the flag must win over it and drive + // the cached ref. const FLAG_REF = "flagflagflagflagflag"; seedMigration(tmp.current, "20240101000000_init.sql", "create table a;\n"); const { layer, cache } = setup(tmp.current); diff --git a/apps/cli/src/commands/migration/squash/squash.diff.ts b/apps/cli/src/commands/migration/squash/squash.diff.ts index b9c9eadfb3..621aeb3926 100644 --- a/apps/cli/src/commands/migration/squash/squash.diff.ts +++ b/apps/cli/src/commands/migration/squash/squash.diff.ts @@ -1,30 +1,14 @@ /** - * The separator comment constant and the line-by-line diff. No Effect, no - * services — just string in, string out — so this stays tightly unit-testable in - * isolation from the Docker/shadow-database machinery `squash.handler.ts` composes. - */ - -/** - * The separator comment — a raw string literal that opens - * immediately with a newline, so the exact bytes carry a LEADING `\n`, not just the - * trailing blank line one might expect from the source layout. + * Byte-exact separator: opens with a newline, so the constant's leading `\n` is + * significant, not just its trailing blank line. */ export const SQUASH_SEPARATOR_COMMENT = "\n--\n-- Dumped schema changes for auth and storage\n--\n\n"; /** - * Scan-lines tokenization for `text`: splits on `\n`, drops the trailing empty token a final `\n` - * would otherwise produce (a `\n`-terminated final line yields no extra token; a - * final line WITHOUT a trailing `\n` still yields a token), then strips exactly one - * trailing `\r` from every token — including the final, EOF-flushed one. An empty `text` - * yields zero tokens. - * - * Deliberate divergence (documented in `SIDE_EFFECTS.md`): the original - * scanner this reproduces also enforces a 64 KiB max token size and silently - * truncates the scan when a single line exceeds it — not - * reproduced here, since replicating a silent-data-loss quirk would only make this - * port worse for users for no observable benefit on any realistic `auth`/`storage` - * dump line. + * Splits `text` on `\n`, drops the trailing empty token a final `\n` would produce, + * and strips one trailing `\r` from each token. Does not enforce a max token size; + * see SIDE_EFFECTS.md. */ export function squashScanLines(text: string): ReadonlyArray<string> { if (text.length === 0) return []; @@ -34,17 +18,10 @@ export function squashScanLines(text: string): ReadonlyArray<string> { } /** - * A single forward pass over `after`'s lines, advancing an - * "anchor" cursor into `before`'s lines whenever the current `after` line matches it - * — emitting every `after` line that DOESN'T match, each with a trailing `\n`. - * Assumes `before` is a subset of `after` (true for a - * schema-only auth/storage dump before vs. after a migration apply — entities in - * those managed schemas are never altered by user migrations). - * - * `anchorText` reproduces the exhausted-scanner sentinel exactly: once every - * `before` token has been consumed, the anchor text returns `""` forever — - * so every subsequent blank line in `after` is silently swallowed rather than - * emitted. + * A single forward pass over `after`'s lines, advancing an anchor cursor into + * `before`'s lines whenever they match and emitting every line that doesn't. Assumes + * `before` is a subset of `after`; once exhausted, remaining `after` lines match an + * empty sentinel and are silently swallowed rather than emitted. */ export function squashLineByLineDiff(before: string, after: string): string { const beforeTokens = squashScanLines(before); diff --git a/apps/cli/src/commands/migration/squash/squash.diff.unit.test.ts b/apps/cli/src/commands/migration/squash/squash.diff.unit.test.ts index 3694a6ec29..83e22dd3d8 100644 --- a/apps/cli/src/commands/migration/squash/squash.diff.unit.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.diff.unit.test.ts @@ -4,12 +4,8 @@ import { describe, expect, it } from "vitest"; import { SQUASH_SEPARATOR_COMMENT, squashLineByLineDiff, squashScanLines } from "./squash.diff.ts"; -/** - * The `before.sql`/`after.sql`/`diff.sql` fixtures are vendored copies of real - * `pg_dump` output rather than hand-transcribed template literals: a byte-for-byte - * corpus of 90+109+19 lines is exactly the kind of content a manual transcription - * would silently corrupt (trailing whitespace, blank lines, quoting). - */ +// before.sql/after.sql/diff.sql are vendored real pg_dump output, not hand-transcribed +// literals, since manual transcription would silently corrupt whitespace/quoting. const testdataDir = fileURLToPath(new URL("./testdata/", import.meta.url)); const readGoFixture = (name: string) => readFileSync(`${testdataDir}${name}`, "utf8"); @@ -40,10 +36,6 @@ describe("squashLineByLineDiff", () => { }); it('swallows every subsequent after line once before is exhausted (the anchor.Text() === "" sentinel)', () => { - // Once `before` runs out of tokens, the anchor text returns `""` forever, so a - // blank line in `after` matches that sentinel and is silently dropped — NOT emitted - // as if it were an unmatched line. `before` has a single non-blank token; every - // remaining `after` line (including two literal blank lines) must vanish. const before = "create schema test;"; const after = "create schema test;\n\n\nselect 1;"; expect(squashLineByLineDiff(before, after)).toBe("select 1;\n"); @@ -52,15 +44,11 @@ describe("squashLineByLineDiff", () => { it("strips one trailing \\r per line like bufio.ScanLines (CRLF before, LF after)", () => { const before = "select 1;\r\nselect 2;\r\n"; const after = "select 1;\nselect 2;\n"; - // After stripping the trailing \r from each `before` token, every `after` line - // matches its anchor — the diff is empty. expect(squashLineByLineDiff(before, after)).toBe(""); }); it("treats a final line without a trailing newline as a token, and a trailing newline as no extra empty token", () => { - // `before` has no trailing newline (one token, "a"); `after` DOES (two tokens: "a", - // "b"), so only "b" is unmatched — a final "\n" must not manufacture a phantom empty - // token that would otherwise consume the "b" match or emit an extra blank line. + // before has one token ("a"); after has two ("a", "b"), so only "b" is unmatched. const before = "a"; const after = "a\nb\n"; expect(squashLineByLineDiff(before, after)).toBe("b\n"); diff --git a/apps/cli/src/commands/migration/squash/squash.dump.ts b/apps/cli/src/commands/migration/squash/squash.dump.ts index 7a4b85bbd0..8b7c897a15 100644 --- a/apps/cli/src/commands/migration/squash/squash.dump.ts +++ b/apps/cli/src/commands/migration/squash/squash.dump.ts @@ -71,7 +71,7 @@ const concatChunks = (chunks: ReadonlyArray<Uint8Array>): Uint8Array => { /** * Buffered convenience over {@link squashDumpSchema} for the before/after * diff dumps — an `auth`/`storage` schema-only dump is tens of KB, not - * a streaming-scale payload. The FULL dump never goes through this — it streams + * a streaming-scale payload. The full dump never goes through this — it streams * straight to the target migration file's own handle at constant memory * (`squash.handler.ts`'s `squashMigrations`). */ diff --git a/apps/cli/src/commands/migration/squash/squash.e2e.test.ts b/apps/cli/src/commands/migration/squash/squash.e2e.test.ts index 8ce1531d5b..db401330dd 100644 --- a/apps/cli/src/commands/migration/squash/squash.e2e.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.e2e.test.ts @@ -18,15 +18,9 @@ describe("supabase migration squash", () => { rmSync(workdir, { recursive: true, force: true }); }); - // Real-subprocess guard for the production layer graph: `--version 0_init` is - // not a valid integer, so the bare `invalid version number` message - // (no repair-style `failed to parse <v>:` prefix) must surface — proving the - // real `migrationSquashRuntimeLayer` builds end to end, without ever - // touching Docker/Postgres. This is the same class of missing-service bug the - // `migration fetch` e2e exists to catch. Unlike a declined confirmation prompt - // (a genuine cancellation), this is a genuine validation error, so the usual - // `--debug` troubleshooting hint still follows it (`output.layer.ts`'s - // `CONTEXT_CANCELED_MESSAGE` guard does not apply here). + // Exercises the real migrationSquashRuntimeLayer end to end without touching + // Docker/Postgres. This is a validation error, not a cancellation, so the usual + // --debug hint still follows it. test( "rejects a non-numeric --version with the bare Go message", { timeout: E2E_TIMEOUT_MS }, @@ -46,9 +40,8 @@ describe("supabase migration squash", () => { }, ); - // Golden path with no Docker required: a single local migration short-circuits - // `squashToVersion` before any shadow-database work, so this proves the whole - // local no-op + `--local` suggestion path end to end. + // A single local migration short-circuits squashToVersion before any + // shadow-database work, exercising the local no-op + suggestion path end to end. test( "no-ops on a single local migration and suggests migration repair", { timeout: E2E_TIMEOUT_MS }, diff --git a/apps/cli/src/commands/migration/squash/squash.errors.ts b/apps/cli/src/commands/migration/squash/squash.errors.ts index b8979fccb7..258bd60e7c 100644 --- a/apps/cli/src/commands/migration/squash/squash.errors.ts +++ b/apps/cli/src/commands/migration/squash/squash.errors.ts @@ -50,10 +50,8 @@ export class MigrationSquashWriteError extends Data.TaggedError("MigrationSquash /** * `baselineMigrations`'s history-table batch (`DELETE_MIGRATION_BEFORE` + * `INSERT_MIGRATION_VERSION`) failed to send/commit. Matches the established - * `"failed to update migration history: " + err` text. Classified `dbConnection`, - * matching `migration repair`'s `MigrationRepairUpdateError` - * (`repair.errors.ts:19`) — both wrap the identical history-table batch-send - * failure shape. + * `"failed to update migration history: " + err` text, classified `dbConnection` + * like `migration repair`'s `MigrationRepairUpdateError` for the same failure shape. */ export class MigrationSquashBaselineError extends Data.TaggedError("MigrationSquashBaselineError")<{ readonly message: string; diff --git a/apps/cli/src/commands/migration/squash/squash.handler.ts b/apps/cli/src/commands/migration/squash/squash.handler.ts index 449e4f05ea..e15fd9a6e9 100644 --- a/apps/cli/src/commands/migration/squash/squash.handler.ts +++ b/apps/cli/src/commands/migration/squash/squash.handler.ts @@ -75,15 +75,10 @@ import { type Spawner = ChildProcessSpawnerType["Service"]; /** - * `squashMigrations`: - * shadow create -> health-wait -> connect -> `start.SetupDatabase` DIRECTLY - * (NOT `setupShadowConn`, so NO `CREATE DATABASE contrib_regression` template) -> dump the - * auth/storage schema before migrating -> apply every migration -> dump auth/storage again -> - * write the target file as the FULL (unrestricted) dump + the separator + the auth/storage - * line diff. `acquire` is only shadow creation (brief, Docker-API-bound); the health-wait/ - * connect/setup/dump/apply sequence runs in the interruptible `use` phase, matching the CLI-1956 - * review ruling `shadow-database.ts`/`diff.handler.ts` already established (a SIGINT during the - * health-wait must land immediately, from a single cancellable scope). + * Creates the shadow database, then runs health-wait/connect/setup/dump/apply/dump in + * the interruptible `use` phase. `acquire` is only the brief, Docker-API-bound shadow + * creation, so a SIGINT during the health-wait lands immediately from a single + * cancellable scope. */ const squashMigrations = Effect.fnUntraced(function* ( spawner: Spawner, @@ -165,9 +160,8 @@ const squashMigrations = Effect.fnUntraced(function* ( const targetRel = path.relative(workdir, targetPath); yield* Effect.scoped( Effect.gen(function* () { - // One open call that both truncates (or creates) the target file AND opens it for the - // writes below, matching `new.handler.ts:87`'s identical `{ flag: "w" }` precedent. - // There is no separate truncate-then-reopen step. + // One open call truncates (or creates) the target file and opens it for the + // writes below; there is no separate truncate-then-reopen step. const file = yield* fs.open(targetPath, { flag: "w", mode: 0o644 }).pipe( Effect.mapError( (cause) => @@ -176,11 +170,9 @@ const squashMigrations = Effect.fnUntraced(function* ( }), ), ); - // The full dump — NO schema restriction — streamed straight into the - // already-truncated file at constant memory. The underlying failure here is - // the docker-log-stream write into the file handle, - // not the line-diff writer below, so it byte-matches "failed to copy - // docker logs:" rather than "failed to write line:". + // The full, unrestricted dump streams into the already-truncated file at + // constant memory; a failure here is the docker-log-stream write, so it + // reports "failed to copy docker logs:" rather than "failed to write line:". yield* squashDumpSchema({ image, conn: connConfig, @@ -196,9 +188,8 @@ const squashMigrations = Effect.fnUntraced(function* ( ), ), }); - // The separator and the auth/storage line diff write sequentially to the - // SAME handle, with nothing observable - // between the two writes — combined into one `writeAll` here. + // Combined into a single writeAll so the separator and diff write atomically + // to the same handle. const tail = SQUASH_SEPARATOR_COMMENT + squashLineByLineDiff(before, after); yield* file.writeAll(new TextEncoder().encode(tail)).pipe( Effect.mapError( @@ -228,9 +219,9 @@ interface SquashToVersionResult { } /** - * `squashToVersion`: loads the local migrations up to `version` (all when empty), squashes - * every one but the last into the shadow-produced dump, then removes the merged files — a - * removal failure is NON-FATAL (only printed to stderr, then continues). + * Loads the local migrations up to `version` (all when empty), squashes every one but + * the last into the shadow-produced dump, then removes the merged files. A removal + * failure is non-fatal: only printed to stderr, then continues. */ const squashToVersion = Effect.fnUntraced(function* ( spawner: Spawner, @@ -292,15 +283,11 @@ const squashToVersion = Effect.fnUntraced(function* ( }); /** - * `baselineMigrations`: re-derives an empty `version` from the (POST-file-removal) local - * version listing, prints the "Baselining…" banner BEFORE connecting, then deletes every - * history row `<= version` and inserts the target migration's row in one transaction. - * - * The re-list runs AFTER `squashToVersion`'s file removals (this function is only ever called - * once that has fully completed) — so when a merged-file removal failed non-fatally, this - * baselines to the surviving OLDER version, not the squash target. Do not "optimise" this by - * passing the already-known target version through instead; that would silently diverge from - * the established behavior on exactly that path. + * Re-derives an empty `version` from the local version listing after + * `squashToVersion`'s removals complete (not the already-known target version), so a + * non-fatal removal failure baselines to the surviving older version instead. Prints + * the "Baselining…" banner before connecting, then deletes every history row `<= + * version` and inserts the target migration's row in one transaction. */ const baselineMigrations = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, @@ -316,8 +303,8 @@ const baselineMigrations = Effect.fnUntraced(function* ( let resolvedVersion = version; if (resolvedVersion.length === 0) { - // A read failure only logs via the debug logger - // and leaves `version` empty; it never aborts the baseline. + // A read failure only logs via the debug logger and leaves version empty; it + // never aborts the baseline. const local = yield* loadLocalVersions(fs, path, migrationsDir).pipe( Effect.catch((cause) => debugLogger.debug(cause.message).pipe(Effect.as<ReadonlyArray<string>>([])), @@ -326,16 +313,14 @@ const baselineMigrations = Effect.fnUntraced(function* ( if (local.length > 0) resolvedVersion = local[0]!; } - // Printed BEFORE connecting — the opposite order from every other prompting migration + // Printed before connecting, the opposite order from every other prompting migration // subcommand. yield* output.raw(`Baselining migration history to ${resolvedVersion}\n`, "stderr"); yield* Effect.scoped( Effect.gen(function* () { - // Always remote: `runSquash` already returned on the local target (step 9) before - // `baselineMigrations` is ever called, so `cfg.isLocal` is necessarily `false` here — - // the unconditional "Connecting to remote database..." on this path is the only - // reachable branch from `baselineMigrations`'s only caller. + // Always remote: runSquash already returns early on a local target, so + // cfg.isLocal is always false when this function runs. yield* output.raw("Connecting to remote database...\n", "stderr"); const session = yield* connection.connect(cfg.conn, { isLocal: cfg.isLocal, dnsResolver }); yield* createMigrationTable(session); @@ -350,9 +335,8 @@ const baselineMigrations = Effect.fnUntraced(function* ( } const m = yield* readMigrationFile(fs, path, resolvedFile.value); - // Data statements only, no schema mutation, so - // (matching `migration repair`'s own `updateMigrationTable`) wrapped in an explicit - // transaction for atomicity between the DELETE and the INSERT. + // Wrapped in an explicit transaction for atomicity between the DELETE and the + // INSERT, like migration repair's updateMigrationTable. const txn = Effect.gen(function* () { yield* session.exec("BEGIN"); yield* session.query(DELETE_MIGRATION_BEFORE, [m.version]); @@ -395,8 +379,7 @@ const runSquash = Effect.fnUntraced(function* ( let linkedRefForCache: string | undefined; yield* Effect.gen(function* () { - // 1. Flag groups — parse-time mutual-exclusivity check, ahead of the root - // pre-run. + // Checked here, ahead of the root pre-run. if (target.setFlags.length > 1) { return yield* Effect.fail( new MigrationTargetFlagsError({ @@ -419,12 +402,10 @@ const runSquash = Effect.fnUntraced(function* ( } const migrationsDir = path.join(cliSettings.workdir, "supabase", "migrations"); - // squash defaults to `--local`, same as `up`/`down`. const connType = target.connType ?? "local"; // `--project-ref` never implies `--linked` and must not be silently - // discarded on a non-linked target — see push.handler.ts's identical guard - // (db push) for the full TS-only rationale. + // discarded on a non-linked target; see push.handler.ts's identical guard. if (Option.isSome(flags.projectRef) && connType !== "linked") { return yield* Effect.fail( new MigrationTargetFlagsError({ @@ -434,10 +415,9 @@ const runSquash = Effect.fnUntraced(function* ( ); } - // 2/3. Linked pre-resolution (mirrors `db diff --linked`, `diff.handler.ts:400-430`): - // resolve + cache the project ref, and read the remote-merged config, BEFORE - // `resolver.resolve()` below. Read unconditionally (base config when not linked) since the - // shadow is provisioned locally regardless of the remote/local target. + // Resolves and caches the project ref, then reads the remote-merged config before + // resolver.resolve() below (like db diff --linked); read unconditionally since the + // shadow is provisioned locally either way. let linkedRef: string | undefined; if (connType === "linked") { const projectRefResolver = yield* ProjectRefResolver; @@ -449,9 +429,8 @@ const runSquash = Effect.fnUntraced(function* ( yield* output.raw(`Loading config override: [remotes.${toml.appliedRemote}]\n`, "stderr"); } - // 4. The shadow's own container spec — always built, and built BEFORE `resolver.resolve()` - // below, matching `diff.handler.ts`'s identical - // rationale: all config load/validation happens ahead of the actual connection resolution. + // The shadow's container spec is always built before resolver.resolve() below, so + // all config load/validation happens ahead of the actual connection resolution. const localInputs = yield* buildLocalDbContainerInputs( spawner, cliSettings.workdir, @@ -462,8 +441,8 @@ const runSquash = Effect.fnUntraced(function* ( toml.remoteOverrideKeys, ); - // 5. Resolve the target connection — the resolver owns `--password`/`DB_PASSWORD`/ - // temp-login-role/IPv6 handling for `--linked`, so squash needs no bespoke password prompt. + // The resolver owns --password/DB_PASSWORD/temp-login-role/IPv6 handling for + // --linked, so squash needs no bespoke password prompt. const cfg = yield* resolver.resolve({ dbUrl: flags.dbUrl, connType, @@ -476,24 +455,21 @@ const runSquash = Effect.fnUntraced(function* ( } if (linkedRef !== undefined) linkedRefForCache = linkedRef; - // 6. The project `.env` loads after the - // flag-group validation above — so a `SUPABASE_YES` set only in `supabase/.env` auto-confirms - // the remote-baseline prompt, but a flag conflict still surfaces before any `.env` read. + // Loads after the flag-group check above, so a flag conflict surfaces before any + // .env read; a SUPABASE_YES set only in supabase/.env still auto-confirms the + // remote-baseline prompt. const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); - // Make an allowlisted `supabase/.env` registry override visible to the - // synchronous `process.env` reader in `getRegistryImageUrl`, reverted - // when this scope closes. The project `.env` is applied - // before any container starts, and each of squash's three - // pg_dump containers resolves its image through the same registry-mirror lookup — - // so a dotenv-only mirror override reaches all three dumps below. + // Makes an allowlisted supabase/.env registry override visible to the synchronous + // process.env reader in getRegistryImageUrl, reverted when the scope closes, so all + // three pg_dump containers below see the same registry-mirror override. yield* applyProjectEnv(projectEnv); const yes = yield* resolveYesWithProjectEnv(projectEnv); - // 7. `--version` validation happens AFTER db-config resolution. + // Runs after DB-config resolution, so an invalid target surfaces first. const version = Option.getOrElse(flags.version, () => ""); if (version.length > 0) { if (parseMigrationVersion(version) === undefined) { - // Bare message — squash does NOT inherit repair's "failed to parse <v>: " prefix. + // Bare message; squash does not inherit repair's "failed to parse <v>:" prefix. return yield* Effect.fail( new MigrationInvalidVersionError({ message: "invalid version number" }), ); @@ -508,7 +484,6 @@ const runSquash = Effect.fnUntraced(function* ( } } - // 8. Squash local migrations. const squashResult = yield* squashToVersion( spawner, fs, @@ -520,7 +495,7 @@ const runSquash = Effect.fnUntraced(function* ( toml, ); - // 9. Local target: suggest `migration repair` instead of touching the remote history. + // Local target: suggest migration repair instead of touching the remote history. if (cfg.isLocal) { if (output.format === "text") { yield* output.raw(`Finished ${aqua("supabase migration squash")}.\n`); @@ -541,9 +516,8 @@ const runSquash = Effect.fnUntraced(function* ( return; } - // 10. Remote target: prompt before touching the remote history table. A DECLINED prompt is - // still a SUCCESS path here (returns cleanly, not a cancellation) — unlike - // repair/fetch/down, so this never raises `OperationCanceledError`. + // A declined prompt is still a success path here (returns cleanly, not a + // cancellation), unlike repair/fetch/down. const confirmed = yield* migrationConfirm("Update remote migration history table?", { defaultValue: true, yes, diff --git a/apps/cli/src/commands/migration/squash/squash.integration.test.ts b/apps/cli/src/commands/migration/squash/squash.integration.test.ts index f5dd2b7a29..0b582342f8 100644 --- a/apps/cli/src/commands/migration/squash/squash.integration.test.ts +++ b/apps/cli/src/commands/migration/squash/squash.integration.test.ts @@ -53,18 +53,10 @@ import { DockerRun, type DockerRunOpts } from "../../../command-internal/docker- import type { MigrationSquashFlags } from "./squash.command.ts"; import { migrationSquash } from "./squash.handler.ts"; -// --------------------------------------------------------------------------- -// A fake `DockerRun` that distinguishes squash's own three one-shot -// `pg_dump` containers from the shadow's PG15+ platform-baseline setup jobs -// purely by their env matrix: only a `pg_dump` invocation ever carries -// `PGDATABASE` (`toDumpEnv`) — none of the realtime/storage/auth -// one-shot jobs do (`db-setup.ts`). Among the dump calls, the first two -// sharing `EXTRA_FLAGS=--schema=auth|storage` are the before/after diff dumps -// (in that call order — `squashMigrations` dumps `before` strictly before -// applying migrations, `after` strictly after); a dump call with no -// `EXTRA_FLAGS` at all is the final, unrestricted full dump. -// --------------------------------------------------------------------------- - +// Distinguishes squash's three pg_dump containers from the shadow's setup jobs by env: +// only a pg_dump invocation carries PGDATABASE. Among dump calls, the first two sharing +// EXTRA_FLAGS=--schema=auth|storage are the before/after dumps in call order; a call +// with no EXTRA_FLAGS is the full dump. function mockSquashDockerRun( opts: { readonly beforeSql?: string; @@ -109,14 +101,8 @@ function mockSquashDockerRun( return { layer, dumpCalls, setupJobCalls }; } -// --------------------------------------------------------------------------- -// Filesystem fault injection — a single wrapper layer covering every -// filesystem failure squash's own scenarios need, keyed by exact absolute -// path so unrelated reads/writes elsewhere in the setup pipeline are -// unaffected. Follows `tests/helpers/command-mocks.ts`'s own -// `failWriteStringOnNthCallFsLayer` pattern. -// --------------------------------------------------------------------------- - +// A wrapper layer covering every filesystem failure these scenarios need, keyed by +// exact absolute path so unrelated setup reads/writes stay unaffected. const simulatedFsError = (path: string, method: string) => new PlatformError( new SystemError({ @@ -129,17 +115,12 @@ const simulatedFsError = (path: string, method: string) => ); interface FsFaultOpts { - /** - * Makes `fs.open(path, { flag: "w" })` itself fail — squash's SINGLE target-file open - * call (CLI-1969 review: collapsed from a truncate-then-reopen two-step into one - * `O_TRUNC`-equivalent open, matching `new.handler.ts:87`'s precedent). - */ + /** Makes `fs.open(path, { flag: "w" })` itself fail — squash's one target-file open call. */ readonly failOpenPath?: string; /** * Lets the Nth+ `writeAll` call on the open handle for `path` fail (1-indexed), - * succeeding on every earlier call — so the full-dump stream's own `writeAll` (call 1) - * and the separator/diff tail's `writeAll` (call 2) can be failed independently, - * exercising both of squash's distinct write-failure call sites. + * succeeding on every earlier call, so the full-dump write (call 1) and the + * separator/diff tail write (call 2) can be failed independently. */ readonly failWriteAllFromCall?: { readonly path: string; readonly fromCall: number }; readonly failRemovePath?: string; @@ -255,10 +236,9 @@ function setup(workdir: string, opts: SetupOpts = {}) { const execs: Array<string> = []; const queries: Array<{ readonly sql: string; readonly params?: ReadonlyArray<unknown> }> = []; - // Every `exec`/`query` call, in ONE combined call-order log — `execs`/`queries` above - // can't prove statement ORDER (`.toContain`/`.find` are order-blind), so a swapped - // DELETE/INSERT in the baseline transaction would ship green against them alone - // (CLI-1969 review item #7). + // A single combined call-order log; execs/queries alone can't prove statement order + // (.toContain/.find are order-blind), so a swapped DELETE/INSERT would still pass + // against them. const statements: Array<{ readonly sql: string; readonly params?: ReadonlyArray<unknown> }> = []; const connectedDatabases: Array<string> = []; const connection = Layer.succeed(DbConnection, { @@ -282,8 +262,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { ? Effect.fail(new DbExecError({ message: "boom" })) : Effect.succeed<ReadonlyArray<Record<string, unknown>>>([]); }), - // A migration file's statements arrive as one batch; replay them through - // `exec`/`query` so the call-order log and failure injection still apply. + // Replays each statement through exec/query so the call-order log and failure + // injection still apply. execBatch: (batch) => sequentialExecBatch(session)(batch), extensionExists: () => Effect.succeed(false), copyToCsv: () => Effect.succeed(new Uint8Array()), @@ -313,9 +293,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { database: "postgres", }, isLocal: opts.isLocal ?? true, - // A real `--local`/`--db-url` resolution can genuinely omit `ref` altogether - // (it's an optional field, not always `None`) — `omitRef` reproduces that - // shape so `runSquash`'s `cfg.ref ?? Option.none()` fallback stays exercised. + // A real --local/--db-url resolution can omit ref altogether (not just None); + // omitRef reproduces that so the cfg.ref ?? Option.none() fallback stays exercised. ...(opts.omitRef === true ? {} : { ref: opts.linkedRef !== undefined ? Option.some(opts.linkedRef) : Option.none() }), @@ -324,9 +303,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { resolvePoolerFallback: () => Effect.succeed(Option.none()), }); - // `loadProjectRef` gives an explicit `--project-ref` flag top precedence, same - // as Go's `flags.LoadProjectRef` — mirror that so a test can prove the flag - // (not just the `opts.linkedRef`/`VALID_REF` fallback) drives the linked ref. + // Gives an explicit --project-ref flag precedence over the opts.linkedRef/VALID_REF + // fallback, so a test can prove the flag drives the linked ref. const projectRef = Layer.succeed(ProjectRefResolver, { resolve: () => Effect.succeed(opts.linkedRef ?? VALID_REF), resolveForLink: () => Effect.succeed(opts.linkedRef ?? VALID_REF), @@ -350,9 +328,8 @@ function setup(workdir: string, opts: SetupOpts = {}) { }); const baseLayer = Layer.mergeAll( - // Listed first so every fake service layer below overrides its real - // implementation — `Layer.mergeAll` is last-wins on a shared service, - // matching `diff.integration.test.ts`'s own established ordering. + // Listed first so every fake service layer below overrides it; Layer.mergeAll is + // last-wins on a shared service. BunServices.layer, out.layer, telemetry.layer, @@ -462,8 +439,6 @@ describe("migration squash", () => { }); it.effect("rejects --project-ref on the default local target", () => { - // No target flag given at all — squash defaults to `--local`, so the - // guard must fire from the flag alone, with no explicit --local needed. const s = setup(tmp.current); return Effect.gen(function* () { const exit = yield* migrationSquash(flags({ projectRef: Option.some(VALID_REF) })).pipe( @@ -562,9 +537,8 @@ describe("migration squash", () => { }); it.effect("surfaces a db-config resolution failure before validating --version", () => { - // Cobra's pre-run order resolves the DB target before `squash.Run`'s own - // `strconv.Atoi` version check — so an unlinked/invalid target wins over a - // bad version, matching `migration repair`'s identical ordering test. + // DB target resolution happens before the version check, matching migration + // repair's identical ordering test. const s = setup(tmp.current, { failResolve: true }); return Effect.gen(function* () { const exit = yield* migrationSquash(flags({ version: Option.some("not-a-number") })).pipe( @@ -576,8 +550,7 @@ describe("migration squash", () => { it.effect("defaults to the local database when no target flag is given", () => { seedMigration(tmp.current, "0_init.sql"); - // `omitRef` matches the real resolver's own `--local` shape: no `ref` at all, - // not merely `None` — exercising the `cfg.ref ?? Option.none()` fallback. + // omitRef matches the real resolver's --local shape: no ref at all, not merely None. const s = setup(tmp.current, { args: [], omitRef: true }); return Effect.gen(function* () { yield* migrationSquash(flags()); @@ -638,7 +611,7 @@ describe("migration squash", () => { ); expect(s.shadowSpawned).toEqual([]); expect(s.dumpCalls).toEqual([]); - // Step 2 still runs on the no-op path (it falls through to it). + // The Finished/repair-suggestion output still runs on the no-op path. expect(stdout(s.out)).toContain("Finished supabase migration squash."); expect(stderr(s.out)).toContain( "Run supabase migration repair --status applied to update your remote migration history table.", @@ -648,8 +621,6 @@ describe("migration squash", () => { ); }); - // Happy path — squashing two-or-more migrations - describe("squashing local migrations", () => { const BEFORE_SQL = "CREATE SCHEMA IF NOT EXISTS auth;\nold auth object;\n"; const AFTER_SQL = "CREATE SCHEMA IF NOT EXISTS auth;\nnew auth object;\n"; @@ -686,10 +657,9 @@ describe("migration squash", () => { expect(existsSync(join(migrationsDir, "0_init.sql"))).toBe(false); expect(existsSync(join(migrationsDir, "1_target.sql"))).toBe(true); - // Hardcoded (not recomputed via `squash.diff.ts`'s own helpers) so a - // regression in the separator constant or the diff algorithm itself - // — not just in how `squashMigrations` wires them together — still - // fails this assertion. + // Hardcoded rather than recomputed via squash.diff.ts's helpers, so a regression + // in the separator constant or the diff algorithm itself still fails this + // assertion. const expectedTail = "\n--\n-- Dumped schema changes for auth and storage\n--\n\n" + "new auth object;\n"; expect(readFileSync(join(migrationsDir, "1_target.sql"), "utf8")).toBe( @@ -732,14 +702,13 @@ describe("migration squash", () => { expect(call.env["PGDATABASE"]).toBe("postgres"); expect(call.network).toEqual({ _tag: "host" }); expect(call.cmd).toEqual(["bash", "-c", dumpSchemaScript, "--"]); - // `streamPgDump` applies the registry mirror itself — - // the default (no override) registry rewrites - // to the ECR mirror, not the bare Dockerfile-manifest tag. + // streamPgDump applies the registry mirror itself; the default registry + // rewrites to the ECR mirror, not the bare Dockerfile-manifest tag. expect(call.image).toBe(getRegistryImageUrl(dockerfileServiceImage("pg"))); } - // Every dump dials the SAME shadow host, whatever this machine's Docker - // context resolves it to (`getHostname`) — self-consistency avoids - // hardcoding the host-dependent value. + // Every dump dials the same shadow host, whatever this machine's Docker context + // resolves (getHostname); checked for self-consistency rather than a hardcoded + // value. const hosts = new Set(s.dumpCalls.map((c) => c.env["PGHOST"])); expect(hosts.size).toBe(1); const [host] = hosts; @@ -791,11 +760,8 @@ describe("migration squash", () => { it.effect( "resolves the pg_dump image via SUPABASE_INTERNAL_IMAGE_REGISTRY from supabase/.env", () => { - // The project `.env` is applied before any of - // squash's three pg_dump containers start; each one resolves its image through - // the same registry-mirror lookup — so a registry mirror set only in `supabase/.env` - // reaches all three. The handler applies that with `applyProjectEnv`, scoped to - // the run and reverted when it completes. + // applyProjectEnv applies the project .env before any pg_dump container starts, + // so a registry mirror set only there reaches all three. const prev = process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; delete process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]; const s = setupHappyPath(); @@ -809,8 +775,8 @@ describe("migration squash", () => { for (const call of s.dumpCalls) { expect(call.image).toMatch(/^my-mirror\.example\.com\/supabase\//u); } - // Reverted once the command's own scope closes (`Effect.scoped` on `runSquash`'s - // terminal pipe) — never leaks into a later command in the same process. + // Reverted once the command's scope closes; never leaks into a later command in + // the same process. expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); }).pipe( Effect.ensuring( @@ -827,9 +793,8 @@ describe("migration squash", () => { it.effect( "resolves the pg_dump network via SUPABASE_NETWORK_ID from supabase/.env when neither the flag nor the ambient env is set", () => { - // Host networking is the default, but an explicit network id - // overrides it whenever that resolves non-empty — a value sourced only from - // `supabase/.env` still wins over host. + // Host networking is the default; an explicit network id overrides it whenever + // it resolves non-empty, even when sourced only from supabase/.env. const prev = process.env["SUPABASE_NETWORK_ID"]; delete process.env["SUPABASE_NETWORK_ID"]; const s = setupHappyPath(); @@ -866,7 +831,6 @@ describe("migration squash", () => { const migrationsDir = join(tmp.current, "supabase", "migrations"); expect(existsSync(join(migrationsDir, "0_init.sql"))).toBe(false); expect(existsSync(join(migrationsDir, "1_target.sql"))).toBe(true); - // The newer file was never touched — outside the `--version 1` window. expect(readFileSync(join(migrationsDir, "2_after.sql"), "utf8")).toBe( "create table c (id int);\n", ); @@ -874,9 +838,8 @@ describe("migration squash", () => { }); }); - // Failure paths — every one leaves the shadow removed (unless creation - // itself is what failed, matching the established leak-on-create-failure behavior). - + // Every failure path removes the shadow, unless creation itself failed (the + // established leak-on-create-failure behavior). describe("squashMigrations failure paths", () => { it.effect("fails when the shadow container cannot be created and never attempts a dump", () => { seedMigration(tmp.current, "0_init.sql"); @@ -886,8 +849,7 @@ describe("migration squash", () => { const exit = yield* migrationSquash(flags()).pipe(Effect.exit); expect(failureTag(exit)).toBe("ShadowDbError"); expect(s.shadowSpawned.filter((c) => c.args[0] === "create")).toHaveLength(1); - // Nothing to release — the container was never created (the established - // leak-on-create-failure behavior, see `createShadowDatabase`'s doc). + // Nothing to release; the container was never created (see createShadowDatabase's doc). expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toEqual([]); expect(s.dumpCalls).toEqual([]); }).pipe(Effect.provide(s.layer)); @@ -980,9 +942,8 @@ describe("migration squash", () => { expect(failureTag(exit)).toBe("MigrationSquashDumpError"); expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); const targetPath = join(tmp.current, "supabase", "migrations", "1_target.sql"); - // Truncated (by the earlier `O_TRUNC`), then only the partial stream the - // dying container managed to write before failing — no separator/diff - // was ever appended, since the whole operation aborted first. + // Truncated by the earlier O_TRUNC, then only the partial stream the dying + // container wrote before failing; no separator/diff was ever appended. expect(readFileSync(targetPath, "utf8")).toBe("partial output before the container died"); }).pipe(Effect.provide(s.layer)); }, @@ -991,9 +952,8 @@ describe("migration squash", () => { it.effect( "fails with 'failed to open migration file' when the target file cannot be truncated/opened", () => { - // Squash's ONE `O_TRUNC`-equivalent open call (CLI-1969 review: collapsed from a - // truncate-then-reopen two-step into a single `fs.open(path, { flag: "w" })`, - // matching `new.handler.ts:87`'s precedent) — a single failure site, not two. + // Squash's single O_TRUNC-equivalent open call, so there is exactly one failure + // site here, not two. seedMigration(tmp.current, "0_init.sql"); seedMigration(tmp.current, "1_target.sql"); const targetPath = join(tmp.current, "supabase", "migrations", "1_target.sql"); @@ -1011,7 +971,7 @@ describe("migration squash", () => { const message = Option.isSome(failure) && (failure.value as { message: string }).message; expect(message).toContain("failed to open migration file:"); - // Relativized (CLI-1969 review item #3): the absolute tmp workdir never leaks. + // Relativized: the absolute tmp workdir never leaks. expect(message).not.toContain(tmp.current); } expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); @@ -1022,9 +982,9 @@ describe("migration squash", () => { it.effect( "fails with 'failed to copy docker logs' when streaming the full dump into the target file fails", () => { - // The underlying failure on this path is the docker-log-stream write into the - // target file, byte-matching "failed to - // copy docker logs:" — NOT `lineByLineDiff`'s own "failed to write line:" below. + // The underlying failure here is the docker-log-stream write into the target + // file, so it reports "failed to copy docker logs:", not lineByLineDiff's + // "failed to write line:". seedMigration(tmp.current, "0_init.sql"); seedMigration(tmp.current, "1_target.sql"); const targetPath = join(tmp.current, "supabase", "migrations", "1_target.sql"); @@ -1058,8 +1018,8 @@ describe("migration squash", () => { beforeDumpSql: "before;\n", afterDumpSql: "after;\n", fullDumpSql: "full;\n", - // `fromCall: 2` lets the full-dump stream's own `writeAll` (call 1) - // succeed, isolating the separator/diff tail's write (call 2). + // fromCall: 2 lets the full-dump write (call 1) succeed, isolating the tail + // write (call 2). fsFaults: { failWriteAllFromCall: { path: targetPath, fromCall: 2 } }, }); return Effect.gen(function* () { @@ -1070,11 +1030,10 @@ describe("migration squash", () => { const message = Option.isSome(failure) && (failure.value as { message: string }).message; expect(message).toContain("failed to write line:"); - // Relativized (CLI-1969 review item #3): the absolute tmp workdir never leaks. + // Relativized: the absolute tmp workdir never leaks. expect(message).not.toContain(tmp.current); } expect(s.shadowSpawned.filter((c) => c.args[0] === "rm")).toHaveLength(1); - // The full dump itself made it onto disk before the tail write failed. expect(readFileSync(targetPath, "utf8")).toBe("full;\n"); }).pipe(Effect.provide(s.layer)); }, @@ -1092,12 +1051,8 @@ describe("migration squash", () => { }); return Effect.gen(function* () { yield* migrationSquash(flags()); - // Non-fatal: the command still finishes successfully. expect(stdout(s.out)).toContain("Finished supabase migration squash."); - // The failed removal's relativized error text reached stderr — pinned, not just - // "non-empty", and proves the workdir-relative path (never the absolute one). expect(stderr(s.out)).toContain("FileSystem.remove (supabase/migrations/0_init.sql)"); - // The file that failed to be removed is still on disk. expect(existsSync(earlierPath)).toBe(true); }).pipe(Effect.provide(s.layer)); }); @@ -1152,8 +1107,6 @@ describe("migration squash", () => { }); }); - // Step 2 — local target - describe("local target", () => { it.effect( "prints Finished on stdout and the repair suggestion on stderr, and never prompts", @@ -1181,8 +1134,6 @@ describe("migration squash", () => { }); }); - // Step 2 — remote target - describe("remote target", () => { function setupRemote(opts: SetupOpts = {}) { seedMigration(tmp.current, "0_init.sql"); @@ -1220,10 +1171,9 @@ describe("migration squash", () => { const s = setupRemote({ confirm: true }); return Effect.gen(function* () { yield* migrationSquash(flags()); - // ONE ordered log (not `execs`/`queries` separately — `.toContain`/`.find` are - // order-blind, so an INSERT-before-DELETE regression would ship green against - // them) — the baseline's own transaction is the LAST 4 statements sent, after - // `createMigrationTable`'s own (exec-only) setup transaction. + // A single ordered log, since execs/queries alone are order-blind and would + // still pass an INSERT-before-DELETE regression; the baseline transaction is + // the last 4 statements, after createMigrationTable's setup transaction. const baseline = s.statements.slice(-4); expect(baseline.map((entry) => entry.sql)).toEqual([ "BEGIN", @@ -1251,9 +1201,8 @@ describe("migration squash", () => { ).toContain("failed to update migration history:"); } expect(s.execs).toContain("ROLLBACK"); - // Exactly one COMMIT — `createMigrationTable`'s own setup transaction, - // which runs (and commits) BEFORE the baseline's own BEGIN/DELETE/INSERT - // batch; the baseline's OWN transaction never reaches COMMIT. + // Exactly one COMMIT: createMigrationTable's own setup transaction, which runs + // before the baseline's BEGIN/DELETE/INSERT batch and never itself reaches COMMIT. expect(s.execs.filter((e) => e === "COMMIT")).toHaveLength(1); expect(s.execs.filter((e) => e === "BEGIN")).toHaveLength(2); }).pipe(Effect.provide(s.layer)); @@ -1311,10 +1260,9 @@ describe("migration squash", () => { ); it.effect("baselines the surviving older version when a merged-file removal failed", () => { - // Local versions are re-listed AFTER the file removals — a failed removal - // means the squash TARGET survives on disk (already true), but so does - // the OLDER merged file whose removal failed, and THAT older version is - // what an empty `--version` baselines to, not the squash target. + // Local versions are re-listed after the file removals, so a failed removal leaves + // the older merged file's version as what an empty --version baselines to, not the + // squash target. seedMigration(tmp.current, "0_init.sql"); seedMigration(tmp.current, "1_target.sql"); const earlierPath = join(tmp.current, "supabase", "migrations", "0_init.sql"); @@ -1330,7 +1278,6 @@ describe("migration squash", () => { return Effect.gen(function* () { yield* migrationSquash(flags()); const insert = s.queries.find((q) => q.sql.includes("INSERT INTO supabase_migrations")); - // "0" (the surviving older file), NOT "1" (the squash target). expect(insert?.params?.[0]).toBe("0"); }).pipe(Effect.provide(s.layer)); }); @@ -1348,11 +1295,9 @@ describe("migration squash", () => { beforeDumpSql: "before;\n", afterDumpSql: "after;\n", fullDumpSql: "full;\n", - // The FIRST `readDirectory(migrationsDir)` call is `squashToVersion`'s own - // listing (must succeed so the squash itself completes); the SECOND is - // `baselineMigrations`'s post-removal re-list, which this fails — the - // THIRD (inside `resolveMigrationFile`, resolving the now-empty - // version) must succeed again so the scenario isolates the reload failure. + // Call 1 is squashToVersion's own listing (must succeed); call 2 is + // baselineMigrations's post-removal re-list, which this fails; call 3 (inside + // resolveMigrationFile) must succeed again to isolate the reload failure. fsFaults: { failReadDirectoryAtCall: { path: migrationsDir, atCall: 2 } }, }); return Effect.gen(function* () { @@ -1361,10 +1306,9 @@ describe("migration squash", () => { expect(s.debugLogs[0]).toContain("failed to read directory"); expect(s.debugLogs[0]).toContain("simulated failure"); expect(stderr(s.out)).toContain("Baselining migration history to \n"); - // `repair.NewMigrationFromVersion("")` finds no match — the empty-version - // glob fails, which surfaces as the baseline's own missing-file error, - // proving `resolvedVersion` genuinely stayed "" rather than falling back - // to the squash target. + // The empty-version glob fails, surfacing as the baseline's missing-file error + // and proving resolvedVersion genuinely stayed "" rather than falling back to + // the squash target. expect(failureTag(exit)).toBe("MigrationFileNotFoundError"); if (Exit.isFailure(exit)) { const failure = Cause.findErrorOption(exit.cause); @@ -1395,9 +1339,8 @@ describe("migration squash", () => { it.effect( "--linked --project-ref overrides the workdir's own linked ref for resolution and caching", () => { - // `opts.linkedRef` (VALID_REF) represents whatever the workdir would - // resolve to absent the flag — the explicit --project-ref flag must win over - // it, both for the resolver call and for what ultimately gets cached. + // opts.linkedRef (VALID_REF) is what the workdir would resolve to without the + // flag; --project-ref must win over it for both the resolver call and the cache. const FLAG_REF = "flagflagflagflagflag"; const s = setup(tmp.current, { isLocal: false, @@ -1534,10 +1477,9 @@ describe("migration squash", () => { return Effect.gen(function* () { yield* migrationSquash(flags()); const success = s.out.messages.find((m) => m.type === "success"); - // "1_target.sql" is the sole surviving local file once "0_init.sql" is removed, so - // the empty-`--version` baseline reload (`loadLocalVersions`, run AFTER the - // removal) resolves to its own version, "1" — matching `squashedInto` below, NOT - // the removed file's "0". + // "1_target.sql" is the sole surviving file once "0_init.sql" is removed, so the + // empty-version baseline reload (run after the removal) resolves to its own + // version, "1", matching squashedInto below. expect(success?.data).toEqual({ squashedInto: "supabase/migrations/1_target.sql", removed: ["supabase/migrations/0_init.sql"], diff --git a/apps/cli/src/commands/migration/up/up.handler.ts b/apps/cli/src/commands/migration/up/up.handler.ts index aa7f2d03d9..de2ffb7f64 100644 --- a/apps/cli/src/commands/migration/up/up.handler.ts +++ b/apps/cli/src/commands/migration/up/up.handler.ts @@ -53,8 +53,7 @@ const runUp = Effect.fnUntraced(function* ( } // `--project-ref` never implies `--linked` and must not be silently - // discarded on a non-linked target — see push.handler.ts's identical guard - // (db push) for the full TS-only rationale. + // discarded on a non-linked target; see push.handler.ts's identical guard. if (Option.isSome(flags.projectRef) && (target.connType ?? "local") !== "linked") { return yield* Effect.fail( new MigrationTargetFlagsError({ @@ -67,7 +66,6 @@ const runUp = Effect.fnUntraced(function* ( const migrationsDir = path.join(cliSettings.workdir, "supabase", "migrations"); const upBody = Effect.gen(function* () { - // up defaults to `--local`. const cfg = yield* resolver.resolve({ dbUrl: flags.dbUrl, connType: target.connType ?? "local", @@ -79,8 +77,6 @@ const runUp = Effect.fnUntraced(function* ( yield* Effect.scoped( Effect.gen(function* () { - // The connect diagnostic prints to stderr before dialing, - // local/remote per the resolved connection. yield* output.raw( `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, "stderr", @@ -115,11 +111,9 @@ const runUp = Effect.fnUntraced(function* ( }), ); } - // `--include-all`: the out-of-order set + everything after the - // applied prefix. Slices the same version-ordered list - // `result.paths` was taken from — indexing a name-ordered list with a - // version-ordered offset would skip a pending migration and re-apply - // an already-applied one. + // Slices the same version-ordered list `result.paths` was taken from; indexing + // a name-ordered list with this offset would skip a pending migration and + // re-apply an already-applied one. pending = [ ...result.paths, ...sortMigrationPathsByVersion(local).slice(remote.length + result.paths.length), From db822a1eb503b3cf86b092f80a1a40a0741cab7c Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 12:57:09 +0100 Subject: [PATCH 27/56] chore(repo): trim comments, shard 09 (1788 -> 1024 comment lines) --- .../db-config.toml-read.unit.test.ts | 350 +++-------- .../src/command-internal/db-config.types.ts | 111 +--- .../command-internal/db-connection.errors.ts | 50 +- .../command-internal/db-connection.layer.ts | 7 +- .../command-internal/db-connection.service.ts | 189 +++--- .../db-connection.sql-pg.integration.test.ts | 78 +-- .../db-connection.sql-pg.layer.ts | 557 ++++++------------ .../db-connection.sql-pg.unit.test.ts | 35 +- apps/cli/src/command-internal/db-dns.ts | 28 +- apps/cli/src/command-internal/db-image.ts | 28 +- .../command-internal/db-pull-run.errors.ts | 9 +- apps/cli/src/command-internal/db-pull-run.ts | 192 +++--- apps/cli/src/command-internal/db-push-core.ts | 66 +-- .../src/command-internal/db-target-flags.ts | 136 ++--- .../db-target-flags.unit.test.ts | 47 +- .../command-internal/detect-content-type.ts | 37 +- .../detect-content-type.unit.test.ts | 8 +- apps/cli/src/command-internal/diff-engine.ts | 50 +- .../command-internal/docker-bind-classify.ts | 23 +- 19 files changed, 618 insertions(+), 1383 deletions(-) diff --git a/apps/cli/src/command-internal/db-config.toml-read.unit.test.ts b/apps/cli/src/command-internal/db-config.toml-read.unit.test.ts index 0e34035a18..fe24e4f64b 100644 --- a/apps/cli/src/command-internal/db-config.toml-read.unit.test.ts +++ b/apps/cli/src/command-internal/db-config.toml-read.unit.test.ts @@ -109,7 +109,6 @@ describe("read (lenient) vs check (throws) split", () => { ).pipe( Effect.tap((v) => Effect.sync(() => { - // The broken config is swallowed → the ignore-file defaults path (no vault). expect(v.vault).toEqual([]); expect(v.port).toBeGreaterThan(0); rmSync(dir, { recursive: true, force: true }); @@ -151,8 +150,7 @@ describe("readDbToml", () => { ); }); - // A known-good test vector: this ciphertext - // decrypts to "value" under the keypair below. + // A known-good test vector: decrypts to "value" under the keypair below. const VAULT_PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; const VAULT_ENCRYPTED = "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; @@ -178,8 +176,6 @@ describe("readDbToml", () => { }); it.effect("fails the load for an encrypted: [db.vault] secret with no private key", () => { - // Go aborts the whole command (`failed to parse config: missing private key`) - // rather than silently skipping the secret. const previous = process.env["DOTENV_PRIVATE_KEY"]; delete process.env["DOTENV_PRIVATE_KEY"]; const dir = withConfig(["[db.vault]", `my_secret = "${VAULT_ENCRYPTED}"`, ""].join("\n")); @@ -205,8 +201,6 @@ describe("readDbToml", () => { }); it.effect("collapses. and .. in relative seed sql_paths like Go's path.Join", () => { - // Go prefixes each relative pattern with `path.Join("supabase", pattern)`, which - // runs `path.Clean`. The cleaned path is the seed_files key. const dir = withConfig( ["[db.seed]", 'sql_paths = ["../seed.sql", "sub/../other.sql", "./plain.sql"]', ""].join( "\n", @@ -223,7 +217,6 @@ describe("readDbToml", () => { }); it.effect("honors SUPABASE_DB_SEED_SQL_PATHS over the TOML array (comma split, no trim)", () => { - // Go's StringToSliceHookFunc(",") splits without trimming, so " b.sql" keeps its space. const previous = process.env["SUPABASE_DB_SEED_SQL_PATHS"]; process.env["SUPABASE_DB_SEED_SQL_PATHS"] = "a.sql, b.sql"; const dir = withConfig(["[db.seed]", 'sql_paths = ["ignored.sql"]', ""].join("\n")); @@ -244,8 +237,6 @@ describe("readDbToml", () => { }); it.effect("decodes a STRING db.seed.sql_paths via StringToSliceHookFunc (comma, no trim)", () => { - // Go decodes a non-array sql_paths string into a slice, not just the - // env override; `"a.sql,b.sql"` → two supabase-prefixed paths, no trimming. const dir = withConfig(["[db.seed]", 'sql_paths = "a.sql,b.sql"', ""].join("\n")); return read(dir).pipe( Effect.tap((v) => @@ -272,8 +263,6 @@ describe("readDbToml", () => { it.effect( "expands env() before splitting a string sql_paths (LoadEnv before StringToSlice)", () => { - // Go runs LoadEnvHook before StringToSliceHookFunc(","), so env(SEEDS)=a.sql,b.sql - // expands first and then splits into two patterns. const previous = process.env["SEEDS"]; process.env["SEEDS"] = "a.sql,b.sql"; const dir = withConfig(["[db.seed]", 'sql_paths = "env(SEEDS)"', ""].join("\n")); @@ -295,8 +284,6 @@ describe("readDbToml", () => { ); it.effect("expands an env() array element but does NOT split it (Go array asymmetry)", () => { - // A TOML array element is decoded string→string: LoadEnvHook expands it, but - // StringToSliceHookFunc does not fire, so it stays one (comma-containing) pattern. const previous = process.env["SEEDS"]; process.env["SEEDS"] = "a.sql,b.sql"; const dir = withConfig(["[db.seed]", 'sql_paths = ["env(SEEDS)"]', ""].join("\n")); @@ -319,9 +306,6 @@ describe("readDbToml", () => { it.effect( "weakly coerces non-string db.seed.sql_paths array elements (Go mapstructure parity)", () => { - // Same `config.Glob` decode path as schema_paths below — a bool/number element - // is coerced to its Go string form ("1"/"0" for bool, decimal for a number), - // not dropped. const dir = withConfig(["[db.seed]", 'sql_paths = [42, true, "seed.sql"]', ""].join("\n")); return read(dir).pipe( Effect.tap((v) => @@ -375,21 +359,11 @@ describe("readDbToml", () => { it.effect( "on Windows, resolves a leading-slash schema/seed path pattern under supabase/ instead of treating it as absolute (Go filepath.IsAbs parity)", () => { - // `resolve()` gates the `supabase/`-join on `!filepath.IsAbs(pattern)`, - // and `filepath.IsAbs` on Windows requires a volume name — - // a drive letter (`C:\`) or UNC prefix (`\\server\share`) — before a path counts as - // absolute (`internal/filepathlite/path_windows.go`'s `IsAbs`/`volumeNameLen`). A - // bare leading `/` has no volume name, so Go treats `/schemas/*.sql` as RELATIVE and - // joins it to `supabase/schemas/*.sql`. Node's `path.win32.isAbsolute`, backing the - // injected `Path.Path` service on an actual Windows host, instead treats a leading - // separator as rooted at the current drive — i.e. absolute — which would otherwise - // skip `supabase/`-join entirely. Exercises `resolveSeedSqlPath` (the - // single function `[db.migrations].schema_paths` and `[db.seed].sql_paths` both - // resolve through) directly with `BunPath.layerWin32`, rather than through the full - // `readDbToml` pipeline: that pipeline's OWN config-file lookup also runs - // through the same injected `Path.Path` service to open the real (POSIX-pathed, - // since this test host isn't Windows) temp config file on disk, so forcing win32 - // path semantics there breaks the read itself rather than exercising the fix. + // A bare leading `/` has no Windows volume name, so it resolves as relative and joins to + // `supabase/`, unlike Node's `path.win32.isAbsolute`, which treats a leading separator as + // rooted at the current drive. Tests `resolveSeedSqlPath` directly with + // `BunPath.layerWin32` instead of the full `readDbToml` pipeline, since that pipeline + // needs the real (POSIX-pathed) `Path.Path` service to open the temp config file on disk. const originalPlatform = process.platform; Object.defineProperty(process, "platform", { value: "win32" }); return Effect.gen(function* () { @@ -433,13 +407,6 @@ describe("readDbToml", () => { it.effect( "formats a large numeric db.migrations.schema_paths entry as fixed decimal, not scientific notation (Go strconv.FormatFloat parity)", () => { - // `decodeString` renders a weakly-converted float via - // `strconv.FormatFloat(v, 'f', -1, 64)` — format `'f'` is ALWAYS fixed decimal, - // never scientific, regardless of magnitude. JS's bare `String(1e21)` switches to - // exponential notation ("1e+21") once the magnitude crosses 1e21, which would - // record (and later search for) the wrong file path. Verified empirically against - // Go's stdlib: `strconv.FormatFloat(1e21, 'f', -1, 64)` returns - // `"1000000000000000000000"`, not `"1e+21"`. const dir = withConfig(["[db.migrations]", "schema_paths = [1e21]", ""].join("\n")); return read(dir).pipe( Effect.tap((v) => @@ -455,14 +422,6 @@ describe("readDbToml", () => { it.effect( "formats TOML special-float db.migrations.schema_paths entries like Go's strconv.FormatFloat, not JS's toString (Go parity)", () => { - // `strconv.FormatFloat` special-cases the three non-finite values BEFORE the - // format verb is even consulted, so `'f'` never applies to them — it renders - // `+Inf` / `-Inf` / `NaN` (verified empirically: a real - // `schema_paths = [inf, -inf, nan]` config load resolves to exactly - // `supabase/{+Inf,-Inf,NaN}`). JS's own `Number.prototype.toString()` renders - // the two infinities as `"Infinity"`/`"-Infinity"` instead — a naive port would - // record (and later glob) the wrong path. TOML v1.0's bare `inf`/`-inf`/`nan` - // float literals parse to exactly these JS values (smol-toml). const dir = withConfig(["[db.migrations]", "schema_paths = [inf, -inf, nan]", ""].join("\n")); return read(dir).pipe( Effect.tap((v) => @@ -478,11 +437,6 @@ describe("readDbToml", () => { it.effect( "weakly coerces a TOP-LEVEL scalar db.migrations.schema_paths (Go mapstructure weak-decode of a []string field)", () => { - // `decodeSlice` wraps a non-array/non-string value into a synthetic - // single-element `[]any{value}` and decodes it through the same per-element - // rules as a real array entry — it does NOT fall back to the `[]` default the - // way an absent key does. Verified empirically: - // `schema_paths = 42` → `["42"]` (resolves to `supabase/42`), `= true` → `["1"]`. const dirNumber = withConfig(["[db.migrations]", "schema_paths = 42", ""].join("\n")); const dirBool = withConfig(["[db.migrations]", "schema_paths = true", ""].join("\n")); return Effect.all([read(dirNumber), read(dirBool)]).pipe( @@ -501,9 +455,6 @@ describe("readDbToml", () => { it.effect( "treats a TOP-LEVEL empty-table db.migrations.schema_paths as no patterns (Go mapstructure zero-length-map special case)", () => { - // `decodeSlice` special-cases a zero-length map BEFORE the generic weak-typing - // wrap above: it decodes straight to an empty slice. Verified empirically against - // verified empirically: `schema_paths = {}` → `[]`. const dir = withConfig(["[db.migrations]", "schema_paths = {}", ""].join("\n")); return read(dir).pipe( Effect.tap((v) => @@ -524,16 +475,12 @@ describe("readDbToml", () => { ])( "aborts the whole config load on a TOP-LEVEL bare $name db.migrations.schema_paths instead of silently treating it as empty (Go mapstructure UnconvertibleTypeError, review CLI-1958)", ({ literal, goType }) => { - // `smol-toml` parses every TOML datetime variant to a `TomlDate` (a `Date` - // subclass) that stores its value internally, not as an enumerable own - // property — so `Object.keys(tomlDate).length === 0`, same as a genuine empty - // inline table (`schema_paths = {}`, tested above). Without excluding `TomlDate` - // from that zero-length-map special case, this would silently resolve to `[]` - // instead of aborting. Verified empirically - // `config.Load`: a bare datetime literal here fails with exactly this message, - // never resolving to an empty/partial glob list — one distinct Go type per TOML - // datetime variant (`time.Time` for the offset form, `toml.Local*` wrappers for - // the 3 zone-less "local" forms). + // `smol-toml` parses every TOML datetime variant to a `TomlDate` (a `Date` subclass) + // that stores its value internally, not as an enumerable own property, so + // `Object.keys(tomlDate).length === 0` — same as a genuine empty inline table + // (`schema_paths = {}`, tested above). `TomlDate` must be excluded from that + // zero-length-map special case, or this would silently resolve to `[]` instead of + // aborting. const dir = withConfig(["[db.migrations]", `schema_paths = ${literal}`, ""].join("\n")); return read(dir).pipe( Effect.exit, @@ -555,11 +502,9 @@ describe("readDbToml", () => { it.effect( "aborts the whole config load on a bare datetime db.migrations.schema_paths ARRAY element (Go mapstructure UnconvertibleTypeError, review CLI-1958)", () => { - // Same `TomlDate`-vs-generic-object collision as the top-level scalar case above, - // but reached through the real-array branch (`goUnconvertibleType`) instead - // of the scalar fallback. Verified empirically: - // `schema_paths = ["schemas/*.sql", 1979-05-27T07:32:00Z]` fails config load with - // exactly this message — the valid glob entry never masks the datetime's failure. + // Same `TomlDate`-vs-generic-object collision as the top-level scalar case above, but + // reached through the real-array branch instead of the scalar fallback: the valid glob + // entry must never mask the datetime's failure. const dir = withConfig( ["[db.migrations]", 'schema_paths = ["schemas/*.sql", 1979-05-27T07:32:00Z]', ""].join( "\n", @@ -606,10 +551,8 @@ describe("readDbToml", () => { it.effect( "aborts the whole config load on a TOP-LEVEL table db.migrations.schema_paths (Go mapstructure UnconvertibleTypeError, synthetic index 0)", () => { - // A non-empty map isn't weakly coercible, so `decodeSlice` wraps it into - // `[]any{value}` and fails decoding element 0 the same way a nested-array/table - // ARRAY element does. Verified empirically: - // `[db.migrations.schema_paths]\nfoo = "bar"` fails with this exact message. + // A non-empty map isn't weakly coercible, so it fails decoding element 0 the same way a + // nested-array/table array element does. const dir = withConfig(["[db.migrations.schema_paths]", 'foo = "bar"', ""].join("\n")); return read(dir).pipe( Effect.exit, @@ -631,10 +574,8 @@ describe("readDbToml", () => { it.effect( "weakly coerces a TOP-LEVEL scalar db.seed.sql_paths instead of falling back to the ['seed.sql'] default", () => { - // The absent-key default (`["seed.sql"]`) only applies when the key is missing - // entirely — a PRESENT scalar still goes through Go's weak-decode wrap, same as - // schema_paths above. Verified empirically: - // `[db.seed]\nenabled = true\nsql_paths = 42` → `["42"]`, not `["seed.sql"]`. + // The absent-key default (`["seed.sql"]`) only applies when the key is missing entirely; + // a present scalar still goes through the weak-decode wrap, same as schema_paths above. const dir = withConfig(["[db.seed]", "enabled = true", "sql_paths = 42", ""].join("\n")); return read(dir).pipe( Effect.tap((v) => @@ -650,11 +591,8 @@ describe("readDbToml", () => { it.effect( "aborts the whole config load on a non-scalar db.migrations.schema_paths element (Go mapstructure UnconvertibleTypeError)", () => { - // Unlike a bool/number (weakly coerced above), a nested array/table is - // mapstructure's `UnconvertibleTypeError`, which fails `UnmarshalExact` entirely - // rather than dropping just that element. Verified empirically against - // verified empirically: `schema_paths = [[]]` fails config load with exactly this - // message, never resolving to an empty/partial glob list. + // Unlike a bool/number (weakly coerced above), a nested array/table fails the whole + // config load rather than dropping just that element. const dir = withConfig(["[db.migrations]", "schema_paths = [[]]", ""].join("\n")); return read(dir).pipe( Effect.exit, @@ -676,9 +614,6 @@ describe("readDbToml", () => { it.effect( "aborts the whole config load on a table db.migrations.schema_paths element, reporting every bad index (Go mapstructure parity)", () => { - // Verified empirically: a second bad entry is reported - // alongside the first (mapstructure aggregates every `UnmarshalExact` error from - // the same decode call), and an inline table decodes as `map[string]interface {}`. const dir = withConfig( ["[db.migrations]", 'schema_paths = ["schemas/*.sql", { path = "x.sql" }]', ""].join("\n"), ); @@ -723,17 +658,6 @@ describe("readDbToml", () => { it.effect( "aggregates unconvertible-entry issues from BOTH db.seed.sql_paths and db.migrations.schema_paths in one error (Go UnmarshalExact single-pass parity, review CLI-1958)", () => { - // `UnmarshalExact` decodes the WHOLE config in a SINGLE mapstructure pass: - // `decodeStructFromMap`'s per-field loop never stops at the first field's error — - // it visits every field, collects every error, then joins them all together - // at the end. So a config invalid in BOTH `Glob` fields reports BOTH, not just - // whichever field is checked first. Verified empirically - // (`config.Load` with `sql_paths = [[]]` + `schema_paths = [[]]`): the single - // returned error contains both lines, `db.migrations.schema_paths[0]` BEFORE - // `db.seed.sql_paths[0]` — `db` struct declares `Migrations` before `Seed`, - // so mapstructure visits (and therefore reports) - // `schema_paths` first regardless of which field this reader happens to resolve - // first internally. const dir = withConfig( ["[db.seed]", "sql_paths = [[]]", "", "[db.migrations]", "schema_paths = [[]]", ""].join( "\n", @@ -752,8 +676,6 @@ describe("readDbToml", () => { "'db.seed.sql_paths[0]' expected type 'string', got unconvertible type '[]interface {}'"; expect(message).toContain(schemaIssue); expect(message).toContain(seedIssue); - // Both issues in ONE combined error, schema_paths first (Go's struct - // field declaration order), not two separate failures. expect(message.indexOf(schemaIssue)).toBeLessThan(message.indexOf(seedIssue)); } rmSync(dir, { recursive: true, force: true }); @@ -766,8 +688,6 @@ describe("readDbToml", () => { it.effect( "an explicit remote db.migrations.schema_paths beats SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS", () => { - // Go applies each matched-remote key via v.Set (override tier) above AutomaticEnv, - // so an explicit remote value wins over the env var. const ref = "schmschmschmschmschm"; const previous = process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"]; process.env["SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS"] = "env-only.sql"; @@ -879,8 +799,6 @@ describe("readDbToml", () => { }); it.effect("SUPABASE_DB_MIGRATIONS_ENABLED still wins when the remote block omits it", () => { - // Control: the env override is suppressed only for keys the matched block explicitly - // set; a block that omits db.migrations.enabled leaves the env override in force. const ref = "abcdefghijklmnopqrst"; const previous = process.env["SUPABASE_DB_MIGRATIONS_ENABLED"]; process.env["SUPABASE_DB_MIGRATIONS_ENABLED"] = "false"; @@ -902,10 +820,6 @@ describe("readDbToml", () => { }); it.effect("collapses. and .. in relative db.migrations.schema_paths like Go's path.Join", () => { - // Go prefixes each relative pattern with `path.Join("supabase", pattern)`, - // which runs `path.Clean` — same helper `db.seed.sql_paths` - // uses above (`resolveSeedSqlPath`), so `./schemas/a.sql` and `schemas/a.sql` - // resolve to the identical string instead of aliasing as two different glob patterns. const dir = withConfig( [ "[db.migrations]", @@ -996,9 +910,6 @@ describe("readDbToml", () => { ); it.effect("an explicit remote experimental.pgdelta.enabled beats its SUPABASE_* env var", () => { - // Go's mergeRemoteConfig applies EVERY matched-block key via v.Set (above AutomaticEnv), - // not just db/seed — so a remote experimental.pgdelta.enabled wins - // over SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED. const ref = "abcdefghijklmnopqrst"; const previous = process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"]; process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"] = "false"; @@ -1028,8 +939,6 @@ describe("readDbToml", () => { }); it.effect("SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED still wins when the block omits pgdelta", () => { - // Control: the env override is suppressed only for keys the matched block explicitly set; - // a block that omits experimental.pgdelta.enabled leaves the env override in force. const ref = "abcdefghijklmnopqrst"; const previous = process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"]; process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"] = "true"; @@ -1051,9 +960,6 @@ describe("readDbToml", () => { }); it.effect("an explicit remote auth.enabled beats its SUPABASE_AUTH_ENABLED env var", () => { - // Same v.Set-above-AutomaticEnv precedence as db.migrations.enabled / pgdelta.enabled, - // but for auth.enabled specifically (CLI-1878): a matched remote - // block's auth.enabled must win over SUPABASE_AUTH_ENABLED. const ref = "abcdefghijklmnopqrst"; const previous = process.env["SUPABASE_AUTH_ENABLED"]; process.env["SUPABASE_AUTH_ENABLED"] = "true"; @@ -1083,8 +989,6 @@ describe("readDbToml", () => { }); it.effect("SUPABASE_AUTH_ENABLED still wins when the remote block omits auth.enabled", () => { - // Control: the env override is suppressed only for keys the matched block explicitly - // set; a block that omits auth.enabled leaves the env override in force. const ref = "abcdefghijklmnopqrst"; const previous = process.env["SUPABASE_AUTH_ENABLED"]; process.env["SUPABASE_AUTH_ENABLED"] = "false"; @@ -1108,11 +1012,9 @@ describe("readDbToml", () => { it.effect( "an explicit remote experimental.webhooks.enabled beats its SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED env var", () => { - // Same v.Set-above-AutomaticEnv precedence as auth.enabled/pgdelta.enabled: - // a matched remote block's experimental.webhooks.enabled must win - // over SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED. Without the fix, the suppressed env value - // (false) would win instead, and the merged [experimental.webhooks] section (present via - // the remote block) would then fail validation ("Webhooks cannot be deactivated"). + // Without this precedence, the suppressed env value would win, and the merged + // [experimental.webhooks] section (present via the remote block) would then fail + // validation ("Webhooks cannot be deactivated"). const ref = "abcdefghijklmnopqrst"; const previous = process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"]; process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = "false"; @@ -1144,10 +1046,8 @@ describe("readDbToml", () => { it.effect( "SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED still wins when the remote block omits webhooks", () => { - // Control: the env override is suppressed only for keys the matched block explicitly - // set; a block that omits experimental.webhooks leaves the env override in force — a - // base [experimental.webhooks] section (present, default true) flipped off by the env - // var still fails Go's "cannot be deactivated" validation. + // A base [experimental.webhooks] section (present, default true) flipped off by the env + // var still fails the "cannot be deactivated" validation. const ref = "abcdefghijklmnopqrst"; const previous = process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"]; process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = "false"; @@ -1181,13 +1081,9 @@ describe("readDbToml", () => { it.effect( "ignores a malformed SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED when [experimental.webhooks] is absent", () => { - // Verified empirically (config.Load with an in-memory fs, - // no [experimental.webhooks] section, env override set to a non-boolean string): Go's - // Load() succeeds and Experimental.Webhooks stays nil — the env override is never applied - // because the key is only "known" to viper (and thus AutomaticEnv-bindable) when the - // section is declared, unlike experimental.pgdelta.enabled (always known via the Eject - // template merged into defaults). Before the presence gate, this reader parsed the env - // override unconditionally and failed the whole config load on the bogus value. + // The env override only applies when [experimental.webhooks] is declared (unlike + // experimental.pgdelta.enabled, always known via defaults); a malformed value must not + // fail the whole config load when the section is absent. const previous = process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"]; process.env["SUPABASE_EXPERIMENTAL_WEBHOOKS_ENABLED"] = "bogus"; const dir = withConfig(""); @@ -1208,8 +1104,7 @@ describe("readDbToml", () => { ); it.effect("matches a remote block by a SUPABASE_REMOTES_<NAME>_PROJECT_ID env override", () => { - // Viper AutomaticEnv supplies/overrides remotes.prod.project_id, so the block merges - // even with no TOML project_id (here it lifts major_version 15 over the base default). + // The env override alone is enough to match the block, with no TOML project_id at all. const ref = "abcdefghijklmnopqrst"; const previous = process.env["SUPABASE_REMOTES_PROD_PROJECT_ID"]; process.env["SUPABASE_REMOTES_PROD_PROJECT_ID"] = ref; @@ -1231,8 +1126,7 @@ describe("readDbToml", () => { }); it.effect("validates a remote project_id supplied only via env (no TOML literal)", () => { - // Without the env value the block (no TOML project_id) would fail Validate; the env - // override supplies a valid ref, so the load succeeds. + // Without the env value, the block (no TOML project_id) would fail validation. const ref = "abcdefghijklmnopqrst"; const previous = process.env["SUPABASE_REMOTES_PROD_PROJECT_ID"]; process.env["SUPABASE_REMOTES_PROD_PROJECT_ID"] = ref; @@ -1240,8 +1134,8 @@ describe("readDbToml", () => { return read(dir).pipe( Effect.tap((v) => Effect.sync(() => { - // Load succeeded (no invalid-remote error); read() without a ref leaves the base - // major_version default (17) since the block is not merged. + // read() without a ref leaves the base major_version default (17) since the block + // isn't merged. expect(v.majorVersion).toBe(17); }), ), @@ -1256,8 +1150,7 @@ describe("readDbToml", () => { }); it.effect("a remote block forcing db.seed.enabled=false beats SUPABASE_DB_SEED_ENABLED", () => { - // Go's mergeRemoteConfig v.Set(false) is an override-tier value above AutomaticEnv, - // so a remote that omits db.seed.enabled stays unseeded even with the env var set. + // A remote block that omits db.seed.enabled stays unseeded even with the env var set. const ref = "abcdefghijklmnopqrst"; const previous = process.env["SUPABASE_DB_SEED_ENABLED"]; process.env["SUPABASE_DB_SEED_ENABLED"] = "true"; @@ -1279,7 +1172,6 @@ describe("readDbToml", () => { }); it.effect("SUPABASE_DB_SEED_ENABLED still wins on the local path (no remote force)", () => { - // Negative control: with no matched remote block, the env override applies normally. const previous = process.env["SUPABASE_DB_SEED_ENABLED"]; process.env["SUPABASE_DB_SEED_ENABLED"] = "false"; const dir = withConfig(["[db.seed]", "enabled = true", ""].join("\n")); @@ -1324,8 +1216,6 @@ describe("readDbToml", () => { }); it.effect("fails with DbConfigLoadError when config.toml is malformed", () => { - // Go's LoadConfig returns the decode error and aborts, rather than silently - // running against the default local database (Codex P2 / config parity). const dir = withConfig("[db]\nport = [unterminated"); return read(dir).pipe( Effect.exit, @@ -1393,8 +1283,6 @@ describe("readDbToml", () => { }); it.effect("forces db.seed.enabled false when the matched remote block omits it", () => { - // Go's mergeRemoteConfig forces db.seed.enabled=false when the - // matched remote block itself doesn't set it — even if the base config enables it. const dir = withConfig( [ 'project_id = "base"', @@ -1441,7 +1329,6 @@ describe("readDbToml", () => { }); it.effect("rejects two remote blocks with the same project_id (any command)", () => { - // Go's config.Load aborts on duplicate project_id regardless of ref. const dir = withConfig( [ "[remotes.a]", @@ -1467,7 +1354,7 @@ describe("readDbToml", () => { }); it.effect("rejects an invalid [edge_runtime] deno_version", () => { - // Go's config.Validate aborts on deno_version other than 1/2. + // Valid values are 1 and 2. const dir = withConfig(["[edge_runtime]", "deno_version = 3", ""].join("\n")); return read(dir).pipe( Effect.exit, @@ -1516,9 +1403,6 @@ describe("readDbToml", () => { }); it.effect("rejects invalid [experimental.pgdelta] format_options JSON during load", () => { - // Go's config.Validate aborts with this exact message when format_options is - // non-empty but not valid JSON, - // before any shadow/catalog container runs. const dir = withConfig('[experimental.pgdelta]\nformat_options = "not-json"\n'); return read(dir).pipe( Effect.exit, @@ -1548,10 +1432,7 @@ describe("readDbToml", () => { }); it.effect("rejects an invalid [storage.buckets.<name>] during load", () => { - // Go's config.Validate runs ValidateBucketName over every bucket key on load, - // aborting with this exact message - // before any db command — the trailing `(...)` is the regex - // source. `#` is outside bucketNamePattern, so this name is rejected. + // `#` is outside the allowed bucket-name characters, so this name is rejected. const dir = withConfig('[storage.buckets."bad#name"]\n'); return read(dir).pipe( Effect.exit, @@ -1561,9 +1442,7 @@ describe("readDbToml", () => { if (Exit.isFailure(exit)) { const json = JSON.stringify(exit.cause); expect(json).toContain("DbConfigLoadError"); - // Prose part is backslash-free, so safe to assert through JSON.stringify; - // the trailing `(<regex source>)` is built from the pattern's `.source`, - // guaranteeing it byte-matches `bucketNamePattern.String()`. + // Prose part is backslash-free, so safe to assert through JSON.stringify. expect(json).toContain( "Invalid Bucket name: bad#name. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed.", ); @@ -1575,9 +1454,7 @@ describe("readDbToml", () => { }); it.effect("rejects an invalid [functions.<slug>] during load", () => { - // Go's config.Validate runs ValidateFunctionSlug over every functions key on load, - // aborting with this exact message. - // `123` starts with a digit → rejected by `^[A-Za-z][A-Za-z0-9_-]*$`. + // `123` starts with a digit, rejected by `^[A-Za-z][A-Za-z0-9_-]*$`. const dir = withConfig("[functions.123]\n"); return read(dir).pipe( Effect.exit, @@ -1605,9 +1482,8 @@ describe("readDbToml", () => { }); it.effect("accepts an underscore bucket name like Go's permissive pattern", () => { - // Go's bucketNamePattern uses `\w` (includes `_`) and is not case-restricted - // despite the prose, so `Bad_Name` actually passes — match the regex, not the - // message text. + // The bucket-name pattern uses `\w` (includes `_`) and is not case-restricted despite the + // prose, so `Bad_Name` actually passes: match the regex, not the message text. const dir = withConfig("[storage.buckets.Bad_Name]\n"); return read(dir).pipe( Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), @@ -1615,9 +1491,7 @@ describe("readDbToml", () => { }); it.effect("rejects an unparseable [storage.buckets.<name>].file_size_limit during load", () => { - // Go's config.Load decodes every bucket's file_size_limit via the sizeInBytes - // decode hook unconditionally, so a malformed value must - // fail config load itself — not only later, deep inside `seedBucketsRun`, + // A malformed value must fail config load itself, not only later inside `seedBucketsRun`, // where it would go unvalidated on a reused-volume restart or the already-running // short-circuit. const dir = withConfig('[storage.buckets.avatars]\nfile_size_limit = "bogus"\n'); @@ -1648,8 +1522,7 @@ describe("readDbToml", () => { }); it.effect("parses [api] auto_expose_new_tables string with Go bool tokens (TRUE → true)", () => { - // Go decodes the *bool via strconv.ParseBool, so `TRUE`/`1`/`t` are true — not only - // the literal lowercase `true`. + // `TRUE`/`1`/`t` are also accepted as true, not just lowercase `true`. const dir = withConfig('[api]\nauto_expose_new_tables = "TRUE"\n'); return read(dir).pipe( Effect.tap((v) => @@ -1675,7 +1548,6 @@ describe("readDbToml", () => { }); it.effect("rejects a malformed [api] auto_expose_new_tables during load", () => { - // Go's UnmarshalExact fails the load on a non-bool string rather than coercing. const dir = withConfig('[api]\nauto_expose_new_tables = "maybe"\n'); return read(dir).pipe( Effect.exit, @@ -1694,7 +1566,6 @@ describe("readDbToml", () => { }); it.effect("honors SUPABASE_API_AUTO_EXPOSE_NEW_TABLES env override (AutomaticEnv)", () => { - // viper AutomaticEnv overrides the TOML value; `1` decodes to true. const dir = withConfig("[api]\nauto_expose_new_tables = false\n"); const saved = process.env["SUPABASE_API_AUTO_EXPOSE_NEW_TABLES"]; process.env["SUPABASE_API_AUTO_EXPOSE_NEW_TABLES"] = "1"; @@ -1715,7 +1586,6 @@ describe("readDbToml", () => { }); it.effect("honors SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED / _DECLARATIVE_SCHEMA_PATH env", () => { - // Go's viper AutomaticEnv overrides TOML for experimental.pgdelta.* before validation. const dir = withConfig(undefined); const savedEnabled = process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"]; const savedPath = process.env["SUPABASE_EXPERIMENTAL_PGDELTA_DECLARATIVE_SCHEMA_PATH"]; @@ -1743,8 +1613,6 @@ describe("readDbToml", () => { }); it.effect("expands an env() indirection in the PGDELTA_DECLARATIVE_SCHEMA_PATH override", () => { - // Go decodes the AutomaticEnv override through LoadEnvHook, so an - // env(VAR) indirection resolves before the supabase/ join — not stored literally. const dir = withConfig(undefined); const savedEnabled = process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"]; const savedPath = process.env["SUPABASE_EXPERIMENTAL_PGDELTA_DECLARATIVE_SCHEMA_PATH"]; @@ -1835,9 +1703,7 @@ describe("readDbToml", () => { }); it.effect("fails with DbConfigLoadError when config.toml is present but unreadable", () => { - // Go's mergeFileConfig swallows only os.ErrNotExist; every other read error aborts - // rather than silently running against the default local database (Codex P2 parity). - // A directory at the config.toml path yields a non-NotFound PlatformError on read. + // A directory at the config.toml path yields a non-NotFound read error. const dir = mkdtempSync(join(tmpdir(), "db-toml-")); mkdirSync(join(dir, "supabase", "config.toml"), { recursive: true }); return read(dir).pipe( @@ -1915,9 +1781,6 @@ describe("readDbToml", () => { }); it.effect("expands env(VAR) in db.seed.sql_paths entries before supabase-prefixing", () => { - // Go's LoadEnvHook expands env(VAR) on every string element of db.seed.sql_paths - // during unmarshal, before resolve() prefixes relative patterns — so the glob is - // the expanded value, not the literal `supabase/env(...)`. process.env["SEED_SQL"] = "custom/data.sql"; const dir = withConfig(["[db.seed]", 'sql_paths = ["env(SEED_SQL)"]', ""].join("\n")); return read(dir).pipe( @@ -1946,9 +1809,6 @@ describe("readDbToml", () => { }); it.effect("expands an env() indirection in SUPABASE_DB_SEED_ENABLED (Go LoadEnvHook)", () => { - // Go decodes the AutomaticEnv override through LoadEnvHook before the bool parse, - // so `env(SEED_ON)` resolves to SEED_ON's value rather - // than failing the load on a literal `env(...)` bool. process.env["SUPABASE_DB_SEED_ENABLED"] = "env(SEED_ON)"; process.env["SEED_ON"] = "false"; const dir = withConfig(["[db.seed]", "enabled = true", ""].join("\n")); @@ -1996,8 +1856,6 @@ describe("readDbToml", () => { it.effect( "expands env(VAR) for the top-level project_id (Go config.Load before Docker IDs)", () => { - // Go expands `project_id` via LoadEnvHook before deriving local container names, - // so a raw `env(...)` must not leak into `supabase_db_env_PROJECT_ID_`. process.env["PROJECT_REF"] = "abcdefghijklmnopqrst"; const dir = withConfig(['project_id = "env(PROJECT_REF)"', ""].join("\n")); return read(dir).pipe( @@ -2013,11 +1871,9 @@ describe("readDbToml", () => { ); it.effect("does not merge a remote block whose project_id is a TOML env() literal", () => { - // Go's in-load matching reads remotes.<name>.project_id via v.GetString, - // which returns the RAW literal `env(STAGING_REF)` — LoadEnvHook only expands it - // during the later UnmarshalExact. So the block is NOT selected by its expanded ref and - // does not merge (major_version stays the base 15), while Validate over the decoded, - // expanded field still passes the load. + // Remote matching happens on the raw `env(...)` literal, before expansion, so this block is + // never selected by its expanded ref (major_version stays the base 15) even though + // validation over the expanded field still passes. process.env["STAGING_REF"] = "stagingrefstagingref"; const dir = withConfig( [ @@ -2034,7 +1890,7 @@ describe("readDbToml", () => { return readRef(dir, "stagingrefstagingref").pipe( Effect.tap((v) => Effect.sync(() => { - expect(v.majorVersion).toBe(15); // block not merged: matched on the raw env() literal + expect(v.majorVersion).toBe(15); delete process.env["STAGING_REF"]; rmSync(dir, { recursive: true, force: true }); }), @@ -2043,7 +1899,6 @@ describe("readDbToml", () => { }); it.effect("rejects an env-backed remote project_id that expands to nothing", () => { - // An unset env() expands to the literal `env(...)`, which fails Go's ref pattern. delete process.env["MISSING_REF"]; const dir = withConfig(["[remotes.staging]", 'project_id = "env(MISSING_REF)"', ""].join("\n")); return read(dir).pipe( @@ -2089,8 +1944,6 @@ describe("readDbToml", () => { }); it.effect("warns (does not fail) for an unset S3 env on an OrioleDB project", () => { - // Go's assertEnvLoaded prints `WARN: environment variable is unset: <NAME>` to - // stderr for an S3 value still holding an unexpanded env(...), and returns nil. delete process.env["S3_KEY"]; const writes: Array<string> = []; const original = process.stderr.write.bind(process.stderr); @@ -2111,7 +1964,6 @@ describe("readDbToml", () => { return read(dir).pipe( Effect.tap((v) => Effect.sync(() => { - // Config load succeeds (warning only), and the orioledb version is parsed. expect(Option.getOrNull(v.orioledbVersion)).toBe("15.1.0.55"); expect(writes.join("")).toContain("WARN: environment variable is unset: S3_KEY"); process.stderr.write = original; @@ -2124,10 +1976,9 @@ describe("readDbToml", () => { it.effect( "warnOnUnresolvedEnv: false suppresses the S3 env WARN (review: Codex, PR #6022)", () => { - // `start`/`db start`'s fresh-volume bootstrap reads this same config.toml more - // than once per invocation (an earlier, authoritative preflight call already - // warned) — internal re-reads pass `warnOnUnresolvedEnv: false` so Go's - // exactly-once `flags.LoadConfig` WARN isn't printed a second/third time. + // `start`/`db start`'s fresh-volume bootstrap reads this same config.toml more than once + // per invocation; internal re-reads pass `warnOnUnresolvedEnv: false` so the warning isn't + // printed a second/third time. delete process.env["S3_KEY_QUIET"]; const writes: Array<string> = []; const original = process.stderr.write.bind(process.stderr); @@ -2166,9 +2017,6 @@ describe("readDbToml", () => { ); it.effect("keeps the literal password when its env var is unset/empty", () => { - // Go's LoadEnvHook only substitutes when len(os.Getenv(name)) > 0; otherwise it - // preserves the literal string. Password is a plain string field, so an - // unresolved env() ref stays literal (it is not validated like the ports). delete process.env["DB_UNSET"]; const dir = withConfig(["[db]", 'password = "env(DB_UNSET)"', ""].join("\n")); return read(dir).pipe( @@ -2184,8 +2032,6 @@ describe("readDbToml", () => { it.effect( "fails when a present port is non-numeric, out of range, or an unresolved env()", () => { - // Go decodes [db].port into uint16 after LoadEnvHook; a present value that cannot - // unmarshal aborts config loading rather than silently defaulting to 54322. delete process.env["DB_UNSET"]; const cases = ['port = "abc"', "port = 70000", "port = -1", 'port = "env(DB_UNSET)"']; return Effect.forEach(cases, (line) => { @@ -2288,9 +2134,7 @@ describe("readDbToml", () => { }); it.effect("fails when a project .env file exists but cannot be read", () => { - // Go's loadEnvIfExists swallows only os.ErrNotExist; any other read error - // aborts rather than hiding a broken env-backed config. A directory at the. - // env path yields a non-NotFound read error. + // A directory at the .env path yields a non-NotFound read error. const dir = withConfig(["[db]", "port = 5000", ""].join("\n")); mkdirSync(join(dir, "supabase", ".env"), { recursive: true }); return read(dir).pipe( @@ -2324,8 +2168,7 @@ describe("readDbToml", () => { Effect.sync(() => { expect(v.port).toBe(6000); expect(v.shadowPort).toBe(6001); - // db.password is tagged `json:"-"` in Go, so it is NOT bound from - // SUPABASE_DB_PASSWORD — the local password stays the config value. + // The password is excluded from SUPABASE_DB_* overrides; it stays the config value. expect(v.password).toBe("hunter2"); for (const [k, val] of Object.entries({ SUPABASE_DB_PORT: prev.PORT, @@ -2342,7 +2185,6 @@ describe("readDbToml", () => { }); it.effect("does not source the local password from SUPABASE_DB_PASSWORD", () => { - // Go's db.Password is json:"-" — not env-bound; the local default is "postgres". const prev = process.env["SUPABASE_DB_PASSWORD"]; process.env["SUPABASE_DB_PASSWORD"] = "remote-secret"; const dir = withConfig(["[db]", "port = 5000", ""].join("\n")); @@ -2359,10 +2201,6 @@ describe("readDbToml", () => { }); it.effect("rejects db.major_version = 0 with Go's missing-required message", () => { - // Divergence #1 fix: this used to fall through to the generic - // "Failed reading config: Invalid db.major_version: 0." message (the same branch that - // catches an unsupported value like 16) — `validateResolvedConfig`'s dedicated `0` case - // now matches `Missing required field in config: db.major_version`. const dir = withConfig(["[db]", "major_version = 0", ""].join("\n")); return read(dir).pipe( Effect.exit, @@ -2427,8 +2265,6 @@ describe("readDbToml", () => { }); it.effect("rejects a non-integer db.major_version string instead of truncating it", () => { - // Go decodes major_version into a uint after LoadEnvHook; `17foo` fails the parse - // rather than being truncated to 17 by a parseInt-style read. const dir = withConfig(["[db]", 'major_version = "17foo"', ""].join("\n")); return read(dir).pipe( Effect.exit, @@ -2477,8 +2313,6 @@ describe("readDbToml", () => { }); it.effect("honors SUPABASE_EDGE_RUNTIME_DENO_VERSION over the TOML value", () => { - // Go binds this via viper AutomaticEnv before Validate, so an env override of 1 - // selects the deno1 edge-runtime image even when the TOML omits/sets a different value. const prev = process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"]; process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = "1"; const dir = withConfig(["[edge_runtime]", "deno_version = 2", ""].join("\n")); @@ -2495,8 +2329,6 @@ describe("readDbToml", () => { }); it.effect("rejects a non-integer edge_runtime.deno_version string instead of defaulting", () => { - // Go decodes deno_version into a uint before Validate; `2foo` fails the parse rather - // than being read as 2 / falling through to the default Deno 2 image. const dir = withConfig(["[edge_runtime]", 'deno_version = "2foo"', ""].join("\n")); return read(dir).pipe( Effect.exit, @@ -2515,8 +2347,6 @@ describe("readDbToml", () => { }); it.effect("rejects a malformed [remotes.*] project_id on every load (Go Validate)", () => { - // Go's Validate requires every remote project_id to match ^[a-z]{20}$, failing even - // local/direct commands. const dir = withConfig(["[remotes.staging]", 'project_id = "staging"', ""].join("\n")); return read(dir).pipe( Effect.exit, @@ -2541,7 +2371,7 @@ describe("readDbToml", () => { return read(dir).pipe( Effect.tap((v) => Effect.sync(() => { - expect(v.majorVersion).toBe(17); // loads successfully (no remote selected) + expect(v.majorVersion).toBe(17); rmSync(dir, { recursive: true, force: true }); }), ), @@ -2582,11 +2412,8 @@ describe("readDbToml", () => { }); it.effect("loadProjectEnv is pure: returns every key and never touches process.env", () => { - // The loader is a pure read: it returns all project-.env keys in the map (config - // env() resolution + the SUPABASE_YES / db-password readers use the map) and does - // NOT mutate process.env. Applying to process.env is the separate, opt-in - // applyProjectEnv (below), so a mere `load` for SUPABASE_YES has no global - // side effect. + // Applying to process.env is the separate, opt-in `applyProjectEnv` below, so a mere load + // for SUPABASE_YES has no global side effect. const saved: Record<string, string | undefined> = {}; for (const k of ["SUPABASE_INTERNAL_IMAGE_REGISTRY", "SUPABASE_PROJECT_ID", "SUPABASE_ENV"]) { saved[k] = process.env[k]; @@ -2601,11 +2428,10 @@ describe("readDbToml", () => { return loadEnv(dir).pipe( Effect.tap((env) => Effect.sync(() => { - // The returned map carries all keys. expect(env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBe("my-mirror.example.com"); expect(env["SUPABASE_PROJECT_ID"]).toBe("envonlyref"); expect(env["SUPABASE_ENV"]).toBe("staging"); - // ...but process.env is untouched, including the allowlisted registry key. + // process.env stays untouched, including the allowlisted registry key. expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); expect(process.env["SUPABASE_PROJECT_ID"]).toBeUndefined(); expect(process.env["SUPABASE_ENV"]).toBeUndefined(); @@ -2622,13 +2448,10 @@ describe("readDbToml", () => { it.effect( "applyProjectEnv sets only the allowlisted keys in-scope, never overrides, reverts on close", () => { - // Go's loadNestedEnv os.Setenv's the project .env, but its root globals - // (project-ref, SUPABASE_ENV, workdir/profile) are resolved from the shell - // BEFORE loadNestedEnv. Our resolvers read process.env lazily, so we apply only - // the allowlisted `SUPABASE_INTERNAL_IMAGE_REGISTRY` (the process.env-only - // reader): a .env project-ref must not retarget the - // lazy ref/pooler resolvers, and a .env SUPABASE_ENV must not switch the - // env-file set. + // Our resolvers read process.env lazily, so only the allowlisted + // `SUPABASE_INTERNAL_IMAGE_REGISTRY` (the process.env-only reader) is applied: a .env + // project-ref must not retarget the lazy ref/pooler resolvers, and a .env SUPABASE_ENV + // must not switch the env-file set. const saved: Record<string, string | undefined> = {}; for (const k of ["SUPABASE_INTERNAL_IMAGE_REGISTRY", "SUPABASE_PROJECT_ID", "SUPABASE_ENV"]) { saved[k] = process.env[k]; @@ -2652,7 +2475,7 @@ describe("readDbToml", () => { // After the scope closes the applied keys are reverted (no test-worker leak). expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); - // An existing process.env value is never overridden, and is NOT deleted on close. + // An existing process.env value is never overridden, and is not deleted on close. process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"] = "shell-wins.example.com"; yield* Effect.scoped(applyProjectEnv(loaded)); expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBe("shell-wins.example.com"); @@ -2670,8 +2493,6 @@ describe("readDbToml", () => { ); it.effect("ignores a [db.pooler] connection_string in config.toml (Go reads .temp only)", () => { - // The Go config field is tagged `toml:"-"`, so a connection_string in config.toml - // is never honored; only supabase/.temp/pooler-url counts. const dir = withConfig( [ "[db.pooler]", @@ -2731,7 +2552,6 @@ describe("readDbToml [experimental.pgdelta]", () => { Effect.tap((v) => Effect.sync(() => { expect(v.pgDelta.enabled).toBe(true); - // Go's config.resolve prefixes a relative path with SupabaseDirPath. expect(Option.getOrNull(v.pgDelta.declarativeSchemaPath)).toBe( join("supabase", "db", "decl"), ); @@ -2828,7 +2648,6 @@ describe("readDbToml auth.Enabled validation (Go config.Validate parity)", () => ), ); - // The reviewer's case: passkey enabled requires a valid [auth.webauthn]. it.effect("rejects passkey enabled without [auth.webauthn]", () => failsWith( ["[auth.passkey]", "enabled = true"], @@ -2857,10 +2676,7 @@ describe("readDbToml auth.Enabled validation (Go config.Validate parity)", () => ]), ); it.effect("accepts a comma-separated rp_origins string instead of rejecting it as missing", () => - // Go decodes `rp_origins` (a `[]string`) through the same `StringToSliceHookFunc(",")` - // mapstructure hook as every other `[]string` field, so a raw string (not just a literal - // TOML array) must split, not read as absent — matches local-config-values.ts's own - // resolveGotruePasskeyWebauthn/strToArr handling of this identical field. + // Matches `local-config-values.ts`'s own handling of this identical field. succeeds([ "[auth.passkey]", "enabled = true", @@ -2997,8 +2813,6 @@ describe("readDbToml auth.Enabled validation (Go config.Validate parity)", () => ), ); - // --- Follow-up parity fixes (Codex re-review) --- - it.effect("defaults [auth.email.smtp] enabled=true when the table omits enabled (Go merge)", () => failsWith( ["[auth.email.smtp]", 'user = "u"'], @@ -3043,8 +2857,8 @@ describe("readDbToml auth.Enabled validation (Go config.Validate parity)", () => }); describe("readDbToml encrypted secret decryption (Go DecryptSecretHookFunc parity)", () => { - // Go decrypts every config.Secret during decode; an undecryptable `encrypted:` value - // anywhere in config.toml aborts `config.Load` with `failed to parse config: <error>`. + // An undecryptable `encrypted:` value anywhere in config.toml aborts the load with + // `failed to parse config: <error>`. const expectFails = (lines: ReadonlyArray<string>, message: string) => Effect.gen(function* () { const dir = withConfig(lines.join("\n")); @@ -3085,8 +2899,8 @@ describe("readDbToml encrypted secret decryption (Go DecryptSecretHookFunc parit expectLoads(["[db]", 'root_key = "env(SOME_UNSET_ROOT_KEY)"']), ); it.effect("does NOT decrypt a non-secret string that starts with encrypted:", () => - // Go's hook only runs while decoding into `config.Secret`; a non-secret field like an - // email-template subject stays plain text, so `db push`/`reset`/`start` must not abort. + // A non-secret field like an email-template subject stays plain text; the load must not + // abort on it. expectLoads([ "[auth.email.template.invite]", 'subject = "encrypted: your invite"', @@ -3112,8 +2926,8 @@ describe("readDbToml encrypted secret decryption (Go DecryptSecretHookFunc parit ), ); it.effect("fails on an undecryptable Secret inside a [remotes.*] block", () => - // Go decodes every remote block into the same struct, so an undecryptable secret in any - // remote (matched or not) aborts the load. + // Every remote block decodes into the same struct, so an undecryptable secret aborts the + // load even when unmatched. expectFails( [ "[remotes.preview]", @@ -3127,9 +2941,8 @@ describe("readDbToml encrypted secret decryption (Go DecryptSecretHookFunc parit }); describe("readDbToml non-scalar config booleans (Go UnmarshalExact parity)", () => { - // `UnmarshalExact` fails to decode a bool field given an array/inline-table value, - // aborting `LoadConfig` before any destructive work. A present non-scalar must not fall - // through to the schema default (which would let `db reset` prompt + drop schemas). + // A present non-scalar boolean must fail the config load rather than falling through to the + // schema default, which would let `db reset` prompt and drop schemas. const failsInvalid = (lines: ReadonlyArray<string>, field: string) => Effect.gen(function* () { const dir = withConfig(lines.join("\n")); @@ -3150,8 +2963,6 @@ describe("readDbToml non-scalar config booleans (Go UnmarshalExact parity)", () describe("readDbToml empty project_id (Go config.Validate parity)", () => { it.effect("rejects a present-but-empty top-level project_id", () => { - // Go keeps the empty override and `config.Validate` fails "Missing required field in - // config: project_id" before any destructive command runs. const dir = withConfig('project_id = ""\n'); return read(dir).pipe( Effect.exit, @@ -3199,8 +3010,6 @@ describe("readDbToml [analytics] validation (Go config.Validate parity)", () => rmSync(dir, { recursive: true, force: true }); }); - // `LogflareBackend.UnmarshalText` is a decode-time enum: it fires whenever - // `backend` is set, even when analytics is disabled. it.effect("rejects an unknown analytics.backend regardless of enabled", () => failsWith( ["[analytics]", "enabled = false", 'backend = "clickhouse"'], @@ -3334,11 +3143,6 @@ describe("readDbToml SUPABASE_PROJECT_ID override (Go AutomaticEnv parity)", () it.effect( "prefers a matched [remotes.<ref>]'s project_id over a conflicting SUPABASE_PROJECT_ID", () => { - // Regression (review: PRRT_kwDOErm0O86XHGDL) — `mergeRemoteConfig` installs the - // matched block's OWN `project_id` at viper's override tier, above `AutomaticEnv`; - // that block is selected BECAUSE its - // `project_id` equals the resolved ref, so it must win even when an unrelated - // `SUPABASE_PROJECT_ID` is set to something else entirely. const previous = process.env["SUPABASE_PROJECT_ID"]; process.env["SUPABASE_PROJECT_ID"] = "local"; const ref = "abcdefghijklmnopqrst"; @@ -3389,9 +3193,8 @@ describe("readDbToml remoteOverrideKeys — auth.captcha.provider / auth.email.t const ref = "abcdefghijklmnopqrst"; it.effect("tracks auth.captcha.provider when a matched remote block supplies it", () => { - // Regression (review: PRRT_kwDOErm0O86XLAYn) — `provider` is a plain string leaf, not one of - // `applyRemoteOverride`'s dynamically-keyed sections, so it must be tracked via - // `ENV_OVERRIDABLE_KEYS` like any other fixed-name field. + // `provider` is a plain string leaf, not part of a dynamically-keyed section, so it must be + // tracked via `ENV_OVERRIDABLE_KEYS` like any other fixed-name field. const dir = withConfig( [ "[auth.captcha]", @@ -3419,9 +3222,9 @@ describe("readDbToml remoteOverrideKeys — auth.captcha.provider / auth.email.t }); it.effect("tracks a matched remote block's auth.email.template.<name> leaves dynamically", () => { - // Regression (review: PRRT_kwDOErm0O86XLAYn) — `auth.email.template.<name>.*` is a - // genuinely arbitrarily-keyed map, same shape as `auth.external.<name>.*`, so it must be - // flattened dynamically instead of relying on a fixed `ENV_OVERRIDABLE_KEYS` entry. + // `auth.email.template.<name>.*` is an arbitrarily-keyed map, same shape as + // `auth.external.<name>.*`, so it must be flattened dynamically instead of relying on a + // fixed `ENV_OVERRIDABLE_KEYS` entry. const dir = withConfig( [ "[remotes.prod]", @@ -3452,8 +3255,7 @@ describe("readDbToml remoteOverrideKeys — auth.captcha.provider / auth.email.t it.effect( "tracks a matched remote block's auth.email.notification.<name> leaves dynamically", () => { - // Regression (review: PRRT_kwDOErm0O86XLAYo) — `auth.email.notification.<name>.*`'s - // sibling case, including `enabled` (a direct `envOverrideBool` throw site). + // Sibling case to auth.email.template, including a boolean leaf (`enabled`). const dir = withConfig( [ "[remotes.prod]", diff --git a/apps/cli/src/command-internal/db-config.types.ts b/apps/cli/src/command-internal/db-config.types.ts index 3e33fefe63..6307d18a0e 100644 --- a/apps/cli/src/command-internal/db-config.types.ts +++ b/apps/cli/src/command-internal/db-config.types.ts @@ -3,22 +3,18 @@ import type { PgConnInput } from "./db-connection.service.ts"; import type { DbConnType } from "./db-target-flags.ts"; /** - * The connection-resolution flags shared by `db lint`, `db advisors`, `test db` - * (and later `db reset` / `db dump`). + * The connection-resolution flags shared by `db lint`, `db advisors`, and `test db`. * - * `connType` encodes which selector flag was explicitly set by the user, derived - * from raw argv via `resolveDbTargetFlags` (Changed-first, matching - * `ParseDatabaseConfig`'s precedence): - * - "db-url" → `--db-url` was changed (read `dbUrl.value`) - * - "linked" → `--linked` was changed (Management API path) - * - "local" → `--local` was changed (explicit local path) - * - undefined → no selector was changed; resolver defaults to local + * `connType` records which selector flag the user explicitly set, derived from raw argv via + * `resolveDbTargetFlags`: + * - "db-url" → `--db-url` was set (read `dbUrl.value`) + * - "linked" → `--linked` was set (Management API path) + * - "local" → `--local` was set (explicit local path) + * - undefined → no selector was set; resolver defaults to local * - * `--db-url` / `--linked` / `--local` are mutually exclusive. - * `dnsResolver` carries the global - * `--dns-resolver` value (`utils.DNSResolver.Value`), used when the - * resolver opens its own remote connection (the linked pooler temp-role probe); - * the handler passes the same value to its primary `connect`. + * `--db-url` / `--linked` / `--local` are mutually exclusive. `dnsResolver` carries the global + * `--dns-resolver` value, used when the resolver opens its own remote connection (the linked + * pooler temp-role probe); the handler passes the same value to its primary `connect`. */ export interface DbConfigFlags { readonly dbUrl: Option.Option<string>; @@ -30,91 +26,40 @@ export interface DbConfigFlags { */ readonly resolveVaultSecrets?: boolean; /** - * The `--password` / `-p` flag value. When `Some`, it - * takes precedence over the `SUPABASE_DB_PASSWORD` env var on the linked path, - * matching the established flag-over-env precedence. Commands without a `--password` - * flag (e.g. `test db`) omit it; the resolver then falls back to env only. + * The `--password`/`-p` flag value. When `Some`, it takes precedence over the + * `SUPABASE_DB_PASSWORD` env var on the linked path. Commands without a `--password` flag + * (e.g. `test db`) omit it, and the resolver falls back to env only. */ readonly password?: Option.Option<string>; /** - * Optional explicit linked project ref override. Commands such as - * `gen types --project-id <ref>` need the linked DB resolver's temp-role and - * pooler fallback behavior without requiring the current workdir to be linked. - * The eight `db` commands that resolve a project ref (`push`, `pull`, `diff`, - * `dump`, `reset`, `lint`, `advisors`, `query`) also thread their own - * `--project-ref` flag value through here, taking effect only on the linked - * path (it does NOT imply `--linked` — those handlers reject the flag - * outright on a non-linked target instead of silently discarding it, unlike - * the env var below). `None` when the flag is unset, which preserves the - * normal `--linked` path's fallback to `.temp/project-ref`. - * - * This shares ONLY the `SUPABASE_PROJECT_ID` env var's linked-ref-resolution - * precedence (flag > env > `.temp/project-ref` file, via - * `ProjectRefResolver.loadProjectRef`) — it is NOT a full substitute for - * that env var. `SUPABASE_PROJECT_ID` also drives the LOCAL container id and - * the pg-delta project id (`resolvePgDeltaProjectId`, read from - * `cliSettings.projectId` in `db diff`/`db pull`/`db reset`), which this flag - * deliberately does NOT touch — the `db` commands' `--project-ref` only ever - * feeds the resolver above, never the local-side id derivation. + * Optional explicit linked project ref override, letting e.g. `gen types --project-id <ref>` + * reuse the linked resolver's temp-role and pooler fallback without the workdir being linked. + * Shares only `SUPABASE_PROJECT_ID`'s ref-resolution precedence (flag > env > + * `.temp/project-ref`) — it never drives the local container id or pg-delta project id the way + * that env var does. `None` preserves the normal `--linked` fallback. */ readonly linkedProjectRef?: Option.Option<string>; /** - * Marks `linkedProjectRef` as an ad-hoc remote target supplied explicitly - * (e.g. `gen types --project-id <ref>`) rather than the current linked - * workdir. The ref may belong to a different project than the cwd, so the - * resolver must NOT inherit workdir-scoped credentials or cached state: - * - it ignores the ambient `SUPABASE_DB_PASSWORD` (shell / `.env*`) so it - * always mints a temporary login role instead of handing pg-meta an - * unrelated password, and - * - on an IPv4-only network it skips the saved `.temp/pooler-url` (which - * belongs to the linked workdir) and fetches the primary pooler config - * for `ref` from the Management API instead of failing with the IPv6 - * "run supabase link" suggestion. - * Absent / false for the normal `--linked` path, which is the workdir's own - * project and may legitimately reuse those env vars and saved files. - * - * The eight `db` commands' `--project-ref` deliberately leave this unset: - * unlike `gen types --project-id`'s genuinely ad-hoc target, `db`'s - * `--project-ref` is meant to have identical workdir credential semantics to - * `SUPABASE_PROJECT_ID` — it may still reuse the ambient `SUPABASE_DB_PASSWORD` - * / `--password`, since forcing ad-hoc would silently break existing - * password-based workflows that already set `--project-ref` expecting - * `SUPABASE_PROJECT_ID`-equivalent behavior. The mismatched-pooler-url risk - * `adHocProjectRef` guards against for a genuinely different project is - * already rejected independently: `poolerConfigFromConnectionString` - * (`db-config.parse.ts`) verifies the saved `.temp/pooler-url`'s - * tenant ref matches the resolved ref before reusing it, so a stale pooler - * URL for a DIFFERENT project than the one `--project-ref` now selects fails - * loudly instead of silently connecting to the wrong project. - * - * Leaving this unset does NOT, however, confine the eight `db` commands to - * the workdir's saved `.temp/pooler-url` on an IPv4-only network: any - * explicit `linkedProjectRef` (this flag's own presence, independent of - * `adHocProjectRef`) additionally unlocks the Management API pooler-config - * fetch (`resolvePoolerConn`'s `fetchFromApi`) whenever that saved URL is - * absent or fails the tenant-ref check above — so `--project-ref` against an - * unlinked workdir (no saved pooler URL at all) still resolves an IPv4 - * pooler connection instead of dead-ending in the "run supabase link" IPv6 - * error. `ignoreSavedUrl` (skip a matching saved URL outright) stays keyed to - * `adHocProjectRef` alone, so a `db` command's own linked workdir's saved URL - * for the SAME ref is still reused with no API call. + * Marks `linkedProjectRef` as an ad-hoc remote target (e.g. `gen types --project-id <ref>`) + * rather than the current linked workdir, so the resolver must not reuse workdir-scoped + * credentials or cached state for it: it ignores the ambient `SUPABASE_DB_PASSWORD` and skips + * the saved `.temp/pooler-url`, fetching pooler config from the Management API instead. Absent + * for the normal `--linked` path, which may reuse both. */ readonly adHocProjectRef?: boolean; } /** - * A resolved Postgres connection plus whether it points at the local stack - * (`utils.IsLocalDatabase`). `isLocal` decides the pg_prove docker network/host - * rewrite in the `test db` handler, so it is computed once here. + * A resolved Postgres connection plus whether it points at the local stack. `isLocal` decides + * the pg_prove docker network/host rewrite in the `test db` handler, so it is computed once here. */ export interface ResolvedDbConfig { readonly conn: PgConnInput; readonly isLocal: boolean; /** - * The resolved linked project ref (`--linked` path only; `None` for - * `--local` / `--db-url`). Lets the caller re-read config with the ref applied - * so a matching `[remotes.<ref>]` block overrides e.g. `db.major_version` for the - * container image, matching Go's remote-merged `utils.Config` on the linked path. + * The resolved linked project ref (`--linked` path only; `None` for `--local` / `--db-url`). + * Lets the caller re-read config with the ref applied so a matching `[remotes.<ref>]` block can + * override fields like `db.major_version` for the container image. */ readonly ref?: Option.Option<string>; } diff --git a/apps/cli/src/command-internal/db-connection.errors.ts b/apps/cli/src/command-internal/db-connection.errors.ts index 793e2b28c8..49f8a507d1 100644 --- a/apps/cli/src/command-internal/db-connection.errors.ts +++ b/apps/cli/src/command-internal/db-connection.errors.ts @@ -6,17 +6,15 @@ import { } from "../shared/telemetry/error-actionability.ts"; /** - * Opening a Postgres connection failed. Mirrors `pgx`/`pgconn` connect - * failures surfaced by `utils.ConnectByConfig`. The `suggestion` carries the - * `utils.CmdSuggestion` text when the connect path sets one. + * Opening a Postgres connection failed. `suggestion` carries actionable follow-up text when the + * connect path sets one. */ export class DbConnectError extends Data.TaggedError("DbConnectError")<{ readonly message: string; readonly suggestion?: string; /** - * True when the failure was dial-level (`isDialFailure`) rather than - * a server, auth, or config error — the fresh-db bootstrap's connect retry - * keys off this field (`db-setup.ts`, #6136). + * True when the failure was dial-level rather than a server, auth, or config error; the + * fresh-db bootstrap's connect retry keys off this field. */ readonly retryable?: boolean; }> { @@ -26,8 +24,7 @@ export class DbConnectError extends Data.TaggedError("DbConnectError")<{ } /** - * Executing a SQL statement against an open connection failed. Mirrors the - * established `conn.Exec` error sites. + * Executing a SQL statement against an open connection failed. */ export class DbExecError extends Data.TaggedError("DbExecError")<{ readonly message: string; @@ -37,23 +34,20 @@ export class DbExecError extends Data.TaggedError("DbExecError")<{ */ readonly statementIndex?: number; /** - * Postgres SQLSTATE (e.g. `42P01` undefined_table), extracted from the driver - * error's `cause` chain when present. Lets callers match Go's error-code checks - * (`pgerrcode.*`) instead of fuzzy message matching — e.g. suppressing only a - * missing migration-history table, not an undefined column. + * Postgres SQLSTATE (e.g. `42P01` undefined_table), extracted from the driver error's `cause` + * chain when present. Lets callers match on error code instead of fuzzy message matching — e.g. + * suppressing only a missing migration-history table, not an undefined column. */ readonly code?: string; /** - * Postgres `Detail` field of a server ErrorResponse (`pgErr.Detail`). - * Only set for server errors that carry a non-empty detail; the migration-apply - * error context renders it on its own line, matching `ExecBatch`. + * Postgres `Detail` field of a server error response. Only set when the server reports one; + * the migration-apply error context renders it on its own line. */ readonly detail?: string; /** - * Postgres error cursor of a server ErrorResponse (`pgErr.Position`): a - * 1-based index into the failing statement. Only set when the server reported a - * position > 0. The migration-apply error context uses it to render `^` - * caret under the error position (`markError`). + * Postgres error cursor of a server error response: a 1-based index into the failing + * statement, present only when the server reports one > 0. The migration-apply error context + * renders a `^` caret under it. */ readonly position?: number; }> { @@ -63,20 +57,10 @@ export class DbExecError extends Data.TaggedError("DbExecError")<{ } /** - * A server-side `COPY (...) TO STDOUT` stream failed. Mirrors - * `copyToCSV`, where - * `conn.CopyTo` returns `failed to copy output: %w`. Raised by the driver's - * `copyToCsv`; the report handler maps a subsequent file-write failure to its - * own `failed to create output file` error (the reference implementation raises that - * one first, when it - * opens the file before copying — the TS port collects the bytes first, so the - * two messages still match on the matching failure). - * - * That "collect bytes first" ordering is also where the two sides diverge on - * disk, not just in message text — Go opens the output file (`O_TRUNC`) before - * running the query, so a failing query still leaves a file behind; TS never - * writes one. See `inspect/report/SIDE_EFFECTS.md` ("Divergence on the query - * that was in flight when `COPY` failed") for the file-residue consequences. + * A server-side `COPY (...) TO STDOUT` stream failed. The report handler maps a later + * file-write failure to its own separate error, since bytes are collected before the output + * file is opened. See `inspect/report/SIDE_EFFECTS.md`, "Divergence on the query that was in + * flight when `COPY` failed", for the on-disk consequences. */ export class DbCopyError extends Data.TaggedError("DbCopyError")<{ readonly message: string; diff --git a/apps/cli/src/command-internal/db-connection.layer.ts b/apps/cli/src/command-internal/db-connection.layer.ts index 4dbfe325ea..f75cf737a4 100644 --- a/apps/cli/src/command-internal/db-connection.layer.ts +++ b/apps/cli/src/command-internal/db-connection.layer.ts @@ -1,10 +1,7 @@ import { dbConnectionSqlPgLayer } from "./db-connection.sql-pg.layer.ts"; /** - * The active `DbConnection` layer — the single swap point for the - * Postgres driver. It re-exports the `@effect/sql-pg` layer, which is verified - * to bundle and round-trip under `bun build --compile` (CLI-1318 spike). All - * call sites import this name, not the driver, so swapping the implementation - * (e.g. to a Bun.SQL-backed layer) is a one-line change here. + * The active `DbConnection` layer. Re-exports the `@effect/sql-pg` layer; call sites import this + * name, not the driver, so swapping the implementation is a one-line change here. */ export const dbConnectionLayer = dbConnectionSqlPgLayer; diff --git a/apps/cli/src/command-internal/db-connection.service.ts b/apps/cli/src/command-internal/db-connection.service.ts index e87e21e0b8..dd43c3f086 100644 --- a/apps/cli/src/command-internal/db-connection.service.ts +++ b/apps/cli/src/command-internal/db-connection.service.ts @@ -3,9 +3,8 @@ import type { ConnectSuggestionContext } from "./connect-errors.ts"; import type { DbConnectError, DbCopyError, DbExecError } from "./db-connection.errors.ts"; /** - * Plain Postgres connection parameters, mirroring pgconn's `pgconn.Config`. - * The password is plain here; - * driver layers wrap it (e.g. `Redacted`) at the boundary. + * Plain Postgres connection parameters. The password is plain here; driver layers wrap it + * (e.g. `Redacted`) at the boundary. */ export interface PgConnInput { readonly host: string; @@ -14,65 +13,54 @@ export interface PgConnInput { readonly password: string; readonly database: string; /** - * Additional HA failover hosts beyond the primary `host`/`port`, in order. - * pgconn accepts libpq multi-host connection strings - * (`postgres://h1:5432,h2:5433/db` or `host=h1,h2 port=5432,5433`) and dials - * each in turn. `host`/`port` are the *primary* - * (`config.Host`/`config.Port`, used for `IsLocalDatabase` and `.pgpass`); these - * are the remaining `config.Fallbacks`. Absent for the common single-host case. + * Additional HA failover hosts beyond the primary `host`/`port`, in order — libpq multi-host + * connection strings (`postgres://h1:5432,h2:5433/db`) dial each in turn. Absent for the + * common single-host case. */ readonly fallbacks?: ReadonlyArray<{ readonly host: string; readonly port: number }>; /** - * libpq `options` startup parameter (Go's `pgconn.Config.RuntimeParams["options"]`). - * Legacy Supavisor pooler URLs identify the tenant via `?options=reference=<ref>` - * instead of a `<user>.<ref>` username; the driver layer must forward this so the - * connection reaches the right tenant. Empty/absent for direct and local connections. + * libpq `options` startup parameter. Legacy Supavisor pooler URLs identify the tenant via + * `?options=reference=<ref>` instead of a `<user>.<ref>` username, so the driver layer must + * forward this to reach the right tenant. Empty/absent for direct and local connections. */ readonly options?: string; /** - * Additional libpq startup `RuntimeParams` parsed from a `--db-url` (e.g. - * `search_path`, `statement_timeout`, `application_name`) — every connection-string - * setting except pgconn's `notRuntimeParams` and `options` (carried separately). Go's - * `ToPostgresURL` re-appends all of these, so pg-delta introspects with the same - * session settings. Absent when the DSN carries none. + * Additional libpq startup parameters parsed from a `--db-url` (e.g. `search_path`, + * `statement_timeout`, `application_name`), excluding `options` (carried separately), + * re-applied so pg-delta introspects with the same session settings. Absent when the DSN + * carries none. */ readonly runtimeParams?: Readonly<Record<string, string>>; /** - * libpq `sslmode` (Go's `pgconn.Config` TLS mode, parsed by `pgconn.ParseConfig` - * from a `--db-url` query string). Controls whether the driver layer negotiates - * TLS and whether it verifies the server certificate. Absent → the remote default - * (TLS without certificate verification, matching pgx's `prefer`/`require`). + * libpq `sslmode`, parsed from a `--db-url` query string. Controls whether the driver layer + * negotiates TLS and verifies the server certificate. Absent → TLS without certificate + * verification (libpq's `prefer`/`require` default). */ readonly sslmode?: string; /** - * libpq `sslrootcert` (Go's `pgconn.Config` `TLSConfig.RootCAs`, from the DSN - * or `PGSSLROOTCERT`): path to a CA bundle the driver layer loads to verify the - * server certificate. pgconn treats `sslmode=require` + a root cert as + * libpq `sslrootcert`, from the DSN or `PGSSLROOTCERT`: path to a CA bundle the driver layer + * loads to verify the server certificate. `sslmode=require` plus a root cert behaves as * `verify-ca`. Absent → system roots / no CA pinning. */ readonly sslrootcert?: string; /** - * libpq client-certificate auth (Go's `pgconn.Config` `TLSConfig.Certificates`, - * from the DSN or `PGSSLCERT`/`PGSSLKEY`/`PGSSLPASSWORD`). `sslcert`/`sslkey` are - * file paths loaded by the driver layer into the client cert; `sslpassword` - * decrypts an encrypted key. pgconn requires both `sslcert` and `sslkey` together, - * so the parser only ever sets them as a pair. + * libpq client-certificate auth, from the DSN or `PGSSLCERT`/`PGSSLKEY`/`PGSSLPASSWORD`. + * `sslcert`/`sslkey` are file paths loaded into the client cert; `sslpassword` decrypts an + * encrypted key. The parser only ever sets `sslcert`/`sslkey` as a pair. */ readonly sslcert?: string; readonly sslkey?: string; readonly sslpassword?: string; /** - * libpq `connect_timeout` in seconds (Go's `pgconn.Config.ConnectTimeout`, from - * the DSN or `PGCONNECT_TIMEOUT`). Only set when explicitly provided and > 0; the - * driver layer applies Go's default otherwise (10s remote, 2s local — see - * `ToPostgresURL`/`ConnectLocalPostgres`). + * libpq `connect_timeout` in seconds, from the DSN or `PGCONNECT_TIMEOUT`. Only set when + * explicitly provided and > 0; the driver layer applies its own default otherwise (10s remote, + * 2s local). */ readonly connectTimeoutSeconds?: number; /** - * Profile context for the connect-failure suggestion (`SetConnectSuggestion`, - * which reads the ambient `CurrentProfile` in `ConnectByUrl`). The resolver attaches - * it so the driver layer can map a refused/auth/IPv6 connect error to Go's actionable - * hint. Absent → the driver omits the suggestion (callers fall back to the generic one). + * Profile context for the connect-failure suggestion. The resolver attaches it so the driver + * layer can map a refused/auth/IPv6 connect error to an actionable hint. Absent → the driver + * omits the suggestion (callers fall back to the generic one). */ readonly suggestionContext?: ConnectSuggestionContext; } @@ -92,94 +80,58 @@ export interface DbBatchStatement { */ export interface DbSession { /** - * SQL that restores the role this session stepped down to after authenticating - * as a temp/privileged login role (`SET SESSION ROLE postgres`). Absent when no - * step-down ran. A migration's own `RESET ROLE` reverts the session to the - * login role — not `postgres` — so file runners re-assert this immediately after - * each top-level role-reverting statement, at the end of each file, and before - * CLI-owned ledger writes (supabase/cli#6236). Must stay a fixed, non-user-derived - * statement: consumers embed it verbatim in batches. + * SQL that restores the role this session stepped down to after authenticating as a + * temp/privileged login role. Absent when no step-down ran. File runners re-assert it after + * each role-reverting statement (a migration's own `RESET ROLE` reverts to the login role, not + * this one) and before CLI-owned ledger writes. Must stay a fixed, non-user-derived statement. */ readonly restoreRoleSql?: string; /** Run a single SQL statement, ignoring any returned rows. */ readonly exec: (sql: string) => Effect.Effect<void, DbExecError>; /** - * Run statements as one extended-protocol batch with a single final Sync. - * On failure, {@link DbExecError.statementIndex} is the number of - * statements that completed before the error. + * Run statements as one extended-protocol batch with a single final Sync. On failure, + * {@link DbExecError.statementIndex} is the number of statements that completed before the + * error. * - * A batch runs on its own pooled connection, which the driver checks out per - * call. Failing to acquire it, or losing it before any of the batch reaches the - * wire, raises `DbConnectError` (a connection failure, surfaced verbatim — - * not masked as an exec error), consistent with {@link queryRaw}; only a batch - * that was actually written raises `DbExecError`. + * A batch runs on its own pooled connection. Failing to acquire it, or losing it before any of + * the batch reaches the wire, raises `DbConnectError` instead of `DbExecError`, consistent + * with {@link queryRaw}. */ readonly execBatch: ( statements: ReadonlyArray<DbBatchStatement>, ) => Effect.Effect<void, DbExecError | DbConnectError>; /** - * Run a parameterized SQL query and return the result rows as plain objects - * keyed by the query's column names (snake_case is preserved — the driver - * layer applies no row-name transform, mirroring `pgxv5.CollectRows`). - * - * Used by the `inspect db` subcommands, which each embed a SQL file and render - * the rows as a Glamour table. `params` are bound positionally (`$1`, `$2`, …), - * matching `conn.Query(ctx, sql, args...)`. + * Run a parameterized SQL query and return the result rows as plain objects keyed by the + * query's column names (snake_case is preserved; no row-name transform is applied). Used by + * the `inspect db` subcommands to render rows as a Glamour table. `params` are bound + * positionally (`$1`, `$2`, …). */ readonly query: ( sql: string, params?: ReadonlyArray<unknown>, ) => Effect.Effect<ReadonlyArray<Record<string, unknown>>, DbExecError>; /** - * Whether an extension named `name` already exists in `pg_extension`, - * **regardless of which schema it lives in**. - * - * The established behavior keys "did pgTAP already exist?" off a `pgx` `OnNotice` callback (notice - * code `42710`, `duplicate_object`). That notice fires whenever - * `CREATE EXTENSION IF NOT EXISTS pgtap ...` finds the extension already - * installed — extensions are global per-database, so the schema is irrelevant. - * `@effect/sql-pg`'s `PgClient` exposes no notice hook, so the port - * detects pre-existence with this query before enabling. Querying by `extname` - * only (not `extname` + `nspname`) matches that behavior: it must not drop a pgTAP the user - * pre-installed in another schema such as `public`. + * Whether an extension named `name` already exists in `pg_extension`, regardless of which + * schema it lives in. Extensions are global per database, so querying by `extname` alone (not + * `extname` + `nspname`) avoids treating a pgTAP already installed in another schema (e.g. + * `public`) as absent and dropping it. */ readonly extensionExists: (name: string) => Effect.Effect<boolean, DbExecError>; /** - * Run a server-side `COPY (...) TO STDOUT` and return its raw bytes. Mirrors - * `copyToCSV`, which - * streams `pgconn.CopyTo` into a file. `sql` is the already-wrapped COPY - * statement (e.g. `COPY (<query>) TO STDOUT WITH CSV HEADER`); the driver does - * not wrap it. Used by `inspect report` to produce byte-identical CSVs by - * construction (the server serializes the values, never the TS side). - * - * The driver opens ONE dedicated raw connection (node-postgres' COPY protocol - * needs the raw client, which `@effect/sql-pg` does not expose) against the same - * resolved dial target the primary connection won — so TLS / fallback / DoH - * parity is preserved — and reuses it for every copy, matching the established single - * `pgconn` for all report queries. The connection is opened lazily on the first - * copy and closed when the owning session's scope closes. Failing to establish - * that connection raises `DbConnectError` (a connection-setup failure); only - * the COPY stream itself raises `DbCopyError`. + * Runs a server-side `COPY (...) TO STDOUT` and returns its raw bytes; `sql` is the + * already-wrapped COPY statement. Uses one dedicated raw connection, since node-postgres' COPY + * protocol needs it and `@effect/sql-pg` does not expose one, dialed against the same resolved + * target as the primary connection and reused for the session. Failing to open it raises + * `DbConnectError`; the COPY stream itself raises `DbCopyError`. */ readonly copyToCsv: (sql: string) => Effect.Effect<Uint8Array, DbCopyError | DbConnectError>; /** - * Run a SQL statement and return its full result metadata, mirroring - * `pgx.Rows` surface used by `db query`: - * the ordered column names (`fields`), the row values **positionally** (so - * duplicate column names survive — node-postgres `rowMode: "array"`), and the - * raw command tag (`rows.CommandTag()`, e.g. `INSERT 0 1`, `CREATE TABLE`). - * - * A statement with no result columns (DDL/DML) returns `fields: []`; the caller - * prints `commandTag`. `@effect/sql-pg` exposes none of this (it returns row - * objects only), so the driver runs the query on a dedicated raw `pg` client — - * the same one `copyToCsv` uses — and captures the command tag from the - * `commandComplete` protocol message (node-postgres otherwise keeps only the - * first tag word, losing e.g. the `TABLE` in `CREATE TABLE`). - * - * Failing to establish that shared raw connection raises `DbConnectError` - * (a connection-setup failure, surfaced verbatim — not masked as an exec - * error), consistent with {@link copyToCsv}; the query itself raises - * `DbExecError`. + * Runs a SQL statement and returns its full result metadata for `db query`: ordered column + * names (`fields`), row values positionally (so duplicate column names survive), and the raw + * command tag, read directly from the `commandComplete` protocol message since node-postgres + * otherwise truncates it (e.g. dropping `TABLE` from `CREATE TABLE`). A statement with no + * result columns returns `fields: []`. Failing to open the connection raises `DbConnectError`; + * the query itself raises `DbExecError`. */ readonly queryRaw: (sql: string) => Effect.Effect<QueryResult, DbExecError | DbConnectError>; } @@ -188,10 +140,9 @@ export interface DbSession { export interface QueryResult { readonly fields: ReadonlyArray<string>; /** - * Postgres type OID per column (node-postgres `FieldDef.dataTypeID`). Lets the - * local/`--db-url` table/CSV formatter render `float4`/`float8` columns with Go's - * `%g` while integer columns stay plain — Go scans by field type - * (`internal/db/query`). Optional so other `queryRaw` callers/mocks need not set it. + * Postgres type OID per column. Lets the table/CSV formatter render `float4`/`float8` columns + * with `%g`-style formatting while integer columns stay plain. Optional so other `queryRaw` + * callers/mocks need not set it. */ readonly fieldTypeIds?: ReadonlyArray<number>; readonly rows: ReadonlyArray<ReadonlyArray<unknown>>; @@ -201,20 +152,15 @@ export interface QueryResult { /** Per-connection options the driver layer cannot infer from `cfg` alone. */ export interface DbConnectOptions { /** - * Whether the target is the local stack (`utils.IsLocalDatabase`). Drives - * TLS: local connections - * set `cc.TLSConfig = nil` (`ConnectLocalPostgres`) → no TLS, while remote - * connections go through `ConnectByUrl`, where pgx defaults to `sslmode=prefer` - * and every non-TLS fallback is stripped → TLS is required (without certificate - * verification, matching pgx's default for `prefer`/`require`). + * Whether the target is the local stack. Drives TLS: local connections skip TLS entirely, + * while remote connections require it (without certificate verification, matching libpq's + * `prefer`/`require` default). */ readonly isLocal: boolean; /** - * The active `--dns-resolver` value (`utils.DNSResolver.Value`). When - * `"https"` and the connection is remote, the driver resolves the host via - * Cloudflare DNS-over-HTTPS before dialing, mirroring Go's - * `cc.LookupFunc = FallbackLookupIP`. `"native"` (the - * default) uses the OS resolver. Ignored for local connections, matching Go. + * The active `--dns-resolver` value. When `"https"` and the connection is remote, the driver + * resolves the host via Cloudflare DNS-over-HTTPS before dialing; `"native"` (the default) uses + * the OS resolver. Ignored for local connections. */ readonly dnsResolver: "native" | "https"; } @@ -227,10 +173,9 @@ interface DbConnectionShape { } /** - * Opens raw Postgres connections for commands (`test db`, and later - * `db reset` / `db dump`). The underlying driver is swappable behind this - * interface — the default is `@effect/sql-pg`; a Bun.SQL fallback exists with - * the same shape. Handlers depend only on this service, never on the driver. + * Opens raw Postgres connections for commands such as `test db`. The underlying driver is + * swappable behind this interface; handlers depend only on this service, never on the driver + * directly. */ export class DbConnection extends Context.Service<DbConnection, DbConnectionShape>()( "supabase/cli/DbConnection", diff --git a/apps/cli/src/command-internal/db-connection.sql-pg.integration.test.ts b/apps/cli/src/command-internal/db-connection.sql-pg.integration.test.ts index 49a14bd053..9ddec2051d 100644 --- a/apps/cli/src/command-internal/db-connection.sql-pg.integration.test.ts +++ b/apps/cli/src/command-internal/db-connection.sql-pg.integration.test.ts @@ -1,10 +1,8 @@ /** - * Connection-failure behavior of the real `@effect/sql-pg` driver layer against - * real sockets: the `DbConnectError` message must carry the established - * `failed to connect to postgres: failed to connect to - * `host=… user=… database=…`: <cause>` structure (pgconn's own connect-error - * wrapping), and the connect suggestion must classify real - * node-postgres error shapes, not libpq wording. + * Connection-failure behavior of the real `@effect/sql-pg` driver layer against real sockets: + * the `DbConnectError` message must carry the established + * `failed to connect to postgres: failed to connect to `host=… user=… database=…`: <cause>` + * structure, and the connect suggestion must classify real node-postgres error shapes. */ import * as net from "node:net"; import { describe, expect, it } from "@effect/vitest"; @@ -25,9 +23,8 @@ const SUGGESTION_CONTEXT = { debug: false, } as const; -// A distinctive sentinel so a regression that leaks the password into the -// rendered message or suggestion fails the assertions below (pgconn embeds only -// host/user/database — never the password). +// A distinctive sentinel so a regression that leaks the password into the rendered message or +// suggestion fails the assertions below. const SENTINEL_PASSWORD = "s3cr3t-pw-do-not-leak"; /** @@ -299,11 +296,10 @@ const fakeBatchServer = ( }); /** - * A fake Postgres server that completes the startup handshake (no auth) and - * answers every simple-protocol query ('Q') via `onQuery`, so tests can drive - * the REAL driver stack — node-postgres wire parsing → `DatabaseError` → - * `@effect/sql-pg`'s `SqlError` wrapping → `toExecError` — through real - * server-side statement failures. + * A fake Postgres server that completes the startup handshake (no auth) and answers every + * simple-protocol query ('Q') via `onQuery`, so tests can drive the real driver stack — node- + * postgres wire parsing, `DatabaseError`, `@effect/sql-pg`'s `SqlError` wrapping, `toExecError` + * — through real server-side statement failures. */ const fakeQueryServer = ( onQuery: (sql: string) => Buffer, @@ -418,10 +414,6 @@ describe("dbConnectionSqlPgLayer connect failures", () => { "keeps the CLI-1942 session-pooler EOF shape unclassified while surfacing the cause", () => Effect.gen(function* () { - // The session pooler dropping the connection (CLI-1942) surfaces as - // node-postgres' `Connection terminated unexpectedly`. Go has no - // suggestion branch for the equivalent `unexpected EOF`, so no - // suggestion may fire — the generic --debug fallback applies. const server = yield* Effect.promise(() => fakePostgresServer((socket) => socket.destroy()), ); @@ -435,7 +427,6 @@ describe("dbConnectionSqlPgLayer connect failures", () => { "Connection terminated unexpectedly", ); expect(error.suggestion).toBeUndefined(); - // An unexpected EOF is not a dial-level failure — never marked retryable. expect(error.retryable).toBeUndefined(); }), ); @@ -445,24 +436,19 @@ describe("dbConnectionSqlPgLayer exec failures", () => { it.live( "maps a real wire ErrorResponse to pgconn's PgError rendering with detail and position", () => - // Tripwire for the server-error extraction: drives the REAL driver stack - // (node-postgres wire parsing → `DatabaseError` → `@effect/sql-pg`'s - // `SqlError` cause chain → `toExecError`), so a dependency bump that - // changes the error wrapping fails here instead of silently degrading - // migration-apply failures back to the opaque driver text. + // Tripwire for the server-error extraction: drives the real driver stack (node-postgres + // wire parsing → `DatabaseError` → `@effect/sql-pg`'s `SqlError` cause chain → + // `toExecError`), so a dependency bump that changes the error wrapping fails here instead + // of degrading migration-apply failures to the opaque driver text. Effect.gen(function* () { const failing = "CREATE TABLE test (path ltree NOT NULL)"; const server = yield* Effect.promise(() => fakeQueryServer((sql) => sql === failing ? Buffer.concat([ - // `S` (localized) and `V` (unlocalized) are deliberately distinct: - // Go renders pgconn's `PgError.Severity`, populated from the wire - // `S` field (pgproto3 `error_response.go` maps 'S'→Severity, - // 'V'→SeverityUnlocalized), so a localized server prints e.g. - // `FEHLER: …`. pg-protocol likewise assigns `severity = fields.S` - // (`parser.js` parseErrorMessage); asserting `FEHLER` below fails - // the tripwire if a dependency bump ever renders `V` instead. + // `S` and `V` differ here since pg-protocol assigns `severity` from the wire + // `S` field; asserting `FEHLER` below pins that against a dependency bump that + // might render `V` instead. errorResponse({ S: "FEHLER", V: "ERROR", @@ -656,8 +642,6 @@ describe("dbConnectionSqlPgLayer extended batches", () => { ); it.live("fails a batch whose connection drops after it was written, then recovers", () => - // A socket dropped after the batch was written must fail that batch and must not leave - // the client to be handed to the next one. Effect.gen(function* () { const server = yield* Effect.promise(() => fakeBatchServer({ destroyOnFirstSync: true })); yield* runWithBatchServer(server, (session) => @@ -678,16 +662,15 @@ describe("dbConnectionSqlPgLayer extended batches", () => { ); it.live("survives an idle raw-client socket death and redials for the next query", () => - // node-postgres emits `error` on an idle client; with no listener that terminates the - // process, so a database dying between two `queryRaw` calls must not take the CLI with it, - // and must not leave the corpse cached for the calls after it. + // node-postgres emits `error` on an idle client with no listener, which would otherwise + // crash the process; the dead client must not be left cached for later `queryRaw` calls. Effect.gen(function* () { const server = yield* Effect.promise(() => fakeBatchServer()); yield* runWithBatchServer(server, (session) => Effect.gen(function* () { yield* session.queryRaw("SELECT 1"); - // `queryRaw` runs on its own client, opened after the pool's, so it is the - // newest connection the server has accepted. + // `queryRaw` opens its own client after the pool's, so it's the newest connection + // the server has accepted. const rawSocket = server.sockets.at(-1); const openedBeforeRedial = server.sockets.length; @@ -697,8 +680,8 @@ describe("dbConnectionSqlPgLayer extended batches", () => { yield* Effect.sync(() => rawSocket?.destroy()); yield* Effect.promise(() => closed); - // Whether the client has already noticed the death decides if this call redials or - // fails on the corpse, and that ordering is not ours to control, so accept either. + // The client's death-detection timing is racy, so accept either a redial or a + // failure here. yield* session.queryRaw("SELECT 1").pipe( Effect.exit, Effect.timeoutOrElse({ @@ -715,11 +698,10 @@ describe("dbConnectionSqlPgLayer extended batches", () => { ); it.live("refuses to write a batch onto a real pooled client whose socket is already gone", () => - // The unit test drives `submit` through a hand-built connection; this pins the same - // refusal against a real node-postgres client, so a driver change that stops making the - // socket unwritable would be caught rather than mocked over. Destroying the socket and - // submitting in one synchronous block keeps the window deterministic: `writable` flips - // immediately, while pg only marks the client unqueryable on the next tick's close. + // Pins the same refusal from the unit test's hand-built connection against a real + // node-postgres client. Destroying the socket and submitting in the same synchronous block + // keeps the window deterministic, since `writable` flips immediately but pg only marks the + // client unqueryable on the next tick's close. Effect.gen(function* () { const server = yield* Effect.promise(() => fakeBatchServer()); yield* Effect.gen(function* () { @@ -751,10 +733,8 @@ describe("dbConnectionSqlPgLayer extended batches", () => { ); it.live("classifies a failed batch-connection acquisition as a connect error", () => - // A batch checks its own connection out of the pool, so a refused checkout is a - // CONNECTION failure — not statement 0 failing. Misclassifying it as an exec error - // would drop the connect suggestion and make the migration-apply formatter blame - // the migration's first statement for the database being unreachable. + // A refused checkout is a connection failure, not statement 0 failing; misclassifying it + // would blame the migration's first statement for the database being unreachable. Effect.gen(function* () { const server = yield* Effect.promise(() => fakeBatchServer({ stall: true })); const error = yield* runWithBatchServer(server, (session) => diff --git a/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts b/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts index b9a2f43cce..940243677a 100644 --- a/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts +++ b/apps/cli/src/command-internal/db-connection.sql-pg.layer.ts @@ -5,10 +5,9 @@ import { PgClient } from "@effect/sql-pg"; import { Cause, Duration, Effect, Exit, Layer, Scope } from "effect"; import * as Reactivity from "effect/unstable/reactivity/Reactivity"; import { ConnectionError, SqlError } from "effect/unstable/sql/SqlError"; -// `pg` is also `@effect/sql-pg`'s transitive driver; we depend on it directly for -// COPY and extended-protocol migration batches, which `@effect/sql-pg` does not -// expose. Keep the direct `pg` version constraint in package.json aligned with the -// one `@effect/sql-pg` resolves so every path uses the same driver. +// `pg` is `@effect/sql-pg`'s transitive driver; used directly here for COPY and +// extended-protocol batches, which `@effect/sql-pg` does not expose. Keep the direct `pg` +// version in package.json aligned with the one `@effect/sql-pg` resolves. import * as Pg from "pg"; import { to as pgCopyTo } from "pg-copy-streams"; import { @@ -29,14 +28,9 @@ import { } from "./db-connection.service.ts"; import { resolveHostsOverHttps } from "./db-dns.ts"; -// node-postgres honors `queryMode: "extended"` to force the Parse/Bind/Execute -// protocol (`pg/lib/query.js` `requiresPreparation`), but `@types/pg` doesn't declare -// it. Augment `QueryConfig` so `queryRaw` can request it without an `as` cast. -// pg-pool likewise honors a `verify(client, callback)` option — run for every -// brand-new physical connection before it is handed to a waiting checkout -// (`pg-pool/index.js` `_acquireClient`) — that `@types/pg` doesn't declare either. -// The batch path also needs the real PoolClient `connection` and Connection -// `sendCopyFail` members, which are absent from the public driver types. +// `@types/pg` doesn't declare `queryMode` on `QueryConfig`, `verify` on `PoolConfig` (run for +// every new physical connection before checkout), or `PoolClient.connection`/ +// `Connection.sendCopyFail`, all of which node-postgres supports at runtime. Augment them here. declare module "pg" { interface QueryConfig { queryMode?: "extended" | "simple"; @@ -52,19 +46,17 @@ declare module "pg" { } } -// Role step-down (`ConnectByConfigStream`): after connecting to a remote database as a -// platform-provisioned login role (`cli_login_*`) or a privileged role -// (`supabase_admin`), run `SET SESSION ROLE postgres` so subsequent statements +// After connecting to a remote database as a platform-provisioned login role (`cli_login_*`) or +// a privileged role (`supabase_admin`), run `SET SESSION ROLE postgres` so subsequent statements // (e.g. `CREATE EXTENSION`) execute as `postgres` rather than the temp role. const SUPERUSER_ROLE = "supabase_admin"; const CLI_LOGIN_PREFIX = "cli_login_"; const SET_SESSION_ROLE = "SET SESSION ROLE postgres"; -// Postgres date / timestamp / timestamptz type OIDs. node-postgres' default parsers -// decode these into a JS `Date`, which is millisecond-resolution and applies the -// local timezone — losing the microseconds that Go's pgx `time.Time` keeps (and -// risking a date shift for `date`). For `db query` we keep the raw Postgres text so -// the formatter can render `time.Time` layout faithfully (microseconds intact). +// Postgres date/timestamp/timestamptz type OIDs. node-postgres' default parsers decode these +// into a JS `Date`, which is millisecond-resolution and applies the local timezone, losing +// precision and risking a date shift. For `db query` we keep the raw Postgres text instead so +// the formatter renders timestamps faithfully, with microseconds intact. const PG_DATE_OID = 1082; const PG_TIMESTAMP_OID = 1114; const PG_TIMESTAMPTZ_OID = 1184; @@ -84,34 +76,27 @@ const queryRawTypes = { }; /** - * Whether the connecting user requires the `SET SESSION ROLE postgres` step-down. - * Strips any Supavisor `.{ref}` tenant suffix first (`strings.Split(user, ".")[0]`) - * before comparing. The step-down `AfterConnect` hook installs **only on the - * remote path** (`ConnectByConfigStream`); the local path - * (`ConnectLocalPostgres`) never installs it, regardless of the configured user — so - * the caller must also gate on `!isLocal` (a local `--db-url` can set any user). + * Whether the connecting user requires the `SET SESSION ROLE postgres` step-down. Strips any + * Supavisor `.{ref}` tenant suffix first. Only applies on the remote path; the caller must also + * gate on `!isLocal`, since a local `--db-url` can set any user without triggering step-down. */ function needsRoleStepDown(user: string): boolean { const base = user.split(".")[0] ?? user; return base.toLowerCase() === SUPERUSER_ROLE || base.startsWith(CLI_LOGIN_PREFIX); } -// pgconn terminates the multi-host fallback chain (rather than trying the next -// host) when the server returns an authentication/authorization/catalog/privilege -// error, surfacing it instead of masking it behind a later host. These are the -// SQLSTATEs pgconn -// breaks on; `28000` is gated on the failed attempt having used TLS -// (`fc.TLSConfig != nil`). +// These SQLSTATEs terminate the multi-host fallback chain instead of trying the next host, +// since they indicate the server rejected the attempt rather than being unreachable; `28000` +// only terminates when the failed attempt used TLS. const TERMINAL_SQLSTATES = new Set(["28P01", "3D000", "42501"]); const TLS_GATED_SQLSTATE = "28000"; /** - * Whether a failed connection attempt should terminate the multi-host fallback - * chain instead of falling through to the next host. Mirrors pgconn's - * `ConnectConfig`, which retries fallbacks only for connection-establishment - * errors and returns server-side auth errors immediately. The `pg` driver attaches - * the Postgres SQLSTATE as a `code` property on the server error (carried through - * `@effect/sql`'s `SqlError.cause`), so we walk the `cause` chain looking for one. + * Whether a failed connection attempt should terminate the multi-host fallback chain instead of + * falling through to the next host: fallbacks are retried only for connection-establishment + * errors, while server-side auth errors return immediately. The `pg` driver attaches the + * Postgres SQLSTATE as a `code` property on the server error, carried through `@effect/sql`'s + * `SqlError.cause`, so this walks the `cause` chain looking for one. */ export function isTerminalConnectError(error: unknown, usedTls: boolean): boolean { const code = extractSqlState(error); @@ -135,7 +120,7 @@ function extractSqlState(error: unknown): string | undefined { return undefined; } -/** Structured fields of a Postgres server ErrorResponse (pgconn's `PgError` subset). */ +/** Structured fields of a Postgres server ErrorResponse. */ interface PgServerError { readonly severity: string; readonly message: string; @@ -145,14 +130,11 @@ interface PgServerError { } /** - * Extracts the server ErrorResponse from a driver error's `cause` chain. The - * `@effect/sql` `SqlError` wraps its reason on `cause`, and the reason wraps the - * node-postgres `DatabaseError` the same way; a `DatabaseError` is identified by - * its string `severity` plus a SQLSTATE-shaped `code` (never a node system error). - * `detail` and `position` mirror `pgErr.Detail`/`pgErr.Position` - * (node-postgres carries `position` as a decimal string): `detail` only when - * non-empty, `position` only when > 0, matching `ExecBatch`'s own gates - * (`markError` no-ops on 0 anyway). + * Extracts the server ErrorResponse from a driver error's `cause` chain. `@effect/sql`'s + * `SqlError` wraps its reason on `cause`, which wraps the node-postgres `DatabaseError` the same + * way; a `DatabaseError` is identified by its string `severity` plus a SQLSTATE-shaped `code` + * (never a node system error). `detail` is included only when non-empty, and `position` (carried + * by node-postgres as a decimal string) only when > 0. */ function extractPgServerError(error: unknown): PgServerError | undefined { let current: unknown = error; @@ -178,13 +160,10 @@ function extractPgServerError(error: unknown): PgServerError | undefined { } /** - * Maps a failed statement to `DbExecError`. A server ErrorResponse renders - * pgconn's `PgError.Error()` byte-for-byte — `<Severity>: <Message> (SQLSTATE - * <Code>)` — which is the head line printed when a - * migration statement fails, and carries the - * structured `detail`/`position` fields the migration-apply error context renders. - * Non-server failures (socket drops, driver errors) keep - * the driver's own text. + * Maps a failed statement to `DbExecError`. A server ErrorResponse renders as + * `<Severity>: <Message> (SQLSTATE <Code>)`, the head line printed when a migration statement + * fails, and carries the structured `detail`/`position` fields the migration-apply error context + * renders. Non-server failures (socket drops, driver errors) keep the driver's own text. */ export function toExecError(error: unknown): DbExecError { const server = extractPgServerError(error); @@ -241,15 +220,13 @@ export function batchFailureError( } /** - * Whether a batch's pooled client must be destroyed rather than returned to the pool. A - * batch that never reached the wire leaves the client looking healthy to pg-pool while its - * socket is already gone, so the next checkout would write into the same dead connection. + * Whether a batch's pooled client must be destroyed rather than returned to the pool. A batch + * that never reached the wire leaves the client looking healthy to pg-pool while its socket is + * already gone, so the next checkout would write into the same dead connection. * - * A batch that WAS written keeps its client: a statement failure should not cost a redial and - * a fresh step-down on a single-connection pool. Recovering from a socket that died after the - * write is left to pg-pool, which drops a released client whose private `_queryable` flag is - * false — so that is the behavior to re-check if a pg-pool bump ever breaks the recovery this - * layer's integration tests assert. + * A batch that was written keeps its client: a statement failure should not cost a redial and a + * fresh step-down on a single-connection pool. Recovery from a socket that died after the write + * is left to pg-pool's own dead-client detection. */ export function shouldDiscardBatchClient( batch: { readonly outcome: BatchOutcome } | undefined, @@ -350,12 +327,10 @@ export class PgBatchQuery implements Pg.Submittable { } /** - * Whether a dial host is a libpq unix-socket path. pgconn skips TLS/DNS entirely for - * a unix `NetworkAddress` regardless of `sslmode` (jackc/pgconn `configTLS`), so a - * socket DSN connects in plaintext — mirrored here. Matches pgconn v1.14.3 - * `isAbsolutePath`: a forward-slash prefix (POSIX) OR a Windows - * absolute path — an **uppercase** drive letter `A`-`Z`, then `:`, then `\` - * (lowercase `c:\…` is NOT a socket in pgconn, so it stays TCP here too). + * Whether a dial host is a libpq unix-socket path: a forward-slash prefix (POSIX), or a Windows + * absolute path — an uppercase drive letter `A`-`Z`, then `:`, then `\` (lowercase `c:\…` is not + * treated as a socket, so it stays TCP). A socket DSN always connects in plaintext, skipping + * TLS/DNS entirely. */ export function isUnixSocketHost(host: string): boolean { if (host.startsWith("/")) return true; @@ -365,37 +340,19 @@ export function isUnixSocketHost(host: string): boolean { } /** - * Build a `postgresql://` connection string carrying the libpq `options` startup - * parameter. `PgClient.make` only forwards a fixed set of discrete fields to the - * underlying `pg` pool and has no `options` field, so the legacy Supavisor pooler - * format (`?options=reference=<ref>`) must travel via the connection string, which - * `pg-connection-string` parses back into the startup `options` param. `host` is - * passed explicitly so a DoH-resolved IP can be substituted while TLS still - * verifies the original hostname (via the `ssl.servername` carried separately). - * The URL carries no `sslmode`, so the explicit `ssl` config wins. - * - * An IPv6 literal host is wrapped in brackets so `new URL()` accepts it, matching - * `ToPostgresURL` (which formats the host via `net.JoinHostPort`). This - * covers a direct IPv6 `--db-url` carrying `?options=…` and the DoH path when a - * Supavisor URL resolves to an AAAA address. - * - * A unix-socket host (an absolute path) is percent-encoded as the authority and - * its port is dropped: `pg-connection-string` only accepts a socket host in a URL - * via its `/^%2f/i` branch (`postgresql://%2Fvar%2Frun%2Fpostgresql/db`), and a - * socket dial has no TCP port. Interpolating the raw path makes `new URL()` throw, - * which would otherwise break a socket DSN carrying startup `options`. + * Builds a `postgresql://` connection string carrying the libpq `options` startup parameter, + * since `PgClient.make` has no `options` field of its own. `host` is passed explicitly so a + * DoH-resolved IP can be substituted while TLS still verifies the original hostname via a + * separately carried `ssl.servername`. An IPv6 literal host is bracketed for `new URL()`, and a + * unix-socket host is percent-encoded as the authority with its port dropped, since a raw path + * makes `new URL()` throw. */ /** - * Merge the libpq `options` startup param with the parsed `runtimeParams`, encoding - * each runtime param as a `-c <key>=<value>` flag. Every - * `pgconn.Config.RuntimeParams` entry is sent as a discrete StartupMessage parameter - * (`ToPostgresURL`), so the live - * query/COPY connection applies `search_path`, `statement_timeout`, etc. - * node-postgres has no discrete startup-param API, but Postgres applies the - * `-c key=value` flags carried in the `options` startup param to the same session - * GUCs — behaviorally equivalent, the same pragmatic mapping already used for - * `options`. Any existing `cfg.options` (e.g. the Supavisor `reference=<ref>` form) - * is preserved, with the `-c` flags appended. Returns `undefined` when neither is set. + * Merges the libpq `options` startup param with the parsed `runtimeParams`, encoding each + * runtime param as a `-c <key>=<value>` flag: node-postgres has no discrete startup-param API, + * but Postgres applies `-c key=value` flags in `options` to the same session GUCs. Any existing + * `cfg.options` (e.g. the Supavisor `reference=<ref>` form) is preserved, with the `-c` flags + * appended. Returns `undefined` when neither is set. */ export function mergedConnectionOptions(cfg: PgConnInput): string | undefined { const base = cfg.options !== undefined && cfg.options.length > 0 ? cfg.options : undefined; @@ -427,29 +384,10 @@ export function buildConnectionUrl( } /** - * Map the established TLS behavior to the `pg` driver's `ssl` option: - * - * - **Local** (`ConnectLocalPostgres` sets `cc.TLSConfig = nil`) → no TLS; - * return `false` so `pg` stays in plaintext mode even when `PGSSLMODE` is set - * in the environment. `sslmode` is ignored — the - * local config overwrites it unconditionally. - * - **Remote** maps the URL's `sslmode` to the *primary* config pgconn would try - * (its fallback list), since the `pg` driver carries a single - * `ssl` option and cannot replay pgconn's TLS↔plaintext fallback: - * - `disable` and `allow` → plaintext (`ssl: false`). pgconn's `allow` list is - * `{nil, tlsConfig}`, i.e. a **non-TLS primary** with a TLS fallback, so an - * `allow` DSN to a plaintext endpoint must connect without TLS. - * - `verify-ca` / `verify-full` → TLS **with** certificate verification; - * - `prefer` (and pgconn's default) / `require` / unset → TLS **without** - * verification (their primary is the TLS config). - * - * `servername` (the original hostname) is carried for **every** TLS mode, not - * just the verifying ones. `sslsni` is enabled by default (pgconn's `config.go` - * sets `tlsConfig.ServerName = host` for all TLS sslmodes when - * the host is not a literal IP) and keeps the original hostname in the - * connection config even when `--dns-resolver https` swaps the dial target for a - * DoH-resolved IP (via `FallbackLookupIP`). Dropping the SNI on `require`/ - * `prefer` would break endpoints/proxies that route TLS on the server name. + * Maps `sslmode` to the `pg` driver's single `ssl` option, since it cannot replay libpq's + * TLS/plaintext fallback list the way multi-attempt dialing does. `servername` (the original + * hostname) is carried for every TLS mode, not just the verifying ones, so SNI still targets the + * hostname even when `--dns-resolver https` substitutes a DoH-resolved dial IP. */ export interface ClientCert { readonly cert: string; @@ -467,12 +405,10 @@ export function sslOptionFor( if (isLocal) return false; if (sslmode === "disable" || sslmode === "allow") return false; const sni = servername !== undefined ? { servername } : {}; - // A configured `sslrootcert` pins the server CA (pgconn loads it into RootCAs); - // it only affects the verifying modes. + // A configured `sslrootcert` pins the server CA; it only affects the verifying modes. const ca = caCert !== undefined ? { ca: caCert } : {}; - // pgconn attaches the client `sslcert`/`sslkey` (and optional `sslpassword`) to the - // single shared `tlsConfig.Certificates` regardless of verification mode, - // so carry it on every TLS config. + // Client cert/key (and optional passphrase) apply regardless of verification mode, so carry + // them on every TLS config. const clientCertOpts: ConnectionOptions = clientCert !== undefined ? { @@ -482,11 +418,8 @@ export function sslOptionFor( } : {}; if (sslmode === "verify-ca") { - // pgconn's `verify-ca` verifies the CA chain but **skips hostname** - // verification (`configTLS` sets a custom `VerifyPeerCertificate` with an - // empty DNSName and does not set `ServerName` for the check); SNI still - // carries the host. Node's equivalent is full chain verification with the - // identity check disabled. + // `verify-ca` verifies the CA chain but skips hostname verification; SNI still carries the + // host. Node's equivalent is full chain verification with the identity check disabled. return { rejectUnauthorized: true, checkServerIdentity: () => undefined, @@ -499,32 +432,16 @@ export function sslOptionFor( // Full verification, including hostname against the servername. return { rejectUnauthorized: true, ...ca, ...clientCertOpts, ...sni }; } - // prefer / require / unset → TLS without verification (pgx default). + // prefer / require / unset → TLS without verification (the default). return { rejectUnauthorized: false, ...clientCertOpts, ...sni }; } /** - * The ordered list of `ssl` configs to try for a connection. pgconn's raw - * `configTLS` fallback list is **post-processed** by Go's - * `ConnectByUrl`, which strips - * every non-TLS fallback whenever the primary config uses TLS ("No fallback from - * TLS to unsecure connection"). The `pg` driver carries a single `ssl` option and - * cannot replay pgconn's internal fallback, so `connect` retries across the - * *post-stripping* list: - * - * - `disable` → `[plaintext]` (primary is plaintext; nothing stripped) - * - `allow` → `[plaintext, TLS]` (`{nil, tlsConfig}` — non-TLS primary, so the - * TLS fallback survives the strip) - * - `prefer` / unset (pgconn's default) → `[TLS]`. pgconn's raw list is - * `{tlsConfig, nil}`, but the primary is TLS, so `ConnectByUrl` drops the - * plaintext fallback. Go therefore **fails** rather than downgrading a default - * remote connection to plaintext, and so must this port. - * - `require` / `verify-ca` / `verify-full` → `[TLS]` (TLS only) - * - * `servername` (the original hostname) is per dial host — set when a DoH-resolved - * IP was substituted so TLS/SNI still targets the hostname. `caCert` is the - * loaded `sslrootcert` bundle; pgconn treats `require` + a root cert as - * `verify-ca`, so it is promoted here. + * The ordered list of `ssl` configs to try for a connection: `disable` → plaintext only; + * `allow` → plaintext then TLS; `prefer`/unset/`require`/`verify-ca`/`verify-full` → TLS only, + * so a failed handshake on the default `prefer` mode fails loudly rather than silently + * downgrading to plaintext. `servername` targets the original hostname per dial host when a + * DoH-resolved IP was substituted; `caCert` promotes `require` to `verify-ca` when set. */ export function sslConfigsFor( sslmode: string | undefined, @@ -535,15 +452,14 @@ export function sslConfigsFor( clientCert?: ClientCert, ): Array<boolean | ConnectionOptions | undefined> { if (isLocal) return [false]; - // pgconn skips TLS entirely for a unix-socket host (`NetworkAddress == "unix"`) - // regardless of `sslmode`, so a socket DSN connects in plaintext; never send an - // SSL negotiation over the socket. Independent of the local/remote flag because a - // socket path is not the local services hostname (so `isLocal` is `false`). + // A unix-socket host always connects in plaintext, regardless of `sslmode`; never send an SSL + // negotiation over the socket. Independent of `isLocal`, since a socket path isn't the local + // services hostname. if (host !== undefined && isUnixSocketHost(host)) return [false]; if (sslmode === "disable") return [false]; if (sslmode === "allow") return [false, sslOptionFor("require", false, servername, caCert, clientCert)]; - // pgconn: `require` + a root cert behaves like `verify-ca` (`configTLS`). + // `require` plus a root cert behaves like `verify-ca`. const effectiveMode = sslmode === "require" && caCert !== undefined ? "verify-ca" : sslmode; if ( effectiveMode === "require" || @@ -552,19 +468,17 @@ export function sslConfigsFor( ) { return [sslOptionFor(effectiveMode, false, servername, caCert, clientCert)]; } - // prefer (and the unset default): pgconn's raw list is `{tlsConfig, nil}`, but - // `ConnectByUrl` strips the plaintext fallback because the primary is TLS, so - // this is TLS-only — a failed TLS handshake must error, never downgrade. + // prefer (and the unset default) is TLS-only: a failed handshake must error, never downgrade + // to plaintext. return [sslOptionFor(sslmode, false, servername, caCert, clientCert)]; } /** - * The raw `pg.ClientConfig` for a dial target, choosing the connection-string form - * whenever a libpq `options`/`runtimeParams` payload must reach the server (see - * `buildConnectionUrl`) and discrete fields otherwise (to avoid round-tripping - * the password through a URL). `copyToCsv` / `queryRaw` reuse it to open a dedicated - * node-postgres client for the COPY protocol and full result metadata (neither is - * surfaced by `@effect/sql-pg`), against whichever target the primary connection won. + * The raw `pg.ClientConfig` for a dial target: the connection-string form when a libpq + * `options`/`runtimeParams` payload must reach the server (see {@link buildConnectionUrl}), + * discrete fields otherwise, to avoid round-tripping the password through a URL. `copyToCsv` / + * `queryRaw` reuse it to open a dedicated node-postgres client against whichever target the + * primary connection won. */ export function buildRawPgConfig( cfg: PgConnInput, @@ -586,26 +500,12 @@ export function buildRawPgConfig( } /** - * The `pg.PoolConfig` for the primary pooled connection. Extends the raw client - * config with the two pool controls this layer depends on: - * - * - `max: 1` — a single physical connection, so a session-scoped `SET SESSION ROLE` - * (the remote step-down) and any session GUCs persist across `exec`/`query` calls. - * - `idleTimeoutMillis: 0` — node-postgres documents `0` as disabling auto-reaping of - * idle connections. `PgClient.make` leaves this unset (→ node-postgres default - * 10_000ms), which during a long-idle `db pull` (shadow-DB provisioning + diff + - * interactive confirm prompt) reaps the stepped-down connection after 10s idle and - * transparently redials a fresh one for the final migration-table write; that fresh - * connection never ran the step-down, so the DDL executes as the bare `cli_login_*` - * role and fails with `permission denied` (42501). Disabling the reaper keeps the - * single stepped-down connection alive for the whole session. - * - * `application_name` mirrors `PgClient.make`'s default so the server-side session name - * is unchanged from the previous `PgClient.make` path. - * - * When `stepDownRequired`, the pool also gets the `verify` step-down hook so every - * new physical connection runs `SET SESSION ROLE postgres` before it is handed out - * (see `poolStepDownVerify`). + * The `pg.PoolConfig` for the primary pooled connection: `max: 1` so a session-scoped + * `SET SESSION ROLE` and any session GUCs persist across `exec`/`query` calls, and + * `idleTimeoutMillis: 0` because the default reaper can silently redial mid-session (e.g. during + * a long-idle `db pull`) onto a fresh connection that never ran the step-down, failing with + * `permission denied`. When `stepDownRequired`, the pool also gets {@link poolStepDownVerify} so + * every new physical connection runs the step-down before being handed out. */ export function buildPoolConfig( cfg: PgConnInput, @@ -624,10 +524,8 @@ export function buildPoolConfig( }; } -// Minimal structural views of `pg.Pool` / `pg.PoolClient` used by the pool hooks, -// so the wiring can be unit-tested with a tiny fake at the driver boundary instead -// of a live pool. A real `pg.Pool`/`pg.PoolClient` satisfies these (their `on` -// overloads and `query` signatures are wider). +// Minimal structural view of `pg.Pool`/`pg.PoolClient` used by the pool hooks, satisfied by +// both the real driver and lightweight test fakes. interface StepDownClient { readonly query: (sql: string) => Promise<unknown>; } @@ -636,18 +534,12 @@ interface PoolErrorSource { } /** - * pg-pool `verify` hook that re-runs the remote role step-down on EVERY new - * physical connection, matching `AfterConnect` — - * including the silent redial after a dropped connection, which the post-connect - * one-shot in `connect` can't reach. pg-pool invokes `verify` for a brand-new - * client BEFORE resolving the pending checkout (`pg-pool/index.js` - * `_acquireClient`), so the `SET` completes before any caller query runs on that - * connection. (A `"connect"`-listener `client.query()` would instead overlap - * pg-pool's own synchronous dispatch of the checked-out query — node-postgres' - * "Calling client.query() when the client is already executing a query" - * deprecation, whose internal queueing pg@9 removes.) A failure propagates to - * the checkout and fails the caller's query, like `AfterConnect` failing - * the connect. + * pg-pool `verify` hook that re-runs the remote role step-down on every new physical connection, + * including a silent redial after a dropped connection that the post-connect one-shot in + * `connect` can't reach. pg-pool invokes `verify` before resolving the pending checkout, so the + * `SET` completes before any caller query runs; a `"connect"`-listener `client.query()` would + * instead race pg-pool's own dispatch of the checked-out query. A failure here fails the + * checkout, propagating to the caller's query. */ export function poolStepDownVerify(client: StepDownClient, callback: (err?: Error) => void): void { client.query(SET_SESSION_ROLE).then( @@ -657,40 +549,28 @@ export function poolStepDownVerify(client: StepDownClient, callback: (err?: Erro } /** - * Swallow the pool's async background errors, like `PgClient.make`: an idle - * client's connection-level failure emits `"error"` on the pool and would crash - * the process without a listener; the next checkout simply redials (and the - * redial re-runs the `verify` step-down). + * Swallows the pool's async background errors: an idle client's connection-level failure emits + * `"error"` on the pool and would crash the process without a listener. The next checkout + * simply redials, re-running the `verify` step-down. */ export function installPoolErrorSwallow(pool: PoolErrorSource): void { pool.on("error", () => {}); } -// Minimal structural view of the `pg.Pool` surface `acquireProbedPool` -// drives — a real `pg.Pool` satisfies it (its `on`/`query`/`end` signatures are -// wider), and a tiny fake can stand in at the driver boundary for unit tests. +// Minimal structural view of the `pg.Pool` surface `acquireProbedPool` drives; a real +// `pg.Pool` or a lightweight test fake both satisfy it. interface ProbePool extends PoolErrorSource { readonly query: (sql: string) => Promise<unknown>; readonly end: () => Promise<void>; } /** - * Acquire a `pg` pool and probe it with `SELECT 1`, guaranteeing the pool is - * `.end()`ed on EVERY non-success path — a probe rejection, a - * `connectTimeoutSeconds` timeout, or an interruption. - * - * The pool.end() finalizer is registered the MOMENT the pool is constructed, via - * `Effect.acquireRelease` with a synchronous (never-failing) resource step, BEFORE - * the probe runs. So even if the probe rejects or the outer timeout fires (a - * black-holed host), the finalizer is already installed and closes the pool along - * with its in-flight dial (sockets/timers) when the scope unwinds. Upstream - * `@effect/sql-pg`'s `PgClient.make` instead probes INSIDE the acquireRelease - * acquire, so a failed/timed-out probe never installs the finalizer and leaks the - * pool — we deliberately diverge to fix that leak while keeping the SqlError / - * ConnectionError shapes identical. - * - * The probe pool is generic so tests can inject a lightweight fake at the driver - * boundary; a real `pg.Pool` is returned unchanged for `PgClient.fromPool`. + * Acquires a `pg` pool and probes it with `SELECT 1`, guaranteeing the pool is closed on every + * non-success path (probe rejection, connect timeout, or interruption). The `pool.end()` + * finalizer registers the moment the pool is constructed, before the probe runs, unlike + * upstream `@effect/sql-pg`'s `PgClient.make`, which probes inside its acquire step and leaks + * the pool on a failed/timed-out probe. The probe pool is generic so tests can inject a + * lightweight fake at the driver boundary. */ export const acquireProbedPool = <P extends ProbePool>( makePool: () => P, @@ -729,7 +609,7 @@ export const acquireProbedPool = <P extends ProbePool>( return pool; }); -/** Map a driver connect failure to the credential-free Go-compatible error. */ +/** Maps a driver connect failure to a credential-free `DbConnectError`. */ const toConnectError = (cfg: PgConnInput, isLocal: boolean, error: unknown): DbConnectError => { const suggestion = cfg.suggestionContext === undefined @@ -743,28 +623,22 @@ const toConnectError = (cfg: PgConnInput, isLocal: boolean, error: unknown): DbC }; /** - * Acquire the winning raw pool through the full Go-compatible connection attempt - * chain. The pool finalizer is owned by the caller's scope; both the session - * adapter and direct-pool consumers use this one acquisition core so their DNS, - * TLS, fallback, and role behavior cannot drift apart. + * Acquires the winning raw pool through the full connection attempt chain (DNS resolution, TLS + * negotiation, host fallback, role step-down). The pool finalizer is owned by the caller's + * scope; both the session adapter and direct-pool consumers share this one acquisition core so + * that behavior cannot drift apart between them. */ const acquirePgPoolConnection = (cfg: PgConnInput, { isLocal, dnsResolver }: DbConnectOptions) => Effect.gen(function* () { - // pgconn dials the primary host then each HA fallback in order; - // `cfg.fallbacks` carries the extras parsed from a - // libpq multi-host connection string. Go installs the Cloudflare DoH resolver - // for remote connections when `--dns-resolver https` is set: - // it resolves each host to **all** its IPs - // (`FallbackLookupIP`) and dials them in order, so we resolve every config host - // up front and retry each. We dial a resolved IP but keep the original hostname - // for the TLS `servername` (carried in the `ssl` option) so verification still - // targets the hostname. Local connections use the host verbatim (native resolver). + // Dials the primary host then each HA fallback from `cfg.fallbacks`, in order. When + // `--dns-resolver https` is set, each host resolves to all its Cloudflare DoH IPs up front + // and each is retried in turn; the original hostname is kept as the TLS `servername` so + // verification still targets it. Local connections use the host verbatim (native resolver). const hostList = [{ host: cfg.host, port: cfg.port }, ...(cfg.fallbacks ?? [])]; const dialTargets: Array<{ dialHost: string; port: number; servername: string | undefined }> = []; for (const { host, port } of hostList) { - // pgconn never resolves a unix-socket host over DNS, so skip DoH for socket - // paths (DoH-resolving `/var/run/postgresql` is meaningless). + // Skip DoH for a unix-socket host; resolving a socket path over DNS is meaningless. const resolved = dnsResolver === "https" && !isLocal && !isUnixSocketHost(host) ? yield* resolveHostsOverHttps(host) @@ -773,25 +647,16 @@ const acquirePgPoolConnection = (cfg: PgConnInput, { isLocal, dnsResolver }: DbC dialTargets.push({ dialHost, port, servername: dialHost === host ? undefined : host }); } } - // Connect timeout parity: `ToPostgresURL` always sets `connect_timeout`, - // defaulting to 10s; `ConnectLocalPostgres` uses 2s for - // local. A DSN/`PGCONNECT_TIMEOUT` value (>0) overrides - // both. Without this a black-holed host would hang to the OS/driver default. + // Defaults to 10s remote / 2s local; a DSN or `PGCONNECT_TIMEOUT` value (>0) overrides + // both. Without this a black-holed host would hang on the OS/driver default. const connectTimeoutSeconds = cfg.connectTimeoutSeconds ?? (isLocal ? 2 : 10); - // Whether the remote step-down runs on this connection. The - // `AfterConnect` hook installs only on the remote path (`ConnectByConfigStream`), - // not `ConnectLocalPostgres`, so gate on `!isLocal`. + // Whether the remote step-down runs on this connection; local connections never step down. const stepDownRequired = !isLocal && needsRoleStepDown(cfg.user); - // Build the primary connection over a self-managed `pg.Pool` rather than - // `PgClient.make`, so we control two pool - // behaviors `PgClient.make` does not expose: `idleTimeoutMillis: 0` (never reap - // the single pooled connection — see `buildPoolConfig`; the fix for the - // `db pull` step-down loss) and the per-connection role step-down `verify` hook - // (see `poolStepDownVerify`). The `acquire` uses `acquireProbedPool`: - // probe with `SELECT 1`, bound the connect by `connectTimeoutSeconds`, and close - // the pool on scope exit AND on every failure/timeout (the leak `PgClient.make` - // has). `probe` (below) runs each attempt in a forked scope so a failed fallback - // attempt's pool closes immediately, before the next host is dialed. + // Uses a self-managed `pg.Pool` rather than `PgClient.make` for two pool behaviors it + // doesn't expose: `idleTimeoutMillis: 0` (see {@link buildPoolConfig}) and the + // per-connection step-down `verify` hook (see {@link poolStepDownVerify}). `probe` below + // runs each attempt in a forked scope so a failed fallback attempt's pool closes + // immediately, before the next host is dialed. const makePool = ( dialHost: string, port: number, @@ -812,20 +677,14 @@ const acquirePgPoolConnection = (cfg: PgConnInput, { isLocal, dnsResolver }: DbC connectTimeoutSeconds, ); - // `ConnectByUrl` calls `SetConnectSuggestion(err)` on every connect failure, - // mapping the driver error to an actionable hint that replaces - // the generic "--debug" suggestion. The resolver attaches the profile context to - // `cfg.suggestionContext`; map it here so the suggestion travels on the error. - // The message mirrors `pgxv5.Connect` wrap of pgconn's `connectError`: the `failed to connect - // to postgres:` prefix plus the `host=… user=… database=…` identity and the - // underlying driver cause — not the bare `SqlError` toString, which drops all - // of that detail. - // Load the `sslrootcert` CA bundle (pgconn reads it into `RootCAs` at parse - // time; a missing/unreadable file aborts). Skipped for local connections, which - // never use TLS. pgconn builds TLS per fallback host, so the CA must be loaded - // whenever ANY dial target is non-socket — a socket primary with a TCP fallback - // still needs it (`sslConfigsFor` already plaintexts socket targets, so - // the CA is never applied to a socket dial). + // The resolver attaches profile context via `cfg.suggestionContext`; map it here so an + // actionable hint replaces the generic "--debug" suggestion. The message carries the + // `failed to connect to postgres:` prefix plus the connection identity and underlying driver + // cause, not the bare `SqlError` toString, which drops that detail. + // Loads the `sslrootcert` CA bundle; a missing/unreadable file aborts. Skipped for local + // connections. Loaded whenever any dial target is non-socket, since a socket primary can + // still have a TCP fallback that needs it ({@link sslConfigsFor} already plaintexts socket + // targets). const rootcertPath = cfg.sslrootcert; const anyTcpTarget = dialTargets.some(({ dialHost }) => !isUnixSocketHost(dialHost)); const caCert = @@ -839,11 +698,9 @@ const acquirePgPoolConnection = (cfg: PgConnInput, { isLocal, dnsResolver }: DbC }) : undefined; - // Load the client `sslcert`/`sslkey` (pgconn's `configTLS` reads both into - // `tlsConfig.Certificates` for cert auth; the parser only sets them as a pair). - // Same non-local/TCP gate as the CA bundle. `sslpassword` decrypts an encrypted - // key (Node's `tls` `passphrase`). Bound to locals so the narrowing holds in the - // `Effect.try` closures. + // Loads the client `sslcert`/`sslkey` for cert auth, using the same non-local/TCP gate as + // the CA bundle; `sslpassword` decrypts an encrypted key. Bound to locals so the narrowing + // holds in the `Effect.try` closures below. const certPath = cfg.sslcert; const keyPath = cfg.sslkey; const clientCert = @@ -867,43 +724,32 @@ const acquirePgPoolConnection = (cfg: PgConnInput, { isLocal, dnsResolver }: DbC } : undefined; - // Build the ordered attempt list, mirroring pgconn's fallback loop - // (`configTLS` fallback configs, expanded across each resolved address by - // `expandWithIPs`): each TLS config (`sslConfigsFor`) is tried against - // each dial target (host × resolved IPs). `servername` is per target (the - // original hostname when we dial a DoH-resolved IP). + // Builds the ordered attempt list: each TLS config from {@link sslConfigsFor} tried against + // each dial target (host × resolved IPs), with `servername` per target set to the original + // hostname when dialing a DoH-resolved IP. const attempts = dialTargets.flatMap(({ dialHost, port, servername }) => sslConfigsFor(cfg.sslmode, isLocal, servername, caCert, dialHost, clientCert).map((ssl) => ({ pool: makePool(dialHost, port, ssl), - // pgconn only short-circuits the fallback chain on an auth error when the - // failed attempt used TLS (gated on `fc.TLSConfig != nil`); - // a TLS config is any non-plaintext `ssl` value. + // The fallback chain only short-circuits on an auth error when the failed attempt used + // TLS; a TLS config is any non-plaintext `ssl` value. usedTls: ssl !== undefined && ssl !== false, rawConfig: buildRawPgConfig(cfg, dialHost, port, ssl, connectTimeoutSeconds), })), ); - // The `pg` driver connects lazily and cannot replay pgconn's fallback, so probe - // every attempt with `select 1` to force the connection, falling through to the - // next on failure. pgconn retries fallbacks only for connection-establishment - // errors; a server-side auth/authorization/catalog/privilege error terminates the - // chain, so a terminal SQLSTATE re-raises instead of masking - // the primary's failure behind a later host. The final attempt is probed too (not - // left lazy): Go always dials eagerly — the main path via `pgx.ConnectConfig` and - // the temp-role wait via `pgconn.ConnectConfig` — so `connect` - // must return a live session for callers like `waitForTempRole` that don't run a - // follow-up query. The winning attempt's `rawConfig` is carried out so `copyToCsv` - // can reuse the exact dial target the primary connection succeeded against. + // The `pg` driver connects lazily, so probe every attempt with `select 1` to force the + // connection, falling through to the next on failure; a terminal SQLSTATE (see + // {@link isTerminalConnectError}) re-raises instead of masking the primary's failure behind + // a later host. The final attempt is probed too, since `connect` must return a live session + // for callers that never run a follow-up query. The winning attempt's `rawConfig` is carried + // out so `copyToCsv` can reuse the exact dial target the primary connection succeeded against. const probe = (attempt: (typeof attempts)[number]) => Effect.gen(function* () { - // Run each attempt's pool in its OWN scope, forked from the session scope. - // Forking (not a detached `Scope.make`) means an abandoned winner — e.g. a - // later step-down failure — is still closed when the session scope unwinds. - // On a probe FAILURE we close the child immediately, so a failed fallback - // attempt's pool (and its in-flight dial) is released before the next host is - // dialed, not left open until session end. On SUCCESS we leave the child open - // (a child of the session scope), so the winning pool lives for the whole - // session and closes with it. + // Each attempt's pool runs in its own scope forked from the session scope, so an + // abandoned winner (e.g. a later step-down failure) still closes when the session scope + // unwinds. On failure the child scope closes immediately, releasing a losing attempt's + // pool before the next host is dialed; on success it stays open as a child of the + // session scope. const sessionScope = yield* Scope.Scope; const attemptScope = yield* Scope.fork(sessionScope); return yield* attempt.pool.pipe( @@ -928,14 +774,11 @@ const acquirePgPoolConnection = (cfg: PgConnInput, { isLocal, dnsResolver }: DbC ) .pipe(Effect.mapError((error) => toConnectError(cfg, isLocal, error))); - // Step down from the temp/privileged login role before any further SQL — but - // only for remote connections: Go installs this hook in `ConnectByConfigStream`, - // not `ConnectLocalPostgres`, so a local `--db-url` using `supabase_admin`/ - // `cli_login_*` must not run it. The pool's `verify` hook already ran this on - // the physical connection (and runs it on any silent redial); this explicit - // one-shot preserves the fail-fast `DbConnectError: failed to set session - // role: ...` path. `max: 1` + `idleTimeoutMillis: 0` keep the stepped-down connection - // alive so the session-scoped role persists for every later `exec`/`query`. + // Steps down from the temp/privileged login role before any further SQL, remote + // connections only; a local `--db-url` using `supabase_admin`/`cli_login_*` must not run it. + // The pool's `verify` hook already ran this on the physical connection, but this explicit + // one-shot preserves a fail-fast error path. `max: 1` plus `idleTimeoutMillis: 0` keep the + // connection alive so the role persists for every later `exec`/`query`. if (stepDownRequired) { yield* Effect.tryPromise({ try: () => pool.query(SET_SESSION_ROLE), @@ -947,10 +790,9 @@ const acquirePgPoolConnection = (cfg: PgConnInput, { isLocal, dnsResolver }: DbC }); /** - * Acquire a live `pg.Pool` using the same scoped lifecycle and connection parity - * as `DbConnection.connect`. The caller owns the surrounding scope; closing - * it ends the winning pool, while every losing fallback attempt is closed before - * the next target is tried. + * Acquires a live `pg.Pool` with the same scoped lifecycle and connection behavior as + * `DbConnection.connect`. The caller owns the surrounding scope; closing it ends the winning + * pool, while every losing fallback attempt closes before the next target is tried. */ export const acquirePgPool = ( cfg: PgConnInput, @@ -959,9 +801,9 @@ export const acquirePgPool = ( acquirePgPoolConnection(cfg, options).pipe(Effect.map(({ pool }) => pool)); /** - * Default `DbConnection` layer, backed by `@effect/sql-pg` (pure-JS `pg` - * driver, no native addon — bundles under `bun build --compile`). Each - * `connect` builds a scoped single-client connection that closes on scope exit. + * Default `DbConnection` layer, backed by `@effect/sql-pg` (pure-JS `pg` driver, no native + * addon, so it bundles under `bun build --compile`). Each `connect` builds a scoped + * single-client connection that closes on scope exit. */ const connect = ( cfg: PgConnInput, @@ -977,36 +819,28 @@ const connect = ( Effect.mapError((error) => toConnectError(cfg, options.isLocal, error)), ); - // `inspect report` runs ~14 `COPY (...) TO STDOUT` statements. node-postgres' - // COPY protocol needs the raw client (which `@effect/sql-pg` does not surface), - // so the session opens ONE dedicated raw connection against the winning dial - // target and reuses it for every copy — matching Go, which runs all copies on a - // single `pgconn`. It is created lazily on first copy (so - // `test db` / `inspect db`, which never copy, never open it) and closed by a - // scope finalizer when the session's scope closes. The step-down runs once, here, - // so every COPY executes with the same privileges as the primary session. + // `inspect report` runs ~14 `COPY (...) TO STDOUT` statements. node-postgres' COPY protocol + // needs a raw client, which `@effect/sql-pg` does not surface, so the session opens one + // dedicated raw connection against the winning dial target and reuses it for every copy. + // Created lazily on first copy, so `test db`/`inspect db` never open it, and closed by a + // scope finalizer when the session's scope closes. let rawClient: Pg.Client | undefined; yield* Effect.addFinalizer(() => rawClient === undefined ? Effect.void : Effect.promise(() => rawClient!.end().catch(() => {})), ); - // A dedicated raw node-postgres client, reused by `copyToCsv` (COPY protocol) - // and `queryRaw` (full result metadata) — neither is surfaced by - // `@effect/sql-pg`. Opened lazily against the winning dial target so TLS / - // fallback / DoH parity is preserved, with the same role step-down as the - // primary session. Establishing this connection (and its step-down) is a - // connection-setup concern, so it fails with `DbConnectError` using the - // same message shape as the primary `connect` — not a copy/exec error. Only - // the COPY stream itself (in `copyToCsv`) raises `DbCopyError`; this - // keeps `queryRaw` failures from surfacing a misleading "failed to copy - // output" message when the shared client cannot be established. + // A dedicated raw node-postgres client, reused by `copyToCsv` and `queryRaw` since neither + // is surfaced by `@effect/sql-pg`. Opened lazily against the winning dial target with the + // same role step-down as the primary session. Establishing this connection is a + // connection-setup concern, so it fails with `DbConnectError`, not a copy/exec error; only + // the COPY stream itself raises `DbCopyError`. const acquireRawClient = Effect.gen(function* () { if (rawClient !== undefined) return rawClient; const fresh = new Pg.Client(winningRawConfig); - // node-postgres emits `error` on a cached client whose socket dies while idle; with - // no listener that terminates the process, so absorb it and drop the dead client so - // the next acquisition redials instead of reusing it. + // node-postgres emits `error` on a cached client whose socket dies while idle, which + // crashes the process without a listener; absorb it and drop the dead client so the next + // acquisition redials. fresh.on("error", () => { if (rawClient === fresh) rawClient = undefined; }); @@ -1024,15 +858,11 @@ const connect = ( return fresh; }); - // Checking a connection out of the pool for a batch is a connection-setup - // concern, so it fails with `DbConnectError` — the same classification - // `acquireRawClient` uses above, and for the same reason: the pool may have to - // redial (its single connection is discarded after an interrupted, poisoned, or - // unsent batch), and a refused/auth/DNS failure there is not a statement failure. - // Mapping it to `DbExecError` would lose the connect suggestion and make the - // migration-apply formatter blame the batch's first statement for a connectivity - // problem — which is also why a batch that never reached the wire reports the same - // way (below). Only a batch that was actually written raises `DbExecError`. + // Checking a connection out of the pool for a batch is a connection-setup concern, so it + // fails with `DbConnectError`, the same classification {@link acquireRawClient} uses: the + // pool may have to redial, and a refused/auth/DNS failure there is not a statement failure. + // Mapping it to `DbExecError` would make the migration-apply formatter blame the batch's + // first statement for a connectivity problem instead. const acquireBatchClient = Effect.callback<Pg.PoolClient, DbConnectError>((resume) => { let done = false; try { @@ -1116,28 +946,23 @@ const connect = ( ), queryRaw: (sql) => Effect.gen(function* () { - // `acquireRawClient` fails with `DbConnectError`; surface it - // verbatim (the public `queryRaw` type allows it) rather than masking a - // connection failure as "failed to execute query". + // `acquireRawClient` fails with `DbConnectError`; surface it verbatim rather than + // masking a connection failure as "failed to execute query". const activeClient = yield* acquireRawClient; - // Capture the raw command tag from the protocol message: node-postgres' - // parsed `Result.command` keeps only the first tag word (e.g. "CREATE" - // for "CREATE TABLE"), but Go prints the full `pgconn` tag. + // Capture the raw command tag from the protocol message directly, since node-postgres' + // parsed `Result.command` keeps only the first tag word (e.g. "CREATE" for + // "CREATE TABLE"). let commandTag = ""; const onComplete = (msg: { readonly text?: string }) => { if (typeof msg.text === "string") commandTag = msg.text; }; activeClient.connection.on("commandComplete", onComplete); const result = yield* Effect.tryPromise({ - // `rowMode: "array"` returns rows positionally so duplicate column - // names survive (Go reads pgx values by index). `types` keeps date/ - // timestamp/timestamptz cells as raw text to preserve microseconds. - // `queryMode: "extended"` forces the Parse/Bind/Execute protocol so a - // multi-statement string is rejected — Go's pgx v4 defaults to the - // extended protocol (`cannot insert multiple commands into a prepared - // statement`), whereas node-postgres' default simple protocol would - // execute every statement (an empty `values` array stays simple, since - // pg gates preparation on `values.length > 0`). + // `rowMode: "array"` returns rows positionally so duplicate column names survive. + // `types` keeps date/timestamp/timestamptz cells as raw text to preserve + // microseconds. `queryMode: "extended"` forces the Parse/Bind/Execute protocol so a + // multi-statement string is rejected, instead of node-postgres' default simple + // protocol executing every statement. try: () => activeClient.query<Array<unknown>>({ text: sql, @@ -1155,8 +980,8 @@ const connect = ( ); return { fields: result.fields.map((field) => field.name), - // Surface the column type OIDs so the table/CSV formatter can render - // float4/float8 with Go's %g while integer columns stay plain. + // Surface the column type OIDs so the table/CSV formatter can render float4/float8 + // with %g-style formatting while integer columns stay plain. fieldTypeIds: result.fields.map((field) => field.dataTypeID), rows: result.rows, commandTag, diff --git a/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts b/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts index 6ad2dc851f..424aa944b9 100644 --- a/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts +++ b/apps/cli/src/command-internal/db-connection.sql-pg.unit.test.ts @@ -137,8 +137,6 @@ describe("sslOptionFor", () => { }); it("uses plaintext for sslmode=disable and sslmode=allow on a remote connection", () => { - // pgconn's `allow` fallback list is `{nil, tlsConfig}` — a non-TLS primary — - // so an `allow` DSN to a plaintext-only endpoint must connect without TLS. expect(sslOptionFor("disable", false, undefined)).toBe(false); expect(sslOptionFor("allow", false, undefined)).toBe(false); }); @@ -150,8 +148,6 @@ describe("sslOptionFor", () => { }); it("verifies the CA chain but skips hostname for verify-ca (pgconn parity)", () => { - // pgconn's verify-ca verifies the chain but not the hostname, so Node must - // keep rejectUnauthorized but disable the identity check. const ssl = sslOptionFor("verify-ca", false, undefined); expect(ssl).toMatchObject({ rejectUnauthorized: true }); if (typeof ssl === "object" && ssl !== null) { @@ -162,7 +158,6 @@ describe("sslOptionFor", () => { it("attaches the client cert (cert/key/passphrase) to every TLS mode (pgconn parity)", () => { const clientCert = { cert: "CERT", key: "KEY", passphrase: "pw" }; - // verify-full / verify-ca / require|prefer all carry the client certificate. expect(sslOptionFor("verify-full", false, undefined, undefined, clientCert)).toMatchObject({ cert: "CERT", key: "KEY", @@ -172,7 +167,6 @@ describe("sslOptionFor", () => { cert: "CERT", key: "KEY", }); - // Plaintext modes carry no client cert. expect(sslOptionFor("disable", false, undefined, undefined, clientCert)).toBe(false); }); @@ -184,8 +178,6 @@ describe("sslOptionFor", () => { }); it("carries the servername for non-verifying TLS modes too (Go enables sslsni by default)", () => { - // Go keeps the original hostname as the TLS ServerName for every TLS mode - // when DoH swaps in a resolved IP, so require/prefer must send SNI as well. expect(sslOptionFor("require", false, "db.example.com")).toEqual({ rejectUnauthorized: false, servername: "db.example.com", @@ -224,9 +216,6 @@ describe("sslConfigsFor (pgconn fallback list)", () => { }); it("prefer and unset are TLS only (ConnectByUrl strips the plaintext fallback)", () => { - // pgconn's raw list is `{tlsConfig, nil}`, but Go's ConnectByUrl removes the - // plaintext fallback when the primary is TLS, so a default remote connection - // fails rather than downgrading to plaintext. expect(sslConfigsFor("prefer", false, undefined)).toEqual([{ rejectUnauthorized: false }]); expect(sslConfigsFor(undefined, false, undefined)).toEqual([{ rejectUnauthorized: false }]); }); @@ -241,14 +230,12 @@ describe("sslConfigsFor (pgconn fallback list)", () => { it("loads sslrootcert into the verifying modes and promotes require → verify-ca", () => { const ca = "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----"; - // require + a root cert behaves like verify-ca (chain verified, hostname skipped). const required = sslConfigsFor("require", false, undefined, ca); expect(required).toHaveLength(1); expect(required[0]).toMatchObject({ rejectUnauthorized: true, ca }); expect((required[0] as { checkServerIdentity?: unknown }).checkServerIdentity).toBeTypeOf( "function", ); - // verify-full keeps full verification but pins the CA. expect(sslConfigsFor("verify-full", false, undefined, ca)).toEqual([ { rejectUnauthorized: true, ca }, ]); @@ -256,18 +243,14 @@ describe("sslConfigsFor (pgconn fallback list)", () => { it("does not attach a CA to non-verifying modes", () => { const ca = "ca-bundle"; - // prefer stays unverified even with a root cert (pgconn: InsecureSkipVerify). expect(sslConfigsFor("prefer", false, undefined, ca)).toEqual([{ rejectUnauthorized: false }]); }); it("forces a single plaintext attempt for a unix-socket host regardless of sslmode", () => { - // pgconn skips TLS for a unix NetworkAddress, so a socket DSN connects in - // plaintext even though the host is not the local services hostname (isLocal=false). expect(sslConfigsFor("require", false, undefined, undefined, "/var/run/postgresql")).toEqual([ false, ]); expect(sslConfigsFor("verify-full", false, undefined, "ca", "/tmp/.s.PGSQL")).toEqual([false]); - // A non-socket host still follows the normal sslmode fallback list. expect(sslConfigsFor("require", false, undefined, undefined, "db.example.com")).toEqual([ { rejectUnauthorized: false }, ]); @@ -338,8 +321,6 @@ describe("buildPoolConfig", () => { const base = { user: "postgres", password: "pw", port: 5432, database: "postgres", host: "h" }; it("disables idle reaping (idleTimeoutMillis 0) and pins one connection (max 1) for a remote config", () => { - // The `db pull` bug: `PgClient.make` left idleTimeoutMillis unset (node-postgres - // default 10s), reaping the stepped-down connection during a long idle. const c = buildPoolConfig( { ...base }, "db.example.com", @@ -351,7 +332,6 @@ describe("buildPoolConfig", () => { expect(c.idleTimeoutMillis).toBe(0); expect(c.max).toBe(1); expect(c.application_name).toBe("@effect/sql-pg"); - // carries the raw client config fields through expect(c).toMatchObject({ host: "db.example.com", connectionTimeoutMillis: 10_000 }); }); @@ -369,9 +349,6 @@ describe("buildPoolConfig", () => { }); it("installs the step-down verify hook only when required", () => { - // Every NEW physical connection (initial + silent redials) runs the step-down - // before pg-pool hands it to a checkout, mirroring Go's per-connection - // AfterConnect. const remote = buildPoolConfig({ ...base }, "db.example.com", 5432, false, 10, true); expect(remote.verify).toBe(poolStepDownVerify); const local = buildPoolConfig({ ...base }, "127.0.0.1", 54322, false, 2, false); @@ -419,8 +396,7 @@ describe("installPoolErrorSwallow", () => { }); describe("acquireProbedPool", () => { - // Tiny fake at the driver boundary: records query/end calls. Standing in for a - // real `pg.Pool` proves the pool is always `.end()`ed — the leak this fixes. + // Tiny fake at the driver boundary recording query/end calls, standing in for a real `pg.Pool`. function makeFakePool(query: () => Promise<unknown>) { const calls = { query: 0, end: 0 }; const pool = { @@ -444,13 +420,10 @@ describe("acquireProbedPool", () => { ); expect(Exit.isFailure(exit)).toBe(true); expect(fake.calls.query).toBe(1); - // The finalizer, installed the moment the pool exists, closes it on failure. expect(fake.calls.end).toBe(1); }); it("ends the pool when the connect probe times out (black-holed host)", async () => { - // A never-resolving probe models a black-holed host: `timeoutOrElse` fires and - // the already-installed finalizer still closes the pool + its in-flight dial. const fake = makeFakePool(() => new Promise<unknown>(() => {})); const exit = await Effect.runPromiseExit( acquireProbedPool(() => fake.pool, 0.05).pipe(Effect.scoped), @@ -464,13 +437,11 @@ describe("acquireProbedPool", () => { const observed = await Effect.runPromise( Effect.gen(function* () { const pool = yield* acquireProbedPool(() => fake.pool, 2); - // While the scope is open the pool is live and not yet ended. return { isSamePool: pool === fake.pool, endWhileOpen: fake.calls.end }; }).pipe(Effect.scoped), ); expect(observed.isSamePool).toBe(true); expect(observed.endWhileOpen).toBe(0); - // Closing the scope ends the pool exactly once. expect(fake.calls.end).toBe(1); }); }); @@ -484,7 +455,6 @@ describe("isUnixSocketHost", () => { }); it("treats an uppercase Windows drive path as a socket, lowercase as TCP (pgconn parity)", () => { - // pgconn's isAbsolutePath accepts `A-Z:\…` (uppercase drive only); `c:\…` is TCP. expect(isUnixSocketHost("C:\\pgsql")).toBe(true); expect(isUnixSocketHost("c:\\pgsql")).toBe(false); expect(isUnixSocketHost("C:")).toBe(false); @@ -710,8 +680,7 @@ describe("batchFailureError", () => { }); it("keeps a partially written batch on the statement path, blaming statement 0", () => { - // A poisoned batch is corked, so the server acknowledged nothing and `completed` - // is always 0 — the failure can only be reported against the batch's first statement. + // A poisoned batch is corked, so `completed` stays 0 and statement 0 is blamed. const error = batchFailureError( new Error("serialization blew up"), { diff --git a/apps/cli/src/command-internal/db-dns.ts b/apps/cli/src/command-internal/db-dns.ts index f19332393d..7caa740034 100644 --- a/apps/cli/src/command-internal/db-dns.ts +++ b/apps/cli/src/command-internal/db-dns.ts @@ -4,7 +4,6 @@ import { Duration, Effect } from "effect"; import { DbConnectError } from "./db-connection.errors.ts"; // Cloudflare DNS-over-HTTPS JSON endpoint + record types (IANA DNS parameters). -// Mirrors `utils.FallbackLookupIP`. const CF_DOH_URL = "https://1.1.1.1/dns-query"; const TYPE_A = 1; // IPv4 const TYPE_AAAA = 28; // IPv6 @@ -15,11 +14,8 @@ function isRecord(value: unknown): value is Record<string, unknown> { } /** - * Extract the first A/AAAA address from a Cloudflare DNS-over-HTTPS JSON - * response. Mirrors `FallbackLookupIP`, which returns the full `[]string` - * of `Answer` entries whose `type` is A (1) or AAAA (28) so pgconn's - * `expandWithIPs` can try each in turn. Throws (Go returns an error) when no - * valid IP is present. + * Extracts every A/AAAA address from a Cloudflare DNS-over-HTTPS JSON response so the caller + * can retry each in turn. Throws when no valid IP is present. */ export function parseResolvedIps(payload: unknown, host: string): string[] { const answers = isRecord(payload) && Array.isArray(payload["Answer"]) ? payload["Answer"] : []; @@ -29,11 +25,10 @@ export function parseResolvedIps(payload: unknown, host: string): string[] { isRecord(answer) && (answer["type"] === TYPE_A || answer["type"] === TYPE_AAAA) && typeof answer["data"] === "string" && - // Require a well-formed IP, not just a non-empty string. Go only ever uses - // the resolved value as a pgconn `LookupFunc` dial target (an IP); here it - // also flows into `buildConnectionUrl`, so a tampered DoH answer like - // `1.2.3.4@attacker.com` could otherwise become the URL authority and - // redirect the credentialed connection (CWE-20/CWE-350). + // Require a well-formed IP, not just a non-empty string: this value flows into + // `buildConnectionUrl`, so a tampered DoH answer like `1.2.3.4@attacker.com` could + // otherwise become the URL authority and redirect the credentialed connection + // (CWE-20/CWE-350). net.isIP(answer["data"]) !== 0 ) { resolved.push(answer["data"]); @@ -46,14 +41,11 @@ export function parseResolvedIps(payload: unknown, host: string): string[] { } /** - * Resolve `host` to its IPs via Cloudflare DNS-over-HTTPS, the fallback resolver - * Go installs when `--dns-resolver https` is set and the native netgo resolver - * is blocked (`utils.FallbackLookupIP`). A host that is already an IP literal is - * returned unchanged (matching `net.ParseIP` short-circuit). + * Resolves `host` to its IPs via Cloudflare DNS-over-HTTPS, used when `--dns-resolver https` is + * set. A host that is already an IP literal is returned unchanged. * - * Returns **all** resolved addresses so the caller can retry each (Go hands the - * full list to pgconn, which dials them in order). The caller dials a returned - * IP but keeps the original hostname for the TLS `servername`, so certificate + * Returns every resolved address so the caller can retry each in turn. The caller dials a + * returned IP but keeps the original hostname for the TLS `servername`, so certificate * verification still targets the hostname. */ export function resolveHostsOverHttps(host: string): Effect.Effect<string[], DbConnectError> { diff --git a/apps/cli/src/command-internal/db-image.ts b/apps/cli/src/command-internal/db-image.ts index 28e5eb1732..1747f84ace 100644 --- a/apps/cli/src/command-internal/db-image.ts +++ b/apps/cli/src/command-internal/db-image.ts @@ -4,17 +4,12 @@ import { postgresImageForDbMajorVersion } from "../shared/services/services.shar import { slimImageForCurrentPin } from "../shared/services/slim-images.ts"; /** - * Resolves the local Postgres Docker image the way `config.Load` does, - * for commands that run a - * pg_dump / shadow-DB container (`db dump`, declarative). Promote/extend this if - * the full service-image resolution is ever needed. - * - * The default PG image is read from the same embedded Dockerfile manifest Go parses - * into `config.Images`, so the TS port tracks Dependabot bumps in that source. + * Resolves the local Postgres Docker image for commands that run a pg_dump/shadow-DB container + * (`db dump`, declarative). Promote/extend this if full service-image resolution is ever needed. */ -// Read per call, not captured at import time, so `SUPABASE_USE_SLIM_IMAGES` is -// observed by the resolver (and by tests that stub the env). +// Read per call, not at import time, so `SUPABASE_USE_SLIM_IMAGES` is observed by the resolver +// and by tests that stub the env. const pgImageRaw = () => dockerfileServiceImageRaw("pg"); /** Replace everything after the first `:` with `tag`. */ @@ -24,8 +19,7 @@ function replaceImageTag(image: string, tag: string): string { } /** - * `VersionCompare`: compares semver, treating a - * 4th+ dotted component as a build suffix. Returns <0, 0, or >0. + * Compares semver, treating a 4th+ dotted component as a build suffix. Returns <0, 0, or >0. */ function versionCompare(a: string, b: string): number { const split = (v: string): [string, string] => { @@ -55,10 +49,9 @@ function compareSemver(a: string, b: string): number { } /** - * Resolve the Postgres image for `majorVersion`, honoring the pinned version - * written by `supabase start` to `supabase/.temp/postgres-version` (Go reads - * `builder.PostgresVersionPath` and only replaces the tag when the configured - * image is at/above 15.1.0.55). + * Resolves the Postgres image for `majorVersion`, honoring the pinned version written by + * `supabase start` to `supabase/.temp/postgres-version`. The tag is only replaced when the + * configured image is at/above 15.1.0.55. */ export const resolveDbImage = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, @@ -67,9 +60,8 @@ export const resolveDbImage = Effect.fnUntraced(function* ( majorVersion: number, orioledbVersion?: string, ) { - // OrioleDB override (`config.Validate`): on a - // 15/17 project with `experimental.orioledb_version` set, the Postgres image is - // replaced with the OrioleDB tag, taking precedence over the default/pinned image. + // OrioleDB override: on a 15/17 project with `experimental.orioledb_version` set, the Postgres + // image is replaced with the OrioleDB tag, taking precedence over the default/pinned image. if ( orioledbVersion !== undefined && orioledbVersion.length > 0 && diff --git a/apps/cli/src/command-internal/db-pull-run.errors.ts b/apps/cli/src/command-internal/db-pull-run.errors.ts index 10fe1ed92a..6717bf6520 100644 --- a/apps/cli/src/command-internal/db-pull-run.errors.ts +++ b/apps/cli/src/command-internal/db-pull-run.errors.ts @@ -28,11 +28,10 @@ export class DbPullMigrationConflictError extends Data.TaggedError("DbPullMigrat export class DbPullInSyncError extends Data.TaggedError("DbPullInSyncError")<{ readonly message: string; /** - * Explains the non-zero exit instead of letting `Output.fail` append the - * generic "Try rerunning the command with --debug" footer — an in-sync - * database is a finding, not a failure to troubleshoot. The message and exit - * code stay Go-identical; only the footer diverges (see - * `docs/go-cli-divergences.md`). + * Explains the non-zero exit instead of letting `Output.fail` append the generic "Try + * rerunning the command with --debug" footer: an in-sync database is a finding, not a failure + * to troubleshoot. See `docs/go-cli-divergences.md` for the established message/exit-code + * contract this preserves. */ readonly suggestion: string; }> { diff --git a/apps/cli/src/command-internal/db-pull-run.ts b/apps/cli/src/command-internal/db-pull-run.ts index 57214d1e73..89dcc90435 100644 --- a/apps/cli/src/command-internal/db-pull-run.ts +++ b/apps/cli/src/command-internal/db-pull-run.ts @@ -91,10 +91,9 @@ const DEPRECATION_LINE = "Flag --use-pg-delta has been deprecated, use --declarative with [experimental.pgdelta] enabled = true in your config.toml instead."; /** - * Explains the in-sync non-zero exit. Go prints its generic - * `Try rerunning the command with --debug…` footer here, which reads like a - * crash for what is really a finding; the message and exit code stay Go-identical - * (see `docs/go-cli-divergences.md`). + * Explains the in-sync non-zero exit instead of the generic "Try rerunning the command with + * --debug…" footer, which would read like a crash for what is really a finding. See + * `docs/go-cli-divergences.md` for the established message/exit-code contract. */ const IN_SYNC_SUGGESTION = "The remote database is already in sync with your local migrations — nothing to pull."; @@ -102,15 +101,14 @@ const IN_SYNC_SUGGESTION = /** Migration-file mode for the initial pg_dump seed. */ const MIGRATION_FILE_MODE = 0o644; -// `--experimental` without `--declarative` used to dump remote SQL through Go's -// multigres AST splitter. That path is deprecated: the in-process declarative -// export covers the same per-object-files outcome. Printed only when the -// experimental gate selected this branch (not when `--declarative` already did). +// `--experimental` without `--declarative` is a deprecated path: the in-process declarative +// export covers the same per-object-files outcome. Printed only when the experimental gate +// selected this branch, not when `--declarative` already did. const EXPERIMENTAL_STRUCTURED_DUMP_DEPRECATION_LINE = "The --experimental structured-dump mode for `db pull` is deprecated and will be removed in a future release. Use --declarative instead to pull the remote schema as per-object files."; export type DbPullInvoke = { - /** Skip `Finished supabase db pull.` — Go's `db remote commit` has no PostRun line. */ + /** Skip printing `Finished supabase db pull.` for callers that must not emit it. */ readonly skipFinishedLine?: boolean; /** * Overrides `--yes`/`SUPABASE_YES`/`supabase/.env` resolution for an @@ -119,15 +117,12 @@ export type DbPullInvoke = { */ readonly assumeYes?: boolean; /** - * Forces migration mode regardless of the ambient `--experimental`/ - * `SUPABASE_EXPERIMENTAL` gate (which `resolveExperimentalWithProjectEnv` - * would otherwise honor) — for an in-process caller (`pull`) whose entire - * design — its dirty-guard, its confirmation message, its SIDE_EFFECTS.md — - * assumes this step only ever writes into `supabase/migrations`. Without - * this override, an ambient experimental flag silently switches this step - * to the declarative export path, writing `supabase/schemas/**` and - * potentially `config.toml`'s `schema_paths`, with none of that guarded or - * disclosed by the caller. `undefined` keeps the existing resolution. + * Forces migration mode regardless of the ambient `--experimental`/`SUPABASE_EXPERIMENTAL` + * gate, for an in-process caller (`pull`) whose dirty-guard, confirmation message, and + * SIDE_EFFECTS.md all assume this step only ever writes into `supabase/migrations`. Without + * this override, an ambient experimental flag would silently switch to the declarative export + * path, writing `supabase/schemas/**` and potentially `config.toml`'s `schema_paths` + * unguarded. `undefined` keeps the existing resolution. */ readonly forceMigrationMode?: boolean; }; @@ -168,19 +163,16 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( const debug = yield* DebugFlag; const cliArgs = yield* CliArgs; - // `--yes` OR `SUPABASE_YES`. The project `.env` is loaded before the migration + // `--yes` or `SUPABASE_YES`. The project `.env` is loaded before the migration // history prompt, so a `SUPABASE_YES` set only in `supabase/.env` auto-confirms // the native initial-migra history repair too. const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); - // `EXPERIMENTAL` resolves from *either* the global `--experimental` pflag or - // `SUPABASE_EXPERIMENTAL`, with the same bound-pflag-wins-over-env precedence - // `resolveExperimentalWithProjectEnv` already implements for `db - // reset`/declarative generate/sync — reuse it here instead of re-deriving the - // gate. Resolved once up front, same as `yes` above; declarative mode ignores it - // below. `invoke?.forceMigrationMode` overrides this entirely for an in-process - // caller (`pull`) that must never take the declarative export path — see - // `DbPullInvoke.forceMigrationMode`'s doc comment. + // `EXPERIMENTAL` resolves from either the global `--experimental` flag or + // `SUPABASE_EXPERIMENTAL`, reusing `resolveExperimentalWithProjectEnv`'s flag-over-env + // precedence instead of re-deriving it. Resolved once, same as `yes` above; declarative mode + // ignores it. `invoke?.forceMigrationMode` overrides this for an in-process caller that must + // never take the declarative export path — see {@link DbPullInvoke.forceMigrationMode}. const experimental = invoke?.forceMigrationMode ? false : yield* resolveExperimentalWithProjectEnv(projectEnv); @@ -194,12 +186,10 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( yield* applyProjectEnv(projectEnv); const name = Option.getOrElse(flags.name, () => "remote_schema"); // `--declarative` and the deprecated `--use-pg-delta` both bind to the same - // `useDeclarative` outcome, so when BOTH are passed the LAST occurrence in - // argv wins (e.g. `--declarative --use-pg-delta=false` => migration mode). The - // parsed Options don't carry order, so for the both-present case this replays - // the last-occurrence rule off the raw argv; OR-ing the two would instead - // diverge on conflicting values. When only one (or neither) is present, its - // Option value already equals its argv value, so the OR is exact. + // `useDeclarative` outcome. When both are passed, the last occurrence in argv wins + // (e.g. `--declarative --use-pg-delta=false` => migration mode); since parsed Options don't + // carry order, the both-present case replays that rule off the raw argv instead of ORing the + // two, which would diverge on conflicting values. const useDeclarative = Option.isSome(flags.declarative) && Option.isSome(flags.usePgDelta) ? (resolveDeclarativeFromArgs(cliArgs.args) ?? false) @@ -260,26 +250,18 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( ); } - // Go's `ParseDatabaseConfig` resolves the linked ref via the hard `LoadProjectRef`, THEN - // reads the `[remotes.<ref>]`-merged config (`LoadConfig`, which prints "Loading config - // override" unconditionally the moment a remote matches — `pkg/config/config.go:605`) — - // and only AFTER that calls `NewDbConfigWithPassword`, which does the actual connection - // work (TCP probe / temp-role mint over the Management API, `internal/utils/flags/ - // db_url.go:87-97`). Pre-load the ref and re-read config here, before `resolver.resolve()` - // below, so the override print (and the merged-config validation) happen in that same - // order. Previously this read — and its print — ran AFTER `resolve()`, so a `resolve()` - // failure (bad password, unreachable host, network-ban lookup, …) left the user never - // knowing which `[remotes.*]` block had matched (review: PRRT_kwDOErm0O86XHvYl). `--local`/ - // `--db-url` never merge a remote block, so only the linked path pre-resolves a ref. + // Pre-load the ref and re-read config here, before `resolver.resolve()` below, so the + // override print and merged-config validation happen before the connection attempt: a + // `resolve()` failure (bad password, unreachable host, network-ban lookup, …) must still + // tell the user which `[remotes.*]` block matched. `--local`/`--db-url` never merge a + // remote block, so only the linked path pre-resolves a ref. let linkedRef: string | undefined; if (connType === "linked") { const projectRefResolver = yield* ProjectRefResolver; linkedRef = yield* projectRefResolver.loadProjectRef(flags.projectRef); - // Cache the ref the moment it's known, not after `toml`/`localInputs` below (both - // fallible) resolve: the project cache should still be written even when a LATER step - // (config validation, connection, the pull itself) fails, so this is set right after - // the ref resolves rather than only after `toml`/`localInputs`/`resolver.resolve()` all - // succeed (`diff.handler.ts`'s identical fix). + // Cache the ref the moment it's known, not after `toml`/`localInputs` below resolve + // (both fallible): the project cache should still be written even when a later step + // (config validation, connection, the pull itself) fails. linkedRefForCache = linkedRef; } const toml = yield* readDbToml(fs, path, cliSettings.workdir, linkedRef); @@ -301,11 +283,10 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( runtimeInfo.platform, debug, // So the shadow's own container spec reflects the matching `[remotes.<ref>]` - // override, same as `toml` above — see `diff.handler.ts`'s identical call site. + // override, same as `toml` above. connType === "linked" ? linkedRef : undefined, - // `toml`'s OWN remote-override-key tracking (same matched block) — so a - // remote-set bootstrap field isn't re-overridden by a conflicting `SUPABASE_*` - // env var when deriving the shadow's container spec. + // `toml`'s remote-override-key tracking (same matched block), so a remote-set + // bootstrap field isn't re-overridden by a conflicting `SUPABASE_*` env var here. toml.remoteOverrideKeys, ), ); @@ -323,11 +304,9 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( if (linkedRef !== undefined) linkedRefForCache = linkedRef; const targetUrl = toPostgresURL(resolved.conn); const ctx: PgDeltaContext = { - // `SUPABASE_PROJECT_ID` env override wins, then config.toml's `project_id`, then - // the workdir basename fallback, with the matched `[remotes.<ref>]` block's own - // `project_id` (`toml.projectId`, already gated on `remoteOverrideKeys` by - // `readDbToml`) suppressing the raw env argument on the linked path — see - // that helper's own doc comment, and `diff.handler.ts`'s identical call site. + // Precedence: `SUPABASE_PROJECT_ID` env override, then config.toml's `project_id`, then + // the workdir basename fallback — with the matched `[remotes.<ref>]` block's own + // `project_id` suppressing the raw env argument on the linked path. projectId: resolvePgDeltaProjectId(cliSettings.projectId, toml, cliSettings.workdir), cwd: cliSettings.workdir, denoVersion: toml.denoVersion, @@ -359,7 +338,7 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( isIPv6ConnectivityError(error.message) ) { // A pooler resolution failure is treated as "no fallback" (re-fail the - // ORIGINAL diff error), not surfaced as its own error. + // original diff error), not surfaced as its own error. const pooler = yield* resolver .resolvePoolerFallback({ dbUrl: flags.dbUrl, @@ -439,10 +418,9 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( declarativeDirRel, ).pipe(Effect.mapError((cause) => new DbPullWriteError({ message: cause.message }))); } - // Prints the config's declarative_schema_path or the relative - // `supabase/schemas` default — never the resolved absolute directory - // (established output contract). The json payload below keeps the - // absolute path for machine consumers. + // Prints the config's declarative_schema_path or the relative `supabase/schemas` + // default, never the resolved absolute directory (established output contract). The + // json payload below keeps the absolute path for machine consumers. yield* output.raw(`Declarative schema written to ${bold(declarativeDirRel)}\n`, "stderr"); return { kind: "declarative", @@ -477,21 +455,18 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( }), ); } - // Initial pull, migra engine: seed the migration file with a pg_dump of the - // remote schema, then run the migra diff below as a second pass appended to - // the same file, which captures default privileges / managed schemas pg_dump - // can't emit. pg-delta initial pulls skip the dump: they diff against an - // empty shadow, which already yields the full schema. + // Initial pull, migra engine: seed the migration file with a pg_dump of the remote + // schema, then run the migra diff below as a second pass appended to the same file, + // which captures default privileges/managed schemas pg_dump can't emit. pg-delta + // initial pulls skip the dump, since diffing against an empty shadow already yields + // the full schema. const seededFromDump = sync.kind === "missing" && !usePgDeltaDiff; // Tracks whether the pg_dump seed wrote any bytes: an empty dump + empty diff // is "in sync", a non-empty dump is a valid initial migration on its own. let seedWroteBytes = false; - // Built above, before `resolver.resolve()` (see that build's doc comment — it's what - // used to run here, right before the initial-dump write below, but even that was still - // after `resolver.resolve()`/`connection.connect()`). `Option.getOrThrow` is safe here: - // this point is only reached after the declarative-export branch already - // returned, so `localInputs` was always built. + // `Option.getOrThrow` is safe here: this point is only reached after the + // declarative-export branch already returned, so `localInputs` was always built. const pullLocalInputs = Option.getOrThrow(localInputs); if (seededFromDump) { @@ -521,11 +496,10 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( // Stream pg_dump → migration file, (re)truncating per attempt so a pooler // retry leaves only the successful attempt's bytes. const runSchemaDump = (target: PgConnInput) => { - // Reset per attempt alongside the truncate, zeroing the file before the - // pooler retry. In-sync is decided from the file on disk, so only the - // final successful attempt's bytes count: a partial direct write that - // then IPv6-fails must not leave this flag stuck true, or an empty pooler - // retry would be mis-reported as a schema write. + // Reset per attempt alongside the truncate: in-sync is decided from the file on + // disk, so only the final successful attempt's bytes count. A partial direct write + // that then IPv6-fails must not leave this flag stuck true, or an empty pooler retry + // would be mis-reported as a schema write. seedWroteBytes = false; return fs .writeFile(migrationPath, new Uint8Array(0), { mode: MIGRATION_FILE_MODE }) @@ -616,19 +590,17 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( ), targetLocal: resolved.isLocal, migrationMode, - // `toml.schemaPathPatterns`, NOT `pullLocalInputs.context.config.db.migrations. - // schema_paths`: the latter is the raw `@supabase/config` field, which never - // applies `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` — `toml` above - // (`readDbToml`) already resolves that env override. + // `toml.schemaPathPatterns` is used here, not the raw `@supabase/config` field on + // `pullLocalInputs`, since only `toml` (via `readDbToml`) resolves the + // `SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS` env override. schemaPaths: toml.schemaPathPatterns, pgDelta: toml.pgDelta, }; - // `withShadowDatabase` (`shadow-cache.ts`) owns the interrupt-safe lifecycle - // and the cache seam. Each pooler-retry attempt still acquires and releases its own - // shadow — on the warm path every attempt restores a fresh container from the same - // cached snapshot. The key's webhooks policy must mirror what - // `prepareShadowSource` selects for this mode (legacy migrate forces `pg_net` - // on, next follows config), or the two engines could restore each other's tars. + // `withShadowDatabase` owns the interrupt-safe lifecycle and the cache seam. Each + // pooler-retry attempt still acquires and releases its own shadow; on the warm path + // every attempt restores a fresh container from the same cached snapshot. The key's + // webhooks policy must mirror what {@link prepareShadowSource} selects for this mode, + // or the two engines could restore each other's tars. return yield* withShadowDatabase( spawner, shadowInput, @@ -715,14 +687,12 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( // execution-aware plan unit. const writtenMigrations: Array<{ path: string; version: string }> = []; if (usePgDeltaDiff) { - // pg-delta: one migration file per plan unit via the shared writer. A - // single-unit plan (the common case) keeps the exact `<ts>_<name>.sql` - // filename; multi-unit plans append the unit name and give each file a - // strictly increasing timestamp so execution + migration-history order stay - // stable. The full set is collision-checked against existing migrations and - // each file is written exclusively so a pre-existing migration is never - // overwritten. Empty plans are handled by the `diffEmpty` in-sync branch - // above, so `planFiles` is non-empty here. + // pg-delta: one migration file per plan unit via the shared writer. A single-unit + // plan (the common case) keeps the exact `<ts>_<name>.sql` filename; multi-unit plans + // append the unit name with a strictly increasing timestamp so execution and + // migration-history order stay stable. Each file is written exclusively so a + // pre-existing migration is never overwritten; `planFiles` is non-empty here since + // empty plans are handled by the `diffEmpty` branch above. const planFiles = diffOutcome.files ?? []; const writtenUnits = yield* writePgDeltaMigrations(fs, path, { workdir: cliSettings.workdir, @@ -777,15 +747,11 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( } } - // A dump that produced nothing followed by an empty diff leaves the file - // empty → in sync. `runSchemaDump` above already truncated `migrationPath` - // to 0 bytes and the `!diffEmpty` append branch above was skipped (its - // guard is the same `diffEmpty`), so nothing else could have written - // content there — remove the empty file before reporting in-sync, or it - // sits on disk as a phantom local migration (with no remote counterpart) - // that a later pull's history reconciliation trips over, and that - // `--with-migration-history` cannot clear since fetching empty remote - // history never deletes local files. + // A dump that produced nothing followed by an empty diff leaves the file empty, since + // nothing else could have written content there. Remove it before reporting in-sync, + // or it sits on disk as a phantom local migration a later pull's history + // reconciliation trips over — one that `--with-migration-history` cannot clear, since + // fetching empty remote history never deletes local files. if (seededFromDump && !seedWroteBytes && diffEmpty) { yield* fs.remove(migrationPath).pipe(Effect.ignore); return yield* Effect.fail( @@ -799,9 +765,8 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( } for (const written of writtenMigrations) { - // Prints the workdir-relative path (established output contract). - // Display-only — `writtenMigrations` keeps absolute paths for file I/O - // and the json payload. + // Prints the workdir-relative path (established output contract); `writtenMigrations` + // itself keeps absolute paths for file I/O and the json payload. yield* output.raw( `Schema written to ${bold(path.relative(cliSettings.workdir, written.path))}\n`, "stderr", @@ -822,9 +787,8 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( ? invoke.assumeYes : yield* promptYesNo(output, yes, updateHistoryTitle, true); if (shouldUpdate) { - // The migration file(s) in `writtenMigrations` are already on disk at this - // point — a failure here must still report them as written (CLI-1272 review: - // `pull.aggregate.ts`'s `hasWrittenSoFar`), so re-raise the SAME + // The migration file(s) in `writtenMigrations` are already on disk at this point, so + // a failure here must still report them as written: re-raise the same // `DbPullWriteError` `updateMigrationHistory` fails with, carrying their paths. yield* updateMigrationHistory(session, fs, path, writtenMigrations).pipe( Effect.mapError( @@ -840,9 +804,9 @@ export const runDbPull = Effect.fn("db.pull.run")(function* ( return { kind: "migration", - // `schemaFiles` lists EVERY written migration path in write order (a - // pg-delta plan writes one file per unit); `dbPull`'s emission - // derives the released `schemaWritten` string field from its first entry. + // `schemaFiles` lists every written migration path in write order (a pg-delta plan + // writes one file per unit); `dbPull`'s emission derives the released `schemaWritten` + // string field from its first entry. schemaFiles: writtenMigrations.map((written) => written.path), remoteHistoryUpdated, engine: usePgDeltaDiff ? "pg-delta" : "migra", diff --git a/apps/cli/src/command-internal/db-push-core.ts b/apps/cli/src/command-internal/db-push-core.ts index 5d192cae06..70cdba0d80 100644 --- a/apps/cli/src/command-internal/db-push-core.ts +++ b/apps/cli/src/command-internal/db-push-core.ts @@ -30,11 +30,9 @@ const CUSTOM_ROLES_PATH = "supabase/roles.sql"; const toSlash = (p: string): string => p.replaceAll("\\", "/"); -/** `confirmPushAll` — bold filenames. */ const confirmPushAll = (filenames: ReadonlyArray<string>): string => filenames.map((name) => ` • ${bold(name)}\n`).join(""); -/** `confirmSeedAll` — bold paths, hash notice. */ const confirmSeedAll = (seeds: ReadonlyArray<SeedFile>): string => seeds .map((seed) => ` • ${bold(seed.dirty ? `${seed.path} (hash update)` : seed.path)}\n`) @@ -43,30 +41,12 @@ const confirmSeedAll = (seeds: ReadonlyArray<SeedFile>): string => const applyError = (message: string) => new DbPushApplyError({ message }); /** - * Everything `push.Run` does once its target connection AND config are - * already resolved. Shared by two - * callers, following the same structure — `push.Run` never - * resolves the project ref or loads `config.toml` itself, it just uses - * whatever its caller already resolved: + * Everything `db push` does once its target connection and config are already resolved. Callers + * (`db push`, `bootstrap`) resolve the project ref, connection, and `config.toml` themselves and + * pass the results in; this core never touches `ProjectRefResolver`/`DbConfigResolver` itself. * - * - `db push` (`push.handler.ts`) resolves `--db-url`/`--linked`/`--local` via - * `DbConfigResolver`/`ProjectRefResolver`, loads + validates - * `config.toml` itself (so the "Loading config override" line — printed by - * the config-load path, which runs before `push.Run` — prints before this - * core runs), then calls this core with the resolved connection. - * - `bootstrap` calls `push.Run(ctx, false, false, true, true, config, fsys)` - * directly — it never re-resolves the - * project ref or db config for push, reusing the config it already derived - * for `.env`. `bootstrap.handler.ts` mirrors that: it passes its own - * `workdir` / `projectRef` / connection directly, never touching - * `ProjectRefResolver` or `DbConfigResolver` (which key off - * `CommandSettings.workdir` — stale after bootstrap's `process.chdir`, see - * bootstrap.handler.ts's workdir comments). - * - * The "DRY RUN: …" heads-up line is the literal first line of `push.Run` — - * i.e. it prints AFTER the connection-resolution phase's - * own output (e.g. "Initialising login role..."), not before — so it lives - * here, right before "Connecting to...", not at either caller's call site. + * The "DRY RUN: …" heads-up line prints first here, after the caller's own connection-resolution + * output (e.g. "Initialising login role..."), so it lives here rather than at either call site. */ export interface DbPushCoreInput { /** Absolute project directory (never read from `CommandSettings.workdir`). */ @@ -76,19 +56,16 @@ export interface DbPushCoreInput { readonly conn: PgConnInput; readonly isLocal: boolean; /** - * Whether `--local` (not `--db-url`/`--linked`) was the explicit target - * selector — distinct from `isLocal` (whether the *resolved* connection - * happens to point at a local address, e.g. a `--db-url` pointing at - * `127.0.0.1`). Only feeds the "missing local migrations" repair - * suggestion's `--local` flag (`connType === "local"` check, - * `push.handler.ts`). `bootstrap` never selects `--local`, so it is always - * `false` there. + * Whether `--local` (not `--db-url`/`--linked`) was the explicit target selector, distinct + * from `isLocal` (whether the resolved connection happens to point at a local address, e.g. a + * `--db-url` pointing at `127.0.0.1`). Only feeds the "missing local migrations" repair + * suggestion's `--local` flag; `bootstrap` never selects `--local`, so it is always `false` + * there. */ readonly repairSuggestsLocalFlag: boolean; /** - * Gates the "DRY RUN: …" heads-up line, the "Would push/seed/create …" - * plan, and the JSON `dryRun` field — matches Go's single `dryRun` check at - * the top of `push.Run`. + * Gates the "DRY RUN: …" heads-up line, the "Would push/seed/create …" plan, and the JSON + * `dryRun` field. */ readonly dryRun: boolean; readonly includeAll: boolean; @@ -101,9 +78,8 @@ export interface DbPushCoreInput { /** Already resolved confirm-prompt default, e.g. via `resolveYesWithProjectEnv`. */ readonly yes: boolean; /** - * Standalone `db push` emits a `--output-format` json/stream-json success - * result for its own invocation; `bootstrap` suppresses it (it emits its own - * top-level result), matching `projectCreateCore`'s `emitStructuredResult`. + * Standalone `db push` emits a `--output-format` json/stream-json success result for its own + * invocation; `bootstrap` suppresses it since it emits its own top-level result instead. */ readonly emitStructuredResult: boolean; } @@ -133,9 +109,8 @@ export const dbPushCore = Effect.fnUntraced(function* (input: DbPushCoreInput) { const vaultSecrets = toml.vault; - // Literal first line of `push.Run` — prints AFTER the - // caller's own connection-resolution output (e.g. "Loading config override", - // "Initialising login role..."), never before. + // Prints first here, after the caller's own connection-resolution output (e.g. "Loading + // config override", "Initialising login role..."), never before. if (dryRun) { yield* output.raw("DRY RUN: migrations will *not* be pushed to the database.\n", "stderr"); } @@ -148,7 +123,6 @@ export const dbPushCore = Effect.fnUntraced(function* (input: DbPushCoreInput) { yield* output.raw(`Connecting to ${isLocal ? "local" : "remote"} database...\n`, "stderr"); const session = yield* dbConn.connect(conn, { isLocal, dnsResolver }); - // --- Collect pending migrations --- let pending: ReadonlyArray<string> = []; if (!toml.migrationsEnabled) { yield* output.raw( @@ -170,7 +144,7 @@ export const dbPushCore = Effect.fnUntraced(function* (input: DbPushCoreInput) { } if (result.kind === "missing-remote") { if (!includeAll) { - // Go's suggestIgnoreFlag lists the workdir-relative paths. + // Workdir-relative paths for `suggestIgnoreFlag`. const relPaths = result.paths.map((p) => toSlash(path.relative(workdir, p))); return yield* Effect.fail( new DbPushMissingRemoteError({ @@ -185,7 +159,6 @@ export const dbPushCore = Effect.fnUntraced(function* (input: DbPushCoreInput) { } } - // --- Collect pending seeds --- let seeds: ReadonlyArray<SeedFile> = []; if (includeSeed) { if (!toml.seed.enabled) { @@ -198,7 +171,6 @@ export const dbPushCore = Effect.fnUntraced(function* (input: DbPushCoreInput) { } } - // --- Collect custom roles --- const globals: Array<string> = []; if (includeRoles) { const exists = yield* fs.exists(path.join(workdir, CUSTOM_ROLES_PATH)).pipe( @@ -212,7 +184,6 @@ export const dbPushCore = Effect.fnUntraced(function* (input: DbPushCoreInput) { if (exists) globals.push(CUSTOM_ROLES_PATH); } - // --- Nothing to push --- if (pending.length === 0 && seeds.length === 0 && globals.length === 0) { if (output.format === "text") { yield* output.raw(`${statusTarget} is up to date.\n`); @@ -241,7 +212,6 @@ export const dbPushCore = Effect.fnUntraced(function* (input: DbPushCoreInput) { yield* output.raw(confirmSeedAll(seeds), "stderr"); } } else { - // --- Custom roles --- if (globals.length > 0) { const ok = yield* promptYesNo( output, @@ -263,7 +233,6 @@ export const dbPushCore = Effect.fnUntraced(function* (input: DbPushCoreInput) { ); } - // --- Migrations --- if (pending.length > 0) { const ok = yield* promptYesNo( output, @@ -284,7 +253,6 @@ export const dbPushCore = Effect.fnUntraced(function* (input: DbPushCoreInput) { yield* output.raw("Schema migrations are up to date.\n", "stderr"); } - // --- Seeds --- if (seeds.length > 0) { const ok = yield* promptYesNo( output, diff --git a/apps/cli/src/command-internal/db-target-flags.ts b/apps/cli/src/command-internal/db-target-flags.ts index aafef0d1f1..ce6d850bd3 100644 --- a/apps/cli/src/command-internal/db-target-flags.ts +++ b/apps/cli/src/command-internal/db-target-flags.ts @@ -1,25 +1,12 @@ /** - * Pure flag-presence helpers for the `--db-url / --linked / --local` target - * selection shared by `db lint`, `db advisors`, and `test db`. + * Pure flag-presence helpers for the `--db-url / --linked / --local` target selection shared by + * `db lint`, `db advisors`, and `test db`. * - * Go's cobra uses `pflag.Changed` to decide which selector was explicitly set by - * the user. Effect CLI's - * parsed flag values don't carry a `Changed` bit, so we re-derive it from the - * raw `process.argv` slice. - * - * cobra's `MarkFlagsMutuallyExclusive` error has TWO bracketed lists: the - * group list keeps REGISTRATION order (`strings.Join(flagNames, " ")`) - * and is NOT sorted, while the "were all set" list IS - * sorted (`sort.Strings(set)`). The FIXED insertion - * order ["db-url","linked","local"] — alphabetical — for the `setFlags` array - * matches only that second, sorted list; each command must hardcode its own - * group list in its own registration order (e.g. seed `[local linked]` - * vs storage `[linked local]`). - * - * pflag accepts `--flag value` (space form) for non-boolean flags: the token - * after a value-consuming flag is its value, not a separate flag. The scan - * skips those value tokens to avoid false positives (e.g. `--schema --linked` - * must not detect `--linked` as a changed selector). + * Effect CLI's parsed flags carry no "was this explicitly set" bit, so this re-derives it from + * raw `process.argv`. The mutually-exclusive-group error's "were all set" list is alphabetically + * sorted regardless of each command's own flag registration order, which `setFlags` always + * returns. A value-consuming flag given in space form (`--flag value`) has its value token + * skipped during the scan, so e.g. `--schema --linked` does not misdetect `--linked` as changed. */ export type DbConnType = "db-url" | "linked" | "local"; @@ -28,62 +15,32 @@ export interface DbTargetSelection { /** Alphabetically-sorted list of explicitly-set selector flags ("db-url", "linked", "local"). */ readonly setFlags: ReadonlyArray<string>; /** - * Changed-first selection, matching `ParseDatabaseConfig` precedence: - * db-url > local > linked (if changed) > undefined (→ local default). - * - * `undefined` means no selector was explicitly set; callers default to "local". + * Changed-first selection: db-url > local > linked (if changed) > undefined (→ local + * default). `undefined` means no selector was explicitly set; callers default to "local". */ readonly connType: DbConnType | undefined; } /** - * Long-form flags (without `--` prefix) that consume the next token as their - * value when given in space-separated form (`--flag value`). Flags in this set - * cause the immediately following token to be skipped during the target-selector - * scan. - * - * Sources: every command that calls `resolveDbTargetFlags` - * (`db lint`, `db advisors`, `test db`, and the `migration` commands `list`/ - * `repair`/`fetch`/`up`/`down`) or `changedLinkedLocalFlags` - * (`seed buckets`, `storage cp/ls/mv/rm`), plus the shared global flags - * (`src/command-internal/global-flags.ts`, `src/shared/cli/global-flags.ts`). - * `Flag.string` / `Flag.choice` / `Flag.integer` → value-consuming; - * `Flag.boolean` → not. - * - * Also consulted by `extractChangedFlagNames` - * (`telemetry/command-telemetry.ts`), which scans EVERY - * command's raw argv, not just the db-target/global subset above — so - * this set additionally lists every other value-consuming (non-boolean) flag - * declared anywhere under `commands/`. Without an entry here, a bare - * `--some-local-flag <token>` is mis-scanned: the value token is treated as a - * separate flag rather than skipped, and if that token happens to look like a - * global flag's long name (e.g. `secrets set --env-file --debug`, where - * `--debug` is `--env-file`'s value under pflag semantics), CLI-1896's - * global-flag fallback fabricates a `flags.debug` value Go never records for - * that invocation. `db-target-flags.unit.test.ts` statically scans - * every `*.command.ts` file's directly-declared `Flag.string`/`Flag.integer`/ - * `Flag.choice`/`Flag.choiceWithValue` calls and asserts they're all - * represented here, so a new command that adds a value-consuming flag and - * forgets to register it fails CI. That scan cannot see flag names built - * through a helper indirection (`issue.command.ts`'s - * `issueOptionalTextFlag`, and the shared `stringSliceFlag` - * builder used by the sso/postgres-config/start/status/network-bans/ - * network-restrictions slice flags — CLI-2005) — those flags are listed - * below by hand. + * Long-form flags that consume the next token as their value in space-separated form + * (`--flag value`), so the target-selector scan skips it. Also consulted by + * `extractChangedFlagNames` across every command's argv, so this lists every value-consuming + * flag declared anywhere under `commands/`; a missing entry can misdetect a flag's value token + * as a global flag's long name. `db-target-flags.unit.test.ts` enforces coverage for + * directly-declared flags; flags built through a name-parameterized helper are listed by hand. */ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ // db-family command flags "db-url", - "password", // db push/pull/dump/remote (StringVarP, short -p) + "password", // db push/pull/dump/remote (short -p) "sql-paths", "schema", "level", "fail-on", "type", - // migration/db credential flag — `StringVarP(&dbPassword, "password", "p", …)` - // consumes the next token as the value. + // migration/db credential flag; consumes the next token as its value. "password", - // inspect report flag (StringVar, no short alias) + // inspect report flag "output-dir", // storage cp command flags (Flag.string / Flag.integer) "cache-control", @@ -97,14 +54,11 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "network-id", "dns-resolver", "agent", - // The CLI library's built-in `--log-level` is deliberately not listed: an - // argv giving it a flag-shaped value fails the real parse before any - // scanner here runs, so nothing can mis-consume around it (issue #6482 - // registered it only where positional counting depends on it). The - // `--completions` action prints and exits before any handler runs, so - // these scans never see it either. - // Every other value-consuming flag declared directly across commands/ - // (CLI-1896 review follow-up — see the doc comment above). + // `--log-level` is not listed: an argv giving it a flag-shaped value fails the real parse + // before any scanner here runs, so nothing can mis-consume around it. `--completions` prints + // and exits before any handler runs, so these scans never see it either. + // Every other value-consuming flag declared directly across commands/ (see the doc comment + // above). "add-domains", "algorithm", "attribute-mapping-file", @@ -162,11 +116,10 @@ export const VALUE_CONSUMING_LONG_FLAGS = new Set([ "token", "valid-for", "version", - // Declared through a name-parameterized helper, invisible to the static - // scan (see the doc comment above): `issue.command.ts`'s - // `issueOptionalTextFlag`. (The `stringSliceFlag`-built names — - // domains, add-domains, remove-domains, config, exclude, override-name, - // db-unban-ip, db-allow-cidr — are already listed in the sections above.) + // Declared through a name-parameterized helper, invisible to the static scan (see the doc + // comment above): `issue.command.ts`'s `issueOptionalTextFlag`. (The `stringSliceFlag`-built + // names — domains, add-domains, remove-domains, config, exclude, override-name, db-unban-ip, + // db-allow-cidr — are already listed in the sections above.) "additional-context", "area", "command", @@ -202,17 +155,13 @@ export const VALUE_CONSUMING_SHORT_FLAGS = new Set([ ]); /** - * Detects which of `--linked` / `--local` were explicitly set on the command - * line, reproducing cobra's `pflag.Changed` for the `MarkFlagsMutuallyExclusive` - * groups on `seedCmd` and `storageCmd`. Shared by `seed buckets` and - * `storage ls/cp/mv/rm`. + * Detects which of `--linked` / `--local` were explicitly set on the command line, for `seed + * buckets` and `storage ls/cp/mv/rm`'s mutually-exclusive-flag error message. * - * Effect CLI's parsed flags carry no `Changed` bit, so this re-derives it from - * raw argv, skipping value tokens of space-separated value-consuming flags - * (`--workdir <path>`, `-o <fmt>`, …) to avoid false positives. The negation - * form (`--no-linked`/`--no-local`) counts as changed. Returned in cobra's - * alphabetically-sorted order `["linked", "local"]` so the rendered conflict - * string matches exactly. + * Effect CLI's parsed flags carry no "was this set" bit, so this re-derives it from raw argv, + * skipping value tokens of space-separated value-consuming flags to avoid false positives. The + * negation form (`--no-linked`/`--no-local`) counts as changed. Returned alphabetically sorted + * (`["linked", "local"]`) so the rendered conflict string matches exactly. */ export function changedLinkedLocalFlags(args: ReadonlyArray<string>): ReadonlyArray<string> { let linked = false; @@ -256,16 +205,14 @@ export function changedLinkedLocalFlags(args: ReadonlyArray<string>): ReadonlyAr } /** - * Resolves the DB target selection from raw CLI args. - * - * Performs a single left-to-right pass, skipping value tokens that follow - * space-separated value-consuming flags to avoid false-positive detection. + * Resolves the DB target selection from raw CLI args with a single left-to-right pass, skipping + * value tokens that follow space-separated value-consuming flags to avoid false-positive + * detection. * - * `setFlags` is built in the fixed order ["db-url","linked","local"] so the - * rendered conflict string (`[db-url linked]`, `[linked local]`, …) matches - * cobra's alphabetically-sorted output exactly. + * `setFlags` is built in the fixed alphabetical order `["db-url", "linked", "local"]` so the + * rendered conflict string (`[db-url linked]`, `[linked local]`, …) matches exactly. * - * `connType` follows `ParseDatabaseConfig`'s Changed-first precedence: + * `connType` follows Changed-first precedence: * 1. `--db-url` if changed → "db-url" * 2. `--local` if changed → "local" * 3. `--linked` if changed → "linked" @@ -278,9 +225,8 @@ export function resolveDbTargetFlags(args: ReadonlyArray<string>): DbTargetSelec let skipNext = false; for (const token of args) { - // pflag: a value-consuming flag consumes the next token as its value even - // when that token is "--". Only a "--" that is NOT a pending value acts as - // the end-of-options sentinel. + // A value-consuming flag consumes the next token as its value even when that token is "--". + // Only a "--" that is not a pending value acts as the end-of-options sentinel. if (skipNext) { skipNext = false; continue; diff --git a/apps/cli/src/command-internal/db-target-flags.unit.test.ts b/apps/cli/src/command-internal/db-target-flags.unit.test.ts index 2834bae780..84c576779d 100644 --- a/apps/cli/src/command-internal/db-target-flags.unit.test.ts +++ b/apps/cli/src/command-internal/db-target-flags.unit.test.ts @@ -49,7 +49,6 @@ describe("resolveDbTargetFlags", () => { const result = resolveDbTargetFlags(["--local=false", "--linked"]); expect(result.setFlags).toEqual(["linked", "local"]); expect(result.setFlags).toHaveLength(2); - // connType: local wins over linked in Changed-first precedence expect(result.connType).toBe("local"); }); @@ -66,34 +65,28 @@ describe("resolveDbTargetFlags", () => { }); it("--db-url (key only, value as next arg) is still detected as changed", () => { - // `--db-url` matches the token exactly, even without `=value`. const result = resolveDbTargetFlags(["--db-url", "postgres://x"]); expect(result.connType).toBe("db-url"); expect(result.setFlags).toEqual(["db-url"]); }); it("setFlags order is always alphabetical [db-url, linked, local] regardless of argv order", () => { - // All three present — setFlags must be sorted to match cobra's %v rendering. const result = resolveDbTargetFlags(["--local", "--db-url=x", "--linked"]); expect(result.setFlags).toEqual(["db-url", "linked", "local"]); }); it("Changed-first precedence: db-url > local > linked", () => { - // db-url wins when all three are present const all = resolveDbTargetFlags(["--db-url=x", "--linked", "--local"]); expect(all.connType).toBe("db-url"); - // local wins over linked when db-url absent const localLinked = resolveDbTargetFlags(["--linked", "--local"]); expect(localLinked.connType).toBe("local"); - // linked wins when only linked is present const linkedOnly = resolveDbTargetFlags(["--linked"]); expect(linkedOnly.connType).toBe("linked"); }); it("skips value token after bare --schema so --linked is not a false positive", () => { - // `--schema --linked` in space form: --linked is the VALUE of --schema, not a flag. const result = resolveDbTargetFlags(["db", "lint", "--schema", "--linked"]); expect(result.connType).toBeUndefined(); expect(result.setFlags).toEqual([]); @@ -106,68 +99,54 @@ describe("resolveDbTargetFlags", () => { }); it("--schema=value (attached form) does NOT skip the next token", () => { - // `--schema=public --linked`: --linked is a real flag here. const result = resolveDbTargetFlags(["--schema=public", "--linked"]); expect(result.connType).toBe("linked"); expect(result.setFlags).toEqual(["linked"]); }); it("skips value token after bare -s (short for --schema)", () => { - // `-s --linked`: --linked is the VALUE of -s, not a flag. const result = resolveDbTargetFlags(["-s", "--linked"]); expect(result.connType).toBeUndefined(); expect(result.setFlags).toEqual([]); }); it("-svalue (attached short form) does NOT skip the next token", () => { - // `-spublic --linked`: --linked is a real flag. const result = resolveDbTargetFlags(["-spublic", "--linked"]); expect(result.connType).toBe("linked"); expect(result.setFlags).toEqual(["linked"]); }); it("skips value token after bare --output so following flags are not false positives", () => { - // --output is a value-consuming global flag. const result = resolveDbTargetFlags(["--output", "json", "--local"]); expect(result.connType).toBe("local"); expect(result.setFlags).toEqual(["local"]); }); it("--output-dir <value> does NOT mark --local as changed (value consumed)", () => { - // inspect report has --output-dir (StringVar, no short alias). In space form - // the next token is the dir value, not a flag. Without output-dir in the - // value-consuming set, --local would be falsely detected as changed. const result = resolveDbTargetFlags(["--output-dir", "--local"]); expect(result.connType).toBeUndefined(); expect(result.setFlags).toEqual([]); }); it("--output-dir=<value> (attached form) DOES mark --local as changed", () => { - // Attached form does not consume the next token, so --local is a real flag. const result = resolveDbTargetFlags(["--output-dir=./reports", "--local"]); expect(result.connType).toBe("local"); expect(result.setFlags).toEqual(["local"]); }); it("--schema -- --linked: -- consumed as schema value, --linked is a real flag (Go pflag parity)", () => { - // pflag: a bare value-consuming flag consumes the very next token as its - // value, even when that token is "--". Only a "--" with no pending value - // terminates the scan. const result = resolveDbTargetFlags(["db", "lint", "--schema", "--", "--linked"]); expect(result.connType).toBe("linked"); expect(result.setFlags).toEqual(["linked"]); }); it("bare -- with no pending skip still stops the scan", () => { - // --linked sets changed; bare -- terminates; --local after is not scanned. const result = resolveDbTargetFlags(["--linked", "--", "--local"]); expect(result.connType).toBe("linked"); expect(result.setFlags).toEqual(["linked"]); }); it("skips value token after bare -p so --local is the password value, not a target", () => { - // Go: `StringVarP(&dbPassword, "password", "p", …)` — `-p --local` means the - // password is `--local`, so `local` is not Changed (linked default applies). const result = resolveDbTargetFlags(["migration", "list", "-p", "--local"]); expect(result.connType).toBeUndefined(); expect(result.setFlags).toEqual([]); @@ -180,7 +159,6 @@ describe("resolveDbTargetFlags", () => { }); it("-ppwd (attached short password) does NOT consume the next token", () => { - // Attached value: `--local` is a real selector. const result = resolveDbTargetFlags(["-ppwd", "--local"]); expect(result.connType).toBe("local"); expect(result.setFlags).toEqual(["local"]); @@ -194,21 +172,11 @@ describe("resolveDbTargetFlags", () => { }); describe("VALUE_CONSUMING_LONG_FLAGS / VALUE_CONSUMING_SHORT_FLAGS completeness (CLI-1896 review)", () => { - // `telemetry/command-telemetry.ts`'s `extractChangedFlagNames` - // relies on these two sets to know which flag consumes the next raw-argv - // token as its value, across EVERY command (not just the db-target - // subset this file's other describe block covers) — see the doc comment on - // `VALUE_CONSUMING_LONG_FLAGS` in `db-target-flags.ts`. This scan is - // static-source-based (same technique as - // `shared/cli/code-structure.unit.test.ts`) rather than importing every - // command module, so it can only see flag names declared as a literal - // string argument to `Flag.string`/`Flag.integer`/`Flag.choice`/ - // `Flag.choiceWithValue`/`Flag.float` — it cannot trace a name passed - // through a helper function (`issue.command.ts`'s - // `issueOptionalTextFlag`, and the shared `stringSliceFlag` - // builder — CLI-2005), so such files/flags are simply not discovered by the - // scan; their flag names are registered by hand in - // `VALUE_CONSUMING_LONG_FLAGS` instead. + // `extractChangedFlagNames` relies on these two sets across every command, not just the + // db-target subset this file's other describe block covers — see the doc comment on + // `VALUE_CONSUMING_LONG_FLAGS` in `db-target-flags.ts`. This scan is static-source-based + // rather than importing every command module, so it can only see flag names declared as a + // literal string; a name passed through a helper function is registered by hand instead. const commandsDir = fileURLToPath(new URL("../commands", import.meta.url)); const INDIRECT_NAME_FILES = new Set(["issue.command.ts"]); const VALUE_FLAG_KINDS = ["string", "integer", "choice", "choiceWithValue", "float"]; @@ -241,9 +209,8 @@ describe("VALUE_CONSUMING_LONG_FLAGS / VALUE_CONSUMING_SHORT_FLAGS completeness const current = calls[i]!; if (!VALUE_FLAG_KINDS.includes(current.kind)) continue; - // Name declared as a literal string (e.g. `Flag.string("schema")`). - // A name passed as an identifier (`Flag.string(name)`) doesn't match - // and is silently skipped — see INDIRECT_NAME_FILES above. + // Name declared as a literal string (e.g. `Flag.string("schema")`); a name passed as an + // identifier doesn't match and is silently skipped — see INDIRECT_NAME_FILES above. const remainder = source.slice(current.index); const nameMatch = remainder.match(/^Flag\.\w+\(\s*"([a-zA-Z0-9-]+)"/); if (!nameMatch) continue; diff --git a/apps/cli/src/command-internal/detect-content-type.ts b/apps/cli/src/command-internal/detect-content-type.ts index 9cd3162be8..2c01986594 100644 --- a/apps/cli/src/command-internal/detect-content-type.ts +++ b/apps/cli/src/command-internal/detect-content-type.ts @@ -1,16 +1,10 @@ /** - * Faithful 1:1 port of `net/http` content sniffer (`net/http/sniff.go`'s - * `DetectContentType` + `sniffSignatures`), reproduced from the Go 1.x stdlib. - * - * `seed buckets` upload path runs `http.DetectContentType` on the first 512 - * bytes of each object, so the - * stored Storage `Content-Type` metadata is byte-driven, not extension-driven. - * Porting this verbatim is the only way to store the same Content-Type the Go CLI - * would. The signature table and its ORDER are 1:1 with `sniffSignatures` - * (first match wins); kept dependency-free and pure for a Go-parity test corpus. + * Content-type sniffer for the `seed buckets` upload path: Storage's `Content-Type` metadata is + * byte-driven, not extension-driven, matching Go's `net/http.DetectContentType` byte-for-byte so + * stored metadata stays reproducible. Signature order matters: first match wins. */ -// The algorithm uses at most sniffLen bytes to make its decision. +// The sniffer considers at most this many leading bytes. const SNIFF_LEN = 512; /** Latin-1 byte view of a string literal (each char code is one byte). */ @@ -22,12 +16,12 @@ function bytesOf(s: string): Uint8Array { return out; } -// isWS reports whether the byte is a whitespace byte (0xWS) per the spec. +// The WHATWG spec's 0xWS whitespace-byte definition. function isWS(b: number): boolean { return b === 0x09 || b === 0x0a || b === 0x0c || b === 0x0d || b === 0x20; } -// isTT reports whether the byte is a tag-terminating byte (0xTT) per the spec. +// The WHATWG spec's 0xTT tag-terminating-byte definition. function isTT(b: number): boolean { return b === 0x20 || b === 0x3e; // ' ' or '>' } @@ -43,7 +37,6 @@ function byteAt(arr: Uint8Array, i: number): number { return b === undefined ? -1 : b; } -// bytes.HasPrefix(data, sig). function exactSig(sig: string, ct: string): SniffSig { const pat = bytesOf(sig); return (data) => { @@ -55,7 +48,7 @@ function exactSig(sig: string, ct: string): SniffSig { }; } -// WHATWG masked pattern match (`maskedSig`). +// WHATWG masked pattern match. function maskedSig(mask: string, pat: string, ct: string, skipWS = false): SniffSig { const m = bytesOf(mask); const p = bytesOf(pat); @@ -70,9 +63,8 @@ function maskedSig(mask: string, pat: string, ct: string, skipWS = false): Sniff }; } -// `htmlSig`: case-insensitive tag prefix followed by a tag-terminating byte. The -// pattern is stored uppercase (as in Go); the data byte is uppercased via & 0xDF -// only where the pattern byte is A-Z. +// Case-insensitive tag prefix followed by a tag-terminating byte. The pattern is stored +// uppercase; the data byte is uppercased via & 0xDF only where the pattern byte is A-Z. function htmlSig(sig: string): SniffSig { const h = bytesOf(sig); return (data, firstNonWS) => { @@ -89,7 +81,7 @@ function htmlSig(sig: string): SniffSig { }; } -// `mp4Sig`: WHATWG MP4 box signature (section 6.2.1). +// WHATWG MP4 box signature (section 6.2.1). const mp4Sig: SniffSig = (data) => { if (data.length < 12) return undefined; const boxSize = @@ -122,7 +114,7 @@ const mp4Sig: SniffSig = (data) => { return undefined; }; -// `textSig` (must be last): text/plain unless a binary control byte is present. +// Must be last: text/plain unless a binary control byte is present. const textSig: SniffSig = (data, firstNonWS) => { for (let i = firstNonWS; i < data.length; i++) { const b = byteAt(data, i); @@ -133,7 +125,7 @@ const textSig: SniffSig = (data, firstNonWS) => { return "text/plain; charset=utf-8"; }; -// 1:1 with `sniffSignatures`, including order (first match wins). +// Signature order matters: first match wins. const SNIFF_SIGNATURES: ReadonlyArray<SniffSig> = [ htmlSig("<!DOCTYPE HTML"), htmlSig("<HTML"), @@ -214,9 +206,8 @@ const SNIFF_SIGNATURES: ReadonlyArray<SniffSig> = [ ]; /** - * Reproduces `http.DetectContentType`: considers at most the first 512 - * bytes and always returns a valid MIME type, falling back to - * `application/octet-stream` when no signature matches. + * Sniffs the MIME type from up to the first 512 bytes, always returning a valid type and + * falling back to `application/octet-stream` when no signature matches. */ export function detectContentType(input: Uint8Array): string { const data = input.length > SNIFF_LEN ? input.subarray(0, SNIFF_LEN) : input; diff --git a/apps/cli/src/command-internal/detect-content-type.unit.test.ts b/apps/cli/src/command-internal/detect-content-type.unit.test.ts index 899446d935..70fc8d0483 100644 --- a/apps/cli/src/command-internal/detect-content-type.unit.test.ts +++ b/apps/cli/src/command-internal/detect-content-type.unit.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { detectContentType } from "./detect-content-type.ts"; -/** Latin-1 byte view of a string (matches Go's []byte("…") for our fixtures). */ +/** Latin-1 byte view of a string for fixture inputs. */ function bytes(s: string): Uint8Array { const out = new Uint8Array(s.length); for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i) & 0xff; @@ -10,8 +10,8 @@ function bytes(s: string): Uint8Array { } describe("detectContentType", () => { - // Expected values produced by running Go's `http.DetectContentType` (go1.x) - // over the identical byte inputs — this locks the port to byte-exact parity. + // Expected values were produced by running Go's `http.DetectContentType` over these same + // byte inputs. const corpus: ReadonlyArray<readonly [string, string, string]> = [ ["png", "\x89PNG\x0D\x0A\x1A\x0A\x00\x00", "image/png"], ["pdf", "%PDF-1.4\n...", "application/pdf"], @@ -43,8 +43,6 @@ describe("detectContentType", () => { } it("considers only the first 512 bytes (a PNG magic past 512 is ignored)", () => { - // 600 bytes of plain text then a PNG magic — beyond the sniff window, so it - // stays text/plain (Go truncates to data[:512]). const padded = "a".repeat(600) + "\x89PNG\x0D\x0A\x1A\x0A"; expect(detectContentType(bytes(padded))).toBe("text/plain; charset=utf-8"); }); diff --git a/apps/cli/src/command-internal/diff-engine.ts b/apps/cli/src/command-internal/diff-engine.ts index c6504d452b..e0ebc1b3a5 100644 --- a/apps/cli/src/command-internal/diff-engine.ts +++ b/apps/cli/src/command-internal/diff-engine.ts @@ -5,11 +5,9 @@ export const schemaPathsTransitionWarning = "WARNING: [db.migrations].schema_paths no longer changes the migrations baseline used by db diff or migration-style db pull. These commands always compare local migrations with the selected database. Use `supabase db schema declarative sync` to compare declarative schema files.\n"; /** - * Whether pg-delta is the active default engine. Mirrors `shouldUsePgDelta`: - * `utils.IsPgDeltaEnabled() || usePgDelta || viper.GetBool("EXPERIMENTAL_PG_DELTA")`. - * The three inputs are the resolved config flag (`[experimental.pgdelta].enabled`), - * the command's `--use-pg-delta` flag, and the `SUPABASE_EXPERIMENTAL_PG_DELTA` - * env var. + * Whether pg-delta is the active default engine: the resolved config flag + * (`[experimental.pgdelta].enabled`), the command's `--use-pg-delta` flag, or the + * `SUPABASE_EXPERIMENTAL_PG_DELTA` env var, whichever is set. */ export function shouldUsePgDelta(inputs: { readonly configEnabled: boolean; @@ -20,11 +18,10 @@ export function shouldUsePgDelta(inputs: { } /** - * Reports whether `db diff` should run in pg-delta mode. Mirrors Go's - * `resolveDiffEngine`: an explicit `--use-migra`, - * `--use-pgadmin`, or `--use-pg-schema` is an authoritative rollback that clears - * pg-delta mode; `--use-migra` defaults to true so only an explicit pass - * (`useMigraChanged`) counts as opting out. + * Reports whether `db diff` should run in pg-delta mode. An explicit `--use-migra`, + * `--use-pgadmin`, or `--use-pg-schema` is an authoritative rollback that clears pg-delta mode; + * `--use-migra` defaults to true, so only an explicit pass (`useMigraChanged`) counts as opting + * out. */ export function resolveDiffEngine(inputs: { readonly useMigraChanged: boolean; @@ -39,11 +36,9 @@ export function resolveDiffEngine(inputs: { } /** - * Selects whether migration-style `db pull` uses pg-delta for the shadow diff - * step. Mirrors `resolvePullDiffEngine`: an explicit - * `--diff-engine` always wins (so `--diff-engine migra` is an authoritative - * rollback even when pg-delta is enabled in config); otherwise the default - * follows the active engine. + * Selects whether migration-style `db pull` uses pg-delta for the shadow diff step. An explicit + * `--diff-engine` always wins (so `--diff-engine migra` is an authoritative rollback even when + * pg-delta is enabled in config); otherwise the default follows the active engine. */ export function resolvePullDiffEngine(inputs: { readonly engineFlagChanged: boolean; @@ -57,9 +52,8 @@ export function resolvePullDiffEngine(inputs: { } /** - * Parses a `viper.GetBool`-style boolean env var. Go's viper delegates to - * `strconv.ParseBool`, which accepts exactly `1 t T TRUE true True` as true and - * treats every other value (including unparseable strings and unset) as false. + * Parses a boolean env var: accepts exactly `1`, `t`, `T`, `TRUE`, `true`, `True` as true, and + * every other value (including unparseable strings and unset) as false. */ export function parseBoolEnv(raw: string | undefined): boolean { switch (raw) { @@ -76,21 +70,11 @@ export function parseBoolEnv(raw: string | undefined): boolean { } /** - * Resolves `db pull` declarative mode from the raw argv, replicating pflag's - * single-variable, last-occurrence-wins binding. Go binds BOTH `--declarative` - * and the deprecated alias `--use-pg-delta` to the same `useDeclarative` - * variable, so when both appear the LAST - * occurrence in argv wins — e.g. `db pull --declarative --use-pg-delta=false` - * ends in migration mode (`false`), and `--use-pg-delta --declarative=false` - * likewise. OR-ing the two parsed booleans would instead take the declarative - * export path for either invocation, diverging from Go. - * - * pflag bool flags are switches: a bare `--declarative` is `true`; `--flag=value` - * parses `value` via `strconv.ParseBool` (same true-set as viper above). A - * space-separated token after a bool flag is NOT consumed (it falls through as a - * positional), so only the `=value` form carries a value. Tokens after the `--` - * argv terminator are positionals, not flags. Returns `undefined` when neither - * flag is present so the caller falls back to the Go default (`false`). + * Resolves `db pull` declarative mode from raw argv: `--declarative` and the deprecated + * `--use-pg-delta` alias bind to the same outcome, so the last occurrence in argv wins (ORing + * the two would wrongly select declarative mode from either flag alone). A bare flag is `true`; + * `--flag=value` parses with {@link parseBoolEnv}'s true-set — a following space-separated token + * is never consumed as a value. Returns `undefined` when neither flag is present. */ export function resolveDeclarativeFromArgs(args: ReadonlyArray<string>): boolean | undefined { const FLAG_PATTERN = /^--(?:declarative|use-pg-delta)(?:=(.*))?$/u; diff --git a/apps/cli/src/command-internal/docker-bind-classify.ts b/apps/cli/src/command-internal/docker-bind-classify.ts index 1fb53cf845..419cd1eae3 100644 --- a/apps/cli/src/command-internal/docker-bind-classify.ts +++ b/apps/cli/src/command-internal/docker-bind-classify.ts @@ -1,15 +1,7 @@ /** - * Go's `loader.ParseVolume` bind-vs-named-volume classification (docker/cli's - * vendored `compose-go/v2/loader.ParseVolume`, read by `DockerStart` when it - * splits `HostConfig.Binds` into bind mounts vs named volumes — - * `apps/cli-go/internal/utils/docker.go:388-399`): a bind's source is a bind - * mount when it looks like a file path (starts with `.`, `/`, `~`, or a - * Windows drive/UNC prefix); otherwise it is a named volume. - * - * Hoisted here so every `docker run`/`docker create` argv builder that needs - * this classification — `docker-run.args.ts` (`docker run`) and - * `command-internal/db-bootstrap/docker-create-args.ts` (`docker create`) — shares one - * implementation instead of duplicating the regex. + * Classifies a bind spec's source as a bind mount or a named volume: it is a bind mount when it + * looks like a file path (starts with `.`, `/`, `~`, or a Windows drive/UNC prefix), otherwise a + * named volume. */ export function isBindMountSource(source: string): boolean { return /^[.~/]/.test(source) || /^[A-Za-z]:[\\/]/.test(source) || source.startsWith("\\\\"); @@ -17,13 +9,8 @@ export function isBindMountSource(source: string): boolean { /** * Extracts the `source` field from a `source:target[:mode]` bind spec, Windows-drive-aware. - * Go's `loader.ParseVolume` (vendored `compose-go/v2/format.ParseVolume`, `format/volume.go`) is a - * character-by-character state machine whose `isWindowsDrive` check treats the colon after a - * single-letter prefix as part of the path, not a field separator — so `C:\repo\functions:/home/ - * deno/functions:ro` keeps `C:\repo\functions` as one field. A naive `bind.split(":")[0]` instead - * truncates that down to `"C"`, which {@link isBindMountSource} then misclassifies as a - * named-volume name instead of a bind-mount path. Every caller that needs the source field for - * classification must use this instead of a raw split. + * A naive `bind.split(":")[0]` truncates `C:\repo\functions:/home/deno/functions:ro` down to + * `"C"`, which {@link isBindMountSource} would then misclassify as a named volume. */ export function bindMountSpecSource(bind: string): string { if (/^[A-Za-z]:[\\/]/.test(bind)) { From 6eff3a520061e6f627d67ac6ec1019ad45dc2392 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 12:58:24 +0100 Subject: [PATCH 28/56] chore(repo): trim comments, shard 37 (714 -> 390 comment lines) --- .../vanity-subdomains.errors.ts | 14 ++- ...ains.experimental-gate.integration.test.ts | 32 ++----- .../vanity-subdomains.integration.test.ts | 29 ++----- apps/cli/src/config/command-settings.layer.ts | 52 +++-------- .../command-settings.layer.unit.test.ts | 38 ++------ .../src/config/command-settings.service.ts | 34 ++------ apps/cli/src/config/project-ref.errors.ts | 4 +- apps/cli/src/config/project-ref.layer.ts | 13 +-- .../src/config/project-ref.layer.unit.test.ts | 7 -- apps/cli/src/config/project-ref.service.ts | 45 +++------- apps/cli/src/docs/docs-introspection.ts | 48 +++-------- apps/cli/src/docs/docs-spec.content.ts | 17 ++-- apps/cli/src/docs/docs-spec.tables.ts | 43 +++------- apps/cli/src/docs/docs-spec.ts | 86 +++++-------------- apps/cli/src/docs/docs-spec.unit.test.ts | 14 +-- .../generate-docs-spec.script.unit.test.ts | 12 ++- apps/cli/src/output/bold.ts | 14 +-- apps/cli/src/output/glamour-table.ts | 21 +---- .../quiet-progress-text-output.layer.ts | 18 ++-- ...et-progress-text-output.layer.unit.test.ts | 8 +- apps/cli/src/shared/auth/api.layer.ts | 10 +-- .../src/shared/auth/api.layer.unit.test.ts | 16 ---- apps/cli/src/shared/auth/credentials.layer.ts | 3 - .../src/shared/auth/crypto.layer.unit.test.ts | 23 ++--- apps/cli/src/shared/auth/jwks.ts | 76 +++++++--------- .../cli-go-path-references.unit.test.ts | 25 ++---- 26 files changed, 187 insertions(+), 515 deletions(-) diff --git a/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.errors.ts b/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.errors.ts index 7f9a79f811..5133a95f6a 100644 --- a/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.errors.ts +++ b/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.errors.ts @@ -8,13 +8,9 @@ import { /** * Raised by the `activate` and `check-availability` handlers when - * `--desired-subdomain` is omitted. Go marks the flag required - * (`cmd/vanitySubdomains.go:67,69`) but cobra validates required flags only - * AFTER `PersistentPreRunE` (`cobra@v1.10.2/command.go:985,1005`) — i.e. after - * the `--experimental` gate, login check, and project-ref resolution - * (`cmd/root.go:93-117`) — so the flag is optional at parse time and enforced - * in the handler instead. Byte-matches cobra's required-flag wording - * (`command.go:1198`), same pattern as `ProjectRefRequiredError`. + * `--desired-subdomain` is omitted. The flag stays optional at parse time so the + * `--experimental` gate, login check, and project-ref resolution run first, and this + * error enforces it in the handler instead. */ export class DesiredSubdomainRequiredError extends Data.TaggedError( "DesiredSubdomainRequiredError", @@ -47,8 +43,8 @@ export class VanitySubdomainsGetUnexpectedStatusError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // Unlike check/activate, this gated wrapper does not yet retain the typed - // entitlement result. Keep 404 conservative rather than masking a plan gate. + // This wrapper doesn't retain the typed entitlement result yet, so a 404 stays + // conservative rather than risk masking a plan gate. return statusCodeActionability(this.status); } } diff --git a/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.experimental-gate.integration.test.ts b/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.experimental-gate.integration.test.ts index de3ee17b8d..408fcca027 100644 --- a/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.experimental-gate.integration.test.ts +++ b/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.experimental-gate.integration.test.ts @@ -15,12 +15,8 @@ import { } from "../../../tests/helpers/command-mocks.ts"; import { vanitySubdomainsCommand } from "./vanity-subdomains.command.ts"; -// See postgres-config.experimental-gate.integration.test.ts for the full -// rationale: this proves `--experimental` is wired into the actual -// `.command.ts` handler pipeline AND runs before -// `managementApiRuntimeLayer`'s eager access-token resolution -// (the `IsExperimental` check precedes `IsManagementAPI` in -// `apps/cli-go/cmd/root.go:91-109`). +// See postgres-config.experimental-gate.integration.test.ts: this proves `--experimental` +// gates the command pipeline before `managementApiRuntimeLayer`'s eager access-token resolution. const tempRoot = useTempWorkdir("supabase-vanity-subdomains-experimental-int-"); @@ -38,23 +34,15 @@ function setup() { out, api, cliSettings: mockCommandSettings({ workdir: tempRoot.current }), - // `RuntimeInfo` is ambient (not provided by `managementApiRuntimeLayer` - // itself), so the real `commandCredentialsLayer` built inline inside the - // command for the "gate open" case resolves ITS `RuntimeInfo` from this - // layer. Point homeDir at this test's isolated tempRoot so the layer's - // file-based token fallback (`<homeDir>/.supabase/access-token`) can't pick - // up a stray token left at the shared default `/tmp/supabase-cli-test-home`. + // Points homeDir at this test's isolated tempRoot so the "gate open" case's real + // `commandCredentialsLayer` can't pick up a stray token from the shared default home. runtimeInfo: mockRuntimeInfo({ homeDir: tempRoot.current }), }); const layer = Layer.mergeAll( runtime, CliOutput.layer(textCliOutputFormatter()), - // The "gate open" case reaches the real `managementApiRuntimeLayer` - // (provided inline inside the command, not by this test's mocked runtime), - // which reads credentials/env directly — an ambient SUPABASE_ACCESS_TOKEN, - // SUPABASE_EXPERIMENTAL, or OS keyring entry on the machine running the - // test would make these assertions non-deterministic. Wipe process.env - // down to just this and disable the keyring fallback. + // Wipes ambient SUPABASE_ACCESS_TOKEN/SUPABASE_EXPERIMENTAL/keyring so the "gate open" + // case's real `managementApiRuntimeLayer` can't pick up host state. processEnvLayer({ SUPABASE_NO_KEYRING: "1" }), Layer.succeed( TelemetryRuntime, @@ -79,12 +67,8 @@ function setup() { } describe("vanity-subdomains experimental gate (Go PersistentPreRunE parity)", () => { - // `check-availability` and `activate` deliberately OMIT `--desired-subdomain`: - // Go marks it required (`cmd/vanitySubdomains.go:67,69`) but cobra validates - // required flags only after `PersistentPreRunE` (`cobra@v1.10.2 - // command.go:985,1005`), so the gate error must win when both flags are - // missing. The TS flag is optional at parse time (enforced in the handler) - // precisely so this ordering holds — these cases assert it end to end. + // `check-availability` and `activate` omit `--desired-subdomain`: it's optional at parse + // time, so the experimental gate error wins when both flags are missing. const leaves: ReadonlyArray<{ readonly name: string; readonly args: ReadonlyArray<string> }> = [ { name: "get", args: ["vanity-subdomains", "get"] }, { diff --git a/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts b/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts index b1488519e3..0869fd3133 100644 --- a/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts +++ b/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts @@ -67,9 +67,8 @@ function runtimeWith(opts: { }); } -// Builds an API mock where the given write endpoint is billing-gated (402) and -// the project/entitlements lookups report no access to `vanity_subdomain`. Used -// to exercise the upgrade-suggestion branch in `activate` and `check-availability`. +// Mocks a billing-gated (402) write endpoint with entitlements reporting no access to +// `vanity_subdomain`, for the upgrade-suggestion branch tests. function gatedApi(matchWrite: (url: string) => boolean) { return mockCommandPlatformApi({ handler: (request) => @@ -192,7 +191,7 @@ describe("vanity-subdomains get", () => { return Effect.gen(function* () { yield* vanitySubdomainsGet({ projectRef: Option.none() }); expect(out.stdoutText).toContain("status: custom-domain-used"); - // yaml.v3 lowercases the whole field name (CLI-1975). + // yaml.v3 lowercases the whole field name. expect(out.stdoutText).toContain("customdomain: example.com"); }).pipe(Effect.provide(layer)); }); @@ -204,8 +203,7 @@ describe("vanity-subdomains get", () => { return Effect.gen(function* () { yield* vanitySubdomainsGet({ projectRef: Option.none() }); - // Go declaration order (CustomDomain before Status) and a single - // trailing newline, matching BurntSushi (CLI-1975). + // CustomDomain precedes Status, with a single trailing newline. expect(out.stdoutText).toBe('CustomDomain = "example.com"\nStatus = "custom-domain-used"\n'); }).pipe(Effect.provide(layer)); }); @@ -423,9 +421,6 @@ describe("vanity-subdomains check-availability", () => { }).pipe(Effect.provide(layer)); }); - // Go marks --desired-subdomain required but cobra validates it only after - // PersistentPreRunE (gate → login → ref resolution), so the handler enforces - // it with cobra's exact wording after the ref resolves. it.live("fails with cobra's required-flag error when --desired-subdomain is omitted", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ response: { status: 201, body: SAMPLE_CHECK } }); @@ -444,8 +439,6 @@ describe("vanity-subdomains check-availability", () => { }).pipe(Effect.provide(layer)); }); - // Cobra's MarkFlagRequired checks the flag was *changed*, not non-empty — - // an explicit empty value must pass the check and reach the API. it.live("passes an explicit empty --desired-subdomain through to the API", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ response: { status: 201, body: SAMPLE_CHECK } }); @@ -504,7 +497,7 @@ describe("vanity-subdomains activate", () => { projectRef: Option.none(), desiredSubdomain: Option.some("example.com"), }); - // yaml.v3 lowercases the whole field name (CLI-1975). + // yaml.v3 lowercases the whole field name. expect(out.stdoutText).toContain("customdomain: example.com"); }).pipe(Effect.provide(layer)); }); @@ -596,14 +589,10 @@ describe("vanity-subdomains activate", () => { expect(errorJson).toContain("VanitySubdomainsActivateNetworkError"); expect(errorJson).toContain("failed activate vanity subdomain"); } - // A network failure is not a billing gate, so no upgrade is suggested. expect(analytics.captured).toHaveLength(0); }).pipe(Effect.provide(layer)); }); - // Go marks --desired-subdomain required but cobra validates it only after - // PersistentPreRunE (gate → login → ref resolution), so the handler enforces - // it with cobra's exact wording after the ref resolves. it.live("fails with cobra's required-flag error when --desired-subdomain is omitted", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ response: { status: 201, body: SAMPLE_ACTIVATE } }); @@ -622,8 +611,6 @@ describe("vanity-subdomains activate", () => { }).pipe(Effect.provide(layer)); }); - // Cobra's MarkFlagRequired checks the flag was *changed*, not non-empty — - // an explicit empty value must pass the check and reach the API. it.live("passes an explicit empty --desired-subdomain through to the API", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ response: { status: 201, body: SAMPLE_ACTIVATE } }); @@ -751,10 +738,8 @@ describe("vanity-subdomains PersistentPostRun parity", () => { }).pipe(Effect.provide(layer)); }); - // In Go the missing-required-flag failure happens AFTER PersistentPreRunE - // completes, so PersistentPostRun still fires telemetry and writes the - // linked-project cache (`cmd/root.go:171-181,212-233`). The handler-level - // check sits inside both `Effect.ensuring` wrappers to match. + // The desired-subdomain check runs inside both `Effect.ensuring` wrappers, so telemetry + // still flushes and the cache still writes even though it fails before any API call. it.live( "flushes telemetry and writes linked-project cache on a missing --desired-subdomain", () => { diff --git a/apps/cli/src/config/command-settings.layer.ts b/apps/cli/src/config/command-settings.layer.ts index d669a366b3..3fa74c6d06 100644 --- a/apps/cli/src/config/command-settings.layer.ts +++ b/apps/cli/src/config/command-settings.layer.ts @@ -18,18 +18,9 @@ function unknownMessage(error: unknown): string { } /** - * Profile resolution precedence: explicit `--profile` flag → - * `SUPABASE_PROFILE` env → persisted `~/.supabase/profile` file → - * `supabase` — then loads the token via `loadProfile`, failing instead - * of falling back to the built-in `supabase` profile, which silently - * targeted the wrong keyring token and API (supabase/cli#6091). - * - * `explicitFlagValue` mirrors pflag: the LAST explicit `--profile` occurrence - * wins (the Effect parser is first-wins), and an explicit `--profile supabase` - * shadows env and file even at the default value, which the parsed value - * alone cannot detect. The persisted file's content is trimmed — a deliberate - * divergence from the raw file bytes, compensated by the sso pflag - * reconciliation (`pflag-reconcile.ts`). + * Resolves the profile: explicit `--profile` (last occurrence wins) → `SUPABASE_PROFILE` env → + * persisted `~/.supabase/profile` file (trimmed) → `supabase`, then loads its token — failing + * rather than silently falling back to the `supabase` profile's token. */ function resolveProfile( flagValue: string, @@ -47,12 +38,11 @@ function resolveProfile( yield* debugLogger.debug(`Loading profile from flag: ${flag}`); token = flag; } else if (envValue !== undefined && envValue.length > 0) { - // Go reads SUPABASE_PROFILE through viper's PROFILE key, so debug output - // cannot distinguish env from an explicitly changed flag. + // Debug output can't distinguish an env value from an explicitly set flag; both log + // the same message. yield* debugLogger.debug(`Loading profile from flag: ${envValue}`); token = envValue; } else { - // Lowest precedence: the persisted `~/.supabase/profile` file. const filePath = profileFilePath(path, homeDir); const content = yield* fs.readFileString(filePath).pipe( Effect.tap(() => debugLogger.debug(`Loading profile from file: ${filePath}`)), @@ -75,29 +65,12 @@ function resolveProfile( } /** - * `--workdir`/`SUPABASE_WORKDIR` can be a relative string (e.g. `.`), but - * every later reader of the resolved workdir (including the - * `Config.ProjectId` cwd-basename default, run on every config load) must - * see the real ABSOLUTE directory, never the raw configured string. This - * resolves the flag/env value against the real process `cwd`, so - * `CommandSettings.workdir` is always absolute — the invariant that - * basename-ing it (e.g. `resolveLocalProjectId`'s workdir-basename - * fallback) operates on a real directory name, not a relative-path fragment - * like `.` (which would sanitize to an empty project id and build a bare, - * all-projects-matching Docker label filter). - * - * The returned `explicit` flag is what lets JSON-capable config loads - * (`config diff`/`config push`/`config pull`/`gen types`/etc — sites that do - * NOT pass `tomlOnly: true`) skip the second ancestor search that - * `@supabase/config`'s `loadCliConfig`/`findCliProjectPaths` would otherwise - * perform by default. It is true iff this function used the flag/env value - * verbatim without climbing. + * Resolves `--workdir`/`SUPABASE_WORKDIR` to an absolute path: it may be given as a relative + * string (e.g. `.`), but downstream readers such as the project-id cwd-basename fallback need + * a real directory name, not a relative fragment that would sanitize to an empty project id. * - * `pflagWorkdirValue` (`command-internal/pflag-reconcile.ts`) is - * a similar-looking pflag-semantics predicate used for a different purpose - * (SSO/dotenv precedence) and deliberately handles a changed-but-empty - * `--workdir=` differently (treats it as explicit-but-falls-through-to-walk-up, - * never to env) — the two are intentionally NOT unified. + * `explicit` is true only when the flag/env value was used verbatim, without walking up to find + * `supabase/config.toml`; some config loads use it to skip a redundant ancestor search. */ export function resolveWorkdir( flagValue: Option.Option<string>, @@ -114,7 +87,6 @@ export function resolveWorkdir( return { workdir: path.resolve(cwd, envValue), explicit: true }; } let current = cwd; - // Walk up until we hit a directory containing supabase/config.toml or the FS root. while (true) { const candidate = path.join(current, "supabase", "config.toml"); if (yield* configTomlExists(candidate)) { @@ -143,8 +115,8 @@ export const commandSettingsLayer = Layer.unwrap( const runtimeInfo = yield* RuntimeInfo; const env = process.env; - // `serviceOption`: tests without argv default to "not explicit". The - // empty command path scans all of argv up to `--`, like pflag. + // Optional service: tests without argv default to "not explicit". An empty command + // path scans all of argv up to `--`, matching pflag. const cliArgs = yield* Effect.serviceOption(CliArgs); const explicitProfileFlag = Option.match(cliArgs, { onNone: () => undefined, diff --git a/apps/cli/src/config/command-settings.layer.unit.test.ts b/apps/cli/src/config/command-settings.layer.unit.test.ts index e03099c645..358b548f5a 100644 --- a/apps/cli/src/config/command-settings.layer.unit.test.ts +++ b/apps/cli/src/config/command-settings.layer.unit.test.ts @@ -33,9 +33,8 @@ function makeLayer(opts: { Layer.provide(Layer.succeed(ProfileFlag, profileFlag)), Layer.provide(Layer.succeed(WorkdirFlag, workdirFlag)), Layer.provide(Layer.succeed(CliArgs, { args: opts.argv ?? [] })), - // The layer reads `<homeDir>/.supabase/profile` through the real BunServices - // filesystem, so homeDir must default to a per-test directory — a shared - // fixed path would leak stale profile files between runs and machines. + // Reads <homeDir>/.supabase/profile through the real filesystem, so homeDir must be a + // per-test directory to avoid leaking stale profile files between runs. Layer.provide( mockRuntimeInfo({ cwd: opts.cwd ?? "/test/cwd", @@ -47,7 +46,7 @@ function makeLayer(opts: { ); } -// Profile load failures surface as layer-build failures (Go: PersistentPreRunE). +// Profile load failures surface as layer-build failures. function configExit(opts: Parameters<typeof makeLayer>[0]) { return Effect.gen(function* () { return yield* CommandSettings; @@ -172,8 +171,6 @@ describe("commandSettingsLayer", () => { ); }); - // Go fails hard on an unloadable profile — never falls back to the built-in - // `supabase` profile and its keyring token (supabase/cli#6091). it.effect( "fails when SUPABASE_PROFILE is neither a known name nor a readable file — Go parity", () => @@ -201,7 +198,6 @@ describe("commandSettingsLayer", () => { }).pipe(Effect.provide(makeLayer({ profileFlag: "SUPABASE-STAGING", cwd: tempRoot }))), ); - // pflag `Changed`: an explicitly passed flag counts even at its default value. it.effect( "explicit --profile supabase shadows an unloadable SUPABASE_PROFILE — pflag Changed", () => @@ -277,8 +273,7 @@ describe("commandSettingsLayer", () => { expect(config.apiUrl).toBe("http://127.0.0.1:9999"); expect(config.projectHost).toBe("localhost"); expect(config.poolerHost).toBe("staging.example.com"); - // Go reads `dashboard_url` from the profile (used by the connect-failure hint); - // the cli-e2e harness points it at the replay server for parity. + // dashboard_url feeds the connect-failure hint; cli-e2e points it at its replay server. expect(config.dashboardUrl).toBe("http://127.0.0.1:9999"); }).pipe(Effect.provide(makeLayer({ env: { SUPABASE_PROFILE: profilePath }, cwd: tempRoot }))); }); @@ -337,7 +332,8 @@ describe("commandSettingsLayer", () => { }); }); - // Files written by older lenient versions still exist and must fail like Go. + // Files written by older, more lenient CLI versions may still exist on disk and must + // still fail validation. it.effect("fails when the persisted profile file names an unloadable profile", () => { const home = join(tempRoot, "home"); mkdirSync(join(home, ".supabase"), { recursive: true }); @@ -386,9 +382,8 @@ describe("commandSettingsLayer", () => { Effect.gen(function* () { const config = yield* CommandSettings; expect(config.workdir).toBe("/flag/workdir"); - // An explicit non-empty --workdir is used verbatim — CLI-2285: this is - // what lets `shouldSearchAncestors` skip the second, un-Go-like - // ancestor climb inside `loadCliConfig`. + // An explicit --workdir is used verbatim, letting `shouldSearchAncestors` skip the + // second ancestor climb inside `loadCliConfig`. expect(config.explicitWorkdir).toBe(true); }).pipe( Effect.provide( @@ -411,10 +406,6 @@ describe("commandSettingsLayer", () => { ), ); - // A --workdir flag present but set to the EMPTY string is treated as - // absent here (distinct from `pflagWorkdirValue`'s handling of the - // same input elsewhere), so a non-empty SUPABASE_WORKDIR still wins and is - // still explicit. it.effect( "an empty --workdir flag falls through to SUPABASE_WORKDIR env, which is still explicit", () => @@ -433,9 +424,6 @@ describe("commandSettingsLayer", () => { ), ); - // With no env either, the same empty --workdir flag falls all the way - // through to the ancestor walk-up, which is the defaulted (non-explicit) - // path. it.effect("an empty --workdir flag with no env falls through to the walk-up", () => Effect.gen(function* () { const config = yield* CommandSettings; @@ -444,12 +432,6 @@ describe("commandSettingsLayer", () => { }).pipe(Effect.provide(makeLayer({ workdirFlag: Option.some(""), cwd: tempRoot }))), ); - // Every later reader of the resolved workdir — including the - // `Config.ProjectId` cwd-basename default — must see the real absolute - // directory, never the raw flag/env string. A relative `--workdir - // .`/`SUPABASE_WORKDIR=.` must therefore resolve to an absolute path - // here too, not stay `"."` (which would later basename to an empty - // project id). it.effect("resolves a relative --workdir flag against the real cwd", () => Effect.gen(function* () { const config = yield* CommandSettings; @@ -490,8 +472,6 @@ describe("commandSettingsLayer", () => { return Effect.gen(function* () { const config = yield* CommandSettings; expect(config.workdir).toBe(projectRoot); - // The ancestor climb found a config.toml — this resolution is the - // defaulted walk-up, not an explicit --workdir/SUPABASE_WORKDIR. expect(config.explicitWorkdir).toBe(false); }).pipe(Effect.provide(makeLayer({ cwd: nested }))); }); @@ -500,8 +480,6 @@ describe("commandSettingsLayer", () => { Effect.gen(function* () { const config = yield* CommandSettings; expect(config.workdir).toBe(tempRoot); - // The climb never found a config.toml and fell back to cwd unchanged — - // still the defaulted path, not an explicit workdir. expect(config.explicitWorkdir).toBe(false); }).pipe(Effect.provide(makeLayer({ cwd: tempRoot }))), ); diff --git a/apps/cli/src/config/command-settings.service.ts b/apps/cli/src/config/command-settings.service.ts index 7383c004cf..a893c2a278 100644 --- a/apps/cli/src/config/command-settings.service.ts +++ b/apps/cli/src/config/command-settings.service.ts @@ -20,40 +20,20 @@ interface CommandSettingsShape { */ readonly projectHost: string; /** - * eTLD+1 the connection pooler hostname must belong to. Sourced from the - * resolved profile — the built-in table for named profiles, or the - * `pooler_host:` key of a YAML profile file — so custom/staging pooler - * domains are honored. An empty string means "no pooler-domain assertion" - * (the case for the built-in `supabase-local` profile). Used by the linked - * db-config resolver's MITM domain check. + * eTLD+1 the connection pooler hostname must belong to, used by the linked db-config + * resolver's MITM domain check. An empty string disables that assertion (the + * `supabase-local` case). */ readonly poolerHost: string; - /** - * Dashboard base URL for the active profile. Sourced from the resolved - * profile — the built-in table for named profiles, or the - * `dashboard_url:` key of a YAML profile file — so staging/custom - * dashboards are honored. Used by the connect-failure suggestion - * (network-restrictions hint). - */ + /** Dashboard base URL for the active profile, used by the connect-failure network-restrictions hint. */ readonly dashboardUrl: string; readonly accessToken: Option.Option<Redacted.Redacted<string>>; readonly projectId: Option.Option<string>; readonly workdir: string; /** - * Whether {@link workdir} came from an explicit `--workdir`/`SUPABASE_WORKDIR` - * (used exactly as given) rather than the default ancestor walk-up. True iff - * the resolution did NOT climb. - * - * Config loads that accept `supabase/config.json` must pass - * `search: shouldSearchAncestors(cliSettings)` to - * `loadCliConfig`/`findCliProjectPaths`/`findCliProjectRoot` — see - * `shouldSearchAncestors` (`command-internal/workdir-search.ts`) - * for the full rule and why callers that also pass `tomlOnly: true` instead - * pass `search: false` unconditionally. A load that also tolerates a `null` - * result carries a paired obligation for that rule's 4th point: hard-fail - * when `explicitWorkdir` is true unless it's one of the documented - * exceptions — see `missingProjectConfigMessage`/ - * `requireExplicitWorkdirProject` (`command-internal/workdir-project.ts`). + * Whether {@link workdir} came from an explicit `--workdir`/`SUPABASE_WORKDIR` rather than + * the default ancestor walk-up. Config loads use it to decide whether to search ancestor + * directories; see `shouldSearchAncestors` in `command-internal/workdir-search.ts`. */ readonly explicitWorkdir: boolean; readonly userAgent: string; diff --git a/apps/cli/src/config/project-ref.errors.ts b/apps/cli/src/config/project-ref.errors.ts index 91b8a17116..5e6c4bb3df 100644 --- a/apps/cli/src/config/project-ref.errors.ts +++ b/apps/cli/src/config/project-ref.errors.ts @@ -25,9 +25,7 @@ export class InvalidProjectRefError extends Data.TaggedError("InvalidProjectRefE /** * Raised by `resolveForLink` on a non-TTY when neither `--project-ref` nor - * `SUPABASE_PROJECT_ID` is set. Byte-matches cobra's required-flag error string - * (`required flag(s) "project-ref" not set`) that `supabase link`'s `PreRunE` - * produces via `cmd.MarkFlagRequired("project-ref")`. + * `SUPABASE_PROJECT_ID` is set. The message matches cobra's required-flag wording. */ export class ProjectRefRequiredError extends Data.TaggedError("ProjectRefRequiredError")<{ readonly message: string; diff --git a/apps/cli/src/config/project-ref.layer.ts b/apps/cli/src/config/project-ref.layer.ts index afacc5f6a7..003c75494b 100644 --- a/apps/cli/src/config/project-ref.layer.ts +++ b/apps/cli/src/config/project-ref.layer.ts @@ -70,8 +70,7 @@ export const projectRefLayer = Layer.effect( }), ), ); - // Go writes "Selected project: <ref>" to stderr (project_ref.go:50). In text mode - // `output.info` lands on stderr; in json/stream-json modes it is a no-op. + // In text mode `output.info` writes to stderr; in json/stream-json modes it's a no-op. yield* output.info(`Selected project: ${chosen}`); return chosen; }); @@ -105,7 +104,7 @@ export const projectRefLayer = Layer.effect( if (Option.isSome(cliSettings.projectId)) { return yield* assertValid(cliSettings.projectId.value); } - // Go skips the ref-file fallback for link (MemMapFs at link.go:30). + // `resolveForLink` skips the ref-file fallback that `resolve` uses. if (tty.stdinIsTty && output.interactive) { const chosen = yield* promptForProjectRef("Select a project:"); return yield* assertValid(chosen); @@ -124,16 +123,12 @@ export const projectRefLayer = Layer.effect( if (Option.isSome(cliSettings.projectId)) { return cliSettings.projectId; } - // Soft load: `projects list` ignores ALL project-ref resolution - // errors and only uses the value as a "linked" marker, so a real - // ref-file read error degrades to "not linked" here (unlike the - // hard `resolve`/`loadProjectRef` paths, which surface it). + // A ref-file read error degrades to "not linked" here, unlike `resolve`/ + // `loadProjectRef`, since `projects list` only uses the value as a display marker. return yield* readRefFile.pipe(Effect.orElseSucceed(() => Option.none<string>())); }), loadProjectRef: (flagValue) => Effect.gen(function* () { - // Resolution order: flag → env → ref file → hard "not linked" - // failure, with format validation, and NO interactive prompt. if (Option.isSome(flagValue) && flagValue.value.length > 0) { return yield* assertValid(flagValue.value); } diff --git a/apps/cli/src/config/project-ref.layer.unit.test.ts b/apps/cli/src/config/project-ref.layer.unit.test.ts index 43d50a4501..69aad60053 100644 --- a/apps/cli/src/config/project-ref.layer.unit.test.ts +++ b/apps/cli/src/config/project-ref.layer.unit.test.ts @@ -179,7 +179,6 @@ describe("projectRefLayer", () => { return Effect.gen(function* () { const { resolve } = yield* ProjectRefResolver; yield* resolve(Option.none()); - // The resolver must not write the file — only `supabase link` does. expect(existsSync(refPath)).toBe(false); }).pipe(Effect.provide(layer)); }); @@ -291,10 +290,6 @@ describe("projectRefLayer", () => { }); it.effect("fails fast with ProjectRefNotLinkedError and never prompts on a TTY", () => { - // Even on an interactive TTY with projects available, loadProjectRef - // must NOT open the picker (that is `resolve`'s job). `db - // lint`/`db advisors --linked` use loadProjectRef, which fails with - // ProjectRefNotLinkedError instead of prompting. const { layer, out } = makeLayer({ workdir: tempRoot, stdinIsTty: true, @@ -349,8 +344,6 @@ describe("projectRefLayer", () => { }); it.effect("skips the ref file (Go MemMapFs) and fails off-TTY with no flag/projectId", () => { - // A ref file is present, but link must ignore it and fail like cobra's - // required-flag check would. writeRefFile(tempRoot, VALID_REF); const { layer } = makeLayer({ workdir: tempRoot }); return Effect.gen(function* () { diff --git a/apps/cli/src/config/project-ref.service.ts b/apps/cli/src/config/project-ref.service.ts index 5115ee2043..7a1783bc1a 100644 --- a/apps/cli/src/config/project-ref.service.ts +++ b/apps/cli/src/config/project-ref.service.ts @@ -18,14 +18,10 @@ interface ProjectRefResolverShape { never >; /** - * Resolution chain used by `supabase link`. The on-disk `project-ref` file - * is deliberately skipped: - * - * flag → `cliSettings.projectId` (env `SUPABASE_PROJECT_ID`) → (TTY) prompt. - * - * On a non-TTY with neither the flag nor `PROJECT_ID` set, fails with - * `ProjectRefRequiredError`, reproducing cobra's - * `required flag(s) "project-ref" not set` error. + * Resolution chain used by `supabase link`, skipping the on-disk `project-ref` file: + * flag → `cliSettings.projectId` (env `SUPABASE_PROJECT_ID`) → (TTY) prompt. Fails with + * `ProjectRefRequiredError` on a non-TTY when neither is set, matching cobra's + * `required flag(s) "project-ref" not set` wording. */ readonly resolveForLink: ( flagValue: Option.Option<string>, @@ -35,33 +31,19 @@ interface ProjectRefResolverShape { never >; /** - * Soft resolution chain (flag -> `cliSettings.projectId` -> ref file) with **no - * prompt and no failure**. Used by `projects list`, which ignores a - * missing/unreadable ref and only uses the value as a "linked" marker. - * Returns `None` when nothing resolves. - * - * Unlike `resolve`, the returned value is **not** format-validated (see - * `services`'s equivalent validate-and-warn handling, CLI-1872, for an - * alternative pattern). `resolveOptional` skips that warning, which is - * safe only because every current caller uses the value purely as a - * display marker, never injected into an API path — a caller that needs - * the warning should validate and warn itself rather than assume this - * does it. + * Soft resolution chain (flag → `cliSettings.projectId` → ref file), with no prompt and no + * failure; returns `None` when nothing resolves. Unlike `resolve`, the value is not + * format-validated — safe only because every caller treats it as a display marker, never + * an API input; a caller needing validation must do it itself. */ readonly resolveOptional: ( flagValue: Option.Option<string>, ) => Effect.Effect<Option.Option<string>, never, never>; /** - * Non-prompting resolution chain (flag -> `cliSettings.projectId` -> ref file) - * that **fails hard** with `ProjectRefNotLinkedError` when nothing - * resolves, with ref-format validation. Used by the `--linked` PreRun of - * the `db` command family and by the linked branch of database-config - * resolution. - * - * Unlike `resolve`, it never reaches the interactive `PromptProjectRef` TTY - * fallback — `db lint`/`db advisors`/`db query` deliberately call - * `loadProjectRef`, not `resolve`, so a `--linked` run with a token but no - * linked-project file must fail fast rather than open a project picker. + * Non-prompting resolution chain (flag → `cliSettings.projectId` → ref file) that fails + * hard with `ProjectRefNotLinkedError` when nothing resolves, with ref-format validation. + * Used by the `--linked` PreRun of the `db` command family, so a run with a token but no + * linked-project file fails fast instead of opening a project picker. */ readonly loadProjectRef: ( flagValue: Option.Option<string>, @@ -87,9 +69,6 @@ export class ProjectRefResolver extends Context.Service< ProjectRefResolverShape >()("supabase/cli/ProjectRefResolver") {} -// `ref-patterns.ts` is the single canonical definition; re-exported under this -// module's established name since telemetry redaction (`safeFlags` gating) and several -// resolvers import it from here. export const PROJECT_REF_PATTERN = BRANCH_PROJECT_REF_PATTERN; export const PROJECT_NOT_LINKED_MESSAGE = "Cannot find project ref. Have you run supabase link?"; diff --git a/apps/cli/src/docs/docs-introspection.ts b/apps/cli/src/docs/docs-introspection.ts index 0d2f81b739..3063ee1339 100644 --- a/apps/cli/src/docs/docs-introspection.ts +++ b/apps/cli/src/docs/docs-introspection.ts @@ -1,28 +1,11 @@ import type { Command, GlobalFlag, Param, Primitive } from "effect/unstable/cli"; /** - * `.config.flags`/`.config.arguments` (a command's own declared params), - * `.contextConfig.flags` (flags inherited via `Command.withSharedFlags`), and - * `.globalFlags` (a command's own declared global flags) are genuinely absent - * from the public `Command`/`Command.Any` TypeScript interface — only `name`, - * `description`, `shortDescription`, `alias`, `examples`, `subcommands`, - * `annotations`, and `hidden` are public — but they exist at runtime - * (`internal/command.ts`'s `makeCommand`, via `Object.assign`; that internal - * module is not importable — its package.json export map entry is `null` — so - * there is no type-safe import to reach for instead). - * - * A bare `as unknown as` would silently paper over that gap (forbidden by - * this repo's typing rules — see `CLAUDE.md`), so this narrows through a - * runtime type guard instead, the same `"<field>" in value` shape - * `param-introspection.ts`'s `isWrappedParam` establishes for - * the identical problem. The guard checks the nested arrays consumers - * actually dereference, so if a future `effect` version drops or reshapes - * one of these fields, `commandInternals` throws a descriptive error - * instead of silently completing against `undefined`. - * - * `cli/complete.ts` keeps its own private equivalents of these - * guards — deliberately not hoisted, so the docs generator stays purely - * additive over the existing tree. + * `.config.flags`/`.contextConfig.flags`/`.globalFlags` exist on `Command` at runtime but are + * absent from the public `Command`/`Command.Any` type, and the internal module that defines + * them isn't importable. This narrows through a runtime type guard instead of an `as` cast + * (same pattern as `param-introspection.ts`'s `isWrappedParam`), so a future `effect` shape + * change throws a descriptive error here instead of silently reading `undefined`. */ export interface CommandInternals { readonly config: { @@ -74,27 +57,18 @@ export function flattenSubcommands( } /** - * A command's user-facing scoped global flag params — the flags commands - * declare themselves. The parser's built-ins (`GlobalFlag.BuiltIns`) are only - * injected at parse time and never stored on a command's `.globalFlags`, so - * the reference omits them by construction (no filter needed — same fact that - * let `complete.ts` drop its similar guard, issue #6482). Whether - * the reference should document the built-ins that `--help` and completion - * now show is a docs-surface decision tracked as a follow-up. + * A command's own declared global flag params. The parser's built-ins (`GlobalFlag.BuiltIns`) + * are injected at parse time and never stored on `.globalFlags`, so they're excluded here by + * construction. */ export function userGlobalFlagParams(command: Command.Command.Any): ReadonlyArray<Param.AnyFlag> { return commandInternals(command).globalFlags.map((entry) => entry.flag); } /** - * `Flag.choice`/`Flag.choiceWithValue`'s `choiceKeys` (the valid value set) is - * attached to the `Choice`-tagged `Primitive<A>` via `Object.assign` at - * runtime (`Primitive.choice`, - * `.repos/effect/packages/effect/src/unstable/cli/Primitive.ts`) but carries - * an `@internal` JSDoc tag and is absent from the public `Primitive<A>` - * interface — the identical gap `CommandInternals` above works around - * for `Command`, so this reuses the same runtime type-guard idiom instead of - * an `as` cast. + * `choiceKeys` (the valid value set for `Flag.choice`/`Flag.choiceWithValue`) is attached to + * the `Primitive<A>` at runtime but carries an `@internal` tag and is absent from the public + * type, so this narrows through the same runtime type-guard idiom as `CommandInternals` above. */ interface ChoicePrimitive { readonly choiceKeys: ReadonlyArray<string>; diff --git a/apps/cli/src/docs/docs-spec.content.ts b/apps/cli/src/docs/docs-spec.content.ts index 983ed954df..b655cc1ea3 100644 --- a/apps/cli/src/docs/docs-spec.content.ts +++ b/apps/cli/src/docs/docs-spec.content.ts @@ -4,12 +4,10 @@ import { parse } from "yaml"; import type { DocsExample } from "./docs-spec.ts"; /** - * Loads the docs-spec content inputs from `apps/cli/docs/`: the description - * overlay markdown under `supabase/` (keyed by docs-relative POSIX path, the - * same keys `docsOverlayPath` produces on every platform) and the - * per-command examples from `templates/examples.yaml` (keyed by doc id). - * Shared by the generator (`scripts/generate-docs-spec.ts`) and the unit - * tests. + * Loads the docs-spec content inputs from `apps/cli/docs/`: description overlay markdown + * under `supabase/` (keyed by docs-relative POSIX path) and per-command examples from + * `templates/examples.yaml` (keyed by doc id). Shared by the generator + * (`scripts/generate-docs-spec.ts`) and the unit tests. */ export interface DocsContent { readonly overlays: ReadonlyMap<string, string>; @@ -35,10 +33,9 @@ export function readDocsContent(docsDir: string): DocsContent { } /** - * Narrows the parsed `examples.yaml` document to its expected shape — a - * mapping of doc id to example entries with optional string fields — failing - * with the offending doc id instead of letting a malformed file flow into - * the published spec. + * Narrows the parsed `examples.yaml` document to its expected shape — a mapping of doc id to + * example entries with optional string fields — failing with the offending doc id instead of + * letting a malformed file flow into the published spec. */ function parseExamples(parsed: unknown): Readonly<Record<string, ReadonlyArray<DocsExample>>> { if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { diff --git a/apps/cli/src/docs/docs-spec.tables.ts b/apps/cli/src/docs/docs-spec.tables.ts index 9ea5c54c12..0fe9bcf86c 100644 --- a/apps/cli/src/docs/docs-spec.tables.ts +++ b/apps/cli/src/docs/docs-spec.tables.ts @@ -1,28 +1,11 @@ import type { DocsFlag } from "./docs-spec.ts"; /** - * Static data for the docs spec generator — information the Effect command - * tree cannot express structurally: - * - * - Docs-site section tags: envelope compatibility only (the site's - * navigation comes from `common-cli-sections.json`, not from tags). - * - Experimental leaves: each appends the root `--experimental` flag with - * `required: true` to its flag list. - * - Deprecated commands: visible in `--help` (with a "(deprecated)" suffix) - * but excluded from the published reference. - * - Flag defaults: `Flag.withDefault` captures the value in a closure - * (`Param.ts`: `map(optional(self), Option.getOrElse(...))`), so defaults - * that differ from the type's zero value are recorded here, keyed - * `"<doc id> <flag id>"`. - * - * The values were frozen from the retired Go generator's output when the - * pipeline was re-pointed at the TS tree, and every entry was verified - * against it while both generators coexisted. - * Flag display types derive purely from the Effect tree — Go-only scalar - * typing (uint/duration/time) was deliberately not ported, so the reference - * shows the TS types `--help` shows. - * `buildDocsSpec` validates every key here against the walked tree and - * fails the build on stale entries. + * Static data for the docs spec generator — information the Effect command tree cannot + * express structurally: docs-site section tags, `--experimental`-required leaves, deprecated + * commands to exclude from the reference (though still shown in `--help`), and flag defaults + * that `Flag.withDefault` only captures in a closure. `buildDocsSpec` validates every key here + * against the walked tree and fails the build on stale entries. */ export interface DocsInfoTag { @@ -238,10 +221,9 @@ export const DOCS_CHOICE_OVERRIDES: Readonly<Record<string, ReadonlyArray<string /** * Flags excluded from the published reference, keyed `"<doc id> <flag id>"`. - * Cobra hid deprecated flags from help and docs; the Effect port keeps - * `--include-raw-output` parse-visible for compatibility (see - * `commands/domains/SIDE_EFFECTS.md`), so the docs exclusion is restored - * here. Validated at build time like the other tables. + * `--include-raw-output` stays parse-visible for compatibility (see + * `commands/domains/SIDE_EFFECTS.md`) but is hidden from docs here. Validated at build time + * like the other tables. */ export const DOCS_EXCLUDED_FLAGS: ReadonlySet<string> = new Set([ "supabase-domains-activate include-raw-output", @@ -252,11 +234,10 @@ export const DOCS_EXCLUDED_FLAGS: ReadonlySet<string> = new Set([ ]); /** - * Usage argument overrides, keyed by doc id — for commands whose published - * argument rendering cannot be derived from the Effect tree. Both were - * hand-written cobra `Use` strings: the parser accepts zero occurrences - * (`secrets set --env-file` passes no positionals; `storage rm` validates in - * the handler), but the documented shape is required. + * Usage argument overrides, keyed by doc id, for commands whose published argument rendering + * can't be derived from the Effect tree: the parser accepts zero occurrences here + * (`secrets set --env-file` passes no positionals; `storage rm` validates in the handler), but + * the documented shape is required. */ export const DOCS_ARG_OVERRIDES: Readonly<Record<string, string>> = { "supabase-secrets-set": "<NAME=VALUE> ...", diff --git a/apps/cli/src/docs/docs-spec.ts b/apps/cli/src/docs/docs-spec.ts index 59a1686d1a..7e7bf61200 100644 --- a/apps/cli/src/docs/docs-spec.ts +++ b/apps/cli/src/docs/docs-spec.ts @@ -26,45 +26,12 @@ import { import type { DocsInfoTag } from "./docs-spec.tables.ts"; /** - * Builds the `clispec 001` document consumed by the supabase.com CLI - * reference (`supabase/supabase` `apps/docs/spec/cli_v1_commands.yaml`) from - * the legacy Effect command tree — replacing the retired Go generator's - * cobra walk. + * Builds the `clispec 001` document consumed by the supabase.com CLI reference + * (`apps/docs/spec/cli_v1_commands.yaml`) from the Effect command tree. A command's `id` is + * its `CommandPath` joined by dashes — the public URL slug and `common-cli-sections.json` join key. * - * The load-bearing contract, verified against the retired Go generator's - * output while both coexisted: command `id` is `CommandPath` with spaces - * replaced by dashes and is the public URL slug plus the join key into - * `common-cli-sections.json`; `subcommands`, `flags`, `tags`, `links` are - * always arrays; `default_value` is always present; flag `name` is a - * preformatted display string (`-p, --password <string>`); enum flags carry - * `accepted_values`; experimental leaves append the root `--experimental` - * flag. All 135 Go command ids, titles, subcommand sets, defaults and - * accepted values matched; flag sets matched once deprecated flags were - * excluded (`DOCS_EXCLUDED_FLAGS`). - * - * Deliberately NOT reproduced from the Go output: - * - go-yaml serialization quirks: trailing-newline padding of long strings - * and the reversed command order (consumers parse the YAML and join on - * `id`); - * - cobra's `UseLine` rule of appending ` [flags]` only when a command - * declares its own local flags — every leaf here accepts flags, so the - * suffix is emitted on all leaf usage strings; - * - cobra's flag ordering where persistent group flags trailed local ones — - * flags that were persistent in cobra are own config flags in the Effect - * tree and sort alphabetically with the rest; - * - Go-only scalar display typing (`uint`/`duration`/`time`/`stringArray`) — - * flag types render as the Effect tree (and `--help`) declares them; - * - argument labels in usage strings render the Effect tree's names, which - * can differ from Go's hand-written `Use` wording (`[name]` vs - * `[project name]`) and are sometimes more precise (`orgs create <name>`, - * `functions deploy [Function name] ...`); - * - TS-only surfaces (commands, flags, examples) are additions by design. - * - * Every static-table key (`docs-spec.tables.ts`) and every content - * input (description overlays, `examples.yaml` entries) is validated against - * the walked tree at build time — a stale entry after a command or flag - * rename fails the build with the offending keys listed instead of silently - * degrading the published reference. + * Every static-table key and content input is validated against the walked tree at build time, + * failing the build on stale entries instead of silently degrading the reference. */ export interface DocsExample { @@ -334,11 +301,10 @@ export function buildDocsSpec(input: DocsSpecInput): DocsSpec { const allowedOrphanOverlayPaths = new Set<string>(); /** - * Builds the visible flag docs for a command's params, dropping hidden - * flags and `DOCS_EXCLUDED_FLAGS` entries (deprecated flags cobra - * hid from the reference). Does NOT record `seenFlagKeys` — callers record - * only the flags that actually reach the output, so table validation - * reflects the published spec, not intermediate candidates. + * Builds the visible flag docs for a command's params, dropping hidden flags and + * `DOCS_EXCLUDED_FLAGS` entries. Does not record `seenFlagKeys` itself — callers record + * only the flags that reach the output, so table validation reflects the published spec, + * not intermediate candidates. */ const flagDocsFor = ( commandPath: ReadonlyArray<string>, @@ -457,10 +423,8 @@ export function buildDocsSpec(input: DocsSpecInput): DocsSpec { flags, }); - // A non-root command's scoped global flags (`Command.withGlobalFlags` - // below root, e.g. `seed`'s `--linked`/`--local`) behave like persistent - // flags: the reference surfaces them on every leaf beneath the command, - // alongside shared (`contextConfig`) flags. + // A non-root command's own global flags (e.g. `seed`'s `--linked`/`--local`) behave like + // persistent flags: they're surfaced on every leaf beneath it, alongside shared flags. const childInherited = [ ...inheritedParams, ...internals.contextConfig.flags, @@ -524,12 +488,11 @@ export function buildDocsSpec(input: DocsSpecInput): DocsSpec { } /** - * Serializes the spec for publication. Emitted under YAML 1.1 quoting rules - * so scalars like `yes`/`no` stay strings for downstream YAML 1.1 parsers - * (PyYAML, Psych, go-yaml v2); the output remains equally valid YAML 1.2. - * Anchors/aliases are disabled — the injected `--experimental` doc is the - * same object on every experimental leaf, and the serializer would otherwise - * emit `&a1`/`*a1` references the published file never carried. + * Serializes the spec for publication. Uses YAML 1.1 quoting so scalars like `yes`/`no` stay + * strings for downstream YAML 1.1 parsers (PyYAML, Psych, go-yaml v2); the output remains + * valid YAML 1.2. Anchors/aliases are disabled since the injected `--experimental` doc is the + * same object on every experimental leaf, and the serializer would otherwise emit `&a1`/`*a1` + * references the published file never carried. */ export function stringifyDocsSpec(spec: DocsSpec): string { return stringify(spec, { @@ -541,10 +504,8 @@ export function stringifyDocsSpec(spec: DocsSpec): string { } /** - * Fails the build when any static-table key no longer resolves against the - * walked tree — the guard that keeps the frozen tables honest after a - * command or flag rename, since nothing else cross-checks them once the Go - * generator is gone. + * Fails the build when any static-table key no longer resolves against the walked tree — the + * guard that keeps the frozen tables honest after a command or flag rename. */ function validateDocsTables(seen: { readonly emittedIds: ReadonlySet<string>; @@ -596,13 +557,10 @@ function validateDocsTables(seen: { } /** - * Extends the build-fails-on-drift guarantee to the content inputs: an - * overlay whose path maps to no walked command, or an `examples.yaml` doc id - * matching no emitted command, would otherwise vanish from the published - * reference silently (the page falls back to the terse tree description). - * Overlays belonging to `DOCS_EXCLUDED` commands are deliberate - * orphans (deprecated pages keep their content until the follow-up cleanup) - * and stay allowed. + * Extends the build-fails-on-drift guarantee to content inputs: an overlay or `examples.yaml` + * doc id matching no emitted command would otherwise vanish from the published reference + * silently. Overlays belonging to `DOCS_EXCLUDED` commands stay allowed as orphans until their + * pages are removed. */ function validateDocsContent( input: DocsSpecInput, diff --git a/apps/cli/src/docs/docs-spec.unit.test.ts b/apps/cli/src/docs/docs-spec.unit.test.ts index 45bc699ed0..0d205e93b7 100644 --- a/apps/cli/src/docs/docs-spec.unit.test.ts +++ b/apps/cli/src/docs/docs-spec.unit.test.ts @@ -78,13 +78,8 @@ describe("docsStripOverlayHeading", () => { describe("buildDocsSpec", () => { it("never publishes an unlisted command subtree", () => { - // The guarantee is that `Command.unlisted` keeps a family out of the public - // docs reference. Nothing pinned it, for the `experimental` family or for the - // older `db test|branch|remote` precedent, so a future refactor could quietly - // start publishing them. - // - // Derived from the tree rather than matching a `supabase-experimental*` - // prefix, so it covers every unlisted subtree that exists now or later. + // Walks the tree rather than matching a `supabase-experimental*` prefix, so it also + // catches any other unlisted subtree, present or future. const { spec } = builtSpec(); const emitted = new Set(spec.commands.map((command) => command.id)); @@ -383,9 +378,8 @@ describe("build guards fail loudly", () => { overlays, examples: { "supabase-lonk": [{ id: "a", code: "x" }] }, }); - // the stale-table throw fires first on this synthetic tree; assert the - // content guard directly by checking its message is reachable when the - // table validation is satisfied — covered via the real-tree fixture below. + // The synthetic tree fails table validation before it reaches content validation; the + // content guard is asserted separately below against the real tree. expect(build).toThrow(/stale static-table entries/); const { content } = builtSpec(); const badExamples = { ...content.examples, "supabase-not-a-command": [{ id: "a" }] }; diff --git a/apps/cli/src/docs/generate-docs-spec.script.unit.test.ts b/apps/cli/src/docs/generate-docs-spec.script.unit.test.ts index b2c752090c..fec21495e2 100644 --- a/apps/cli/src/docs/generate-docs-spec.script.unit.test.ts +++ b/apps/cli/src/docs/generate-docs-spec.script.unit.test.ts @@ -5,13 +5,11 @@ import { parse } from "yaml"; const cliRoot = path.resolve(import.meta.dirname, "../.."); /** - * Subprocess-level coverage of the `scripts/generate-docs-spec.ts` entrypoint - * — the contract `docs/README.md` documents: the spec YAML is the ONLY thing - * on stdout (`> cli_v1_commands.yaml` must yield a clean parseable file), the - * version defaults to `latest`, and a `v`-prefixed argument is stripped. The - * builder itself is covered in-process by `docs-spec.unit.test.ts`; - * these two spawns pin the argv handling, path resolution, and stdout purity - * that direct helper calls cannot. + * Subprocess-level coverage of the `scripts/generate-docs-spec.ts` entrypoint (see + * `docs/README.md` for the full contract): the spec YAML is the only thing on stdout, the + * version defaults to `latest`, and a `v`-prefixed argument is stripped. `docs-spec.unit.test.ts` + * covers the builder itself in-process; these spawns pin the argv handling and stdout purity + * that direct calls can't. */ describe("generate-docs-spec.ts entrypoint", () => { function runScript(args: ReadonlyArray<string>): { stdout: string; stderr: string } { diff --git a/apps/cli/src/output/bold.ts b/apps/cli/src/output/bold.ts index 2015a3b9b7..b162812a0d 100644 --- a/apps/cli/src/output/bold.ts +++ b/apps/cli/src/output/bold.ts @@ -1,15 +1,9 @@ /** - * Renders a string bold, reproducing lipgloss's `Bold(true)` behavior. + * Renders text bold for a TTY, unchanged otherwise. * - * lipgloss emits ANSI only when its output stream is detected as a TTY (termenv's - * color profile is `Ascii` — no escapes — when stdout is not a terminal, e.g. when - * piped or captured in e2e). lipgloss's default renderer keys this off **stdout**, - * regardless of which stream the bolded text is ultimately written to, so callers - * pass `Tty.stdoutIsTty` here even when the text goes to stderr (as `inspect report` - * does for "Reports saved to <bold>"). - * - * - TTY → wrap in SGR bold (`\x1b[1m … \x1b[0m`). - * - non-TTY → return the string unchanged (matching termenv's `Ascii` profile). + * Callers pass `Tty.stdoutIsTty` here even when the bolded text is written to stderr (e.g. + * `inspect report`'s "Reports saved to <bold>"), since whether bolding is enabled is keyed off + * stdout, not the destination stream. */ export function bold(text: string, isTty: boolean): string { return isTty ? `\x1b[1m${text}\x1b[0m` : text; diff --git a/apps/cli/src/output/glamour-table.ts b/apps/cli/src/output/glamour-table.ts index 29c6778716..8d3d2253a1 100644 --- a/apps/cli/src/output/glamour-table.ts +++ b/apps/cli/src/output/glamour-table.ts @@ -1,21 +1,8 @@ /** - * renderGlamourTable - Reproduces the byte output of glamour's `RenderTable` - * using `styles.AsciiStyle` for the markdown tables this CLI emits. - * - * Output shape (each line terminated by "\n"): - * - * <blank line> - * <2-space prefix><nothing> <- decorative empty line Glamour emits - * <space><header-cell><space>|<space><header-cell><space>|...<space> - * <2-space prefix><dashes>|<dashes>|...<dashes> - * <space><data-cell><space>|<space>...<space> - * ... - * <blank line> - * - * Each cell is padded to the column width: max(len(header), max(len(row[i]))). - * The padded cell is wrapped with " ... " (one space either side), so the cell - * width in the output is colWidth + 2. The separator row uses dashes of the - * same width, joined by "|". + * Renders the CLI's established ASCII table format: a blank line, a decorative empty line, the + * header row, a dash separator, each data row, and a trailing blank line — every line ending in + * "\n". Each cell is padded to `max(len(header), max(len(row[i])))` and wrapped with one space + * on each side; the separator uses dashes of the same width, joined by "|". */ export function renderGlamourTable( headers: ReadonlyArray<string>, diff --git a/apps/cli/src/output/quiet-progress-text-output.layer.ts b/apps/cli/src/output/quiet-progress-text-output.layer.ts index 678cfb1644..0d1da13fe5 100644 --- a/apps/cli/src/output/quiet-progress-text-output.layer.ts +++ b/apps/cli/src/output/quiet-progress-text-output.layer.ts @@ -4,19 +4,11 @@ import { textOutputLayer } from "../shared/output/output.layer.ts"; import { Output } from "../shared/output/output.service.ts"; /** - * Wrapper over the shared text output layer for the Go-compat - * machine-format flag (`-o json|yaml|toml|env`). - * - * `--output` selects a machine encoder that the handler writes via - * `output.raw`. If the text layer stays fully active, its progress spinner - * writes ANSI escape sequences to stdout and corrupts that payload — see - * CLI-1546, where `branches list -o json` emitted a hide-cursor sequence ahead - * of the JSON and broke `JSON.parse`. - * - * This layer suppresses ONLY the transient progress UI (`task`/`progress`). - * Everything else (errors -> red text on stderr, `raw`, logs, `format: "text"`) - * delegates to the text layer unchanged, so output stays byte-identical - * while stdout stays parseable. + * Wrapper over the shared text output layer for the machine-format flag (`-o + * json|yaml|toml|env`) that suppresses only the transient progress UI (`task`/`progress`), + * since a live spinner would write ANSI escapes to stdout and corrupt the machine payload the + * handler writes via `output.raw`. Everything else delegates to the text layer unchanged, so + * output stays byte-identical while stdout stays parseable. */ export const quietProgressTextOutputLayer = Layer.effect( Output, diff --git a/apps/cli/src/output/quiet-progress-text-output.layer.unit.test.ts b/apps/cli/src/output/quiet-progress-text-output.layer.unit.test.ts index 677ebe3627..e1f39746c4 100644 --- a/apps/cli/src/output/quiet-progress-text-output.layer.unit.test.ts +++ b/apps/cli/src/output/quiet-progress-text-output.layer.unit.test.ts @@ -74,8 +74,7 @@ describe("quietProgressTextOutputLayer", () => { const out = yield* Output; const task = yield* out.task("Fetching branches..."); yield* task.message("Still fetching..."); - // Past TASK_SPINNER_DELAY_MS (200ms) — the text layer would have shown a - // spinner by now; the quiet wrapper must not. + // TASK_SPINNER_DELAY_MS is 200ms; the text layer would show a spinner by now. vi.advanceTimersByTime(500); yield* task.clear(); @@ -99,9 +98,8 @@ describe("quietProgressTextOutputLayer", () => { it.effect("stays on the text layer so errors keep Go parity (red text on stderr)", () => Effect.gen(function* () { const out = yield* Output; - // `format === "text"` is what routes withJsonErrorHandling back to the - // top-level text `output.fail` (red text on stderr) instead of a JSON - // envelope on stdout — i.e. it preserves Go error-output parity. + // `format === "text"` routes `withJsonErrorHandling` to the top-level text `output.fail` + // (red text on stderr) instead of a JSON envelope on stdout. expect(out.format).toBe("text"); }).pipe(Effect.provide(layer)), ); diff --git a/apps/cli/src/shared/auth/api.layer.ts b/apps/cli/src/shared/auth/api.layer.ts index a01eecefb3..d0046b8b49 100644 --- a/apps/cli/src/shared/auth/api.layer.ts +++ b/apps/cli/src/shared/auth/api.layer.ts @@ -12,13 +12,9 @@ import { Api, type LoginSessionResponse } from "./api.service.ts"; const BODY_DECODE_REASONS = new Set<string>(["DecodeError", "EmptyBodyError"]); /** - * Maps any fetcher failure to an {@link ApiError}, preserving the classification - * signal: - * - a received status → `statusCode` (transport error → status-less, classified - * as network); - * - a body/schema decode failure — whether an `HttpClientError` decode reason or - * a non-`HttpClientError` thrown while decoding — → `decode: true`, classified - * as an API response problem instead of network. + * Maps any fetcher failure to an {@link ApiError}, preserving the classification signal: a + * received status → `statusCode` (no status → classified as network); a body/schema decode + * failure → `decode: true`, classified as an API response problem instead of network. */ function mapToApiError(error: unknown): Effect.Effect<never, ApiError> { if (HttpClientError.isHttpClientError(error)) { diff --git a/apps/cli/src/shared/auth/api.layer.unit.test.ts b/apps/cli/src/shared/auth/api.layer.unit.test.ts index eb67dd685b..4b719e0026 100644 --- a/apps/cli/src/shared/auth/api.layer.unit.test.ts +++ b/apps/cli/src/shared/auth/api.layer.unit.test.ts @@ -5,19 +5,11 @@ import { Api } from "./api.service.ts"; import { ApiError } from "./errors.ts"; import { makeApi } from "./api.layer.ts"; -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - const API_URL = "https://api.supabase.com"; const SESSION_ID = "test-session-id"; const DEVICE_CODE = "test-device-code"; const EXPECTED_URL = `${API_URL}/platform/cli/login/${SESSION_ID}?device_code=${DEVICE_CODE}`; -// --------------------------------------------------------------------------- -// Mock factory -// --------------------------------------------------------------------------- - function mockHttpClient(opts: { status?: number; body?: unknown; transportError?: string }) { const requests: string[] = []; @@ -55,10 +47,6 @@ function mockHttpClient(opts: { status?: number; body?: unknown; transportError? }; } -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - function getFailError(exit: Exit.Exit<unknown, unknown>): unknown { if (!Exit.isFailure(exit)) throw new Error("Expected failure"); const fail = exit.cause.reasons.find(Cause.isFailReason); @@ -66,10 +54,6 @@ function getFailError(exit: Exit.Exit<unknown, unknown>): unknown { return fail.error; } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe("Api", () => { describe("fetchLoginSession", () => { it.effect("parses JSON on successful response", () => { diff --git a/apps/cli/src/shared/auth/credentials.layer.ts b/apps/cli/src/shared/auth/credentials.layer.ts index 6a75fdf0e8..01f959f592 100644 --- a/apps/cli/src/shared/auth/credentials.layer.ts +++ b/apps/cli/src/shared/auth/credentials.layer.ts @@ -27,7 +27,6 @@ const makeCredentials = Effect.gen(function* () { : yield* Effect.tryPromise(() => import("@napi-rs/keyring")).pipe(Effect.option); return Credentials.of({ - // Read current storage first, then fall back to legacy account and finally the filesystem. getAccessToken: Effect.gen(function* () { if (Option.isSome(keyringModule)) { try { @@ -57,7 +56,6 @@ const makeCredentials = Effect.gen(function* () { return Option.none(); }).pipe(Effect.orElseSucceed(() => Option.none())), - // Writes follow the same policy: keyring when possible, filesystem when necessary. saveAccessToken: (token: string | Redacted.Redacted<string>) => Effect.gen(function* () { const plainToken = typeof token === "string" ? token : Redacted.value(token); @@ -75,7 +73,6 @@ const makeCredentials = Effect.gen(function* () { yield* fs.writeFileString(fallbackPath, plainToken, { mode: 0o600 }); }).pipe(Effect.orDie), - // Deletes the token from all storage locations. Returns true if anything was deleted. deleteAccessToken: Effect.gen(function* () { let anyDeleted = false; diff --git a/apps/cli/src/shared/auth/crypto.layer.unit.test.ts b/apps/cli/src/shared/auth/crypto.layer.unit.test.ts index caed26c365..4021000bff 100644 --- a/apps/cli/src/shared/auth/crypto.layer.unit.test.ts +++ b/apps/cli/src/shared/auth/crypto.layer.unit.test.ts @@ -60,16 +60,12 @@ describe("Crypto", () => { const { generateKeyPair } = yield* Crypto; const { ecdh, publicKeyHex } = yield* generateKeyPair; - // Uncompressed EC public keys on prime256v1 are 65 bytes = 130 hex chars, - // and always start with the 0x04 prefix byte. expect(publicKeyHex).toHaveLength(130); expect(publicKeyHex.startsWith("04")).toBe(true); - // The ECDH object must have a private key so we can compute shared secrets. expect(ecdh.getPrivateKey()).toBeInstanceOf(Buffer); expect(ecdh.getPrivateKey().length).toBeGreaterThan(0); - // The public key reported by the object must match the returned hex string. expect(ecdh.getPublicKey("hex", "uncompressed")).toBe(publicKeyHex); }).pipe(Effect.provide(testLayer)); }); @@ -121,8 +117,7 @@ describe("Crypto", () => { const name = yield* defaultTokenName; const after = Date.now(); - // Extract the trailing numeric timestamp from the token name. - // Both formats end with _<timestamp>: cli_<ts> or cli_<user>@<host>_<ts> + // Token names end with _<timestamp>: cli_<ts> or cli_<user>@<host>_<ts>. const match = name.match(/_(\d+)$/); expect(match).not.toBeNull(); const ts = Number(match![1]); @@ -133,13 +128,11 @@ describe("Crypto", () => { it.effect("falls back to cli_<ts> when userInfo throws", () => { mockOs.userInfoShouldThrow = true; - // The Crypto layer is a sync layer, so we need to build a fresh one - // after the mock is set up; re-use testLayer since defaultTokenName - // calls userInfo lazily at invocation time (inside Effect.sync). + // testLayer can be reused here since defaultTokenName calls userInfo lazily, inside + // Effect.sync, at invocation time rather than at layer-construction time. return Effect.gen(function* () { const { defaultTokenName } = yield* Crypto; const name = yield* defaultTokenName; - // The fallback format is exactly cli_<timestamp> with no @ or host part expect(name).toMatch(/^cli_\d+$/); }) .pipe(Effect.provide(testLayer)) @@ -157,7 +150,6 @@ describe("Crypto", () => { return Effect.gen(function* () { const { defaultTokenName } = yield* Crypto; const name = yield* defaultTokenName; - // Empty username makes the if-condition falsy, producing the bare timestamp format expect(name).toMatch(/^cli_\d+$/); }) .pipe(Effect.provide(testLayer)) @@ -176,17 +168,14 @@ describe("Crypto", () => { return Effect.gen(function* () { const { generateKeyPair, decryptToken } = yield* Crypto; - // Client (CLI) side: generate key pair const { ecdh: clientEcdh, publicKeyHex: clientPublicKeyHex } = yield* generateKeyPair; - // Server side: encrypt a known plaintext directed at the client's public key const serverEcdh = createECDH("prime256v1"); serverEcdh.generateKeys(); const serverPrivateKeyHex = serverEcdh.getPrivateKey("hex"); const plaintext = "sbp_test_secret_access_token_12345"; const payload = encryptWithEcdh(serverPrivateKeyHex, clientPublicKeyHex, plaintext); - // Client (CLI) side: decrypt using the private key const decrypted = yield* decryptToken(clientEcdh, payload); expect(decrypted).toBe(plaintext); }).pipe(Effect.provide(testLayer)); @@ -223,11 +212,9 @@ describe("Crypto", () => { const payload1 = encryptWithEcdh(serverPrivateKeyHex, clientPublicKeyHex, plaintext); const payload2 = encryptWithEcdh(serverPrivateKeyHex, clientPublicKeyHex, plaintext); - // Different nonces → different ciphertexts expect(payload1.nonce).not.toBe(payload2.nonce); expect(payload1.ciphertext).not.toBe(payload2.ciphertext); - // Both ciphertexts must still decrypt to the same original plaintext const decrypted1 = yield* decryptToken(clientEcdh, payload1); const decrypted2 = yield* decryptToken(clientEcdh, payload2); expect(decrypted1).toBe(plaintext); @@ -256,8 +243,8 @@ describe("Crypto", () => { ciphertext: flippedChar + payload.ciphertext.slice(1), }; - // AES-GCM auth tag verification should cause a Die (defect) since decryptToken - // uses Effect.sync and the underlying crypto call throws on tampered ciphertext + // decryptToken wraps the crypto call in Effect.sync, so the auth-tag failure surfaces + // as a Die (defect), not a typed error. const exit = yield* decryptToken(clientEcdh, tamperedPayload).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { diff --git a/apps/cli/src/shared/auth/jwks.ts b/apps/cli/src/shared/auth/jwks.ts index d9102d0062..56e33e1496 100644 --- a/apps/cli/src/shared/auth/jwks.ts +++ b/apps/cli/src/shared/auth/jwks.ts @@ -1,12 +1,10 @@ const remoteJwksTimeoutMs = 10_000; /** - * Structural JWK shape shared by the two in-tree JWK types - * (`command-internal/go-jwt.ts`'s `Jwk` and `shared/functions/serve.ts`'s - * `SigningKeyJwk`) so either can be passed to {@link toPublicJwk} without conversion. Defined - * locally rather than importing `Jwk` because `shared/` cannot import from the command tree (see - * `apps/cli/CLAUDE.md`'s "Shared Code" rules) — both existing types already satisfy this shape - * structurally, so no explicit relationship is needed. + * Structural JWK shape shared by the two in-tree JWK types (`command-internal/go-jwt.ts`'s + * `Jwk` and `shared/functions/serve.ts`'s `SigningKeyJwk`), so either can be passed to + * {@link toPublicJwk} without conversion. Defined locally since `shared/` cannot import from + * the command tree. */ export interface JwkLike { readonly kty: string; @@ -23,12 +21,10 @@ export interface JwkLike { } /** - * Filters `key_ops` down to `"verify"` entries, returning `undefined` (never an empty array) when - * none remain — matches Go's `encoding/json` `omitempty` on a slice field, which drops the field - * entirely for a zero-length slice (`apps/cli-go/pkg/config/auth.go:92,126-130`): `ToPublicJWK` - * only ever appends to a nil slice, so Go can emit an absent field or a non-empty array, never - * `"key_ops":[]`. `key.key_ops?.filter(...)` alone doesn't reproduce this: it only returns - * `undefined` when `key.key_ops` itself is `undefined`, not when filtering empties it out. + * Filters `key_ops` down to `"verify"` entries, returning `undefined` — never `[]` — when none + * remain, so the published JWK omits the field instead of serializing an empty array. A plain + * `.filter(...)` doesn't do this: it only returns `undefined` when `key_ops` itself was already + * `undefined`. */ function publicKeyOps( keyOps: ReadonlyArray<string> | undefined, @@ -38,12 +34,11 @@ function publicKeyOps( } /** - * Go's `(j JWK) ToPublicJWK()` (`apps/cli-go/pkg/config/auth.go:111-145`): strips private key - * material (`d`/`p`/`q`/`dp`/`dq`/`qi`) from a signing key before it's published in a JWKS, and - * filters `key_ops` down to `"verify"` entries only (Go never republishes `"sign"`). Field order - * in the returned object matches Go's `JWK` struct declaration order (`kty, kid, use, key_ops, - * alg, ext, n, e` for RSA / `..., crv, x, y` for EC), since both Go's `encoding/json` and JS - * `JSON.stringify` serialize object keys in insertion/declaration order. + * Strips private key material (`d`/`p`/`q`/`dp`/`dq`/`qi`) from a signing key before it's + * published in a JWKS, and filters `key_ops` down to `"verify"` entries only. Field order in + * the returned object is fixed (`kty, kid, use, key_ops, alg, ext, n, e` for RSA / `..., crv, + * x, y` for EC) since `JSON.stringify` serializes keys in declaration order and that order is + * part of the published output's byte contract. */ export function toPublicJwk(key: JwkLike): JwkLike { if (key.kty === "RSA") { @@ -74,10 +69,9 @@ export function toPublicJwk(key: JwkLike): JwkLike { /** * One `[auth.third_party.<provider>]` section, structurally matching `@supabase/config`'s - * `CliConfig["auth"]["third_party"]` — both `shared/functions/serve.ts`'s - * `PlainServeAuthConfig["third_party"]` (itself typed as `CliConfig["auth"]["third_party"]`) - * and `command-internal/local-config-values.ts`'s env-override-resolved third-party object - * satisfy this shape without conversion. + * `CliConfig["auth"]["third_party"]` so both `shared/functions/serve.ts`'s resolved auth + * config and `command-internal/local-config-values.ts`'s env-override-resolved object satisfy + * this shape without conversion. */ export interface ThirdPartyProvidersLike { readonly firebase: { readonly enabled: boolean; readonly project_id?: string }; @@ -98,11 +92,10 @@ export interface ThirdPartyProvidersLike { const clerkDomainPattern = /^(clerk([.][a-z0-9-]+){2,}|([a-z0-9-]+[.])+clerk[.]accounts[.]dev)$/; /** - * Go's `(tpa *thirdParty) validate()` + `(tpa *thirdParty) IssuerURL()` - * (`apps/cli-go/pkg/config/config.go:1635-1707`): rejects more than one enabled provider, - * validates the enabled provider's required field(s), then builds its OIDC issuer URL. Throws a - * plain `Error` with Go's exact message text on a validation failure; returns `undefined` when no - * provider is enabled. + * Rejects more than one enabled third-party provider, validates the enabled provider's + * required field(s), then builds its OIDC issuer URL. Throws a plain `Error` with the + * established message text on a validation failure; returns `undefined` when no provider is + * enabled. */ export function resolveThirdPartyIssuerUrl( thirdParty: ThirdPartyProvidersLike, @@ -182,18 +175,11 @@ export function resolveThirdPartyIssuerUrl( } /** - * Go's `(tpa *thirdParty) IssuerURL()` ALONE (`apps/cli-go/pkg/config/config.go:1685-1707`, each - * provider's own unconditional `issuerURL()` at `config.go:1556-1636`) — no validation at all. Go's - * `Auth.ThirdParty.validate()` (the "at most one enabled" + required-field checks - * {@link resolveThirdPartyIssuerUrl} above performs) only runs inside `Config.Validate`'s `if - * c.Auth.Enabled` block (`config.go:1087-1153`), but `ResolveJWKS`/`IssuerURL()` is called - * unconditionally (formerly `internal/start/start.go:274`, deleted as unreachable in CLI-1966; - * last present at commit a253ccba2) regardless of `auth.enabled`. So when auth is - * disabled, only this unchecked, fixed-priority string builder applies: the first enabled - * provider (firebase, auth0, aws_cognito, clerk, workos, in that order) wins, with no "more than - * one enabled" rejection and no required-field check — a missing required field for the winning - * provider just produces a URL with an empty segment, matching Go's own unchecked string - * interpolation (`fmt.Sprintf` never errors on an empty string argument). + * Builds the OIDC issuer URL for whichever third-party provider is enabled, with no + * validation: the first enabled provider (firebase, auth0, aws_cognito, clerk, workos, in that + * order) wins, and a missing required field produces a URL with an empty segment rather than + * throwing. Used where {@link resolveThirdPartyIssuerUrl}'s "at most one enabled" and + * required-field checks don't apply. */ export function thirdPartyIssuerUrlUnchecked( thirdParty: ThirdPartyProvidersLike, @@ -219,14 +205,10 @@ export function thirdPartyIssuerUrlUnchecked( } /** - * Go's OIDC-discovery + remote-JWKS fetch inside `(a *auth) ResolveJWKS` - * (`apps/cli-go/pkg/config/config.go:1730-1774`): resolves `<issuerUrl>/.well-known/ - * openid-configuration`'s `jwks_uri`, then fetches that URI's `keys` array. Throws/rejects on any - * failure rather than swallowing it — Go's `start` treats a failure here as a hard, - * command-failing error (formerly `internal/start/start.go:274-277`, deleted as unreachable in - * CLI-1966; last present at commit a253ccba2); `shared/functions/serve.ts`'s own - * caller-side leniency (continuing with zero remote keys) is a `functions serve`-only choice made - * at the call site, not part of this function's contract. + * Resolves `<issuerUrl>/.well-known/openid-configuration`'s `jwks_uri`, then fetches that + * URI's `keys` array. Rejects on any failure rather than swallowing it; any leniency (e.g. + * continuing with zero remote keys) is a caller-side choice, not part of this function's + * contract. */ export async function resolveRemoteJwks(issuerUrl: string): Promise<ReadonlyArray<unknown>> { const discoveryResponse = await fetch(`${issuerUrl}/.well-known/openid-configuration`, { diff --git a/apps/cli/src/shared/cli-go-path-references.unit.test.ts b/apps/cli/src/shared/cli-go-path-references.unit.test.ts index 2cbb0de97b..9c69f4e41f 100644 --- a/apps/cli/src/shared/cli-go-path-references.unit.test.ts +++ b/apps/cli/src/shared/cli-go-path-references.unit.test.ts @@ -4,17 +4,11 @@ import { fileURLToPath } from "node:url"; import { describe, expect, it } from "vitest"; /** - * Guards against exactly the failure mode found and fixed in CLI-1966: a test - * or build script reading an `apps/cli-go/...` path directly off disk via - * `new URL("...cli-go/...", import.meta.url)` (see e.g. the fixed - * `shared/functions/serve-main-offline.e2e.test.ts`, which read the now-deleted - * `internal/start/templates/kong.yml`). Those reads only fail loudly when the - * specific test/script actually runs -- for an `.e2e.test.ts` file that's a - * slow, non-default-loop tier (see `apps/cli/CLAUDE.md`'s "Testing" section), - * so a Go-source deletion elsewhere in this milestone could silently strand - * one of these until CI's e2e/live tier finally executes it. This test - * enumerates every such literal across the repo and fails fast, in the - * default unit tier, the moment the referenced path stops existing. + * Every `new URL("...cli-go/...", import.meta.url)` literal reads an `apps/cli-go/...` path + * off disk. Those reads only fail when the specific test or script actually runs — a slow, + * non-default-loop tier for `.e2e.test.ts` files — so a Go-source deletion could silently + * strand a reference until CI's e2e/live tier executes it. This test enumerates every literal + * and fails fast in the default unit tier instead. */ const repoRoot = fileURLToPath(new URL("../../../..", import.meta.url)); @@ -45,16 +39,15 @@ function findCliGoReferences(): Array<Reference> { const references: Array<Reference> = []; for (const dir of scanDirs) { for (const sourceFile of walk(dir)) { - // Excludes this file itself -- its own doc comment and regex source - // above are themselves full of literal text that would otherwise + // Excludes this file itself: its own doc comment and regex source would otherwise // match the pattern being scanned for. if (sourceFile === thisFile) continue; const source = readFileSync(sourceFile, "utf8"); for (const match of source.matchAll(CLI_GO_URL_LITERAL)) { const literal = match[1]!; - // Mirrors `new URL(literal, import.meta.url)`'s own resolution: relative - // to the referencing file's own directory, treating a trailing "/" as a - // directory (matching WHATWG URL semantics, unlike path.resolve alone). + // Mirrors `new URL(literal, import.meta.url)`'s own resolution: relative to the + // referencing file's directory, with a trailing "/" kept as a directory (`path.resolve` + // alone drops it). const resolved = literal.endsWith("/") ? `${path.resolve(path.dirname(sourceFile), literal)}/` : path.resolve(path.dirname(sourceFile), literal); From a97dec55c036f730228a1a49e600f768845f114b Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:00:40 +0100 Subject: [PATCH 29/56] chore(repo): trim comments, shard 28 (804 -> 405 comment lines) --- .../src/commands/inspect/inspect.layers.ts | 36 +--- .../inspect/inspect.layers.unit.test.ts | 21 +-- .../commands/inspect/report/report.config.ts | 43 ++--- .../commands/inspect/report/report.csvq.ts | 98 +++------- .../inspect/report/report.csvq.unit.test.ts | 9 - .../inspect/report/report.e2e.test.ts | 9 +- .../commands/inspect/report/report.errors.ts | 10 +- .../commands/inspect/report/report.handler.ts | 23 +-- .../inspect/report/report.integration.test.ts | 33 +--- .../commands/inspect/report/report.layers.ts | 8 +- .../commands/inspect/report/report.queries.ts | 25 +-- .../commands/inspect/report/report.rules.ts | 26 +-- apps/cli/src/commands/link/link.command.ts | 9 +- apps/cli/src/commands/link/link.errors.ts | 59 +++---- apps/cli/src/commands/link/link.handler.ts | 167 ++++++------------ .../commands/link/link.integration.test.ts | 117 +++--------- .../src/commands/login/login-api.service.ts | 18 +- .../src/commands/login/login-claude-hint.ts | 11 +- .../commands/login/login-crypto.service.ts | 16 +- apps/cli/src/commands/login/login.e2e.test.ts | 7 +- apps/cli/src/commands/login/login.errors.ts | 37 ++-- apps/cli/src/commands/login/login.handler.ts | 20 +-- .../commands/login/login.integration.test.ts | 5 - apps/cli/src/commands/login/login.layers.ts | 15 +- .../src/commands/logout/logout.e2e.test.ts | 10 +- apps/cli/src/commands/logout/logout.errors.ts | 11 +- .../cli/src/commands/logout/logout.handler.ts | 33 ++-- .../logout/logout.integration.test.ts | 11 -- apps/cli/src/commands/logout/logout.layers.ts | 15 +- .../commands/migration/down/down.command.ts | 10 +- 30 files changed, 253 insertions(+), 659 deletions(-) diff --git a/apps/cli/src/commands/inspect/inspect.layers.ts b/apps/cli/src/commands/inspect/inspect.layers.ts index d6c80c1653..f132ef3c8f 100644 --- a/apps/cli/src/commands/inspect/inspect.layers.ts +++ b/apps/cli/src/commands/inspect/inspect.layers.ts @@ -7,49 +7,31 @@ import { identityStitchLayer } from "../../command-internal/identity-stitch.ts"; import { debugLoggerLayer } from "../../command-internal/debug-logger.layer.ts"; import { telemetryStateLayer } from "../../telemetry/telemetry-state.layer.ts"; -/** - * `commandSettingsLayer` is provided to the resolver AND exposed at the top level - * because `Layer.provide` does not share to merge siblings (legacy CLAUDE.md item - * 5); the resolver requires it internally and so it is provided to `dbConfig`, - * while the merge keeps it available alongside. - */ +// `Layer.provide` does not merge into sibling layers, so `commandSettingsLayer` is provided +// here (for `dbConfig`) and merged again below so both have it. const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); const dbConfig = dbConfigLayer.pipe( Layer.provide(cliSettings), Layer.provide(dbConnectionLayer), Layer.provide(debugLoggerLayer), - // The resolver's lazy `--linked` stack snapshots the one per-command - // `IdentityStitch` — a single memoized identity-stitch attempt. Layer.provide(identityStitchLayer), ); /** - * The services every `inspect` leaf shares, minus the command-runtime identity: - * the DB-config resolver, the Postgres connection, the CLI config (for the - * `--workdir` config rules `inspect report` reads), and telemetry state. Mirrors - * `command-internal/test-db.layers.ts` minus the docker layer. - * - * The Management API stack is NOT merged here — it resolves an access token - * eagerly, which would break the auth-free `--local` / `--db-url` paths. The - * `--linked` path provides it lazily inside the resolver (`db-config.layer.ts`). + * The services every `inspect` leaf shares, minus the command-runtime identity: the DB-config + * resolver, the Postgres connection, the CLI config, and telemetry state. * - * Hoisted out of `db/db.layers.ts` so both the `inspect db <leaf>` subcommands and - * `inspect report` (a sibling of `db`, not a child) share one definition rather - * than each carrying a parallel copy. + * The Management API stack is not merged here — it resolves an access token eagerly, which would + * break the auth-free `--local` / `--db-url` paths. The `--linked` path provides it lazily + * inside the resolver. */ export const inspectBaseLayer = Layer.mergeAll( dbConfig, dbConnectionLayer, cliSettings, - // The one per-command identity stitcher — a single memoized identity-stitch - // attempt — exposed at top level so `withCommandTelemetry` can read - // `stitchedDistinctId()` and attribute the cli_command_executed event to the - // gotrue id. The SAME reference is provided to dbConfig above, so memoisation - // gives the lazy linked stack and the instrumentation hook the same - // `stitchAttempted` guard — aliasing/persisting at most once. Its - // Analytics / TelemetryRuntime / FileSystem / Path deps are ambient (root - // runtime). Mirrors advisors.layers.ts / lint.layers.ts. + // The same instance is provided to `dbConfig` above so both it and `withCommandTelemetry` + // share one memoized identity-stitch attempt. identityStitchLayer, telemetryStateLayer, ); diff --git a/apps/cli/src/commands/inspect/inspect.layers.unit.test.ts b/apps/cli/src/commands/inspect/inspect.layers.unit.test.ts index 1253bb2987..22d23b9853 100644 --- a/apps/cli/src/commands/inspect/inspect.layers.unit.test.ts +++ b/apps/cli/src/commands/inspect/inspect.layers.unit.test.ts @@ -1,15 +1,3 @@ -/** - * Layer-exposure test for `inspectBaseLayer`. - * - * Verifies that `IdentityStitch` is exposed at the top level of the - * runtime layer so that `withCommandTelemetry` can read - * `stitchedDistinctId()` via `Effect.serviceOption(IdentityStitch)` and - * attribute the `cli_command_executed` event to the gotrue id. - * - * See `db/lint/lint.layers.unit.test.ts` for the canonical pattern and a - * detailed explanation of the bug this guards against. - */ - import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { Effect, Layer, Option } from "effect"; @@ -47,11 +35,7 @@ import { inspectBaseLayer } from "./inspect.layers.ts"; const tempRoot = useTempWorkdir("supabase-inspect-layers-"); -/** - * Stub layer satisfying every external service required by - * `inspectBaseLayer` from the root runtime. Services under test are - * left as `Effect.die` no-ops — layer construction must not invoke them. - */ +// Services under test are stubbed as `Effect.die` — layer construction must not invoke them. function ambientStubs() { const analytics = mockAnalytics(); const out = mockOutput(); @@ -78,8 +62,7 @@ function ambientStubs() { return Layer.mergeAll( BunServices.layer, - // The runtime layer under test builds the REAL commandSettingsLayer against - // the real filesystem — see isolatedHomeLayer's docs. + // Builds the real commandSettingsLayer against the real filesystem; see isolatedHomeLayer's docs. isolatedHomeLayer(tempRoot.current), mockTty(), mockProcessControl().layer, diff --git a/apps/cli/src/commands/inspect/report/report.config.ts b/apps/cli/src/commands/inspect/report/report.config.ts index db72674687..28ffba2433 100644 --- a/apps/cli/src/commands/inspect/report/report.config.ts +++ b/apps/cli/src/commands/inspect/report/report.config.ts @@ -13,11 +13,9 @@ function asRecord(value: unknown): RawDoc | undefined { } /** - * Coerce a rule field value to a string, using weakly-typed decoding: a - * string passes through; a number/bigint becomes its decimal string; a boolean - * becomes `"1"`/`"0"`; a missing field is the zero value `""`. Any other type (a - * nested table/array/datetime as a scalar field) is NOT coercible — this - * returns `undefined` to signal the caller to fail with `DbConfigLoadError`. + * Coerces a rule field to a string: numbers/bigints become their decimal string, booleans + * become `"1"`/`"0"`, a missing field is `""`, and anything else (a nested table, array, or + * datetime) returns `undefined` so the caller can fail with `DbConfigLoadError`. */ function coerceRuleField(value: unknown): string | undefined { if (value === undefined) return ""; @@ -29,18 +27,10 @@ function coerceRuleField(value: unknown): string | undefined { } /** - * Read `[experimental.inspect.rules]` from `<workdir>/supabase/config.toml`: - * when present and non-empty, these custom rules replace the embedded defaults. - * - * Follows the `readDbToml` policy exactly — a **missing** config file yields - * `[]` (defaults apply), but a **malformed** file is a hard error - * (`DbConfigLoadError`). Each rule's string fields are run through - * `env(VAR)` expansion (`expandEnv`), resolving against the - * shell environment first and then the project `.env` files. - * - * `fs`/`path` are passed in so the caller controls the platform layer; the read is - * colocated here for now and hoisted to `command-internal/` if a second command reads - * `[experimental.inspect.*]`. + * Reads `[experimental.inspect.rules]` from `<workdir>/supabase/config.toml`; when present and + * non-empty, these rules replace the embedded defaults. A missing file yields `[]`; a malformed + * file fails with `DbConfigLoadError`. Each field goes through `env(VAR)` expansion against the + * shell environment, then the project `.env` files. */ export const readInspectRules = Effect.fnUntraced(function* ( fs: FileSystem.FileSystem, @@ -78,14 +68,8 @@ export const readInspectRules = Effect.fnUntraced(function* ( const inspect = asRecord(asRecord(doc?.["experimental"])?.["inspect"]); const rawRules = inspect?.["rules"]; - // Normalize `rules` into the list of entries to decode, using weakly-typed - // decoding: - // - absent → no custom rules (defaults apply) - // - array-of-tables → decode each element as a rule - // - a single table → weak-typing wraps it into a 1-element slice → one rule - // - an EMPTY table → wraps into an empty slice → no custom rules (defaults) - // - a scalar (string/number/…) → wrapped into `[scalar]`, then decoding a scalar - // into a rule struct aborts ("expected a map or struct") — surfaced below. + // A single table wraps into one rule entry; an empty table yields no rules. A scalar also + // wraps into an entry, which fails the table check below. let entries: ReadonlyArray<unknown>; if (rawRules === undefined) { return [] as ReadonlyArray<InspectRule>; @@ -102,16 +86,13 @@ export const readInspectRules = Effect.fnUntraced(function* ( const RULE_FIELDS = ["query", "name", "pass", "fail"] as const; - // Resolve `env(VAR)` against the shell env first, then the project `.env` files. const projectEnv = yield* loadProjectEnv(fs, path, workdir); const lookup = (name: string): string | undefined => process.env[name] ?? projectEnv[name]; const rules: Array<InspectRule> = []; for (let index = 0; index < entries.length; index++) { const record = asRecord(entries[index]); - // A non-table entry (e.g. `rules = ["foo"]` or `rules = "foo"`) is rejected: - // it fails to load with "expected a map or struct" rather than being - // silently skipped. + // Rejects a non-table entry (e.g. `rules = ["foo"]`) instead of silently skipping it. if (record === undefined) { return yield* Effect.fail( new DbConfigLoadError({ @@ -119,8 +100,7 @@ export const readInspectRules = Effect.fnUntraced(function* ( }), ); } - // An unknown/misspelled key in a rule table (e.g. - // `fails = "bad"`) aborts the whole config load — there is no escape hatch. + // An unknown or misspelled key aborts the whole load instead of being ignored. const unknownKeys = Object.keys(record).filter( (key) => !(RULE_FIELDS as ReadonlyArray<string>).includes(key), ); @@ -134,7 +114,6 @@ export const readInspectRules = Effect.fnUntraced(function* ( const fields: Record<string, string> = {}; for (const field of RULE_FIELDS) { const coerced = coerceRuleField(record[field]); - // A non-coercible field type (nested table/array/datetime) aborts too. if (coerced === undefined) { return yield* Effect.fail( new DbConfigLoadError({ diff --git a/apps/cli/src/commands/inspect/report/report.csvq.ts b/apps/cli/src/commands/inspect/report/report.csvq.ts index b1e890fd91..b697be03a8 100644 --- a/apps/cli/src/commands/inspect/report/report.csvq.ts +++ b/apps/cli/src/commands/inspect/report/report.csvq.ts @@ -7,51 +7,17 @@ import { } from "../../../shared/telemetry/error-actionability.ts"; /** - * A bounded, hand-written evaluator for the subset of the csvq SQL dialect that - * the `inspect report` validation rules use. There is no JS port of csvq - * (`github.com/mithrandie/csvq`), and neither DuckDB (native addon) nor alasql - * accepts csvq's dialect, so the report's rule queries are evaluated here. - * - * Supported grammar (anything outside it throws `InspectCsvqError`, which - * the rule evaluator turns into the rule's STATUS cell — a per-rule - * csvq error becomes the cell rather than failing the command): - * - * SELECT <agg|expr> [AS <ident>] - * FROM `<file>.csv` [<alias>] - * [WHERE <condition>] - * [;] - * - * agg := LISTAGG '(' colRef ',' string ')' - * | COUNT '(' ('*' | colRef) ')' - * | (SUM|MIN|MAX|AVG) '(' colRef ')' - * condition := or - * or := and (OR and)* - * and := not (AND not)* - * not := NOT not | predicate - * predicate := '(' condition ')' | comparison - * comparison:= arith ( (op arith) | (IS [NOT] NULL) )? - * expr := concat - * concat := arith ('||' arith)* - * arith := term (('+'|'-') term)* - * term := factor (('*'|'/') factor)* - * factor := number | string | colRef | FLOAT '(' expr ')' - * | REPLACE '(' expr ',' string ',' string ')' | '(' expr ')' - * colRef := ident ('.' ident)? (the alias prefix is ignored — single table) - * - * csvq value semantics replicated for parity: - * - Every CSV cell is a string (csvq reads an empty field as `""`, not NULL); the - * only NULL values are *computed* (an aggregate over zero rows, or arithmetic on - * a non-numeric operand). - * - A comparison numerically compares its operands only when **both** convert to a - * number under strict numeric rules (no surrounding whitespace, no digit - * grouping); otherwise it compares them as strings. This mirrors csvq's type - * promotion, including the quirk that a thousands-grouped `to_char` value such as - * `" 2,000"` falls back to a string comparison. - * - WHERE keeps a row only when the condition evaluates to TRUE (three-valued - * logic: NULL/false exclude the row). + * A hand-written evaluator for the csvq SQL subset (`github.com/mithrandie/csvq`) that `inspect + * report` rule queries use — no JS port exists, and neither DuckDB nor alasql speaks csvq's + * dialect. Every CSV cell is a string, so only computed values (an aggregate over zero rows, or + * arithmetic on a non-numeric operand) can be NULL. A comparison is numeric only when both sides + * parse under strict rules — no whitespace or digit grouping, so `" 2,000"` compares as a string. */ -/** Thrown for grammar or evaluation outside the supported csvq subset. */ +/** + * Thrown for grammar or evaluation outside the supported csvq subset; the rule evaluator + * surfaces it as the rule's STATUS cell instead of failing the command. + */ export class InspectCsvqError extends Error { static readonly [ErrorActionabilityFingerprintId] = "InspectCsvqError"; override readonly name = "InspectCsvqError"; @@ -61,8 +27,8 @@ export class InspectCsvqError extends Error { } } -// CSV parsing (RFC 4180, the shape Postgres `COPY ... WITH CSV HEADER` emits). -// Every field is a string; quoting only affects escaping, not value identity. +// RFC 4180 CSV, the shape Postgres `COPY ... WITH CSV HEADER` emits. Every field is a string; +// quoting only affects escaping, not value identity. /** A parsed CSV table: header → column index, plus the data rows as strings. */ export interface CsvTable { @@ -112,8 +78,8 @@ function parseCsvRecords(text: string): Array<Array<string>> { // `pushRow` resets `started`, so a trailing `\n` leaves no phantom row. pushRow(); } else if (ch === "\r") { - // Swallow a bare/`\r\n` CR outside quotes WITHOUT marking the record started, - // so a stray trailing CR cannot synthesise an empty record at EOF. + // Swallows a bare or `\r\n` CR outside quotes without marking the record started, so a + // trailing CR can't synthesize an empty record at EOF. } else { field += ch; started = true; @@ -141,8 +107,6 @@ export function parseReportCsv(input: Uint8Array | string): CsvTable { return { columns, rows: records.slice(1) }; } -// Tokenizer - type Token = | { readonly t: "ident"; readonly v: string } | { readonly t: "btick"; readonly v: string } @@ -191,7 +155,7 @@ function tokenize(sql: string): Array<Token> { value += sql[i]; i++; } - i++; // closing backtick + i++; tokens.push({ t: "btick", v: value }); continue; } @@ -253,8 +217,6 @@ function tokenize(sql: string): Array<Token> { return tokens; } -// AST - type ValNode = | { readonly k: "num"; readonly n: number } | { readonly k: "str"; readonly s: string } @@ -291,8 +253,6 @@ interface SelectStmt { const AGG_FNS = new Set(["LISTAGG", "COUNT", "SUM", "MIN", "MAX", "AVG"]); -// Parser (recursive descent) - class Parser { private pos = 0; constructor(private readonly tokens: ReadonlyArray<Token>) {} @@ -333,7 +293,6 @@ class Parser { parse(): SelectStmt { this.expectKeyword("SELECT"); const { agg, expr } = this.parseSelectExpr(); - // optional `AS <ident>` if (this.eatKeyword("AS")) { const tok = this.next(); if (tok.t !== "ident") throw new InspectCsvqError("expected alias after AS"); @@ -343,7 +302,7 @@ class Parser { if (tableTok.t !== "btick") { throw new InspectCsvqError("expected a backtick-quoted CSV table name"); } - // optional table alias (a bare ident that is not a clause keyword) + // A bare ident here is a table alias unless it's the WHERE keyword. if (this.peek().t === "ident" && !this.isKeyword("WHERE")) { this.pos++; } @@ -529,8 +488,6 @@ class Parser { } } -// Evaluation - type EvalValue = | { readonly kind: "null" } | { readonly kind: "num"; readonly n: number } @@ -538,9 +495,6 @@ type EvalValue = const NULL_VALUE: EvalValue = { kind: "null" }; -// Strict numeric parsing accepts no surrounding whitespace and no digit grouping, -// so a `to_char`-formatted value (e.g. `" 2,000"`) does NOT convert -// to a number and falls back to a string comparison, exactly as csvq does. const STRICT_NUMERIC = /^[+-]?(?:\d+(?:\.\d+)?|\.\d+)(?:[eE][+-]?\d+)?$/; function toNumber(value: EvalValue): number | undefined { @@ -691,11 +645,8 @@ function evalAggregate( if (agg.fn === "COUNT" && agg.star === true) { return Option.some(String(rows.length)); } - // Resolve the aggregate's column up front, so an unknown column errors at - // "bind time" regardless of how many rows match — matching csvq, which validates - // referenced columns against the table schema before evaluating rows. (This is - // what surfaces default rule 6's `s.tbl` typo as a STATUS cell even when the - // matched set is empty.) + // Resolves the column before checking any rows, so an unknown column always errors — + // matching csvq's schema validation, even when the matched set would otherwise be empty. const index = columnIndex(table, agg.col!); if (agg.fn === "COUNT") { // CSV cells are never NULL, so COUNT(col) == COUNT(*) == the matched-row count. @@ -730,17 +681,14 @@ function evalAggregate( export type CsvTableProvider = (name: string) => CsvTable | undefined; /** - * Evaluate a csvq rule query to its scalar first-column result. + * Evaluates a csvq rule query to its scalar first-column result. * - * Returns `Option.none()` for the two cases that map to a passing rule with a `-` - * matches cell: an aggregate over zero matched rows (csvq NULL) and a - * non-aggregate select that matches no rows. Returns - * `Option.some(value)` otherwise — including `Option.some("")` for a valid empty - * string, which is also treated as a pass but renders as an empty matches cell. + * Returns `Option.none()` when an aggregate matches zero rows (csvq NULL) or a non-aggregate + * select matches no rows — both render as a passing rule with a `-` matches cell. Returns + * `Option.some(value)` otherwise, including `Option.some("")` for a valid empty string. * - * Throws `InspectCsvqError` for unsupported grammar, an unknown table, or an - * unknown column; the rule evaluator catches it and uses the message as the STATUS - * cell, using csvq's own error text. + * Throws `InspectCsvqError` (using csvq's own error text) for unsupported grammar or an unknown + * table/column; the rule evaluator turns it into the STATUS cell. */ export function evalCsvqScalar(query: string, provider: CsvTableProvider): Option.Option<string> { const duplicateIndexes = evalDuplicateIndexesQuery(query, provider); diff --git a/apps/cli/src/commands/inspect/report/report.csvq.unit.test.ts b/apps/cli/src/commands/inspect/report/report.csvq.unit.test.ts index 8aac0a4dbb..4683515bb4 100644 --- a/apps/cli/src/commands/inspect/report/report.csvq.unit.test.ts +++ b/apps/cli/src/commands/inspect/report/report.csvq.unit.test.ts @@ -113,7 +113,6 @@ describe("default rules — pass and fail fixtures", () => { "table_stats.csv": "name,seq_scans,estimated_row_count\npublic.t,500,2000\n", }), ).toEqual(Option.some("public.t")); - // estimated_row_count <= 1000 → excluded by the second predicate. expect( evalScalar(q, { "table_stats.csv": "name,seq_scans,estimated_row_count\npublic.t,500,500\n", @@ -136,8 +135,6 @@ describe("default rules — pass and fail fixtures", () => { }); it("evaluator mechanics: alias-qualified string + numeric AND predicate", () => { - // Generic query (not a default rule) covering `s.col` alias refs, string `=`, - // numeric `>`, and AND against the real vacuum_stats columns. const q = "SELECT LISTAGG(s.name, ',') FROM `vacuum_stats.csv` s WHERE s.expect_autovacuum = 'yes' AND s.rowcount > 1000"; expect( @@ -232,9 +229,6 @@ describe("csvq value semantics", () => { }); it("string-compares a thousands-grouped to_char value (csvq parity quirk)", () => { - // `" 2,000"` is not strictly numeric (leading space, comma), so `rowcount > 1000` - // falls back to a string comparison: `" 2,000"` < `"1000"` → the row is excluded, - // exactly as csvq behaves on a `to_char`-formatted column. expect( evalScalar("SELECT LISTAGG(tbl, ',') FROM `vacuum_stats.csv` WHERE rowcount > 1000", { "vacuum_stats.csv": 'tbl,rowcount\npublic.t," 2,000"\n', @@ -260,7 +254,6 @@ describe("comparison operators", () => { }); it("string-compares when one side is a non-numeric string", () => { - // `name = 'postgres'` is a pure string comparison. expect( evalScalar("SELECT COUNT(*) FROM `t.csv` WHERE name = 'postgres'", { "t.csv": "name\npostgres\nother\n", @@ -281,11 +274,9 @@ describe("arithmetic", () => { expect(evalScalar("SELECT COUNT(*) FROM `t.csv` WHERE a / b > 2", data)).toEqual( Option.some("1"), ); - // Division by zero → NULL → row excluded. expect(evalScalar("SELECT COUNT(*) FROM `t.csv` WHERE a / 0 > 0", data)).toEqual( Option.some("0"), ); - // Arithmetic on a non-numeric column → NULL → row excluded. expect( evalScalar("SELECT COUNT(*) FROM `t.csv` WHERE c * 1 > 0", { "t.csv": "c\nabc\n" }), ).toEqual(Option.some("0")); diff --git a/apps/cli/src/commands/inspect/report/report.e2e.test.ts b/apps/cli/src/commands/inspect/report/report.e2e.test.ts index 2ea62b8434..dc4a2a3570 100644 --- a/apps/cli/src/commands/inspect/report/report.e2e.test.ts +++ b/apps/cli/src/commands/inspect/report/report.e2e.test.ts @@ -7,11 +7,8 @@ import { makeTempHome, runSupabase } from "../../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; -// A definitely-closed local port: the `--db-url` is parsed directly (no config.toml -// / running stack needed), so the native handler creates the dated output directory, -// prints the connect diagnostic, then fails fast dialing. This exercises the real -// subprocess path — flag parse → resolution → mkdir → native connect — without -// depending on a live database in CI. +// A closed local port: no config.toml or running stack needed, and the connection fails fast +// without depending on a live database in CI. const DEAD_DB_URL = "postgres://postgres:postgres@127.0.0.1:1/postgres"; // `--agent no` forces text-mode output deterministically (the CLI otherwise @@ -30,10 +27,8 @@ describe("supabase inspect report", () => { { home: home.dir, env: { HOME: home.dir } }, ); expect(exitCode).toBe(1); - // The native handler writes the connect diagnostic to stderr. expect(stderr).toContain("Connecting to remote database..."); expect(stderr).toMatch(/failed to connect to postgres|connection refused|ECONNREFUSED/i); - // mkdir runs before the connection, so the dated folder exists even on failure. const dated = readdirSync(outputDir).filter((name) => /^\d{4}-\d{2}-\d{2}$/.test(name)); expect(dated.length).toBe(1); expect(existsSync(join(outputDir, dated[0]!))).toBe(true); diff --git a/apps/cli/src/commands/inspect/report/report.errors.ts b/apps/cli/src/commands/inspect/report/report.errors.ts index b56789b4e1..df99f9f3b5 100644 --- a/apps/cli/src/commands/inspect/report/report.errors.ts +++ b/apps/cli/src/commands/inspect/report/report.errors.ts @@ -5,10 +5,7 @@ import { ErrorActionabilityId, } from "../../../shared/telemetry/error-actionability.ts"; -/** - * Creating the dated `<output-dir>/<YYYY-MM-DD>/` directory failed. Wraps the - * failure as `failed to mkdir: %w`. - */ +/** Creating the dated `<output-dir>/<YYYY-MM-DD>/` directory failed. */ export class InspectReportMkdirError extends Data.TaggedError("InspectReportMkdirError")<{ readonly message: string; }> { @@ -18,9 +15,8 @@ export class InspectReportMkdirError extends Data.TaggedError("InspectReportMkdi } /** - * Writing one of the report CSV files failed. Wraps an open/write - * failure as `failed to create output file: %w`. This port collects the COPY - * bytes first and writes them afterwards, so a file-write failure surfaces here. + * Writing one of the report CSV files failed. The COPY bytes are collected first and written + * afterwards, so a file-write failure surfaces separately from a query failure. */ export class InspectReportWriteError extends Data.TaggedError("InspectReportWriteError")<{ readonly message: string; diff --git a/apps/cli/src/commands/inspect/report/report.handler.ts b/apps/cli/src/commands/inspect/report/report.handler.ts index fcb6181838..35113d7c07 100644 --- a/apps/cli/src/commands/inspect/report/report.handler.ts +++ b/apps/cli/src/commands/inspect/report/report.handler.ts @@ -63,10 +63,8 @@ const runInspectReport = Effect.fnUntraced(function* ( const cliArgs = yield* CliArgs; const isText = output.format === "text"; - // Mutual exclusivity is keyed off raw argv (which flags were explicitly - // passed), not the parsed boolean value. `--local=false` was explicitly - // passed even though its value is false; value-based detection would miss - // it and route to linked incorrectly. + // Mutual exclusivity is keyed off raw argv, not the parsed boolean value: `--local=false` + // was explicitly passed, so value-based detection would miss it and default to linked. const target = resolveDbTargetFlags(cliArgs.args); if (target.setFlags.length > 1) { return yield* Effect.fail( @@ -76,18 +74,13 @@ const runInspectReport = Effect.fnUntraced(function* ( ); } - // Read + validate the custom `[experimental.inspect.rules]` BEFORE any DB work, - // so a malformed `inspect.rules` config aborts before connecting or writing any - // CSV files. They are applied later (in the summary rendering below), but - // validated here up front. + // Validated before any DB work so a malformed config aborts before connecting or writing + // CSVs; applied later in the summary rendering below. const configRules = yield* readInspectRules(fs, path, cliSettings.workdir); // `--linked` is the default, so absence of the others resolves to linked. const connType = target.connType ?? "linked"; - // `--project-ref` never implies `--linked` and must not be silently - // discarded on a non-linked target — see push.handler.ts's identical guard - // (db push) for the full TS-only rationale. if (Option.isSome(flags.projectRef) && connType !== "linked") { return yield* Effect.fail( new InspectMutuallyExclusiveFlagsError({ @@ -104,14 +97,12 @@ const runInspectReport = Effect.fnUntraced(function* ( linkedProjectRef: flags.projectRef, }); - // `outDir = <output-dir>/<date>`, resolved against the process CWD when relative - // (NOT `--workdir`). + // Resolved against the process CWD when relative, not `--workdir`. const epochMillis = yield* Clock.currentTimeMillis; let outDir = path.join(flags.outputDir, reportDateFolder(epochMillis)); if (!path.isAbsolute(outDir)) { outDir = path.join(runtimeInfo.cwd, outDir); } - // The output dir is pinned to 0755 and each CSV to 0644. yield* fs .makeDirectory(outDir, { recursive: true, mode: 0o755 }) .pipe( @@ -120,7 +111,6 @@ const runInspectReport = Effect.fnUntraced(function* ( ), ); - // The connect diagnostic is written to stderr before dialing. if (isText) { yield* output.raw(`Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, "stderr"); } @@ -155,8 +145,7 @@ const runInspectReport = Effect.fnUntraced(function* ( yield* output.raw(`Reports saved to ${bold(outDir, tty.stdoutIsTty)}\n`, "stderr"); } - // Custom `[experimental.inspect.rules]` (read + validated up front) replace the 7 - // defaults when present. + // Custom rules (validated above) replace the defaults when present. const rules = configRules.length > 0 ? configRules : DEFAULT_INSPECT_RULES; if (configRules.length === 0 && isText) { yield* output.raw("Loading default rules...\n", "stderr"); diff --git a/apps/cli/src/commands/inspect/report/report.integration.test.ts b/apps/cli/src/commands/inspect/report/report.integration.test.ts index f6af68d1d2..b8516793bb 100644 --- a/apps/cli/src/commands/inspect/report/report.integration.test.ts +++ b/apps/cli/src/commands/inspect/report/report.integration.test.ts @@ -29,8 +29,8 @@ const LOCAL_CONN: PgConnInput = { database: "postgres", }; -// Map each query's wrapped COPY statement back to its file name so the mocked -// `copyToCsv` can return the right canned CSV. +// Maps each query's wrapped COPY statement back to its file name so the mocked `copyToCsv` +// can return the right canned CSV. const WRAPPED_TO_FILE = new Map<string, string>(); for (const { fileName, sql } of REPORT_QUERIES) { WRAPPED_TO_FILE.set(wrapReportQuery(sql, reportIgnoreSchemas(), "'postgres'"), fileName); @@ -113,7 +113,7 @@ interface SetupOpts { stdoutIsTty?: boolean; cwd?: string; workdir?: string; - /** Raw CLI args slice — drives Changed-based flag detection (cobra parity). */ + /** Raw CLI args slice, used to detect which flags were explicitly passed. */ cliArgs?: ReadonlyArray<string>; } @@ -154,9 +154,8 @@ const flags = (over: Partial<InspectReportFlags> = {}): InspectReportFlags => ({ outputDir: over.outputDir ?? ".", }); -// One CSV per referenced file with the REAL column headers each query emits, so -// column lookups resolve exactly as they would against Postgres. `locks.csv` -// carries an old (rule 1 fail) but granted (rule 2 pass) row. +// Real column headers for each query, so column lookups resolve as they would against +// Postgres. `locks.csv` has an old (rule 1 fail) but granted (rule 2 pass) row. const DEFAULT_RULE_CSVS: Record<string, string> = { "locks.csv": "stmt,age,granted\nLOCK_A,00:05:00,t\n", "unused_indexes.csv": "index\n", @@ -194,14 +193,12 @@ describe("inspect report", () => { expect(files).toContain("db_stats.csv"); expect(files).toContain("unused_indexes.csv"); expect(files).not.toContain("db-stats.csv"); - // Every query was copied with both placeholders substituted. expect(connection.copiedSql.length).toBe(14); expect( connection.copiedSql.every( (s) => s.startsWith("COPY (") && s.endsWith("TO STDOUT WITH CSV HEADER"), ), ).toBe(true); - // The date folder is pinned to 0755 and each CSV to 0644. expect(statSync(dir).mode & 0o777).toBe(0o755); expect(statSync(join(dir, "db_stats.csv")).mode & 0o777).toBe(0o644); }).pipe(Effect.provide(layer), Effect.ensuring(Effect.sync(() => process.umask(prevUmask)))); @@ -231,7 +228,6 @@ describe("inspect report", () => { expect(Option.isSome((resolver.resolveInput as { dbUrl: Option.Option<string> }).dbUrl)).toBe( true, ); - // The connect diagnostic reflects a non-local target. expect(out.stderrText).toContain("Connecting to remote database..."); }).pipe(Effect.provide(layer)); }); @@ -300,8 +296,6 @@ describe("inspect report", () => { const { layer, resolver } = setupReport({ csvs: DEFAULT_RULE_CSVS }); return Effect.gen(function* () { yield* inspectReport(flags({ outputDir: base, projectRef: Option.some(FLAG_REF) })); - // `inspect report` never caches the ref — the resolver call it threads the - // flag into is the strongest observable this harness offers. const resolveInput = resolver.resolveInput as { connType: string; linkedProjectRef: Option.Option<string>; @@ -342,7 +336,6 @@ describe("inspect report", () => { expect(out.stderrText).toContain("Running queries..."); expect(out.stderrText).toContain("Reports saved to "); expect(out.stderrText).toContain("Loading default rules..."); - // stdout carries the Glamour rules table. expect(out.stdoutText).toContain("RULE"); expect(out.stdoutText).toContain("STATUS"); expect(out.stdoutText).toContain("MATCHES"); @@ -356,10 +349,8 @@ describe("inspect report", () => { const { layer, out } = setupReport({ csvs: DEFAULT_RULE_CSVS }); return Effect.gen(function* () { yield* inspectReport(flags({ outputDir: base })); - // Rule 1 fails (old lock): message + matched statement. expect(out.stdoutText).toContain("There is at least one lock older than 2 minutes"); expect(out.stdoutText).toContain("LOCK_A"); - // Rule 2 passes (lock is granted): ✔. expect(out.stdoutText).toContain("✔"); expect(out.stdoutText).toContain("No duplicate indexes"); }).pipe(Effect.provide(layer)); @@ -390,7 +381,6 @@ describe("inspect report", () => { yield* inspectReport(flags({ outputDir: base })); expect(out.stderrText).not.toContain("Loading default rules..."); expect(out.stdoutText).toContain("Custom rule"); - // No-match COUNT(*) returns 0, a non-empty value → fail status for this rule. expect(out.stdoutText).toContain("bad"); }).pipe(Effect.provide(layer)); }, @@ -404,8 +394,7 @@ describe("inspect report", () => { join(workdir, "supabase", "config.toml"), [ "[[experimental.inspect.rules]]", - // References a CSV that was never produced — the provider returns no table - // and the evaluator surfaces the error as the STATUS cell (not a failure). + // `nope.csv` doesn't exist, so this surfaces as the rule's STATUS cell. 'query = "SELECT COUNT(*) FROM `nope.csv`"', 'name = "Broken rule"', 'pass = "ok"', @@ -425,8 +414,6 @@ describe("inspect report", () => { const base = tempDir("supabase-report-out-"); const workdir = tempDir("supabase-report-workdir-"); mkdirSync(join(workdir, "supabase"), { recursive: true }); - // An invalid rule config (unknown key) must abort before the DB connection - // and before any CSV files are written. writeFileSync( join(workdir, "supabase", "config.toml"), [ @@ -446,8 +433,7 @@ describe("inspect report", () => { if (Exit.isFailure(exit)) { expect(JSON.stringify(exit.cause)).toContain("invalid keys: typo"); } - // No connection and no dated output folder — config validation ran first, - // before mkdir / connect / COPY (base itself is the pre-created temp dir). + // `base` is the pre-created temp dir itself; no dated subfolder is created. expect(connection.copiedSql.length).toBe(0); expect(readdirSync(base).length).toBe(0); }).pipe(Effect.provide(layer)); @@ -513,8 +499,8 @@ describe("inspect report", () => { it.live("aborts with a failed-to-create-output-file error when a CSV cannot be written", () => { const base = tempDir("supabase-report-out-"); - // Pre-create the first CSV target (`bloat.csv`) as a DIRECTORY so the file - // write fails (EISDIR) while mkdir (recursive, idempotent) still succeeds. + // Pre-creates `bloat.csv` as a directory so the file write fails (EISDIR) while the + // recursive mkdir still succeeds. mkdirSync(join(base, localDateFolder(), "bloat.csv"), { recursive: true }); const { layer } = setupReport({ csvs: DEFAULT_RULE_CSVS }); return Effect.gen(function* () { @@ -566,7 +552,6 @@ describe("inspect report", () => { const { layer } = setupReport({ csvs: DEFAULT_RULE_CSVS, cwd }); return Effect.gen(function* () { yield* inspectReport(flags({ outputDir: base })); - // Written under the absolute base, not under the CWD. expect(dateFolderContents(base).files.length).toBe(14); expect(readdirSync(cwd).length).toBe(0); }).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/commands/inspect/report/report.layers.ts b/apps/cli/src/commands/inspect/report/report.layers.ts index 2a2b2a4c3f..90365c2728 100644 --- a/apps/cli/src/commands/inspect/report/report.layers.ts +++ b/apps/cli/src/commands/inspect/report/report.layers.ts @@ -6,12 +6,8 @@ import { inspectBaseLayer } from "../inspect.layers.ts"; /** * Runtime layer for `supabase inspect report`. * - * `inspect report` is a sibling of `inspect db` (a direct child of `inspect`, not - * under `db`), so its command-runtime path is `["inspect", "report"]` — two levels. - * It shares - * the same `inspectBaseLayer` (resolver + connection + CLI config + telemetry) - * as the `db` leaves. `FileSystem` / `Path` / `Tty` / `RuntimeInfo` / `Clock` are - * provided by the global run harness (`shared/cli/run.ts`), not here. + * `FileSystem` / `Path` / `Tty` / `RuntimeInfo` / `Clock` come from the global run harness + * (`shared/cli/run.ts`), not here. */ export const inspectReportRuntimeLayer = Layer.merge( inspectBaseLayer, diff --git a/apps/cli/src/commands/inspect/report/report.queries.ts b/apps/cli/src/commands/inspect/report/report.queries.ts index f108351635..f6db51df6d 100644 --- a/apps/cli/src/commands/inspect/report/report.queries.ts +++ b/apps/cli/src/commands/inspect/report/report.queries.ts @@ -14,11 +14,8 @@ import { trafficProfileSpec } from "../db/traffic-profile/traffic-profile.query. import { vacuumStatsSpec } from "../db/vacuum-stats/vacuum-stats.query.ts"; /** - * The `unused_indexes` query. The `inspect db` - * tree folds `unused-indexes` into a deprecated alias of `index-stats`, so this - * distinct query (columns: `name`, `index`, `index_size`, `index_scans`) has no - * existing `InspectQuerySpec`; the report still emits its own `unused_indexes.csv`, - * walking all 14 query files. + * The `unused_indexes` query. `inspect db` folds `unused-indexes` into a deprecated alias of + * `index-stats`, so there's no existing `InspectQuerySpec` for it; the report defines its own. */ const UNUSED_INDEXES_REPORT_SQL = `SELECT FORMAT('%I.%I', schemaname, relname) AS name, @@ -35,14 +32,11 @@ ORDER BY pg_relation_size(i.indexrelid) DESC`; /** - * One report query: the basename derived from the embedded SQL filename and - * the SQL it runs. + * One report query: the CSV basename (SQL filename with underscores, e.g. `db_stats` — not the + * `inspect db` spec name `db-stats`) and the SQL to run. * - * The `fileName` is the **SQL basename with underscores** (`db_stats`), which is - * also the CSV name (`<fileName>.csv`) — NOT the `inspect db` spec `name` - * (`db-stats`). The report does not bind parameters: `COPY` cannot, so the - * placeholders are substituted textually by `wrapReportQuery`, not via - * `spec.params()`. + * `COPY` can't bind parameters, so placeholders are substituted textually by `wrapReportQuery` + * instead of `spec.params()`. */ export interface ReportQuery { readonly fileName: string; @@ -80,11 +74,8 @@ export function reportIgnoreSchemas(): string { } /** - * Substitutes each positional placeholder (`$1`, `$2`, …) with the - * corresponding `arg`, replacing every occurrence (`$1` appears 3× in - * `index_stats.sql`), in order, then wraps - * the result in `COPY (...) TO STDOUT WITH CSV HEADER`. With no args it is a pure - * wrap. + * Substitutes each `$1`, `$2`, … placeholder with the corresponding `arg` (every occurrence, + * since a placeholder can repeat), then wraps the result in `COPY (...) TO STDOUT WITH CSV HEADER`. */ export function wrapReportQuery(sql: string, ...args: ReadonlyArray<string>): string { let query = sql; diff --git a/apps/cli/src/commands/inspect/report/report.rules.ts b/apps/cli/src/commands/inspect/report/report.rules.ts index 1956bc0eea..b2792ea405 100644 --- a/apps/cli/src/commands/inspect/report/report.rules.ts +++ b/apps/cli/src/commands/inspect/report/report.rules.ts @@ -13,10 +13,7 @@ export interface InspectRule { readonly fail: string; } -/** - * The default rules. Used when - * `[experimental.inspect.rules]` is absent or empty in `config.toml`. - */ +/** The default rules, used when `[experimental.inspect.rules]` is absent or empty in `config.toml`. */ export const DEFAULT_INSPECT_RULES: ReadonlyArray<InspectRule> = [ { query: "SELECT LISTAGG(stmt, ',') AS match FROM `locks.csv` WHERE age > '00:02:00'", @@ -112,16 +109,7 @@ export interface InspectRuleResult { readonly matches: string; } -/** - * Evaluate one rule against the written CSVs and map it to its summary cells: - * - * - aggregate over zero rows / non-aggregate with no rows (csvq NULL / ErrNoRows) - * → STATUS = `pass`, MATCHES = `-`; - * - a valid empty string → STATUS = `pass`, MATCHES = `` (empty); - * - a non-empty value → STATUS = `fail`, MATCHES = the value; - * - a csvq error → STATUS = the error message, MATCHES = `-` (the command does not - * fail; the error becomes the cell). - */ +/** Evaluates one rule against the written CSVs and maps the result to summary cells. */ export function evaluateInspectRule( rule: InspectRule, provider: CsvTableProvider, @@ -151,13 +139,9 @@ function summarizeInspectRuleMatch(match: string): string { } /** - * Build the `[RULE, STATUS, MATCHES]` summary rows in rule order, for - * `renderGlamourTable`. Each cell wraps in backticks inside its markdown: - * Glamour strips a non-empty inline code span (so a populated - * cell renders bare), but an EMPTY code span (`` `` ``) is passed through as the - * two literal backtick characters — the same rule `inspectText` encodes for - * the `inspect db` tables. A valid empty `matches` cell therefore renders as `` `` - * (width 2); `name`/`status` are never empty. + * Builds `[RULE, STATUS, MATCHES]` summary rows for `renderGlamourTable`. Glamour strips a + * non-empty inline code span but renders an empty one as its two literal backticks, so a valid + * empty `matches` cell renders as `` `` `` (width 2) instead of vanishing. */ export function buildRuleSummaryRows( results: ReadonlyArray<InspectRuleResult>, diff --git a/apps/cli/src/commands/link/link.command.ts b/apps/cli/src/commands/link/link.command.ts index 75e105284e..6623b37d60 100644 --- a/apps/cli/src/commands/link/link.command.ts +++ b/apps/cli/src/commands/link/link.command.ts @@ -34,15 +34,10 @@ const config = { export type LinkFlags = CliCommand.Command.Config.Infer<typeof config>; -// Exported so integration tests can drive the exact wiring `Command.withHandler` -// uses below, instead of re-asserting the generic instrumentation mechanism. export const linkHandler = (flags: LinkFlags) => link(flags).pipe( - // Only `--project-ref` is `markFlagTelemetrySafe` in Go (cmd/link.go:52). - // The boolean `--skip-pooler` is logged verbatim regardless; `--password` - // stays redacted. CLI-2167: `--project-ref` now also accepts a branch - // name, so it's only safe to log verbatim when it's actually ref-shaped - // — otherwise a user-created branch name would leak to PostHog verbatim. + // `--project-ref` is only safe to log verbatim when it's ref-shaped — it can also be a + // branch name, which is user data. `--skip-pooler` is always safe; `--password` stays redacted. withCommandTelemetry({ flags, safeFlags: diff --git a/apps/cli/src/commands/link/link.errors.ts b/apps/cli/src/commands/link/link.errors.ts index c38ed90867..5178ec88c9 100644 --- a/apps/cli/src/commands/link/link.errors.ts +++ b/apps/cli/src/commands/link/link.errors.ts @@ -15,9 +15,8 @@ export class LinkProjectStatusNetworkError extends Data.TaggedError( )<{ readonly message: string; /** - * Set when the failure was the generated client rejecting the response body - * (`SchemaError`) rather than a transport failure — an API response problem - * instead of a network one. + * Set when the failure was the generated client rejecting the response body (`SchemaError`) + * rather than a transport failure. */ readonly decode?: boolean; }> { @@ -29,8 +28,8 @@ export class LinkProjectStatusNetworkError extends Data.TaggedError( } /** - * `GET /v1/projects/{ref}` returned a non-200, non-404 status. Byte-matches Go's - * `"Unexpected error retrieving remote project status: " + body` (`link.go:252`). + * `GET /v1/projects/{ref}` returned a non-200, non-404 status; the message is + * `"Unexpected error retrieving remote project status: " + body`. */ export class LinkProjectStatusError extends Data.TaggedError("LinkProjectStatusError")<{ readonly status: number; @@ -43,17 +42,15 @@ export class LinkProjectStatusError extends Data.TaggedError("LinkProjectStatusE } /** - * The remote project is paused (`status == INACTIVE`). Message `"project is paused"` - * with the dashboard unpause suggestion attached, mirroring `errProjectPaused` - * + `utils.CmdSuggestion`. + * The remote project is paused (`status == INACTIVE`). Message `"project is paused"` with the + * dashboard unpause suggestion attached. */ export class ProjectPausedError extends Data.TaggedError("ProjectPausedError")<{ readonly message: string; readonly suggestion: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // The rendered remediation is "unpause it from the Supabase dashboard" — - // remote project state, not local config and not an entitlement failure. + // Remote project state, not local config or an entitlement failure. return { error_kind: CliErrorKind.UserActionable, error_category: CliErrorCategory.ProjectPaused, @@ -76,9 +73,8 @@ export class LinkApiKeysNetworkError extends Data.TaggedError("LinkApiKeysNetwor } /** - * `GET /v1/projects/{ref}/api-keys` returned a non-200 status. Byte-matches Go's - * `ErrAuthToken` (`"Authorization failed for the access token and project ref pair"`) - * formatted with the response body (`client.go:78`). + * `GET /v1/projects/{ref}/api-keys` returned a non-200 status; the message is + * `"Authorization failed for the access token and project ref pair"` plus the response body. */ export class LinkAuthTokenError extends Data.TaggedError("LinkAuthTokenError")<{ readonly status: number; @@ -86,16 +82,14 @@ export class LinkAuthTokenError extends Data.TaggedError("LinkAuthTokenError")<{ readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // The shared mapper wraps any non-200 in this tag; the status policy maps - // 401 → re-login, 404 → user-supplied ref not found, everything else → - // API status. + // 401 maps to re-login, 404 to an invalid user-supplied ref, everything else to API status. return statusCodeActionability(this.status, { notFoundIsInvalidInput: true }); } } /** - * The api-keys response contained no usable anon/service-role key. Byte-matches - * `errMissingKey` (`"Anon key not found."`, `client.go:15`). + * The api-keys response contained no usable anon/service-role key; the message is + * `"Anon key not found."`. */ export class LinkMissingKeyError extends Data.TaggedError("LinkMissingKeyError")<{ readonly message: string; @@ -105,10 +99,7 @@ export class LinkMissingKeyError extends Data.TaggedError("LinkMissingKeyError") } } -/** - * Both the `[ref-or-branch]` positional argument and `--project-ref` were given - * (non-empty). TS-only surface (CLI-2167, no Go counterpart). - */ +/** Both the `[ref-or-branch]` positional argument and `--project-ref` were given (non-empty). */ export class LinkRefArgConflictError extends Data.TaggedError("LinkRefArgConflictError")<{ readonly message: string; }> { @@ -118,11 +109,9 @@ export class LinkRefArgConflictError extends Data.TaggedError("LinkRefArgConflic } /** - * A non-ref-shaped value was given (treated as a branch name) but no linked - * parent project could be resolved to search for that branch — none of - * `SUPABASE_PROJECT_ID`, `supabase/.temp/linked-project.json`, or - * `supabase/.temp/project-ref` yielded a candidate at all. TS-only surface - * (CLI-2167, no Go counterpart). + * A non-ref-shaped value was given (treated as a branch name) but no linked parent project + * could be resolved to search for that branch — none of `SUPABASE_PROJECT_ID`, + * `supabase/.temp/linked-project.json`, or `supabase/.temp/project-ref` yielded a candidate. */ export class LinkBranchNotLinkedError extends Data.TaggedError("LinkBranchNotLinkedError")<{ readonly message: string; @@ -133,10 +122,8 @@ export class LinkBranchNotLinkedError extends Data.TaggedError("LinkBranchNotLin } /** - * A parent-project candidate exists (`SUPABASE_PROJECT_ID`, - * `supabase/.temp/linked-project.json`, or `supabase/.temp/project-ref`) but - * none of them is ref-shaped — corrupt or stale linked state. TS-only surface - * (CLI-2167, no Go counterpart). + * A parent-project candidate exists (`SUPABASE_PROJECT_ID`, `supabase/.temp/linked-project.json`, + * or `supabase/.temp/project-ref`) but none of them is ref-shaped — corrupt or stale linked state. */ export class LinkParentRefInvalidError extends Data.TaggedError("LinkParentRefInvalidError")<{ readonly message: string; @@ -146,10 +133,7 @@ export class LinkParentRefInvalidError extends Data.TaggedError("LinkParentRefIn } } -/** - * No branch with the given name/UUID exists on the resolved parent project. - * TS-only surface (CLI-2167, no Go counterpart). - */ +/** No branch with the given name/UUID exists on the resolved parent project. */ export class LinkBranchNotFoundError extends Data.TaggedError("LinkBranchNotFoundError")<{ readonly message: string; }> { @@ -159,9 +143,8 @@ export class LinkBranchNotFoundError extends Data.TaggedError("LinkBranchNotFoun } /** - * The resolved branch has no `project_ref` yet (e.g. `status: CREATING_PROJECT`). - * Guards against silently falling through to an unrelated ref elsewhere in the - * resolver chain. TS-only surface (CLI-2167, no Go counterpart). + * The resolved branch has no `project_ref` yet (e.g. `status: CREATING_PROJECT`). Guards + * against silently falling through to an unrelated ref elsewhere in the resolver chain. */ export class LinkBranchNotReadyError extends Data.TaggedError("LinkBranchNotReadyError")<{ readonly branch: string; diff --git a/apps/cli/src/commands/link/link.handler.ts b/apps/cli/src/commands/link/link.handler.ts index 1875cbca3e..f0f11e8932 100644 --- a/apps/cli/src/commands/link/link.handler.ts +++ b/apps/cli/src/commands/link/link.handler.ts @@ -77,12 +77,8 @@ const mapApiKeysError = mapTenantApiKeysError({ statusError: LinkAuthTokenError, }); -// Same reasoning + duration as `branch-target.ts`'s branch-lookup bound -// (`BRANCH_LOOKUP_TIMEOUT`) — duplicated locally rather than -// shared across two otherwise-unrelated modules: the best-effort 404-path -// stale-cache correlation lookup below must not let an otherwise-successful -// `link` silently stall ~6 minutes at the very end on the generated client's -// own 60s×5-retry defaults (PR #6168 review). +// Bounds the best-effort cache-correlation lookup below so it can't silently stall for minutes +// on the generated client's default 60s×5 retries. const LINK_CACHE_CORRELATION_TIMEOUT = Duration.seconds(5); const LINK_MAX_LISTED_BRANCHES = 20; @@ -99,8 +95,8 @@ function linkBranchNotFoundMessage( const sortedNames = branches.map((branch) => branch.name).toSorted(); const shown = sortedNames.slice(0, LINK_MAX_LISTED_BRANCHES); const remaining = sortedNames.length - shown.length; - // Branch names are API-provided; sanitize the same way response bodies are - // before embedding them in an error message (module policy). + // Branch names are API-provided; sanitize them the same way response bodies are before + // embedding them in an error message. const shownSanitized = sanitizeInlineName(shown.join(", ")); const namesList = remaining > 0 @@ -109,8 +105,8 @@ function linkBranchNotFoundMessage( const trimmedLower = value.trim().toLowerCase(); const nearMiss = branches.find((branch) => branch.name.toLowerCase() === trimmedLower); - // Sanitized like the list above — an API-provided name must not be able to - // inject ANSI/OSC/newline controls into the terminal (PR #6168 review). + // Sanitized like the list above — an API-provided name must not be able to inject + // ANSI/OSC/newline controls into the terminal. const didYouMean = nearMiss !== undefined ? ` Did you mean "${sanitizeInlineName(nearMiss.name)}"?` : ""; @@ -121,22 +117,12 @@ function linkBranchNotFoundMessage( } /** - * Resolves a non-ref-shaped `[ref-or-branch]`/`--project-ref` value to the - * branch's project ref by looking it up (by name or UUID) against the PARENT - * project's branches. TS-only surface (CLI-2167, no Go counterpart). + * Resolves a non-ref-shaped `[ref-or-branch]`/`--project-ref` value to the branch's project ref + * by looking it up (by name or UUID) against the parent project's branches. A ref-shaped value + * (20 lowercase letters) is always treated as a ref and never looked up as a branch name. * - * A value that is ref-shaped (20 lowercase letters) is ALWAYS treated as a - * ref and never looked up as a branch name — this keeps every - * currently-working invocation byte-identical, and CLI-2167 accepts the - * (vanishingly rare) collision with a 20-lowercase-letter branch name. - * - * Deliberately uses the LIST endpoint (`GET /v1/projects/{ref}/branches`) - * rather than `branches.resolver.ts`'s single-branch lookup - * (`resolveBranchProjectRef`, which calls `GET /v1/branches/{id}` for a - * UUID or `GET /v1/projects/{ref}/branches/{name}` for a name): the full list - * powers the available-branches error enrichment below, and — unlike that - * resolver, which is handed an already-resolved `projectRef` by its caller — - * `link` has to resolve the parent project itself first anyway. + * Uses the list endpoint (`GET /v1/projects/{ref}/branches`) rather than a single-branch lookup + * so the full list can power the available-branches error enrichment below. */ const resolveLinkBranchRef = Effect.fnUntraced(function* (value: string) { const output = yield* Output; @@ -173,9 +159,9 @@ const resolveLinkBranchRef = Effect.fnUntraced(function* (value: string) { yield* task?.clear() ?? Effect.void; const found: LinkBranch | undefined = branches.find( - // UUID matching is case-insensitive (canonical branch ids are lowercase - // hex, but uppercase input is a valid UUID spelling — PR #6168 review); - // name matching stays exact, with the did-you-mean hint covering near misses. + // UUID matching is case-insensitive (canonical ids are lowercase hex, but uppercase input + // is a valid UUID spelling); name matching stays exact, with the did-you-mean hint covering + // near misses. (branch) => branch.name === value || branch.id.toLowerCase() === value.toLowerCase(), ); if (found === undefined) { @@ -213,24 +199,16 @@ export const link = Effect.fn("link")(function* (flags: LinkFlags) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - // Captured as soon as ref/branch resolution succeeds (mirrors `projects - // delete`'s `resolvedRef` pattern, `delete.handler.ts:52`) — everything - // from ref resolution onward now sits inside the `Effect.ensuring` wrapper - // below (PR #6168 review): previously, a branch-name resolution failure - // (not found, not ready, not linked, a parent-list failure) — or even the - // `--project-ref`/positional conflict check — exited BEFORE that wrapper - // was ever reached, so telemetry state was silently never flushed for - // those failures. This is a strict improvement (TS-only feature, no Go - // behavior to preserve here); `undefined` still means "never resolved a - // ref at all", so the post-run cache fill correctly stays a no-op for it. + // Captured once ref/branch resolution succeeds; `undefined` means no ref was ever resolved, + // so the post-run cache fill below stays a no-op. let resolvedRef: string | undefined; - // Persist the linked-project cache and telemetry state whether the link - // succeeds or fails. `link` itself writes `linked-project.json` on success - // (below), so `cache` only fires for the failure / 404 paths. + // Persists the linked-project cache and telemetry state whether the link succeeds or fails. + // `link` itself writes `linked-project.json` on success (below), so `cache` only fires for + // the failure / 404 paths. yield* Effect.gen(function* () { - // Normalize inputs: an empty-string positional or flag value is absent, - // mirroring the resolver's own treatment of an empty `--project-ref`. + // An empty-string positional or flag value is treated as absent, matching the resolver's + // own treatment of an empty `--project-ref`. const refArg = Option.filter(flags.refOrBranch, (value) => value.length > 0); const projectRefFlag = Option.filter(flags.projectRef, (value) => value.length > 0); @@ -288,7 +266,7 @@ export const link = Effect.fn("link")(function* (flags: LinkFlags) { "stderr", ); } - // Update postgres image version to match the remote project (link.go:269). + // Updates the postgres image version to match the remote project. const version = project.value.database.version; if (version.length > 0) { yield* writeTempFile(paths.postgresVersion, version); @@ -315,15 +293,11 @@ export const link = Effect.fn("link")(function* (flags: LinkFlags) { // 4. Save project ref (mandatory — a write failure fails the command). yield* writeTempFile(paths.projectRef, ref); - // 5. Telemetry + linked-project cache (only for resolvable projects, i.e. - // not the 404 branch path). `link.go:40-67`. + // 5. Telemetry + linked-project cache (only for resolvable projects, not the 404 branch path). if (Option.isSome(project)) { const p = project.value; - // SaveLinkedProject — best-effort (debug-logged in Go, never fatal). - // Same fail-safe fallback as the branch path (PR #6168 review): if the - // rewrite fails while a stale cache for a DIFFERENT project survives, - // delete it rather than leave the parent chain trusting the old - // project — no cache beats a wrong one. + // Best-effort: if the rewrite fails while a stale cache for a different project + // survives, delete it rather than leave the parent chain trusting the old project. yield* writeTempFile( paths.linkedProjectCache, JSON.stringify({ @@ -349,12 +323,9 @@ export const link = Effect.fn("link")(function* (flags: LinkFlags) { organization_slug: p.organization_slug, }); } - // Confirmed on staging (PR #6168 review): a DEFAULT branch's - // `project_ref` IS the parent's own ref, so `link main` resolves via - // `branchResolution` above but `getProject(ref)` still returns 200 - // (this arm), never the 404 arm below — without this, a - // default-branch link would be misclassified as a plain project link - // and lose `linked_via`. + // A default branch's `project_ref` is the parent's own ref, so `link main` still hits + // this 200 arm (never the 404 arm below); check `branchResolution` here too or a + // default-branch link loses `linked_via`. const linkedViaProperties: Record<string, unknown> = Option.isSome(branchResolution) ? { [PropLinkedVia]: "branch", @@ -365,50 +336,35 @@ export const link = Effect.fn("link")(function* (flags: LinkFlags) { .capture(EventProjectLinked, linkedViaProperties) .pipe(withAnalyticsContext({ groups })); } else { - // 404 path: `ref` is a branch (assumed, or confirmed when - // `branchResolution` resolved it by name/UUID). The plain-project arm - // above never writes `linked-project.json` for THIS ref, and the - // post-run `LinkedProjectCache.cache(ref)` fill (`Effect.ensuring` - // below) GETs `ref` itself — a branch ref 404s there too — so without - // this, the PARENT evidence `resolveLinkedParentRef`'s chain - // depends on can be lost forever or silently go stale (PR #6168 review, - // two confirmed gaps). Both arms are best-effort — `Effect.ignore` — - // and never affect `link`'s own outcome. + // 404 path: `ref` is a branch. The plain-project arm above never writes + // `linked-project.json` for this ref, and the post-run cache fill (`Effect.ensuring` + // below) also GETs `ref` and 404s on a branch — so without persisting parent evidence + // here, `resolveLinkedParentRef`'s chain can lose it or go stale. Both branches below + // are best-effort and never affect `link`'s own outcome. const cachedParent = yield* fs.readFileString(paths.linkedProjectCache).pipe( Effect.map(parseCachedLinkedProject), Effect.orElseSucceed(() => Option.none<CachedLinkedProject>()), ); if (Option.isSome(branchResolution)) { - // (a) The branch was resolved by name/UUID, so its parent is KNOWN - // here — persist it durably so it survives even when the existing - // cache is missing or malformed (previously: never written at all - // for this ref, so a missing/malformed cache stayed that way - // forever). Leave an already-agreeing cache untouched — it may be - // richer (name/org) than the ref-only record below, e.g. from a - // real `link <parent-ref>` run. + // (a) The branch's parent is known here, so persist it durably even when the + // existing cache is missing or malformed. Leave an already-agreeing cache untouched — + // it may be richer (name/org) than the ref-only record below, e.g. from a real + // `link <parent-ref>` run. const parentRef = branchResolution.value.parentRef; if (Option.isNone(cachedParent) || cachedParent.value.ref !== parentRef) { - // Fail-safe fallback (PR #6168 review): if the replacement write - // fails (e.g. an unwritable stale cache file), DELETE the stale - // cache instead of leaving a wrong parent trusted by the parent - // chain — no parent info beats wrong parent info. Both steps stay - // best-effort; the mandatory `project-ref` write in the same - // directory already succeeded, so a residual double-failure here - // is practically unreachable. + // If the replacement write fails, delete the stale cache instead of leaving a + // wrong parent trusted — no parent info beats wrong parent info. yield* writeTempFile(paths.linkedProjectCache, JSON.stringify({ ref: parentRef })).pipe( Effect.catch(() => fs.remove(paths.linkedProjectCache, { force: true })), Effect.ignore, ); } - // TS-only event extension (CLI-2167): a branch name/UUID was resolved - // to `ref` above, so we know definitively this is a branch link — - // fire the same event with `linked_via`/`parent_project_ref` so - // branch links are no longer telemetry-invisible. Only refs go out - // (never the branch name, which is user-created content); no - // `groupIdentify` here since we have no org/name metadata for the - // branch, just the group association on the capture itself. + // A resolved branch name/UUID means this is definitively a branch link — fire the + // same event with `linked_via`/`parent_project_ref`. Only the ref goes out, never the + // branch name (user data); no `groupIdentify` since there's no org/name metadata for + // a branch. yield* analytics .capture(EventProjectLinked, { [PropLinkedVia]: "branch", @@ -420,27 +376,15 @@ export const link = Effect.fn("link")(function* (flags: LinkFlags) { cachedParent.value.ref !== ref && PROJECT_REF_PATTERN.test(cachedParent.value.ref) ) { - // (b) A raw ref-shaped branch link (no name resolution attempted, so - // its parent is unknown here) whose cache names a DIFFERENT project — - // best-effort correlate the two: if `cachedParent`'s own branches - // verifiably still include `ref`, the cache is still accurate, keep - // it; anything else — verifiably absent, or ANY lookup failure - // (network, decode, 403, 404, timeout) — deletes it. Fail-SAFE, not - // fail-convenient (PR #6168 review): an unverified divergent cache - // silently misdirects parent-scoped MUTATIONS onto the wrong project, - // while deleting merely downgrades later branches commands to a loud - // not-linked error the user recovers from by relinking the parent. - // The window is tiny anyway — link's own API calls just succeeded - // moments before this runs. One extra API call, only on this path, - // only when a cache exists and diverges from `ref`; the plain - // 404-ref path with no cache, or a cache that already agrees with - // `ref`, needs no correlation. Hard-bounded - // (`LINK_CACHE_CORRELATION_TIMEOUT`). - // By this point the user has already seen the linking work happen - // (service-link warnings, a resolved-branch line, ...), so a - // successful link otherwise feels DONE right before this silently - // runs for up to 5s more ahead of "Finished supabase link." — show a - // spinner in text mode so it never sits silent (PR #6168 review). + // (b) A raw ref-shaped branch link has no known parent, so if the cache names a + // different project, verify it: keep the cache only if that parent's branches still + // include `ref`; any mismatch or lookup failure deletes it. An unverified divergent + // cache would silently misdirect future parent-scoped mutations to the wrong project, + // while deleting it only downgrades to a loud not-linked error the user fixes by + // relinking. + // + // Shows a spinner in text mode so this silent, timeout-bounded correlation lookup + // doesn't make an otherwise-finished link feel stalled. const correlating = output.format === "text" ? yield* output.task("Checking branch parent...") : undefined; const verified = yield* api.v1.listAllBranches({ ref: cachedParent.value.ref }).pipe( @@ -455,15 +399,14 @@ export const link = Effect.fn("link")(function* (flags: LinkFlags) { } } - // 6. PostRun: `Finished supabase link.` to stdout (text), structured success - // otherwise. + // 6. PostRun: `Finished supabase link.` to stdout (text), structured success otherwise. if (output.format === "text") { yield* output.raw("Finished supabase link.\n"); } else { yield* output.success("", { project_ref: ref, - // Purely additive — only present when a branch name/UUID was resolved - // to `ref` above; absent for a plain project-ref link (CLI-2167). + // Present only when a branch name/UUID was resolved above; absent for a plain + // project-ref link. ...(Option.isSome(branchResolution) ? { branch: branchResolution.value.branchName, diff --git a/apps/cli/src/commands/link/link.integration.test.ts b/apps/cli/src/commands/link/link.integration.test.ts index 113896a197..4b1a479232 100644 --- a/apps/cli/src/commands/link/link.integration.test.ts +++ b/apps/cli/src/commands/link/link.integration.test.ts @@ -75,7 +75,7 @@ const POOLER_PRIMARY = [ type LinkBranches = typeof V1ListAllBranchesOutput.Type; type LinkBranch = LinkBranches[number]; -// The currently-linked PARENT project's ref (env / cache / temp-file candidate). +// The currently-linked parent project's ref (env / cache / temp-file candidate). const PARENT_REF = VALID_REF; // Distinct 20-lowercase-letter refs used to disambiguate which parent // candidate (env / linked-project.json cache / project-ref file) won. @@ -105,9 +105,8 @@ const LINK_BRANCH_OTHER: LinkBranch = { project_ref: OTHER_BRANCH_PROJECT_REF, }; -// A DEFAULT branch's `project_ref` IS the parent's own ref (PR #6168 review) — -// `getProject(ref)` therefore returns 200 for it, routing telemetry into the -// normal 200 arm rather than the 404 `else if (branchResolution)` arm. +// A default branch's `project_ref` is the parent's own ref, so `getProject(ref)` returns 200 +// for it, routing telemetry into the normal 200 arm rather than the 404 branch arm. const LINK_BRANCH_DEFAULT: LinkBranch = { ...LINK_BRANCH, id: "77777777-8888-4999-8aaa-bbbbbbbbbbbb", @@ -134,8 +133,7 @@ const LINK_BRANCH_STAGING: LinkBranch = { name: "staging", }; -// `status: CREATING_PROJECT` with an empty `project_ref` — the branch exists -// but hasn't finished provisioning yet. +// `status: CREATING_PROJECT` with an empty `project_ref`: not finished provisioning yet. const LINK_BRANCH_NOT_READY: LinkBranch = { ...LINK_BRANCH, id: "66666666-7777-4888-8999-aaaaaaaaaaaa", @@ -151,10 +149,6 @@ function manyBranches(count: number): LinkBranches { })); } -// --------------------------------------------------------------------------- -// Setup -// --------------------------------------------------------------------------- - interface V1StubResult { readonly ok?: unknown; readonly fail?: unknown; @@ -275,15 +269,13 @@ function writeTempContent(workdir: string, name: string, content: string): void writeFileSync(tempFile(workdir, name), content); } -// Seeds `<workdir>/supabase/.temp/project-ref` — the 3rd-priority parent -// candidate for a branch-name lookup, and also the file `resolver.resolveForLink` -// falls back to for a plain ref link. +// Seeds the 3rd-priority parent candidate for a branch-name lookup, and the file +// `resolver.resolveForLink` falls back to for a plain ref link. function writeLinkedParentRef(workdir: string, ref: string): void { writeTempContent(workdir, "project-ref", ref); } -// Seeds `<workdir>/supabase/.temp/linked-project.json` — the 2nd-priority parent -// candidate. Real content shape mirrors what `link`'s own success path writes. +// Seeds the 2nd-priority parent candidate, in the shape `link`'s own success path writes. function writeLinkedProjectCacheFile(workdir: string, content: string): void { writeTempContent(workdir, "linked-project.json", content); } @@ -301,10 +293,6 @@ function transportFailureForMock() { return transportFailure(HttpClientRequestModule.get("https://api.supabase.com/mock")); } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe("link integration", () => { describe("plain project-ref linking", () => { it.live("links a project, writing the project-ref and version files", () => { @@ -317,7 +305,6 @@ describe("link integration", () => { expect(readTemp(workdir, "rest-version")).toBe("v11.1.0"); expect(readTemp(workdir, "gotrue-version")).toBe("v2.74.2"); expect(readTemp(workdir, "storage-version")).toBe("v1.28.0"); - // [YOUR-PASSWORD] stripped + transaction-mode port rewritten to 5432. expect(readTemp(workdir, "pooler-url")).toBe( "postgresql://postgres.ref@pooler.example.co:5432/postgres", ); @@ -356,8 +343,6 @@ describe("link integration", () => { properties: { name: "My Project", organization_slug: "acme" }, }, ]); - // A plain (non-branch) ref link never carries the CLI-2167 branch-link - // telemetry extension. const capture = analytics.captured.find((c) => c.event === "cli_project_linked"); expect(capture?.properties).not.toHaveProperty("linked_via"); expect(capture?.properties).not.toHaveProperty("parent_project_ref"); @@ -414,14 +399,8 @@ describe("link integration", () => { return Effect.gen(function* () { yield* link(flags()); expect(readTemp(workdir, "project-ref")).toBe(VALID_REF); - // No postgres-version / linked-project.json and no telemetry for a 404. expect(existsSync(tempFile(workdir, "postgres-version"))).toBe(false); expect(existsSync(tempFile(workdir, "linked-project.json"))).toBe(false); - // This is a plain ref link that happens to 404 (assumed to be a branch), - // with NO name/UUID resolution — `branchResolution` never fired, so the - // CLI-2167 branch-link telemetry extension doesn't fire either. Emits - // nothing at all for `cli_project_linked`, unlike the resolved-branch - // case (see "branch-name resolution: telemetry" below). expect(analytics.captured.map((c) => c.event)).not.toContain("cli_project_linked"); expect(analytics.groupIdentified).toHaveLength(0); }).pipe(Effect.provide(layer)); @@ -498,7 +477,6 @@ describe("link integration", () => { }); it.live("resolves keys by legacy name when no type field is present", () => { - // Untyped keys exercise the `name`-based fallback in extractServiceKeys. const { layer, out, workdir } = setup({ apiKeys: { ok: [ @@ -515,8 +493,6 @@ describe("link integration", () => { }); it.live("fails with missing-key error when the only secret key is not service_role", () => { - // A `secret` key whose JWT role is not `service_role` is skipped, leaving no - // usable key — exercises the secret-branch `continue` + missing-key path. const { layer } = setup({ apiKeys: { ok: [ @@ -546,10 +522,8 @@ describe("link integration", () => { }); return Effect.gen(function* () { yield* link(flags()); - // Link still succeeds and writes the project-ref. expect(readTemp(workdir, "project-ref")).toBe(VALID_REF); expect(out.stdoutText).toContain("Finished supabase link."); - // The best-effort files are absent because their services errored. expect(existsSync(tempFile(workdir, "storage-migration"))).toBe(false); expect(existsSync(tempFile(workdir, "rest-version"))).toBe(false); }).pipe(Effect.provide(layer)); @@ -566,9 +540,8 @@ describe("link integration", () => { }); it.live("fails when writing the project-ref file errors", () => { - // Make `<workdir>/supabase` a file so creating supabase/.temp fails for every - // temp write. The project status carries no version, so the first mandatory - // write to hit the broken path is project-ref (mirrors Go's read-only FS test). + // Makes `<workdir>/supabase` a file so every temp write fails; with no version in the + // project status, project-ref is the first mandatory write to hit the broken path. const out = mockOutput({ format: "text" }); const apiMock = mockCommandPlatformApiService({ v1: { @@ -645,9 +618,6 @@ describe("link integration", () => { "Cannot use both the [ref-or-branch] argument and the --project-ref flag.", ); } - // PR #6168 review: this check now sits INSIDE the `Effect.ensuring` - // wrapper too — previously the earliest possible failure in the - // handler, exiting before telemetry's finalizer was ever reached. expect(telemetry.flushed).toBe(true); expect(linkedCache.cached).toBe(false); }).pipe(Effect.provide(layer)); @@ -716,9 +686,9 @@ describe("link integration", () => { it.live( "THE HEADLINE REGRESSION: relinking a different branch resolves via the cached real parent, not the previously-linked branch ref", () => { - // Simulate the state left behind by a PRIOR `supabase link feature-branch`: - // project-ref holds the branch's own ref, but linked-project.json still - // holds the real parent (untouched, since branch links 404 on getProject). + // Simulates the state left behind by a prior `supabase link feature-branch`: + // project-ref holds the branch's own ref, but linked-project.json still holds the + // real parent. const { layer, workdir, apiMock } = setup({ branches: { ok: [LINK_BRANCH, LINK_BRANCH_OTHER] }, project: { fail: statusCodeFailure(404) }, @@ -730,11 +700,8 @@ describe("link integration", () => { flags({ refOrBranch: Option.some("other-branch"), projectRef: Option.none() }), ); const branchCall = apiMock.requests.find((r) => r.method === "listAllBranches"); - // The dealbreaker bug: this must be the PARENT ref, never BRANCH_PROJECT_REF. expect(branchCall?.input).toMatchObject({ ref: PARENT_REF }); expect(readTemp(workdir, "project-ref")).toBe(OTHER_BRANCH_PROJECT_REF); - // The 404 branch-link path leaves the cache untouched — the invariant a - // THIRD relink still depends on. expect(readTemp(workdir, "linked-project.json")).toBe(linkedProjectCacheJson(PARENT_REF)); }).pipe(Effect.provide(layer)); }, @@ -764,10 +731,8 @@ describe("link integration", () => { it.live( "a garbage SUPABASE_PROJECT_ID hard-fails the branch lookup with LinkParentRefInvalidError, never falling through to the cache (PR #6168 review)", () => { - // Superseded behavior: first-VALID-wins used to skip a malformed env - // in favor of the cache. The first PRESENT candidate now decides — - // an explicit-but-typo'd override must not silently resolve a - // different parent. + // The first present candidate decides, even if invalid — a typo'd override must not + // silently fall through to the cache. const { layer, apiMock, workdir } = setup({ branches: { ok: [LINK_BRANCH] }, projectId: Option.some("not-a-valid-ref"), @@ -800,9 +765,8 @@ describe("link integration", () => { ]; return Effect.gen(function* () { for (const content of corruptCacheContents) { - // Re-seed on every iteration: a successful link overwrites project-ref - // with the resolved branch ref, so the prior iteration's own write - // would otherwise clobber this fixture before the next check runs. + // Re-seeds every iteration: a successful link overwrites project-ref with the + // resolved branch ref, clobbering this fixture for the next iteration. writeLinkedParentRef(workdir, FILE_ONLY_REF); writeLinkedProjectCacheFile(workdir, content); yield* link( @@ -835,7 +799,6 @@ describe("link integration", () => { ); expect(json).toContain("Relink the parent project first: supabase link --project-ref"); } - // The invalid parent is rejected before any API call is attempted. expect(apiMock.requests).toHaveLength(0); }).pipe(Effect.provide(layer)); }, @@ -864,11 +827,10 @@ describe("link integration", () => { "cache alone (linked-project.json with no project-ref file) is never proof of a link: fails with LinkBranchNotLinkedError, no API call (PR #6168 review)", () => { const { layer, apiMock, workdir } = setup(); - // Simulates a FAILED prior `link --project-ref <parent>`: `getProject` - // returned 200 (so `linked-project.json` got written via the failure - // arm's `Effect.ensuring`) but a later step failed before `project-ref` - // itself was ever written. That stale cache entry must never be trusted - // as parent-resolution evidence for a NEW branch lookup. + // Simulates a failed prior `link --project-ref <parent>`: `getProject` returned 200 + // (so `linked-project.json` got written) but a later step failed before `project-ref` + // itself was written. That stale cache entry must never be trusted as parent-resolution + // evidence for a new branch lookup. writeLinkedProjectCacheFile(workdir, linkedProjectCacheJson(PARENT_REF)); return Effect.gen(function* () { const exit = yield* Effect.exit( @@ -889,9 +851,7 @@ describe("link integration", () => { "treats an unreadable project-ref path (e.g. a directory) as no candidate rather than failing", () => { const { layer, workdir, apiMock } = setup(); - // A directory at the project-ref path makes `fs.readFileString` fail with - // a real (non-not-exist) read error, exercising the defensive fallback - // distinct from the plain "file missing" case. + // A directory (not a missing file) makes `fs.readFileString` fail with a real read error. mkdirSync(tempFile(workdir, "project-ref"), { recursive: true }); return Effect.gen(function* () { const exit = yield* Effect.exit( @@ -924,11 +884,9 @@ describe("link integration", () => { JSON.stringify({ ref: PARENT_REF }), ); - // Follow-up: a second branch-name link must still resolve via the - // parent this write just persisted — proving it's real parent-chain - // evidence, not a dead write. `project-ref` was overwritten to the - // first branch's own ref by the first call, so this also proves the - // cache (not the file) is what a second resolution actually used. + // A second branch-name link must resolve via the parent this write just persisted. + // project-ref was overwritten by the first call, so this also proves the cache (not + // the file) is what a second resolution uses. yield* link( flags({ refOrBranch: Option.some("other-branch"), projectRef: Option.none() }), ); @@ -996,10 +954,6 @@ describe("link integration", () => { it.live( "raw ref-shaped 404 link where the correlation lookup itself fails: DELETES the unverified cache, link still succeeds (fail-safe, PR #6168 review)", () => { - // Superseded behavior: an unverifiable divergent cache used to be - // kept. Fail-safe wins — a wrong parent claim silently misdirects - // parent-scoped mutations, while deletion just downgrades later - // branches commands to a loud, recoverable not-linked error. const { layer, workdir } = setup({ project: { fail: statusCodeFailure(404) }, branches: { fail: statusCodeFailure(500) }, @@ -1086,7 +1040,6 @@ describe("link integration", () => { `Branch \\"feature-branch\\" has no project ref yet (status: CREATING_PROJECT)`, ); } - // No project-ref written, and no attempt to link the parent (env) ref instead. expect(existsSync(tempFile(workdir, "project-ref"))).toBe(false); expect(apiMock.requests).toEqual([ { method: "listAllBranches", input: { ref: PARENT_REF } }, @@ -1097,8 +1050,7 @@ describe("link integration", () => { it.live("a failed branch lookup leaves an existing project-ref file untouched", () => { const { layer, workdir } = setup({ branches: { ok: [LINK_BRANCH] } }); - // The project-ref file doubles as both "the existing link" and the parent - // candidate for this lookup — a realistic prior-link state. + // The project-ref file doubles as the existing link and the parent candidate here. writeLinkedParentRef(workdir, PARENT_REF); return Effect.gen(function* () { const exit = yield* Effect.exit( @@ -1143,7 +1095,6 @@ describe("link integration", () => { if (Exit.isFailure(exit)) { const json = JSON.stringify(exit.cause); expect(json).toContain(`Did you mean \\"staging\\"?`); - // "Staging" has an uppercase letter, so no ref-typo hint. expect(json).not.toContain("If you meant a project ref"); } }).pipe(Effect.provide(layer)); @@ -1207,12 +1158,6 @@ describe("link integration", () => { `Branch \\"missing-branch\\" not found for project ${PARENT_REF}. Available branches: alpha, zeta`, ); } - // PR #6168 review: a branch-name resolution failure now sits INSIDE the - // `Effect.ensuring` wrapper, so telemetry still flushes even though the - // link itself never reached ref resolution — previously this failure - // exited before the wrapper was ever reached, and telemetry silently - // never flushed. `ref` itself never resolved, so the linked-project - // cache fill correctly stays a no-op. expect(telemetry.flushed).toBe(true); expect(linkedCache.cached).toBe(false); }).pipe(Effect.provide(layer)); @@ -1375,10 +1320,8 @@ describe("link integration", () => { const { layer, workdir } = setup({ branches: { ok: [LINK_BRANCH] }, analytics, - // A branch's own project ref always 404s on `getProject` (it isn't a - // real top-level project) — this is what routes telemetry into the - // CLI-2167 `else if (branchResolution)` branch instead of the plain - // `if (project)` branch. + // A branch's own project ref always 404s on `getProject`, routing telemetry into + // the branch-resolution arm instead of the plain project arm. project: { fail: statusCodeFailure(404) }, }); writeLinkedParentRef(workdir, PARENT_REF); @@ -1394,8 +1337,6 @@ describe("link integration", () => { }); expect(properties?.groups).toEqual({ project: BRANCH_PROJECT_REF }); expect(analytics.groupIdentified).toHaveLength(0); - // The branch NAME is user-created content and must never leave the - // machine in any captured analytics payload. expect(JSON.stringify(analytics.captured)).not.toContain("feature-branch"); }).pipe(Effect.provide(layer)); }, @@ -1409,8 +1350,7 @@ describe("link integration", () => { branches: { ok: [LINK_BRANCH_DEFAULT] }, analytics, // No `project` override — `HEALTHY_PROJECT.ref === PARENT_REF`, so - // `getProject(PARENT_REF)` returns 200 here, unlike the 404 test - // above: a default branch's own `project_ref` IS the parent's ref. + // `getProject(PARENT_REF)` returns 200 here, unlike the 404 test above. }); writeLinkedParentRef(workdir, PARENT_REF); return Effect.gen(function* () { @@ -1423,7 +1363,6 @@ describe("link integration", () => { parent_project_ref: PARENT_REF, }); expect(properties?.groups).toEqual({ organization: "org_123", project: PARENT_REF }); - // The normal 200 arm's usual richness is untouched by the extension. expect(analytics.groupIdentified).toEqual([ { groupType: "organization", diff --git a/apps/cli/src/commands/login/login-api.service.ts b/apps/cli/src/commands/login/login-api.service.ts index 550a185bee..827120ade0 100644 --- a/apps/cli/src/commands/login/login-api.service.ts +++ b/apps/cli/src/commands/login/login-api.service.ts @@ -3,10 +3,7 @@ import { Context } from "effect"; import type { LoginVerificationError } from "./login.errors.ts"; -/** - * Subset of `AccessTokenResponse` (`login.go:39-45`) the decrypt step - * consumes. `id` / `created_at` are returned by the API but unused. - */ +/** Subset of `AccessTokenResponse` the decrypt step consumes; `id`/`created_at` are unused. */ export type LoginApiSessionResponse = { readonly access_token: string; readonly public_key: string; @@ -15,10 +12,9 @@ export type LoginApiSessionResponse = { interface LoginApiShape { /** - * Polls `GET {apiHost}/platform/cli/login/{sessionId}?device_code=<code>` - * (`pollForAccessToken`, `login.go:132-157`). Expects HTTP 200 with a - * 10s timeout; any transport / status / parse failure becomes a - * `LoginVerificationError` that drives the retry loop. + * Polls `GET {apiHost}/platform/cli/login/{sessionId}?device_code=<code>` with a 10s + * timeout. Any transport, status, or parse failure becomes a `LoginVerificationError` + * that drives the retry loop. */ readonly fetchLoginSession: ( apiHost: string, @@ -26,10 +22,8 @@ interface LoginApiShape { deviceCode: string, ) => Effect.Effect<LoginApiSessionResponse, LoginVerificationError>; /** - * Best-effort fetch of the authenticated user's `gotrue_id` from - * `GET {apiHost}/v1/profile` (`getProfileGotrueID`, `login.go:301-310`). - * Returns `None` on any failure so the caller clears the telemetry - * `distinct_id`, matching the `handleTelemetryAfterLogin` error branch. + * Best-effort fetch of the authenticated user's `gotrue_id` from `GET {apiHost}/v1/profile`. + * Returns `None` on any failure so the caller clears the telemetry `distinct_id`. */ readonly fetchGotrueId: (apiHost: string, token: string) => Effect.Effect<Option.Option<string>>; } diff --git a/apps/cli/src/commands/login/login-claude-hint.ts b/apps/cli/src/commands/login/login-claude-hint.ts index bec856be0f..ccbc6d0d89 100644 --- a/apps/cli/src/commands/login/login-claude-hint.ts +++ b/apps/cli/src/commands/login/login-claude-hint.ts @@ -1,13 +1,6 @@ /** - * Port of `utils.SuggestClaudePlugin` (`apps/cli-go/internal/utils/misc.go:43-57`). - * - * Returns the Claude Code plugin-install hint **only** when both: - * 1. the CLI is running inside Claude Code (`CLAUDECODE` / `CLAUDE_CODE` env — - * `agent.IsClaudeCode`), and - * 2. stdout is an interactive terminal (`term.IsTerminal(stdout)`). - * - * Otherwise returns `""`. Pure: env + TTY state are passed in so the helper is - * trivially unit-testable and free of service dependencies. + * Returns the Claude Code plugin-install hint only when the CLI is running inside Claude Code + * (`CLAUDECODE`/`CLAUDE_CODE` env) and stdout is an interactive terminal; otherwise returns `""`. */ const CLAUDE_CODE_HINT = `<claude-code-hint v="1" type="plugin" value="supabase@claude-plugins-official" />`; diff --git a/apps/cli/src/commands/login/login-crypto.service.ts b/apps/cli/src/commands/login/login-crypto.service.ts index 2c0ac7bc59..4f12e6e708 100644 --- a/apps/cli/src/commands/login/login-crypto.service.ts +++ b/apps/cli/src/commands/login/login-crypto.service.ts @@ -12,25 +12,21 @@ export type LoginEncryptedPayload = { interface LoginCryptoShape { /** - * Generates a P-256 (prime256v1) ECDH keypair and the uncompressed, - * hex-encoded public key sent to the dashboard. Mirrors Go's - * `LoginEncryption.generateKeys` + `encodedPublicKey` (`login.go:71-84`). + * Generates a P-256 (prime256v1) ECDH keypair and the uncompressed, hex-encoded public key + * sent to the dashboard. */ readonly generateKeyPair: Effect.Effect< { readonly ecdh: ECDH; readonly publicKeyHex: string }, LoginCryptoError >; - /** Fresh login session UUID (`uuid.New().String()`, `login.go:187`). */ + /** Fresh login session UUID. */ readonly generateSessionId: Effect.Effect<string>; /** - * Default token name `cli_<user>@<host>_<unix>`, falling back to `cli_<unix>` - * when the username/hostname lookup fails (`login.go:249-271`). + * Default token name `cli_<user>@<host>_<unix>`, falling back to `cli_<unix>` when the + * username/hostname lookup fails. */ readonly defaultTokenName: Effect.Effect<string>; - /** - * Derives the ECDH shared secret and AES-256-GCM decrypts the access token. - * Mirrors `decryptAccessToken` (`login.go:86-128`). - */ + /** Derives the ECDH shared secret and AES-256-GCM decrypts the access token. */ readonly decryptToken: ( ecdh: ECDH, payload: LoginEncryptedPayload, diff --git a/apps/cli/src/commands/login/login.e2e.test.ts b/apps/cli/src/commands/login/login.e2e.test.ts index 3beb0c51e2..9938c2e9e5 100644 --- a/apps/cli/src/commands/login/login.e2e.test.ts +++ b/apps/cli/src/commands/login/login.e2e.test.ts @@ -9,10 +9,8 @@ const E2E_TIMEOUT_MS = 30_000; const VALID_TOKEN = "sbp_" + "a".repeat(40); describe("supabase login", () => { - // Golden path: --token persists the access token and reports success. The e2e - // harness sets SUPABASE_NO_KEYRING=1 and points SUPABASE_HOME at the isolated - // home dir, so the token lands in <SUPABASE_HOME>/access-token rather than the - // OS keyring. + // The e2e harness sets SUPABASE_NO_KEYRING=1, so the token lands in + // <SUPABASE_HOME>/access-token rather than the OS keyring. test( "login --token persists the token and prints the logged-in message", { timeout: E2E_TIMEOUT_MS }, @@ -28,7 +26,6 @@ describe("supabase login", () => { }, ); - // Non-TTY with no token cannot use the automatic flow. test( "login with no token in a non-TTY exits non-zero with the missing-token message", { timeout: E2E_TIMEOUT_MS }, diff --git a/apps/cli/src/commands/login/login.errors.ts b/apps/cli/src/commands/login/login.errors.ts index 3a73978790..7ec722529d 100644 --- a/apps/cli/src/commands/login/login.errors.ts +++ b/apps/cli/src/commands/login/login.errors.ts @@ -6,22 +6,15 @@ import { ErrorActionabilityId, } from "../../shared/telemetry/error-actionability.ts"; -/** - * `ErrMissingToken`. The reference implementation Aqua-styles the - * `--token` / `SUPABASE_ACCESS_TOKEN` substrings, but the port renders - * styling as plain text (color is stripped on a non-TTY anyway), so this is - * byte-exact. - */ export const LOGIN_MISSING_TOKEN_MESSAGE = `Cannot use automatic login flow inside non-TTY environments. ` + `Please provide --token flag or set the SUPABASE_ACCESS_TOKEN environment variable.`; /** - * Token-path save failure — message format `cannot save provided token: %w` - * (`login.go:171`). Only ever constructed on the provided-token paths (`--token` - * / `SUPABASE_ACCESS_TOKEN` / piped stdin); the browser flow saves via the raw - * `credentials.saveAccessToken`. A malformed provided token is not fixable by - * `supabase login`, so the remediation is to correct that input. + * Token-path save failure. Only ever constructed on the provided-token paths (`--token` / + * `SUPABASE_ACCESS_TOKEN` / piped stdin); the browser flow saves via `credentials.saveAccessToken` + * directly. A malformed provided token isn't fixable by `supabase login`, so the remediation is + * to correct that input. */ export class LoginSaveTokenError extends Data.TaggedError("LoginSaveTokenError")<{ readonly message: string; @@ -31,7 +24,7 @@ export class LoginSaveTokenError extends Data.TaggedError("LoginSaveTokenError") } } -/** Non-TTY environment with no token supplied (`login.go:34-35`). */ +/** Non-TTY environment with no token supplied. */ export class LoginMissingTokenError extends Data.TaggedError("LoginMissingTokenError")<{ readonly message: string; }> { @@ -41,10 +34,9 @@ export class LoginMissingTokenError extends Data.TaggedError("LoginMissingTokenE } /** - * A single login-session poll/parse failure. Carries the underlying message so - * the retry notifier can print `<err>\nRetry (n/2): ` exactly like Go's - * `newErrorCallback` (`login.go:159-166`); also the value `verifyWithRetries` - * surfaces after the final attempt. + * A single login-session poll/parse failure. Carries the underlying message so the retry + * notifier can print `<err>\nRetry (n/2): `; also the value `verifyWithRetries` surfaces after + * the final attempt. */ export class LoginVerificationError extends Data.TaggedError("LoginVerificationError")<{ readonly message: string; @@ -64,12 +56,9 @@ export class LoginVerificationError extends Data.TaggedError("LoginVerificationE } /** - * All verification retries exhausted (`login.go:214-216`). Carries the LAST - * poll failure's discriminant so classification distinguishes "the user never - * completed the browser flow" (the endpoint keeps returning a pending 4xx, or - * no signal) from a genuine platform problem (5xx / transport). See the Go - * poll protocol: `pollForAccessToken` treats every non-200 as a retryable - * error (`login.go:132-157`, `pkg/fetcher/http.go:102-113`). + * All verification retries exhausted. Carries the last poll failure's discriminant so + * classification distinguishes "the user never completed the browser flow" (a pending 4xx, or + * no signal) from a genuine platform problem (5xx or transport). */ export class LoginFailedError extends Data.TaggedError("LoginFailedError")<{ readonly message: string; @@ -96,7 +85,7 @@ export class LoginFailedError extends Data.TaggedError("LoginFailedError")<{ } } -/** ECDH / AES-GCM decryption failure — message `cannot decrypt access token` (`login.go:47`). */ +/** ECDH / AES-GCM decryption failure; the message is `cannot decrypt access token`. */ export class LoginDecryptError extends Data.TaggedError("LoginDecryptError")<{ readonly message: string; }> { @@ -105,7 +94,7 @@ export class LoginDecryptError extends Data.TaggedError("LoginDecryptError")<{ } } -/** ECDH keypair generation failure — message `cannot generate crypto keys` (`login.go:66`). */ +/** ECDH keypair generation failure; the message is `cannot generate crypto keys`. */ export class LoginCryptoError extends Data.TaggedError("LoginCryptoError")<{ readonly message: string; }> { diff --git a/apps/cli/src/commands/login/login.handler.ts b/apps/cli/src/commands/login/login.handler.ts index d2f3761226..a835634d96 100644 --- a/apps/cli/src/commands/login/login.handler.ts +++ b/apps/cli/src/commands/login/login.handler.ts @@ -36,14 +36,10 @@ export const login = Effect.fn("login")(function* (flags: LoginFlags) { const claudeHint = suggestClaudePlugin({ stdoutIsTty: tty.stdoutIsTty }); - // Mirrors login's `PostRunE` (`cmd/login.go:42-48`): persist the chosen - // profile to `<SUPABASE_HOME or ~/.supabase>/profile` on success. The raw - // token is written so a YAML-path profile round-trips; a write failure is - // fatal. An explicitly passed flag counts even at its default value, and the - // LAST occurrence wins (pflag `Changed` + last-wins, same argv scan as the - // config layer), so `login --profile supabase` persists "supabase" — - // shadowing SUPABASE_PROFILE and healing a stale profile file like Go, never - // re-persisting the shadowed env value. + // Persists the chosen profile to `<SUPABASE_HOME or ~/.supabase>/profile` on success — the + // raw token, so a YAML-path profile round-trips; a write failure is fatal. An explicit flag + // counts even at its default value and the last occurrence wins, so `login --profile supabase` + // persists "supabase", shadowing SUPABASE_PROFILE without re-persisting it. const cliArgs = yield* Effect.serviceOption(CliArgs); const explicitProfileFlag = Option.match(cliArgs, { onNone: () => undefined, @@ -85,8 +81,7 @@ export const login = Effect.fn("login")(function* (flags: LoginFlags) { }); const body = Effect.gen(function* () { - // Token resolution priority: --token → SUPABASE_ACCESS_TOKEN → piped stdin - // (non-TTY only). Matches `cmd/login.go:31-39` + `login.go:236-247`. + // Token resolution priority: --token → SUPABASE_ACCESS_TOKEN → piped stdin (non-TTY only). const resolved = yield* resolveToken(flags); if (Option.isSome(resolved)) { return yield* tokenPath(resolved.value); @@ -94,9 +89,8 @@ export const login = Effect.fn("login")(function* (flags: LoginFlags) { return yield* browserLogin({ openBrowser: !flags.noBrowser, tokenName: flags.name }); }); - // `Effect.tap` runs the profile save only on success (`PostRunE`); - // `Effect.ensuring` persists telemetry state on success and failure alike - // (`PersistentPostRun`, `cmd/root.go:176`). + // `Effect.tap` runs the profile save only on success; `Effect.ensuring` persists telemetry + // state on success and failure alike. return yield* body.pipe( Effect.tap(() => persistProfileName), Effect.ensuring(telemetryState.flush), diff --git a/apps/cli/src/commands/login/login.integration.test.ts b/apps/cli/src/commands/login/login.integration.test.ts index 2206ea16e6..f0b6f75672 100644 --- a/apps/cli/src/commands/login/login.integration.test.ts +++ b/apps/cli/src/commands/login/login.integration.test.ts @@ -199,7 +199,6 @@ describe("login integration", () => { const { layer, out } = setupLogin({ isTTY: true }); return Effect.gen(function* () { yield* login(flags()); - // mockLoginCrypto default token name. expect(out.stdoutText).toContain("Token cli_test@host_123 created successfully."); }).pipe(Effect.provide(layer)); }); @@ -210,7 +209,6 @@ describe("login integration", () => { yield* login(flags()); expect(out.stderrText).toContain("Retry (1/2): "); expect(out.stderrText).toContain("Retry (2/2): "); - // 2 failures + 1 success = 3 poll attempts. expect(loginApi.loginCallCount).toBe(3); expect(out.stdoutText).toContain("You are now logged in. Happy coding!"); }).pipe(Effect.provide(layer)); @@ -224,7 +222,6 @@ describe("login integration", () => { if (Exit.isFailure(exit)) { expect(JSON.stringify(exit.cause)).toContain("LoginFailedError"); } - // The 3rd (final) failure gives up without printing a Retry notice. expect(out.stderrText).toContain("Retry (2/2): "); expect(out.stderrText).not.toContain("Retry (3/2): "); }).pipe(Effect.provide(layer)); @@ -345,7 +342,6 @@ describe("login integration", () => { }).pipe(Effect.provide(layer)); }); - // The shadowed env value must never be re-persisted (Go: pflag `Changed`). it.live("explicit --profile supabase persists 'supabase', shadowing SUPABASE_PROFILE", () => { const prev = process.env["SUPABASE_PROFILE"]; process.env["SUPABASE_PROFILE"] = "rogue-profile"; @@ -368,7 +364,6 @@ describe("login integration", () => { ); }); - // Permanently heals a file persisted by an older lenient version (#6091). it.live("explicit --profile supabase heals a stale persisted profile file", () => { mkdirSync(join(tempRoot.current, ".supabase"), { recursive: true }); writeFileSync(join(tempRoot.current, ".supabase", "profile"), "resms"); diff --git a/apps/cli/src/commands/login/login.layers.ts b/apps/cli/src/commands/login/login.layers.ts index 30886cb620..eca3c0e03d 100644 --- a/apps/cli/src/commands/login/login.layers.ts +++ b/apps/cli/src/commands/login/login.layers.ts @@ -11,17 +11,12 @@ import { stdinLayer } from "../../shared/runtime/stdin.layer.ts"; import { loginApiLayer } from "../../command-internal/login-api.layer.ts"; import { loginCryptoLayer } from "../../command-internal/login-crypto.layer.ts"; -// `login` is the only command that writes the access token, so it builds its own -// lean runtime instead of `managementApiRuntimeLayer` — it must NOT eagerly -// construct the platform-API client (which fails when no token exists yet). +// `login` builds its own lean runtime instead of `managementApiRuntimeLayer`, since it must not +// eagerly construct the platform-API client (which fails before a token exists). // -// `commandSettingsLayer` is provided to both `commandCredentialsLayer` and -// `loginApiLayer`, and exposed at the top level for the handler's direct -// `CommandSettings` reads. `Layer.provide` does not share to siblings inside a -// `Layer.mergeAll` (CLAUDE.md invariant 5), so the shared sub-layers are -// memoised by reference to avoid building two keyring readers / config loaders. -// `Analytics`, `Output`, `Stdio`, `Tty`, `TelemetryRuntime`, `FileSystem`, and -// `Path` come from the root layer. +// `commandSettingsLayer` is shared by reference between `commandCredentialsLayer` and +// `loginApiLayer` (and exposed at the top level) because `Layer.provide` doesn't merge into +// sibling layers — this avoids building two keyring readers / config loaders. const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); const httpClient = httpClientLayer.pipe(Layer.provide(debugLoggerLayer)); const credentials = commandCredentialsLayer.pipe( diff --git a/apps/cli/src/commands/logout/logout.e2e.test.ts b/apps/cli/src/commands/logout/logout.e2e.test.ts index 2b100d579f..c4c1c2118c 100644 --- a/apps/cli/src/commands/logout/logout.e2e.test.ts +++ b/apps/cli/src/commands/logout/logout.e2e.test.ts @@ -17,9 +17,8 @@ function seedTokenFile(home: string): string { } describe("supabase logout", () => { - // Deliberate Go quirk (parity note 1): under SUPABASE_NO_KEYRING=1 the profile - // keyring delete is unsupported, so logout removes the file token yet still - // reports "not logged in" and exits 0. + // Under SUPABASE_NO_KEYRING=1, keyring delete is unsupported, so logout removes the file + // token yet still reports "not logged in" and exits 0. test( "logout --yes removes a file token but reports not-logged-in under no-keyring", { timeout: E2E_TIMEOUT_MS }, @@ -36,10 +35,6 @@ describe("supabase logout", () => { }, ); - // Declining the confirmation must print a single `context canceled` line on - // stderr and exit 1, with NO `--debug` troubleshooting hint — - // `recoverAndExit` skips `SuggestDebugFlag` for `context.Canceled` - // (apps/cli-go/cmd/root.go:287-303). CLI-1973. test( "declining the logout prompt prints only context canceled, no --debug hint", { timeout: E2E_TIMEOUT_MS }, @@ -58,7 +53,6 @@ describe("supabase logout", () => { }, ); - // No token at all: same not-logged-in message, exit 0. test( "logout --yes with no token reports not-logged-in and exits 0", { timeout: E2E_TIMEOUT_MS }, diff --git a/apps/cli/src/commands/logout/logout.errors.ts b/apps/cli/src/commands/logout/logout.errors.ts index cf75c8e885..d2ae4585b7 100644 --- a/apps/cli/src/commands/logout/logout.errors.ts +++ b/apps/cli/src/commands/logout/logout.errors.ts @@ -6,14 +6,9 @@ import { } from "../../shared/telemetry/error-actionability.ts"; /** - * Raised when the user declines the logout confirmation prompt. The - * reference implementation returns `errors.New(context.Canceled)`, which the - * root error handler renders as `context canceled` on stderr with exit code - * 1 and no `--debug` suggestion (`cmd/root.go:287-303` skips - * `SuggestDebugFlag` for `context.Canceled`). The TS renderer mirrors that: - * constructing this error with `CONTEXT_CANCELED_MESSAGE` - * (`shared/output/errors.ts`) is what makes the text `Output.fail` withhold - * the debug hint (CLI-1973). + * Raised when the user declines the logout confirmation prompt. Constructing it with + * `CONTEXT_CANCELED_MESSAGE` (`shared/output/errors.ts`) is what makes `Output.fail` render + * `context canceled` on stderr without the `--debug` suggestion. */ export class LogoutCancelledError extends Data.TaggedError("LogoutCancelledError")<{ readonly message: string; diff --git a/apps/cli/src/commands/logout/logout.handler.ts b/apps/cli/src/commands/logout/logout.handler.ts index 8c8c3512d4..07536224ac 100644 --- a/apps/cli/src/commands/logout/logout.handler.ts +++ b/apps/cli/src/commands/logout/logout.handler.ts @@ -14,45 +14,38 @@ export const logout = Effect.fn("logout")(function* () { const output = yield* Output; const credentials = yield* CommandCredentials; const telemetryState = yield* TelemetryState; - // `--yes` OR `SUPABASE_YES` (`viper.GetBool("YES")`, root.go:318-320): this is - // read before scanning stdin, so the env var auto-confirms logout too. + // `--yes` or `SUPABASE_YES`, read before scanning stdin, so the env var auto-confirms too. const yes = yield* resolveYes; const confirmLabel = "Do you want to log out? This will remove the access token from your system."; const body = Effect.gen(function* () { - // Confirm prompt, honoring the global `--yes`/`SUPABASE_YES` (`logout.go:15-16`). + // Confirm prompt, honoring the global `--yes`/`SUPABASE_YES`. const confirmed = yield* Effect.gen(function* () { - // Machine (json/stream-json) mode without `--yes` has no Go equivalent — - // fail loudly on a non-interactive prompt rather than silently defaulting, - // preserving the existing contract. + // Machine (json/stream-json) mode without `--yes` fails loudly on a non-interactive + // prompt rather than silently defaulting. if (!yes && output.format !== "text") { return yield* output.promptConfirm(confirmLabel, { defaultValue: false }); } - // Mirrors `PromptYesNo(..., false)` (`logout.go:16`): `--yes`/ - // `SUPABASE_YES` auto-confirms WITH the `<label> [y/N] y` stderr echo - // (`console.go:70-72`) — do not short-circuit before the helper, or that - // echo line goes missing (CLI-1974). Without `--yes` it - // scans piped stdin before falling back to the default (`console.go:64-82`), - // so `printf 'y\n' | supabase logout` deletes the token. + // `--yes`/`SUPABASE_YES` auto-confirms through the helper, including its `<label> [y/N] y` + // stderr echo — don't short-circuit before calling it, or that echo line goes missing. + // Without `--yes` it scans piped stdin before falling back to the default, so + // `printf 'y\n' | supabase logout` deletes the token. return yield* promptYesNo(output, yes, confirmLabel, false); }); if (!confirmed) { return yield* Effect.fail(new LogoutCancelledError({ message: CONTEXT_CANCELED_MESSAGE })); } - // Delete the access token. `NotLoggedInError` is the not-logged-in - // path (print to stderr, exit 0, and do NOT sweep project credentials — - // Go returns before `DeleteAll`, `logout.go:21-23`). `DeleteTokenError` - // propagates as exit 1 (`logout.go:24-26`). + // `NotLoggedInError` prints to stderr and exits 0 without sweeping project credentials; + // `DeleteTokenError` propagates as exit 1. const notLoggedIn = yield* credentials.deleteAccessToken.pipe( Effect.as(false), Effect.catchTag("NotLoggedInError", (err) => Effect.gen(function* () { if (output.format !== "text") { - // Machine modes have no Go equivalent (Go is text-only). Emit the - // message as the structured result so consumers can distinguish the + // Emits the message as the structured result so consumers can distinguish the // not-logged-in outcome from a real logout instead of an empty blob. yield* output.success(err.message); } else { @@ -69,7 +62,7 @@ export const logout = Effect.fn("logout")(function* () { return; } - // Best-effort sweep of all stored project DB passwords (`logout.go:29-31`). + // Best-effort sweep of all stored project DB passwords. yield* credentials.deleteAllProjectCredentials; // Forget the telemetry identity (in-process stamp + persisted distinct_id) @@ -83,6 +76,6 @@ export const logout = Effect.fn("logout")(function* () { yield* output.raw(`${LOGGED_OUT_MSG}\n`, "stdout"); }); - // PersistentPostRun parity: persist telemetry state on success and failure. + // Persists telemetry state on success and failure alike. return yield* body.pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/commands/logout/logout.integration.test.ts b/apps/cli/src/commands/logout/logout.integration.test.ts index b143f85515..c3e1c225a9 100644 --- a/apps/cli/src/commands/logout/logout.integration.test.ts +++ b/apps/cli/src/commands/logout/logout.integration.test.ts @@ -62,8 +62,6 @@ describe("logout integration", () => { return Effect.gen(function* () { yield* logout(); expect(credentials.deletedAll).toBe(true); - // The `viper.GetBool("YES")` branch still echoes the accepted prompt to - // stderr (`console.go:70-72`) — `--yes` runs must not be silent (CLI-1974). expect(out.stderrText).toContain( "Do you want to log out? This will remove the access token from your system. [y/N] y\n", ); @@ -86,9 +84,6 @@ describe("logout integration", () => { }); it.live("empty non-interactive stdin takes Go's default (false) and cancels", () => { - // `PromptYesNo(..., false)` scans stdin and falls back to the default when - // the scan is empty (`logout.go:16`, `console.go:64-82`). With no piped input - // logout cancels — without hanging on the clack confirm. const { layer, credentials } = setupLogout({ stdinIsTty: false }); return Effect.gen(function* () { const exit = yield* Effect.exit(logout()); @@ -101,8 +96,6 @@ describe("logout integration", () => { }); it.live("honors a piped 'y' on non-interactive stdin and logs out", () => { - // Regression: Go scans piped stdin before defaulting (`console.go:74-82`), so - // `printf 'y\n' | supabase logout` deletes the token even on a non-terminal. const { layer, credentials } = setupLogout({ stdinIsTty: false, pipedAnswers: ["y"] }); return Effect.gen(function* () { yield* logout(); @@ -111,10 +104,6 @@ describe("logout integration", () => { }); it.live("honors SUPABASE_YES and logs out even when a piped 'n' is present", () => { - // Go reads `viper.GetBool("YES")` (incl. the SUPABASE_YES env var) BEFORE - // scanning stdin (`console.go:71`), so `SUPABASE_YES=1 printf 'n\n' | supabase - // logout` auto-confirms and deletes rather than consuming the piped `n`. The - // handler resolves `yes` via resolveYes, not the raw --yes flag. const prev = process.env["SUPABASE_YES"]; process.env["SUPABASE_YES"] = "1"; const { layer, credentials } = setupLogout({ stdinIsTty: false, pipedAnswers: ["n"] }); diff --git a/apps/cli/src/commands/logout/logout.layers.ts b/apps/cli/src/commands/logout/logout.layers.ts index 2de48d4c97..98e904e6ad 100644 --- a/apps/cli/src/commands/logout/logout.layers.ts +++ b/apps/cli/src/commands/logout/logout.layers.ts @@ -8,17 +8,12 @@ import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer. import { stdinLayer } from "../../shared/runtime/stdin.layer.ts"; /** - * Lean runtime for `logout`. Like `unlink`, it must NOT use - * `managementApiRuntimeLayer` — that layer eagerly builds the platform-API - * client, which fails with "Access token not provided" when logging out without - * a token. It provides only what the handler + instrumentation consume. + * Lean runtime for `logout`; it must not use `managementApiRuntimeLayer`, which eagerly builds + * the platform-API client and fails with "Access token not provided" when logging out without + * a token. * - * `commandSettingsLayer` is provided to `commandCredentialsLayer` and also exposed - * at the top level (`Layer.provide` does not share to siblings inside a merge — - * CLAUDE.md invariant 5). `Analytics`, `Output`, `Stdio`, `Tty`, `FileSystem`, - * `Path`, `TelemetryRuntime`, and `YesFlag` come from the root layer; - * `stdinLayer` (the shared piped-stdin reader for the logout confirm) builds its - * `Stdin` from the root `Tty`, like the migration runtimes. + * `commandSettingsLayer` is provided to `commandCredentialsLayer` and also exposed at the top + * level, since `Layer.provide` doesn't share to siblings inside a merge. */ const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); const credentials = commandCredentialsLayer.pipe( diff --git a/apps/cli/src/commands/migration/down/down.command.ts b/apps/cli/src/commands/migration/down/down.command.ts index 031848b6b4..429a515d46 100644 --- a/apps/cli/src/commands/migration/down/down.command.ts +++ b/apps/cli/src/commands/migration/down/down.command.ts @@ -7,9 +7,8 @@ import { migrationDbRuntimeLayer } from "../migration.layers.ts"; import { migrationDown } from "./down.handler.ts"; const config = { - // `--last` is conceptually a uint, default 1. Effect has no uint, so reject - // negatives explicitly to reproduce the established `ParseUint`-style rejection - // (the message differs slightly — an accepted small divergence). + // `--last` is conceptually a uint (default 1); Effect has no uint type, so negatives are + // rejected explicitly. last: Flag.integer("last").pipe( Flag.withDescription("Reset up to the last n migration versions."), Flag.withDefault(1), @@ -37,7 +36,6 @@ const config = { Flag.withDescription("Resets applied migrations on the local database."), Flag.withDefault(true), ), - // TS-only override of the linked project ref — see push.command.ts (db push). projectRef: Flag.string("project-ref").pipe( Flag.withDescription("Project ref of the Supabase project."), Flag.optional, @@ -59,9 +57,7 @@ export const migrationDownCommand = Command.make("down", config).pipe( local: flags.local, "project-ref": flags.projectRef, }, - // TS-only flag with no Go telemetry-safety baseline; Go's nearest - // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most - // others) are unmarked, so it stays redacted. + // `--project-ref` isn't marked safe here, so it stays redacted. }), withJsonErrorHandling, ), From 207d2a7b8fd03a3568ec15ef4e11429ef0fab878 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:01:36 +0100 Subject: [PATCH 30/56] chore(repo): trim comments, shard 32 (697 -> 447 comment lines) --- .../commands/seed/buckets/buckets.classify.ts | 16 +- .../commands/seed/buckets/buckets.command.ts | 21 +- .../commands/seed/buckets/buckets.e2e.test.ts | 8 +- .../commands/seed/buckets/buckets.errors.ts | 30 +- .../commands/seed/buckets/buckets.flags.ts | 22 +- .../commands/seed/buckets/buckets.handler.ts | 45 ++- .../seed/buckets/buckets.integration.test.ts | 291 ++++++------------ .../commands/seed/buckets/buckets.upload.ts | 16 +- apps/cli/src/commands/seed/seed.flags.ts | 18 +- .../src/commands/services/services.handler.ts | 37 +-- .../services/services.integration.test.ts | 19 +- .../src/commands/services/services.layers.ts | 18 +- .../services/services.layers.unit.test.ts | 14 +- .../snippets/download/download.handler.ts | 52 +--- .../download/download.integration.test.ts | 22 +- .../download/download.uuid.unit.test.ts | 15 +- .../commands/snippets/list/list.handler.ts | 30 +- .../snippets/list/list.integration.test.ts | 23 +- .../commands/snippets/snippets.e2e.test.ts | 7 +- .../src/commands/snippets/snippets.errors.ts | 7 +- .../src/commands/snippets/snippets.format.ts | 21 +- .../ssl-enforcement/get/get.command.ts | 15 +- .../get/get.integration.test.ts | 4 +- ...ment.experimental-gate.integration.test.ts | 16 +- .../ssl-enforcement/ssl-enforcement.format.ts | 12 +- .../ssl-enforcement.go-payload.ts | 6 +- .../ssl-enforcement/update/update.command.ts | 15 +- .../ssl-enforcement/update/update.handler.ts | 8 +- .../update/update.integration.test.ts | 14 - 29 files changed, 280 insertions(+), 542 deletions(-) diff --git a/apps/cli/src/commands/seed/buckets/buckets.classify.ts b/apps/cli/src/commands/seed/buckets/buckets.classify.ts index 72dcc34963..9b4c164c7a 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.classify.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.classify.ts @@ -1,21 +1,17 @@ /** - * Vector-bucket error classifiers — ports of `isVectorBucketsFeatureNotEnabled` - * and `isLocalVectorBucketsUnavailable` (`apps/cli-go/internal/seed/buckets/buckets.go:71-84`). - * - * Both inspect the error message string. The Storage gateway client raises - * status errors whose message reproduces `Error status <d>: <body>`, so the - * same substring checks apply. + * Vector-bucket error classifiers based on the Storage gateway's error message text, which + * reproduces `Error status <d>: <body>`. */ -/** Remote region has not enabled vector buckets yet (`buckets.go:71-73`). */ +/** True when the remote region has not enabled vector buckets yet. */ export function isVectorBucketsFeatureNotEnabled(message: string): boolean { return message.includes("FeatureNotEnabled"); } /** - * The local Storage service does not expose the vector routes (`buckets.go:75-84`): - * either it reports the vector service is not configured, or the `ListVectorBuckets` - * route returns 404 (older local image without vector support). + * True when the local Storage service does not expose the vector routes: either it reports the + * vector service is not configured, or `ListVectorBuckets` returns 404 (older local image without + * vector support). */ export function isLocalVectorBucketsUnavailable(message: string): boolean { return ( diff --git a/apps/cli/src/commands/seed/buckets/buckets.command.ts b/apps/cli/src/commands/seed/buckets/buckets.command.ts index e7ee2a2d3d..5e502ac9b8 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.command.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.command.ts @@ -11,17 +11,15 @@ import { storageGatewayRuntimeLayer } from "../../../command-internal/storage-ru import { seedBuckets } from "./buckets.handler.ts"; const config = { - // TS-only override of the linked project ref — see push.command.ts (db push). - // No Go equivalent: `seed.go` never registers `--project-ref` on this command. + // TS-only flag; see push.command.ts (db push) for the same pattern. projectRef: Flag.string("project-ref").pipe( Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), }; -// `--linked`/`--local` are scoped globals on the `seed` group (`seed.flags.ts`), -// so this leaf only owns `--project-ref` above; the handler selects the target -// from the changed argv set, not these parsed values. +// `--linked`/`--local` are scoped globals on the `seed` group (`seed.flags.ts`); the handler +// selects the target from the changed argv set, not these parsed values. export type BucketsFlags = { readonly linked: boolean; readonly local: boolean; @@ -33,13 +31,12 @@ export const bucketsCommand = Command.make("buckets", config).pipe( Command.withShortDescription("Seed buckets declared in [storage.buckets]"), Command.withHandler((leafFlags) => Effect.gen(function* () { - // Enforce --local/--linked mutual exclusivity BEFORE instrumentation, so a - // flag-validation rejection doesn't emit `cli_command_executed` (Go rejects - // it at cobra flag validation, before RunE/PostRun). + // Enforce --local/--linked mutual exclusivity before instrumentation, so a + // flag-validation rejection doesn't emit `cli_command_executed`. const cliArgs = yield* CliArgs; yield* assertSeedTargetsExclusive(cliArgs.args); - // Read the persistent seed-group flags for the telemetry flags map (Go logs - // the resolved flag values); target selection itself uses the changed set. + // Persistent seed-group flags for the telemetry flags map; target selection + // itself uses the changed set above. const flags: BucketsFlags = { linked: yield* SeedLinkedFlag, local: yield* SeedLocalFlag, @@ -52,9 +49,7 @@ export const bucketsCommand = Command.make("buckets", config).pipe( local: flags.local, "project-ref": flags.projectRef, }, - // TS-only flag with no Go telemetry-safety baseline; Go's nearest - // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most - // others) are unmarked, so it stays redacted. + // Not in the safe-flags allowlist, so this stays redacted. }), ); }).pipe(withJsonErrorHandling), diff --git a/apps/cli/src/commands/seed/buckets/buckets.e2e.test.ts b/apps/cli/src/commands/seed/buckets/buckets.e2e.test.ts index 26f5a3b283..5a6b270c20 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.e2e.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.e2e.test.ts @@ -12,7 +12,7 @@ const E2E_TIMEOUT_MS = 30_000; * network-free paths of `seed buckets`: * - an empty `[storage]` config is a no-op (exit 0, no stdout); * - `--local --linked` is rejected by the mutually-exclusive flag check. - * Bucket/object seeding parity is covered by the integration + unit suites. + * Bucket/object seeding is covered by the integration and unit suites. */ describe("supabase seed buckets", () => { let projectDir: string; @@ -50,9 +50,8 @@ describe("supabase seed buckets", () => { ); }); - // Go registers --linked/--local on seedCmd.PersistentFlags() (seed.go:27-29), - // so they're accepted BEFORE the subcommand too. These two cases exercise the - // real parser boundary, which the in-process suites bypass. + // --linked/--local are accepted before the subcommand token too; these two + // cases exercise the real parser boundary, which the in-process suites bypass. test( "accepts --local before the subcommand (Go PersistentFlags)", { timeout: E2E_TIMEOUT_MS }, @@ -60,7 +59,6 @@ describe("supabase seed buckets", () => { const { exitCode, stdout, stderr } = await runSupabase(["seed", "--local", "buckets"], { cwd: projectDir, }); - // Parsed (no "Unrecognized flag") and routed to the local no-op path. expect(`${stdout}${stderr}`).not.toContain("Unrecognized flag"); expect(exitCode).toBe(0); expect(stdout.trim()).toBe(""); diff --git a/apps/cli/src/commands/seed/buckets/buckets.errors.ts b/apps/cli/src/commands/seed/buckets/buckets.errors.ts index 504315c621..1bbdb77b06 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.errors.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.errors.ts @@ -6,20 +6,15 @@ import { } from "../../../shared/telemetry/error-actionability.ts"; /** - * Domain errors specific to `supabase seed buckets`. - * - * The Storage gateway and credential-derivation errors are shared with - * `storage ls/cp/mv/rm` and live in `command-internal/storage-gateway.errors.ts` - * and `command-internal/storage-credentials.errors.ts`. This file keeps only - * the seed-specific errors. + * Domain errors specific to `supabase seed buckets`. Storage gateway and + * credential-derivation errors are shared with `storage ls/cp/mv/rm` and live + * in `command-internal/storage-*.errors.ts`. */ /** * Raised when `supabase/config.toml` cannot be parsed, or a config-load-time - * validation Go runs before any Storage call fails (bucket name regex, - * `file_size_limit` numeral). Mirrors the `config push` CLI-1489 tradeoff: - * `loadCliConfig` raises `CliConfigParseError` on `env(...)` refs over - * numeric/bool fields, which Go resolves transparently. + * validation fails before any Storage call (bucket name regex, `file_size_limit` + * numeral), or by `loadCliConfig` for `env(...)` refs over numeric/bool fields. */ export class SeedConfigLoadError extends Data.TaggedError("SeedConfigLoadError")<{ readonly message: string; @@ -29,10 +24,7 @@ export class SeedConfigLoadError extends Data.TaggedError("SeedConfigLoadError") } } -/** - * Raised when `--local` and `--linked` are both passed, reproducing cobra's - * `MarkFlagsMutuallyExclusive("local", "linked")`. - */ +/** Raised when both `--local` and `--linked` are passed. */ export class SeedMutuallyExclusiveFlagsError extends Data.TaggedError( "SeedMutuallyExclusiveFlagsError", )<{ @@ -45,9 +37,8 @@ export class SeedMutuallyExclusiveFlagsError extends Data.TaggedError( /** * The resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a - * directory (`validateWorkdirIsDirectory`). Only reachable when the - * user explicitly set it — beats the `--project-ref` guard and every - * network call. + * directory. Only reachable when explicitly set — beats the `--project-ref` + * guard and every network call. */ export class SeedWorkdirError extends Data.TaggedError("SeedWorkdirError")<{ readonly message: string; @@ -59,9 +50,8 @@ export class SeedWorkdirError extends Data.TaggedError("SeedWorkdirError")<{ /** * An explicit `--workdir`/`SUPABASE_WORKDIR` holds no project config — - * raised instead of silently falling back to the embedded default (empty) - * bucket configuration, which would authenticate and seed nothing while - * still exiting 0. + * raised instead of silently falling back to the embedded default, which + * would authenticate and seed nothing while still exiting 0. */ export class SeedMissingProjectConfigError extends Data.TaggedError( "SeedMissingProjectConfigError", diff --git a/apps/cli/src/commands/seed/buckets/buckets.flags.ts b/apps/cli/src/commands/seed/buckets/buckets.flags.ts index 88139dd2d9..4a6b8a8342 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.flags.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.flags.ts @@ -4,27 +4,21 @@ import { changedLinkedLocalFlags } from "../../../command-internal/db-target-fla import { SeedMutuallyExclusiveFlagsError } from "./buckets.errors.ts"; /** - * Detects which of `--local` / `--linked` were explicitly set, reproducing - * cobra's `pflag.Changed` for `seed`'s `MarkFlagsMutuallyExclusive`. - * Delegates to the shared linked/local scanner (also used by `storage`). The - * seed target is selected from this changed set (`flag.Changed`, via - * `internal/utils/flags/db_url.go:46-63`), not the parsed flag value. + * Detects which of `--local` / `--linked` were explicitly set on argv, + * delegating to the shared linked/local scanner also used by `storage`. The + * seed target is selected from this changed set, not the parsed flag value. */ export function seedChangedTargetFlags(args: ReadonlyArray<string>): ReadonlyArray<string> { return changedLinkedLocalFlags(args); } /** - * Reproduce cobra's `MarkFlagsMutuallyExclusive("local", "linked")`. This is - * rejected at flag validation — before `RunE`/`PersistentPostRun` — so it - * must NOT emit `cli_command_executed`; the command calls this BEFORE - * `withCommandTelemetry`. + * Rejects `--local` and `--linked` set together. Must run before + * `withCommandTelemetry` so the rejection doesn't emit `cli_command_executed`. * - * The first bracket keeps seed's REGISTRATION order `[local linked]` — cobra - * joins the group names unsorted (`flag_groups.go:73`) and only sorts the - * "were all set" list (`flag_groups.go:203-204`). `storage` registers the same - * pair in the opposite order, so the two commands' first brackets legitimately - * differ. + * The error message's bracket lists `[local linked]` in registration order, + * not sorted — `storage`'s equivalent registers the same pair in the opposite + * order, so the two commands' messages legitimately differ. */ export const assertSeedTargetsExclusive = Effect.fnUntraced(function* ( args: ReadonlyArray<string>, diff --git a/apps/cli/src/commands/seed/buckets/buckets.handler.ts b/apps/cli/src/commands/seed/buckets/buckets.handler.ts index 2d59e480f1..b340e80532 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.handler.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.handler.ts @@ -18,18 +18,13 @@ import { /** * `supabase seed buckets` — seeds Storage buckets from - * `[storage.buckets]` / `[storage.vector]` in `supabase/config.toml`. - * - * Port of `apps/cli-go/internal/seed/buckets/buckets.go`. When `--linked` is - * passed, the remote Storage gateway is used with the project's service-role key; - * otherwise the local stack is used. The seeding work lives in the hoisted - * `seedBucketsRun` (shared with `db reset --local`); this handler owns the - * target-flag resolution and the post-run cache + telemetry side effects. + * `[storage.buckets]` / `[storage.vector]` in `supabase/config.toml`. The + * seeding work lives in the hoisted `seedBucketsRun` (shared with `db reset + * --local`); this handler owns target-flag resolution and telemetry. */ export const seedBuckets = Effect.fn("seed.buckets")(function* ( - // Target (linked vs. local) is selected from the changed-flag set - // (`flag.Changed`), not the parsed `linked`/`local` values — only - // `projectRef` is read directly below. + // Target (linked vs. local) is selected from the changed-flag set, not the + // parsed `linked`/`local` values — only `projectRef` is read directly below. flags: BucketsFlags, ) { const telemetryState = yield* TelemetryState; @@ -39,9 +34,8 @@ export const seedBuckets = Effect.fn("seed.buckets")(function* ( const fs = yield* FileSystem.FileSystem; // Set once --linked resolves a ref; drives the post-run linked-project cache - // write + org/project group identify (`cmd/root.go`'s `ensureProjectGroupsCached`, - // gated on a non-empty `flags.ProjectRef`). Empty on the local - // path, so the cache is never written there. + // write and org/project group identify. Empty on the local path, so the + // cache is never written there. let linkedRef = ""; yield* Effect.gen(function* () { @@ -49,17 +43,16 @@ export const seedBuckets = Effect.fn("seed.buckets")(function* ( Effect.mapError((error) => new SeedWorkdirError({ message: error.message })), ); - // Resolve the project ref for --linked BEFORE loading config, so that the - // matching `[remotes.<name>]` override (whose `project_id == ref`) is merged - // over the base config by `loadCliConfig`. The target is selected from - // `flag.Changed`, not the flag value: `--linked` is the linked path whenever - // it's *set* (even `--linked=false`). + // Resolve the project ref for --linked before loading config, so the + // matching `[remotes.<name>]` override (whose `project_id == ref`) is + // merged over the base config by `loadCliConfig`. `--linked` selects the + // linked path whenever it's set, even `--linked=false`. const setFlags = seedChangedTargetFlags(cliArgs.args); const isLinked = setFlags.includes("linked"); // `--project-ref` never implies `--linked` and must not be silently // discarded on the local target — see push.handler.ts's identical guard - // (db push) for the full TS-only rationale. + // (db push) for the rationale. if (Option.isSome(flags.projectRef) && !isLinked) { return yield* Effect.fail( new SeedMutuallyExclusiveFlagsError({ @@ -69,12 +62,10 @@ export const seedBuckets = Effect.fn("seed.buckets")(function* ( ); } - // An explicit `--workdir`/`SUPABASE_WORKDIR` that holds no project config - // fails HERE, before the api-keys fetch and any Storage call — fixes the - // "authenticates, seeds nothing, exits 0" bug. `start`/`db reset` never - // reach this handler (they call `seedBucketsRun` directly), so - // their behavior is unaffected. A DEFAULTED workdir is untouched — see - // `requireExplicitWorkdirProject`'s own doc comment. + // An explicit `--workdir`/`SUPABASE_WORKDIR` with no project config fails + // here, before any Storage call, instead of authenticating and seeding + // nothing while exiting 0. `start`/`db reset` skip this by calling + // `seedBucketsRun` directly. yield* requireExplicitWorkdirProject(cliSettings).pipe( Effect.mapError((error) => new SeedMissingProjectConfigError({ message: error.message })), ); @@ -85,8 +76,8 @@ export const seedBuckets = Effect.fn("seed.buckets")(function* ( yield* seedBucketsRun({ projectRef, emitSummary: true }); }).pipe( - // Caches the linked project + fires org/project group identify whenever - // `flags.ProjectRef` is set — only on the --linked path. + // Caches the linked project and fires org/project group identify — only + // on the --linked path, when `linkedRef` is set. Effect.ensuring( Effect.suspend(() => (linkedRef === "" ? Effect.void : linkedProjectCache.cache(linkedRef))), ), diff --git a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts index 7a89655bb0..a68e669277 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -156,9 +156,9 @@ function setupSeedBuckets( ) : Effect.succeed(projectRefRef), resolveOptional: () => Effect.succeed(Option.some(projectRefRef)), - // Gives an explicit `--project-ref` flag top precedence, same as Go's - // `flags.LoadProjectRef` — short-circuits BEFORE `linkedFails`, so a test - // can prove the flag resolves a ref even for an "unlinked" workdir. + // An explicit `--project-ref` flag takes precedence and short-circuits + // before `linkedFails`, so a test can prove the flag resolves a ref even + // for an "unlinked" workdir. loadProjectRef: (flagValue: Option.Option<string>) => Option.isSome(flagValue) && flagValue.value.length > 0 ? Effect.succeed(flagValue.value) @@ -222,12 +222,9 @@ const VAULT_ENCRYPTED = describe("seed buckets", () => { const tmp = useTempWorkdir("supabase-seed-buckets-"); - // Ambient `SUPABASE_API_*`/`SUPABASE_AUTH_*` values and the two canonical - // dotenvx private-key names (`DOTENV_PRIVATE_KEY`, `DOTENV_PRIVATE_KEY_LOCAL`) - // would shadow the dotenv fixtures below — the project-env walk skips keys - // already present in the shell env — so pin them to unset for every test in - // this file (the ambient-override test sets its own value back through - // `withEnvVar`). + // Ambient SUPABASE_API_*/SUPABASE_AUTH_* values and the dotenvx private-key + // env vars would shadow the dotenv fixtures below, so pin them unset for + // every test here (the ambient-override test restores its own via `withEnvVar`). const OVERRIDE_ENV_KEYS = [ "SUPABASE_API_ENABLED", "SUPABASE_API_EXTERNAL_URL", @@ -271,8 +268,6 @@ describe("seed buckets", () => { it.live( "hard-fails a malformed SUPABASE_API_PORT even when nothing is configured to seed", () => { - // The override decode belongs to config load, which runs before the no-op - // short-circuit — same principle as the bucket-name/size validations. const { layer, requests } = setupSeedBuckets(tmp.current, { toml: 'project_id = "test"\n', files: { "supabase/.env": "SUPABASE_API_PORT=not-a-port\n" }, @@ -289,8 +284,6 @@ describe("seed buckets", () => { it.live( "hard-fails a broken TLS cert/key pairing even when nothing is configured to seed", () => { - // The canonical `[api]` block (port, then TLS presence) is enforced on the - // no-op path too — same principle as the malformed-port case above. const { layer, requests } = setupSeedBuckets(tmp.current, { toml: 'project_id = "test"\n[api.tls]\nenabled = true\ncert_path = "kong.crt"\n', }); @@ -308,8 +301,6 @@ describe("seed buckets", () => { it.live( "hard-fails an undecryptable encrypted: service_role_key even when nothing is configured to seed", () => { - // The auth override/decrypt step is part of the same config-load - // validation as the `[api]` block above, so it runs on the no-op path too. const { layer, requests } = setupSeedBuckets(tmp.current, { toml: 'project_id = "test"\n[auth]\nservice_role_key = "encrypted:not-a-real-ciphertext"\n', }); @@ -331,24 +322,20 @@ describe("seed buckets", () => { const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests).toHaveLength(0); - // Scripted json callers get a result object even for the no-op short-circuit. const success = out.messages.find((m) => m.type === "success"); expect(success?.data?.["buckets_created"]).toEqual([]); expect(success?.data?.["objects_uploaded"]).toEqual([]); }); }); - // --local/--linked mutual exclusivity is enforced at the command level, before - // instrumentation (so it doesn't emit telemetry, matching the flag-validation - // rejection semantics). It's covered by `assertSeedTargetsExclusive` in - // buckets.flags.unit.test.ts rather than here, since the handler no longer - // performs the check. + // --local/--linked mutual exclusivity is enforced at the command level, so it + // doesn't reach this handler; see `assertSeedTargetsExclusive` in + // buckets.flags.unit.test.ts for that coverage. it.live("tolerates null string fields in 200 responses (Go encoding/json zero value)", () => { - // Go decodes these bodies into plain `string` fields (not *string); a JSON - // `null` leaves them at "" and does NOT abort (fetcher/http.go:144-151). A + // A JSON `null` for a string field decodes to "" and must not abort — a // list entry with `name: null` and a create response with `message: null` - // must therefore be tolerated, not treated as a parse failure. + // are both tolerated, not treated as a parse failure. const { layer, out, requests } = setupSeedBuckets(tmp.current, { toml: "[storage.buckets.docs]\npublic = false\n", routes: [ @@ -367,11 +354,9 @@ describe("seed buckets", () => { }); it.live("tolerates a null element in a bucket list (Go zero-value struct)", () => { - // encoding/json decodes a null array element into the zero-value struct - // (BucketResponse{Name:"", Id:""}) and the upsert loop continues - // (pkg/storage/buckets.go:21-27). A null element must not abort the run; the - // configured bucket is still created. A genuine type mismatch (string/number - // element) still aborts — that's covered by the malformed-response test. + // A null array element decodes to an empty-name entry and must not abort + // the run; the configured bucket is still created. A genuine type mismatch + // (string/number element) still aborts — see the malformed-response test. const { layer, out, requests } = setupSeedBuckets(tmp.current, { toml: "[storage.buckets.docs]\npublic = false\n", routes: [ @@ -439,11 +424,9 @@ describe("seed buckets", () => { pipedAnswers: ["n"], }); return Effect.gen(function* () { - // db reset seeds buckets with interactive=false (`buckets.Run(ctx, "", - // false, fsys)` forces console.IsTTY=false). This does NOT silently take - // the default: the prompt still prints its label, scans one line, and - // honors a parsed answer — so the piped "n" must skip the overwrite - // (default is yes). + // interactive: false doesn't silently take the default — the prompt + // still prints its label, scans one line, and honors the parsed + // answer, so the piped "n" skips the overwrite (default is yes). const exit = yield* seedBucketsRun({ projectRef: "", emitSummary: false, @@ -496,7 +479,6 @@ describe("seed buckets", () => { toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.documents-openai]\n", routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, - // Go decodes `{"vectorBuckets": null}` into a nil slice → empty, not an error. { method: "POST", match: VECTOR_LIST, body: { vectorBuckets: null } }, { method: "POST", match: VECTOR_CREATE, body: {} }, ], @@ -536,9 +518,9 @@ describe("seed buckets", () => { it.live( "prunes a stale vector bucket when the caller passes yes (db reset project-env path)", () => { - // `db reset` resolves `yes` with the nested project `.env` and passes it into the runner - // with `interactive: false`; the pre-resolved `yes` must drive pruning even though no - // prompt is answered and the runner's own shell-only resolveYes would default to false. + // db reset resolves `yes` from the nested project `.env` and passes it with + // `interactive: false`; the pre-resolved `yes` must drive pruning even + // though no prompt is answered and resolveYes would otherwise default to false. const { layer, out, requests } = setupSeedBuckets(tmp.current, { toml: "[storage.vector]\nenabled = true\n[storage.vector.buckets.keep-vec]\n", routes: [ @@ -625,7 +607,7 @@ describe("seed buckets", () => { it.live("uploads objects from a bucket's objects_path", () => { const { layer, out, requests } = setupSeedBuckets(tmp.current, { - // Relative objects_path resolves under supabase/ (Go config.go:757-759). + // Relative objects_path resolves under supabase/. toml: '[storage.buckets.images]\npublic = true\nobjects_path = "./assets"\n', files: { "supabase/assets/a.txt": "hello", @@ -648,10 +630,9 @@ describe("seed buckets", () => { }); it.live("sets the object Content-Type from the file bytes, not the extension", () => { - // Go sniffs the first 512 bytes with http.DetectContentType and only refines - // a generic text/plain by extension (objects.go:77-108). A PNG named `.txt` - // must upload as image/png (bytes win), and a JSON text file refines to - // application/json via its extension. + // Content-type is sniffed from the first 512 bytes; only a generic + // text/plain sniff is refined by extension. A PNG named `.txt` uploads as + // image/png (bytes win), and a JSON text file refines to application/json. mkdirSync(join(tmp.current, "supabase", "assets"), { recursive: true }); // Real PNG magic bytes — written raw (a UTF-8 string would mangle 0x89). writeFileSync( @@ -672,9 +653,7 @@ describe("seed buckets", () => { expect(Exit.isSuccess(exit)).toBe(true); const uploads = requests.filter((r) => r.url.includes("/storage/v1/object/")); const byKey = (suffix: string) => uploads.find((r) => r.url.endsWith(suffix)); - // PNG content named .txt → image/png (the bytes win over the extension). expect(byKey("images/logo.txt")?.headers["content-type"]).toBe("image/png"); - // JSON text → text/plain sniff refined to application/json by extension. expect(byKey("images/data.json")?.headers["content-type"]).toBe("application/json"); }); }); @@ -721,16 +700,12 @@ describe("seed buckets", () => { return Effect.gen(function* () { const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); - // json mode does not prompt; overwrite default (yes) → bucket updated. expect(out.promptConfirmCalls).toHaveLength(0); expect(requests.some((r) => r.method === "PUT" && r.url.includes("/bucket/test"))).toBe(true); }); }); it.live("treats a missing config file as embedded defaults: local no-op, no text output", () => { - // Go never aborts on a missing config.toml — it uses embedded defaults and - // no-ops the LOCAL path on empty buckets (internal/seed/buckets/buckets.go:16-20). - // Text mode emits nothing for the no-op, same as before. const { layer, out, requests } = setupSeedBuckets(tmp.current, {}); return Effect.gen(function* () { const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); @@ -741,8 +716,6 @@ describe("seed buckets", () => { }); it.live("emits an empty JSON result for a missing config file (local no-op, json mode)", () => { - // The missing-config local no-op flows through the same empty-summary path as - // an empty-but-present config, so scripted json callers still get a result. const { layer, out, requests } = setupSeedBuckets(tmp.current, { format: "json" }); return Effect.gen(function* () { const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); @@ -755,10 +728,9 @@ describe("seed buckets", () => { }); it.live("does not skip a --linked run when the config file is absent", () => { - // A linked run never short-circuits (gating is len(projectRef) == 0), so even - // with no config file Go still builds the remote client, fetches the - // service-role key, and lists buckets — failures surface instead of a silent - // success. With no configured buckets the remote LIST must still happen. + // A linked run never short-circuits on empty config: even with no config + // file, the remote client is built, the service-role key fetched, and + // buckets listed — failures surface instead of a silent success. const flags: BucketsFlags = { linked: true, local: false, projectRef: Option.none() }; const { layer, requests } = setupSeedBuckets(tmp.current, { projectRef: VALID_REF, @@ -776,7 +748,6 @@ describe("seed buckets", () => { return Effect.gen(function* () { const exit = yield* seedBuckets(flags).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); - // The remote list call fired against the linked project — not a silent no-op. expect( requests.some( (r) => @@ -808,9 +779,7 @@ describe("seed buckets", () => { return Effect.gen(function* () { const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); - // baseUrl is the configured external_url, not the 127.0.0.1 default. expect(requests.every((r) => r.url.startsWith("http://gateway.test:9999"))).toBe(true); - // A non-`sb_` key is treated as a JWT: both apikey and bearer are sent. expect(requests.every((r) => r.headers["apikey"] === "explicit-key")).toBe(true); expect(requests.every((r) => r.headers["authorization"] === "Bearer explicit-key")).toBe( true, @@ -834,7 +803,6 @@ describe("seed buckets", () => { return Effect.gen(function* () { const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); - // `withAuthToken` sends only `apikey` for opaque `sb_...` keys. expect(requests.every((r) => r.headers["apikey"] === "sb_secret_localkey")).toBe(true); expect(requests.every((r) => r.headers["authorization"] === undefined)).toBe(true); }); @@ -853,8 +821,6 @@ describe("seed buckets", () => { return Effect.gen(function* () { const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); - // An empty key is regenerated from the default secret (a signed JWT), not - // sent verbatim — `generateAPIKeys` fills it on len == 0. expect( requests.every((r) => (r.headers["authorization"] ?? "").startsWith("Bearer ey")), ).toBe(true); @@ -872,15 +838,14 @@ describe("seed buckets", () => { expect(JSON.stringify(exit)).toContain( "Invalid config for auth.jwt_secret. Must be at least 16 characters", ); - // Validation fails before any Storage call. expect(requests).toHaveLength(0); }); }); it.live("fails on an invalid bucket file_size_limit before any Storage call", () => { const { layer, requests } = setupSeedBuckets(tmp.current, { - // First bucket is valid; the second has an unparseable size. Go parses all - // sizes at config-load before NewStorageAPI, so nothing is mutated. + // First bucket is valid; the second has an unparseable size — all sizes + // are parsed at config-load, before any Storage call, so nothing is mutated. toml: [ "[storage.buckets.ok]", "public = true", @@ -896,15 +861,14 @@ describe("seed buckets", () => { const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(JSON.stringify(exit)).toContain("invalid size"); - // No list/create happened — validation precedes every Storage side effect. expect(requests).toHaveLength(0); }); }); it.live("rejects a malformed file_size_limit numeral (Go strconv.ParseFloat)", () => { const { layer, requests } = setupSeedBuckets(tmp.current, { - // JS parseFloat would parse "1.2.3" as 1.2; `strconv.ParseFloat` rejects - // the whole config before NewStorageAPI. + // parseFloat would parse "1.2.3" as 1.2; the whole config must be + // rejected instead. toml: '[storage.buckets.media]\npublic = true\nfile_size_limit = "1.2.3MiB"\n', routes: [{ method: "GET", match: "/storage/v1/bucket", body: [] }], }); @@ -918,8 +882,8 @@ describe("seed buckets", () => { it.live("fails on an invalid storage-level file_size_limit (only vector buckets)", () => { const { layer, requests } = setupSeedBuckets(tmp.current, { - // No storage buckets inherit it, only a vector bucket is configured — Go - // still unmarshals storage.FileSizeLimit at config-load and aborts. + // No storage buckets inherit it, only a vector bucket is configured — the + // storage-level file_size_limit is still validated at config-load and aborts. toml: [ '[storage]\nfile_size_limit = "bogus"', "[storage.vector]\nenabled = true", @@ -937,9 +901,9 @@ describe("seed buckets", () => { it.live("fails on an invalid storage-level file_size_limit even with nothing to seed", () => { const { layer, requests } = setupSeedBuckets(tmp.current, { - // No buckets and no vector buckets — but Go decodes storage.FileSizeLimit - // at config-load before buckets.Run's no-op path, so it still aborts. The - // config-load validations must run before the no-op short-circuit. + // No buckets and no vector buckets — the storage-level file_size_limit is + // still validated at config-load, before the no-op short-circuit, so it + // still aborts. toml: '[storage]\nfile_size_limit = "bogus"\n', }); return Effect.gen(function* () { @@ -952,8 +916,8 @@ describe("seed buckets", () => { it.live("inherits the storage-level file_size_limit when a bucket omits it", () => { const { layer, requests } = setupSeedBuckets(tmp.current, { - // Custom storage-level limit; the bucket omits file_size_limit, so Go's - // resolve() copies the storage-level value (5MiB) into the bucket. + // Custom storage-level limit; the bucket omits file_size_limit, so the + // storage-level value (5MiB) is inherited. toml: '[storage]\nfile_size_limit = "5MiB"\n[storage.buckets.media]\npublic = true\n', routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, @@ -1027,8 +991,8 @@ describe("seed buckets", () => { it.live("omits the port-conflict hint on a connection-refused local failure", () => { const { layer } = setupSeedBuckets(tmp.current, { - // Stack simply stopped → ECONNREFUSED. `localGatewayHint` does NOT fire - // for connection-refused (only malformed/timeout), so neither do we. + // Stack simply stopped → ECONNREFUSED. The local-gateway hint only fires + // for malformed/timeout failures, not connection-refused. toml: "[api]\nport = 7654\n[storage.buckets.test]\npublic = true\n", routes: [{ method: "GET", match: "/storage/v1/bucket", transport: true }], }); @@ -1041,8 +1005,8 @@ describe("seed buckets", () => { it.live("reports the external_url port (not api.port) in the local hint", () => { const { layer } = setupSeedBuckets(tmp.current, { - // external_url overrides the host:port the gateway actually targets; Go's - // localGatewayHint parses that URL, so the hint reports 9999, not 7654. + // external_url overrides the host:port the gateway targets, so the hint + // reports 9999, not 7654. toml: '[api]\nport = 7654\nexternal_url = "http://127.0.0.1:9999"\n[storage.buckets.test]\npublic = true\n', routes: [ { @@ -1161,9 +1125,8 @@ describe("seed buckets", () => { }); it.live("calls the gateway on the SUPABASE_API_PORT override, not the config.toml port", () => { - // #6452: the resolved api.port must reach the storage gateway like every - // other consumer of that setting. Setup runs before the env mutation so a - // throwing mkdir/write can never leak the override into later tests. + // Setup runs before the env mutation so a throwing mkdir/write can never + // leak the override into later tests. const { layer, requests } = setupSeedBuckets(tmp.current, { toml: "[api]\nport = 54321\n[storage.buckets.images]\npublic = true\n", routes: [ @@ -1406,7 +1369,7 @@ describe("seed buckets", () => { it.live("sends a SUPABASE_AUTH_SERVICE_ROLE_KEY set only in supabase/.env as the api key", () => { // The auth vars go through the same env/dotenv override composition as the - // `SUPABASE_API_*` family (#6467 follow-up). + // SUPABASE_API_* family. const { layer, requests } = setupSeedBuckets(tmp.current, { toml: "[storage.buckets.images]\npublic = true\n", files: { "supabase/.env": "SUPABASE_AUTH_SERVICE_ROLE_KEY=sb_secret_dotenv_only_key\n" }, @@ -1437,17 +1400,12 @@ describe("seed buckets", () => { const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); expect(requests.length).toBeGreaterThan(0); - // The exact token `status` prints for the same secret: signed with the - // dotenv secret through the same deterministic signer. const apiKey = generateGoJwt(secret, "service_role"); expect(requests.every((r) => r.headers["apikey"] === apiKey)).toBe(true); }); }); it.live("decrypts an encrypted: service_role_key with the private key from supabase/.env", () => { - // The config.toml ciphertext is decrypted with the `DOTENV_PRIVATE_KEY*` - // carried by the project dotenv, so the gateway sees the plaintext, not - // the ciphertext. const { layer, requests } = setupSeedBuckets(tmp.current, { toml: `[auth]\nservice_role_key = "${VAULT_ENCRYPTED}"\n[storage.buckets.images]\npublic = true\n`, files: { "supabase/.env": `DOTENV_PRIVATE_KEY=${VAULT_PRIVATE_KEY}\n` }, @@ -1465,8 +1423,8 @@ describe("seed buckets", () => { }); it.live("hard-fails an undecryptable encrypted: service_role_key before any gateway call", () => { - // `encrypted:` values are decrypted like the status/stop resolver does; - // an undecryptable one aborts instead of being sent as literal key material. + // An undecryptable `encrypted:` value aborts instead of being sent as + // literal key material. const { layer, requests } = setupSeedBuckets(tmp.current, { toml: '[auth]\nservice_role_key = "encrypted:not-a-real-ciphertext"\n[storage.buckets.images]\npublic = true\n', routes: [{ method: "GET", match: "/storage/v1/bucket", body: [] }], @@ -1496,8 +1454,8 @@ describe("seed buckets", () => { const { layer, requests } = setupSeedBuckets(tmp.current, { toml: "[storage.buckets.images]\npublic = true\n", routes: [ - // A missing `name` decodes to the zero value (""), tolerated like Go's - // json.Decode. (Non-object elements / wrong-typed fields are NOT — see below.) + // A missing `name` decodes to "" and is tolerated (non-object elements + // and wrong-typed fields are not — see below). { method: "GET", match: "/storage/v1/bucket", body: [{ id: "x" }] }, { method: "POST", match: "/storage/v1/bucket", body: { name: "images" } }, ], @@ -1513,8 +1471,8 @@ describe("seed buckets", () => { const { layer, requests } = setupSeedBuckets(tmp.current, { toml: "[storage.buckets.images]\npublic = true\n", routes: [ - // A non-object element / wrong-typed field — `ParseJSON` aborts here - // (cannot unmarshal string into BucketResponse), before any create. + // A non-object element or wrong-typed field aborts parsing before any + // create. { method: "GET", match: "/storage/v1/bucket", @@ -1551,7 +1509,6 @@ describe("seed buckets", () => { toml: "[storage.buckets.images]\npublic = true\n", routes: [ { method: "GET", match: "/storage/v1/bucket", body: [] }, - // The gateway uses WithExpectedStatus(200); a 201 is an error. { method: "POST", match: "/storage/v1/bucket", status: 201, body: { name: "images" } }, ], }); @@ -1565,10 +1522,8 @@ describe("seed buckets", () => { it.live( "trusts the Kong CA for an explicit https external_url even when tls.enabled is false", () => { - // Go installs status.NewKongClient unconditionally for the local client, so - // an https external_url with tls.enabled false/omitted still trusts the - // embedded CA. The handler must take the CA-injection path (no validation, - // no error) here, not skip it on `tls.enabled`. + // An https external_url always takes the CA-injection path (no + // validation, no error), regardless of `tls.enabled`. const { layer, requests } = setupSeedBuckets(tmp.current, { toml: '[api]\nexternal_url = "https://127.0.0.1:54321"\n[storage.buckets.images]\npublic = true\n', routes: [ @@ -1624,7 +1579,7 @@ describe("seed buckets", () => { return Effect.gen(function* () { const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); - // `net.JoinHostPort` brackets IPv6: http://[::1]:54321, not http://::1:54321. + // IPv6 hosts are bracketed: http://[::1]:54321, not http://::1:54321. expect(requests.every((r) => r.url.startsWith("http://[::1]:54321"))).toBe(true); }).pipe( Effect.ensuring( @@ -1654,8 +1609,7 @@ describe("seed buckets", () => { return Effect.gen(function* () { const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); - // `GetHostname` dials the TCP daemon host, not loopback, when only - // DOCKER_HOST is set (misc.go:305-310). + // Dials the TCP daemon host, not loopback, when only DOCKER_HOST is set. expect(requests.every((r) => r.url.startsWith("http://docker.internal:54321"))).toBe(true); }).pipe( Effect.ensuring( @@ -1720,10 +1674,9 @@ describe("seed buckets", () => { }); it.live("skips OS metadata files during the object walk (CLI-1950)", () => { - // .DS_Store (macOS Finder), Thumbs.db and desktop.ini (Windows Explorer) - // must never be uploaded as seeded objects — they are never even attempted, - // covering the "silently becomes a public object" failure mode, not just - // an upload-time abort. + // These files must never even be attempted for upload — covering the + // "silently becomes a public object" failure mode, not just an + // upload-time abort. mkdirSync(join(tmp.current, "supabase", "assets"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "assets", "a.txt"), "hello"); writeFileSync(join(tmp.current, "supabase", "assets", ".DS_Store"), "junk"); @@ -1752,11 +1705,10 @@ describe("seed buckets", () => { it.live( "skips a .DS_Store file in a MIME-restricted bucket instead of uploading it (CLI-1950)", () => { - // Reproduces the original bug report shape: a bucket with allowed_mime_types - // restricted to images. The test harness's mock HTTP route doesn't enforce - // allowed_mime_types server-side (that's real Storage-service behavior), so - // this doesn't simulate the 415 abort itself — it asserts the junk file is - // skipped and never uploaded, while the real image file still uploads. + // The mock HTTP route doesn't enforce allowed_mime_types server-side + // (real Storage-service behavior), so this doesn't simulate a 415 — it + // only asserts the junk file is skipped client-side while the real image + // still uploads. mkdirSync(join(tmp.current, "supabase", "assets"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "assets", "logo.png"), "fake-png-bytes"); writeFileSync(join(tmp.current, "supabase", "assets", ".DS_Store"), "junk"); @@ -1811,13 +1763,10 @@ describe("seed buckets", () => { it.live.skipIf(isRoot)( "skips a symlink to an unreadable regular file and keeps seeding siblings (Go opens, not stats)", () => { - // `isUploadableEntry` OPENS the symlink target (batch.go:73), which needs - // read permission; a stat-only check would queue this unreadable file and then - // abort the whole run when uploadObject opens it to stream. Mode 000 makes - // stat succeed (type File) but open fail — the entry must be skipped, not fatal. - // The real unreadable file lives OUTSIDE the walked tree: a plain regular file - // inside assets/ would be queued without an open-probe and would legitimately - // abort, so only the symlink may reach the unreadable target. + // The symlink target is opened, not just stat'd, so mode 000 (stat + // succeeds, open fails) is skipped rather than aborting the run. The + // unreadable file must live outside the walked tree — a plain file there + // would be queued and legitimately abort. mkdirSync(join(tmp.current, "supabase", "assets"), { recursive: true }); mkdirSync(join(tmp.current, "supabase", "private"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "assets", "a.txt"), "hello"); @@ -1843,7 +1792,6 @@ describe("seed buckets", () => { "Skipping non-regular file: supabase/assets/link-to-secret", ); expect(out.stderrText).toContain("Uploading: supabase/assets/a.txt => images/a.txt"); - // Only the readable sibling is uploaded; the unreadable symlink target is not. const uploads = requests.filter((r) => r.url.includes("/storage/v1/object/")); expect(uploads).toHaveLength(1); }); @@ -1880,9 +1828,8 @@ describe("seed buckets", () => { ); it.live("follows a symlinked objects_path root and uploads its files (Go fs.WalkDir)", () => { - // `io/fs.WalkDir` follows a symlinked ROOT ("if root itself is a - // symbolic link, its target will be walked"); only NESTED symlinks are - // skipped. fs.stat on the root follows the link, so the target dir is walked. + // A symlinked root is followed and its target walked; only nested + // symlinks are skipped. mkdirSync(join(tmp.current, "supabase", "real-assets"), { recursive: true }); writeFileSync(join(tmp.current, "supabase", "real-assets", "a.txt"), "hello"); symlinkSync("./real-assets", join(tmp.current, "supabase", "linked-assets")); @@ -1928,11 +1875,9 @@ describe("seed buckets", () => { it.live( "auto-confirms the overwrite from SUPABASE_YES in the project .env (Go loadNestedEnv, CLI-1878)", () => { - // SUPABASE_YES lives only in supabase/.env, not the shell or the --yes flag. `seed - // buckets` defaults to `--local` (Go: `cmd/seed.go:31`), and root's - // `ParseDatabaseConfig` loads the project `.env` files before `buckets.Run`'s - // overwrite prompt (`root.go:118`), so the standalone command's own fallback - // resolution (not the `db reset`-passed `opts.yes`) must read it too. + // SUPABASE_YES lives only in supabase/.env, not the shell or --yes flag. + // The standalone command must resolve it itself, not rely on the + // `db reset`-passed `opts.yes`. const { layer, out, requests } = setupSeedBuckets(tmp.current, { toml: "[storage.buckets.assets]\npublic = true\n", files: { "supabase/.env": "SUPABASE_YES=true\n" }, @@ -1977,8 +1922,6 @@ describe("seed buckets", () => { }); }); - // --linked remote path tests - it.live("--linked seeds the remote storage project", () => { const flags: BucketsFlags = { linked: true, local: false, projectRef: Option.none() }; const { layer, out, requests } = setupSeedBuckets(tmp.current, { @@ -2065,7 +2008,6 @@ describe("seed buckets", () => { }); it.live("--linked=false still takes the linked path (Go flag.Changed, not value)", () => { - // Go selects the target from flag.Changed: `--linked=false` is still linked. const { layer, requests } = setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", projectRef: VALID_REF, @@ -2082,7 +2024,6 @@ describe("seed buckets", () => { projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); - // Remote URL → the linked path ran despite the parsed value being false. expect(requests.every((r) => r.url.startsWith(`https://${VALID_REF}.supabase.co`))).toBe( true, ); @@ -2105,8 +2046,8 @@ describe("seed buckets", () => { projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); - // Local path (not the remote https host) — `--local` changed selects local. - // Asserting "not remote" keeps this independent of the loopback host env. + // Asserts "some request happened, not the remote host" — keeps this + // independent of the loopback host env. expect(requests.length).toBeGreaterThan(0); expect(requests.every((r) => r.url.startsWith("http://"))).toBe(true); expect(requests.some((r) => r.url.includes("supabase.co"))).toBe(false); @@ -2128,17 +2069,15 @@ describe("seed buckets", () => { projectRef: Option.none(), }).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); - // `tenant.GetApiKeys` → errMissingKey, before NewStorageAPI. expect(JSON.stringify(exit)).toContain("Anon key not found."); expect(requests.some((r) => r.url.includes("/storage/v1/"))).toBe(false); }); }); it.live("--linked surfaces tenant.GetApiKeys auth error on a non-200 api-keys response", () => { - // Go resolves the service-role key via tenant.GetApiKeys (storage/client/api.go:22), - // which maps a non-200 to `Authorization failed for the access token and project - // ref pair: <body>` (tenant/client.go:15,77-78) — NOT the projects api-keys - // helper's `unexpected get api keys status ...`. + // A non-200 api-keys response maps to `StorageAuthTokenError` with + // "Authorization failed for the access token and project ref pair", not + // the generic "unexpected get api keys status" message. const { layer, requests } = setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", projectRef: VALID_REF, @@ -2157,15 +2096,13 @@ describe("seed buckets", () => { expect(json).toContain("StorageAuthTokenError"); expect(json).toContain("Authorization failed for the access token and project ref pair"); expect(json).not.toContain("unexpected get api keys status"); - // Fails before any remote Storage call. expect(requests.some((r) => r.url.includes("/storage/v1/"))).toBe(false); }); }); it.live("caches the linked project on --linked but not on local", () => { - // Mirrors `ensureProjectGroupsCached` (cmd/root.go), gated on a non-empty - // resolved ref: --linked writes the linked-project cache + group identify; - // the local path must not. + // Gated on a non-empty resolved ref: --linked writes the linked-project + // cache and group identify; the local path does not. const linked = setupSeedBuckets(tmp.current, { toml: "[storage.buckets.test]\npublic = true\n", projectRef: VALID_REF, @@ -2330,12 +2267,10 @@ describe("seed buckets", () => { }); it.live("succeeds on the TLS local path and uses an https base URL", () => { - // The integration harness mocks HttpClient.HttpClient directly (bypassing fetch), - // so real TLS cert verification cannot be exercised here. This test confirms - // the TLS code path (embedded CA resolution + FetchHttpClient.Fetch override) - // does not throw, and that the gateway is called with https:// URLs — matching - // the existing "builds an https base URL" test but going through the full - // CA-resolution branch in the handler. + // The mock replaces HttpClient.HttpClient directly (bypassing fetch), so + // real TLS cert verification isn't exercised here — this only confirms the + // embedded-CA resolution path doesn't throw and the gateway is called with + // https:// URLs. const previousHost = process.env["SUPABASE_SERVICES_HOSTNAME"]; process.env["SUPABASE_SERVICES_HOSTNAME"] = "localhost"; const { layer, requests } = setupSeedBuckets(tmp.current, { @@ -2363,8 +2298,8 @@ describe("seed buckets", () => { }); it.live("reads cert_path and key_path from disk when both api.tls paths are set", () => { - // Writes a dummy CA PEM and key to disk. Both must be present and readable - // for the handler to succeed (Go validateLocalKongTls parity). + // Writes a dummy CA PEM and key to disk; both must be present and readable + // for the handler to succeed. const certContent = "-----BEGIN CERTIFICATE-----\nZHVtbXk=\n-----END CERTIFICATE-----\n"; const keyContent = "-----BEGIN PRIVATE KEY-----\nZHVtbXk=\n-----END PRIVATE KEY-----\n"; mkdirSync(join(tmp.current, "supabase"), { recursive: true }); @@ -2389,11 +2324,9 @@ describe("seed buckets", () => { it.live( "re-roots an absolute cert_path/key_path under supabase/ (Go path.Join, no IsAbs guard)", () => { - // Go resolves api.tls.cert_path/key_path with path.Join(SupabaseDirPath, p) - // and NO filepath.IsAbs guard (config.go:795-801), so an absolute-looking - // "/tmp/kong.crt" is read from supabase/tmp/kong.crt — NOT from the real - // /tmp. We only write the cert/key under supabase/tmp/; if the handler tried - // the literal /tmp path it would fail to read and error out. + // An absolute-looking "/tmp/kong.crt" is resolved relative to supabase/, + // not the real /tmp — we only write the cert/key under supabase/tmp/, so + // a handler that read the literal /tmp path would fail here. const certContent = "-----BEGIN CERTIFICATE-----\nZHVtbXk=\n-----END CERTIFICATE-----\n"; const keyContent = "-----BEGIN PRIVATE KEY-----\nZHVtbXk=\n-----END PRIVATE KEY-----\n"; mkdirSync(join(tmp.current, "supabase", "tmp"), { recursive: true }); @@ -2416,14 +2349,11 @@ describe("seed buckets", () => { }, ); - // Fix 1 — --linked merges [remotes.*] config overrides - it.live("--linked merges [remotes.*] storage config override before seeding", () => { - // The base config has [storage.buckets.base] with public=true; the remote block - // overrides it to public=false and adds [storage.buckets.remote]. Both buckets - // appear after the merge (`mergeRemoteConfig` merges subtrees recursively; - // it does not wholesale replace [storage.buckets]). - const remoteRef = VALID_REF; // "abcdefghijklmnopqrst" + // The remote block overrides `base.public` to false and adds a `remote` + // bucket; `mergeRemoteConfig` merges subtrees recursively rather than + // replacing [storage.buckets] wholesale, so both buckets are seeded. + const remoteRef = VALID_REF; const flags: BucketsFlags = { linked: true, local: false, projectRef: Option.none() }; const { layer, out, requests } = setupSeedBuckets(tmp.current, { toml: [ @@ -2447,13 +2377,9 @@ describe("seed buckets", () => { return Effect.gen(function* () { const exit = yield* seedBuckets(flags).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); - // Go prints the override notice from inside config load (config.go:513). expect(out.stderrText).toContain("Loading config override: [remotes.production]"); - // Both base and remote are present after the merge; the remote override - // changed base.public from true → false (but both are still seeded). expect(out.stderrText).toContain("Creating Storage bucket: base"); expect(out.stderrText).toContain("Creating Storage bucket: remote"); - // Two POST /bucket calls — both buckets seeded. expect( requests.filter((r) => r.method === "POST" && r.url.includes("/storage/v1/bucket")), ).toHaveLength(2); @@ -2461,7 +2387,6 @@ describe("seed buckets", () => { }); it.live("local run uses base config (no [remotes.*] merge)", () => { - // Without --linked, the base [storage.buckets.base] is used verbatim. const remoteRef = VALID_REF; const { layer, out, requests } = setupSeedBuckets(tmp.current, { toml: [ @@ -2489,8 +2414,6 @@ describe("seed buckets", () => { }); }); - // Fix 2 — validate bucket names up front - it.live("fails with exact error message on an invalid bucket name", () => { const { layer, requests } = setupSeedBuckets(tmp.current, { // "good-name" is valid; "bad/name" contains "/" which is not in the allowed set. @@ -2513,13 +2436,11 @@ describe("seed buckets", () => { expect(JSON.stringify(exit)).toContain( "Invalid Bucket name: bad/name. Only lowercase letters, numbers, dots, hyphens, and spaces are allowed. (^(\\\\w|!|-|\\\\.|\\\\*|'|\\\\(|\\\\)| |&|\\\\$|@|=|;|:|\\\\+|,|\\\\?)*$)", ); - // Validation fails before any Storage call. expect(requests).toHaveLength(0); }); }); it.live("accepts valid bucket names that use allowed special characters", () => { - // Bucket names with spaces, dots, underscores, etc. are valid per the regex. const { layer, requests } = setupSeedBuckets(tmp.current, { toml: [ '[storage.buckets."my.bucket"]', @@ -2541,8 +2462,6 @@ describe("seed buckets", () => { }); }); - // Fix 3 — SUPABASE_AUTH_JWT_SECRET / SUPABASE_AUTH_SERVICE_ROLE_KEY for local - it.live("local run: SUPABASE_AUTH_JWT_SECRET overrides auth.jwt_secret", () => { const prevJwt = process.env["SUPABASE_AUTH_JWT_SECRET"]; const prevKey = process.env["SUPABASE_AUTH_SERVICE_ROLE_KEY"]; @@ -2564,7 +2483,6 @@ describe("seed buckets", () => { return Effect.gen(function* () { const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); - // A derived JWT is sent (not opaque sb_ key), so Authorization is present. expect( requests.every((r) => (r.headers["authorization"] ?? "").startsWith("Bearer ey")), ).toBe(true); @@ -2627,8 +2545,6 @@ describe("seed buckets", () => { ); }); - // Fix 5 — validate api.tls cert/key pairing before seeding - it.live("fails when cert_path is set but key_path is missing", () => { mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( @@ -2699,10 +2615,9 @@ describe("seed buckets", () => { }); it.live("skips TLS validation when api.enabled is false (Go gates on c.Api.Enabled)", () => { - // Cert/key are resolved and validated only inside `if c.Api.Enabled` blocks - // (config.go:795, 841), so a config with [api] enabled=false, [api.tls] - // enabled=true and only cert_path set is valid and must NOT fail here on - // the missing key_path — it seeds normally instead. + // Cert/key pairing is validated only when api.enabled is true, so a config + // with api.enabled=false and only cert_path set must not fail on the + // missing key_path — it seeds normally. const { layer, requests } = setupSeedBuckets(tmp.current, { toml: '[api]\nenabled = false\n[api.tls]\nenabled = true\ncert_path = "custom-ca.crt"\n[storage.buckets.docs]\npublic = false\n', routes: [ @@ -2722,10 +2637,9 @@ describe("seed buckets", () => { it.live( "fails before the api-keys fetch when --workdir names a config-less subdirectory of a real ancestor project", () => { - // CLI-2285 regression: the ancestor project genuinely has a valid - // config.toml declaring a bucket, and the subdirectory genuinely has - // none of its own — an EXPLICIT --workdir must never silently climb - // to the ancestor's config and seed buckets there. + // The ancestor project has a valid config.toml declaring a bucket; the + // subdirectory has none of its own — an explicit --workdir must never + // silently climb to the ancestor's config. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), @@ -2740,7 +2654,6 @@ describe("seed buckets", () => { const exit = yield* seedBuckets(DEFAULT_FLAGS).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isFailure(exit)).toBe(true); expect(JSON.stringify(exit)).toContain("SeedMissingProjectConfigError"); - // Fires before any credential/api-keys resolution. expect(requests).toHaveLength(0); expect(telemetry.flushed).toBe(true); }); @@ -2765,12 +2678,10 @@ describe("seed buckets", () => { it.live( "seedBucketsRun succeeds with a caller-supplied resolvedConfig even when cliSettings.explicitWorkdir is true", () => { - // `start`/`db reset` never reach `buckets.handler.ts`'s own - // `requireExplicitWorkdirProject` guard — they call this shared - // core directly with an already-resolved `resolvedConfig`, bypassing - // the reload entirely. This regression guard proves that reuse path - // stays untouched by the CLI-2285 fix even when the settings passed - // happen to carry `explicitWorkdir: true`. + // `start`/`db reset` call `seedBucketsRun` directly with an + // already-resolved `resolvedConfig`, bypassing `buckets.handler.ts`'s + // `requireExplicitWorkdirProject` guard entirely — this proves that + // reuse path stays untouched even when `explicitWorkdir` is true. mkdirSync(join(tmp.current, "supabase"), { recursive: true }); writeFileSync( join(tmp.current, "supabase", "config.toml"), diff --git a/apps/cli/src/commands/seed/buckets/buckets.upload.ts b/apps/cli/src/commands/seed/buckets/buckets.upload.ts index 8ab14c8b9f..bf47aa7830 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.upload.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.upload.ts @@ -1,23 +1,19 @@ import * as nodePath from "node:path"; /** - * Pure path helper for `seed buckets` object upload, ported from - * `UpsertObjects` (`apps/cli-go/pkg/storage/batch.go`). Content-type resolution + * Pure path helper for `seed buckets` object upload. Content-type resolution * and the sniff read live in `command-internal/storage-content-type.ts` - * (shared with `storage cp`); size parsing in + * (shared with `storage cp`); size parsing lives in * `command-internal/storage-bucket-config.ts`. */ /** - * Destination object key for a local file, ported from `UpsertObjects` - * (`batch.go:101-118`). Mirrors `filepath.Rel(localPath, filePath)` + - * `path.Join(name, …)`: - * - single-file `objects_path` (the file is the path itself, `relPath == "."`) - * → `<bucket>/<basename>` + * Destination object key for a local file relative to `objectsPath`: + * - single-file `objects_path` (the file is the path itself) → `<bucket>/<basename>` * - otherwise → `<bucket>/<relative-posix-path>` * - * `objectsPath` and `filePath` are OS paths; the relative segment is normalised - * to forward slashes (`filepath.ToSlash`) for the remote key. + * `objectsPath` and `filePath` are OS paths; the relative segment is normalized + * to forward slashes for the remote key. */ export function bucketObjectKey(bucketName: string, objectsPath: string, filePath: string): string { const relPath = nodePath.relative(objectsPath, filePath); diff --git a/apps/cli/src/commands/seed/seed.flags.ts b/apps/cli/src/commands/seed/seed.flags.ts index fd80b7c266..2888ec211a 100644 --- a/apps/cli/src/commands/seed/seed.flags.ts +++ b/apps/cli/src/commands/seed/seed.flags.ts @@ -1,19 +1,11 @@ import { Flag, GlobalFlag } from "effect/unstable/cli"; /** - * `--linked` / `--local` are declared on the `seed` GROUP as scoped global - * flags, mirroring cobra persistent flags: they're inherited by subcommands - * and accepted BEFORE or AFTER the subcommand token, so both `supabase seed - * --linked buckets` and `supabase seed buckets --linked` are valid. Effect - * CLI's scoped globals give the same semantics — position-independent - * within the group's subtree and rejected out-of-scope. Declared in a - * standalone module so the `seed` group and the `buckets` leaf can both - * import them without a circular dependency. - * - * `--local` defaults to `true`; the seed target is actually selected from - * the changed-flag set (`flag.Changed`, see `buckets.flags.ts`), not these - * parsed values, so the defaults only affect the help text and the - * telemetry flags map. + * `--linked` / `--local` are scoped global flags on the `seed` group, so both + * `supabase seed --linked buckets` and `supabase seed buckets --linked` work. + * `--local` defaults to `true`, but the seed target is actually selected from + * the changed-flag set (`buckets.flags.ts`), not these parsed values — the + * defaults only affect help text and the telemetry flags map. */ export const SeedLinkedFlag = GlobalFlag.setting("linked")({ flag: Flag.boolean("linked").pipe( diff --git a/apps/cli/src/commands/services/services.handler.ts b/apps/cli/src/commands/services/services.handler.ts index 182eb0644d..15f08d162c 100644 --- a/apps/cli/src/commands/services/services.handler.ts +++ b/apps/cli/src/commands/services/services.handler.ts @@ -35,9 +35,8 @@ import type { ServicesFlags } from "./services.command.ts"; import { ServicesEnvNotSupportedError } from "./services.errors.ts"; /** - * Type shape for the hand-written `imageVersion` struct — declaration order - * is Name, Local, Remote (not alphabetical), and `Remote` is always emitted - * even when empty (CLI-1975). + * Struct shape for `imageVersion`: field order is name, local, remote (not + * alphabetical), and `remote` is always emitted even when empty. */ const GO_IMAGE_VERSION = goStruct([ ["name", goString], @@ -70,14 +69,9 @@ export const services = Effect.fn("services")(function* (_flags: ServicesFlags) return Option.none<string>(); } - // Warns on a ref-file READ error (as opposed to the file simply - // not existing) and keeps going as unlinked (`fmt.Fprintln(os.Stderr, err)` - // with `LoadProjectRef`'s `failed to load project ref: %w`, - // `project_ref.go:71-72`). A NotFound between the exists() check above and - // this read (TOCTOU) maps to the `os.ErrNotExist` → `ErrNotLinked` branch: - // silent, no warning. The warning's error suffix is Effect's description, - // not the reference implementation's `*PathError` text — the prefix is - // the compatibility-bearing part. + // Warns on a ref-file read error, but treats a NotFound race between the + // exists() check and this read as simply unlinked (silent, no warning). + // Only the "failed to load project ref: " prefix is compatibility-bearing. const content = yield* fs .readFileString(projectRefPath) .pipe( @@ -107,15 +101,11 @@ export const services = Effect.fn("services")(function* (_flags: ServicesFlags) const validLinkedRef = Option.filter(linkedProjectRef, (ref) => PROJECT_REF_PATTERN.test(ref)); if (Option.isSome(linkedProjectRef) && Option.isNone(validLinkedRef)) { - // `flags.LoadProjectRef` (project_ref.go:54-76) validates the ref but - // the reference `Run` only warns on the error and keeps going, still - // calling `listRemoteImages` with the malformed ref (services.go:61-62). - // TS matches the warning but deliberately skips the remote call instead of - // reproducing it: the ref is embedded unescaped into the tenant gateway - // hostname in `fetchLinkedServiceVersions`, so proceeding would let a - // malformed ref redirect the service-role key to an attacker-controlled host. - // Emitted before the config-load warning below to match the order these - // are printed in (services.go:18-24). + // A malformed linked ref still warns, but the remote call is skipped: + // `fetchLinkedServiceVersions` embeds the ref unescaped into the tenant + // gateway hostname, so proceeding could redirect the service-role key to + // an attacker-controlled host. Emitted before the config-load warning to + // preserve output order. yield* output.raw(`${INVALID_PROJECT_REF_MESSAGE}\n`, "stderr"); } @@ -208,10 +198,9 @@ export const services = Effect.fn("services")(function* (_flags: ServicesFlags) return; } - // goOutput is undefined or "pretty" — defer to the TS --output-format flag for - // machine output, otherwise render the `--output pretty` table. Guarding the - // table behind this (rather than treating "pretty" as force-table) keeps - // `--output pretty --output-format json` emitting JSON, per CLI-1546. + // goOutput is undefined or "pretty" — defer to --output-format for machine + // output, otherwise render the `--output pretty` table. This keeps + // `--output pretty --output-format json` emitting JSON. if (output.format === "json" || output.format === "stream-json") { yield* output.success("", { services: rows }); return; diff --git a/apps/cli/src/commands/services/services.integration.test.ts b/apps/cli/src/commands/services/services.integration.test.ts index 61cdccf87e..a36de3d333 100644 --- a/apps/cli/src/commands/services/services.integration.test.ts +++ b/apps/cli/src/commands/services/services.integration.test.ts @@ -370,12 +370,9 @@ major_version = 15 } if (url.pathname === "/v1/projects/abcdefghijklmnopqrst/api-keys") { - // Deliberately no service-role key: this test only needs to prove the - // handler wires the fetch+merge branch through, not re-test - // `fetchLinkedServiceVersions`'s own tenant-probe logic (already - // covered in services.shared.unit.test.ts). Omitting the - // service-role key keeps this test free of a second, tenant-gateway - // mock without weakening the assertion below. + // No service-role key: this test only proves the handler wires the + // fetch+merge branch through, not `fetchLinkedServiceVersions`'s own + // tenant-probe logic (covered in services.shared.unit.test.ts). return Response.json([ { name: "anon", @@ -481,8 +478,8 @@ major_version = 15 }); it.live("emits structured JSON for --output pretty combined with --output-format json", () => { - // Regression guard (CLI-1546): a Go `--output pretty` must defer to the TS - // `--output-format json` flag instead of forcing the human-readable table. + // --output pretty defers to --output-format json instead of forcing the + // human-readable table. const { layer, out } = setup({ format: "json", goOutput: Option.some("pretty") }); return Effect.gen(function* () { @@ -526,7 +523,7 @@ major_version = 15 expect(out.stdoutText).toContain("[[services]]"); // The hand-written imageVersion struct emits PascalCase field names in - // declaration order (Name, Local, Remote) with 2-space indent (CLI-1975). + // declaration order (Name, Local, Remote) with 2-space indent. expect(out.stdoutText).toContain(' Name = "supabase/postgres"'); }); }); @@ -552,8 +549,8 @@ major_version = 15 }); it.live("warns to stderr when the project-ref file exists but cannot be read", () => { - // A directory at the ref path makes `exists()` true but `readFileString()` fail - // (EISDIR), exercising the READ-error branch distinct from "file absent". + // A directory at the ref path makes `exists()` true but `readFileString()` + // fail (EISDIR), exercising the read-error branch distinct from "file absent". const workdir = mkdtempSync(join(tmpdir(), "supabase-services-")); mkdirSync(join(workdir, "supabase", ".temp", "project-ref"), { recursive: true }); const { layer, out } = setup({ workdir }); diff --git a/apps/cli/src/commands/services/services.layers.ts b/apps/cli/src/commands/services/services.layers.ts index b8d38cff8f..e168d91fad 100644 --- a/apps/cli/src/commands/services/services.layers.ts +++ b/apps/cli/src/commands/services/services.layers.ts @@ -19,9 +19,8 @@ import { CommandRuntime } from "../../shared/runtime/command-runtime.service.ts" /** * `services` always prints the local service matrix and only performs linked - * version checks when both a linked project ref and an access token are present. - * Keep this runtime lean so a tokenless local invocation does not fail before - * the handler can choose the local-only path. + * version checks when both a linked project ref and an access token are + * present. Keep this runtime lean so a tokenless local invocation succeeds. */ export const servicesRuntimeLayer = (() => { const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); @@ -41,18 +40,13 @@ export const servicesRuntimeLayer = (() => { Layer.provide(cliSettings), Layer.provide(httpClient), // The cache GET stitches session identity via the one per-command - // `IdentityStitch` (a single per-command `sync.Once`). + // `IdentityStitch` (only ever run once per command). Layer.provide(identityStitchLayer), ), telemetryStateLayer, - // The one per-command identity stitcher (a single per-command `sync.Once`), - // exposed at top level so `withCommandTelemetry` can read - // `stitchedDistinctId()` and attribute the cli_command_executed event to the - // gotrue id. The SAME reference is provided to linkedProjectCache above, so - // memoisation gives the cache GET and the instrumentation hook one - // `stitchAttempted` guard — aliasing/persisting at most once. Its - // Analytics / TelemetryRuntime / FileSystem / Path deps are ambient (root - // runtime). Mirrors advisors.layers.ts / lint.layers.ts. + // Exposed at top level so `withCommandTelemetry` can attribute + // cli_command_executed to the gotrue id via `stitchedDistinctId()`. The + // same instance is shared with linkedProjectCache so both stitch at most once. identityStitchLayer, commandRuntimeLayer(["services"]), ).pipe(Layer.provide(FetchHttpClient.layer)); diff --git a/apps/cli/src/commands/services/services.layers.unit.test.ts b/apps/cli/src/commands/services/services.layers.unit.test.ts index 91f44f614e..05643d909e 100644 --- a/apps/cli/src/commands/services/services.layers.unit.test.ts +++ b/apps/cli/src/commands/services/services.layers.unit.test.ts @@ -1,13 +1,7 @@ /** - * Layer-exposure test for `servicesRuntimeLayer`. - * - * Verifies that `IdentityStitch` is exposed at the top level of the - * runtime layer so that `withCommandTelemetry` can read - * `stitchedDistinctId()` via `Effect.serviceOption(IdentityStitch)` and - * attribute the `cli_command_executed` event to the gotrue id. - * - * See `db/lint/lint.layers.unit.test.ts` for the canonical pattern and a - * detailed explanation of the bug this guards against. + * Verifies `IdentityStitch` is exposed at the top level of + * `servicesRuntimeLayer` so `withCommandTelemetry` can attribute + * `cli_command_executed` to the gotrue id. See lint.layers.unit.test.ts. */ import { describe, expect, it } from "@effect/vitest"; @@ -65,7 +59,7 @@ function ambientStubs() { return Layer.mergeAll( BunServices.layer, - // The runtime layer under test builds the REAL commandSettingsLayer against + // The runtime layer under test builds the real commandSettingsLayer against // the real filesystem — see isolatedHomeLayer's docs. isolatedHomeLayer(tempRoot.current), mockTty(), diff --git a/apps/cli/src/commands/snippets/download/download.handler.ts b/apps/cli/src/commands/snippets/download/download.handler.ts index bae83faf57..b77d959c3f 100644 --- a/apps/cli/src/commands/snippets/download/download.handler.ts +++ b/apps/cli/src/commands/snippets/download/download.handler.ts @@ -23,10 +23,7 @@ function canonicalFromHex(hex32: string): string { return `${hex32.slice(0, 8)}-${hex32.slice(8, 12)}-${hex32.slice(12, 16)}-${hex32.slice(16, 20)}-${hex32.slice(20)}`; } -/** - * Reads `[start, end)` of `s` as lowercase hex, or `undefined` on the first - * non-hex byte — the byte-wise equivalent of google/uuid's `xtob` loop. - */ +/** Reads `[start, end)` of `s` as lowercase hex, or `undefined` on the first non-hex byte. */ function readHexRange(s: Uint8Array, start: number, end: number): string | undefined { let out = ""; for (let i = start; i < end; i++) { @@ -43,11 +40,9 @@ function readHexRange(s: Uint8Array, start: number, end: number): string | undef } /** - * `strings.EqualFold(s[:9], "urn:uuid:")` equivalent. ASCII-only folding over - * the raw bytes is exact here: no non-ASCII rune case-folds to any rune of - * `"urn:uuid:"` (Unicode's only ASCII-target simple folds are `ſ`→`s` and - * `K`→`k`, neither of which appears), and multibyte runes can never - * byte-match an ASCII target. + * Case-insensitive match against "urn:uuid:" over raw bytes. ASCII-only + * folding is exact here: no non-ASCII rune case-folds to any rune of + * "urn:uuid:", and multibyte runes can never byte-match an ASCII target. */ function isUrnUuidPrefix(bytes: Uint8Array): boolean { const expected = "urn:uuid:"; @@ -60,27 +55,11 @@ function isUrnUuidPrefix(bytes: Uint8Array): boolean { } /** - * Faithful port of `uuid.Parse` (google/uuid v1.6.0, `uuid.go:68-117`), used - * to validate the snippet id before it reaches the Management API. Accepts - * the same 4 forms — hyphenated (36), `urn:uuid:`-prefixed (45), braced - * `{…}` (38, where only the middle 36 bytes are examined — the trailing byte - * is never validated, mirroring `s = s[1:]`), and raw 32-hex — and returns - * the CANONICAL lowercase hyphenated form (the parsed `uuid.UUID`'s - * `String()` is always lowercase — never the raw arg). Error strings - * reproduce `uuid.Parse`'s three branches verbatim; the caller wraps them - * like `fmt.Errorf("invalid snippet ID: %w", err)`. - * - * Everything operates on the argument's UTF-8 BYTES: `uuid.Parse`'s `switch - * len(s)` counts bytes where a JS string's `length` counts UTF-16 code - * units, so a non-ASCII argument like `é<canonical-uuid>}` (JS length 38, 39 - * bytes) must take the default branch and report `invalid UUID length: 39` - * — not slip into the braced branch and issue a request (verified against - * go1.26 + google/uuid v1.6.0). - * - * This pre-check is load-bearing for error-message parity: the generated - * `V1GetASnippetInput` schema already pattern-checks UUIDs, so without it a - * non-UUID input would surface as a `SchemaError` with a `failed to download - * snippet:` prefix instead of `invalid snippet ID:`. + * Validates a snippet id before it reaches the Management API, accepting the + * 4 forms handled below and always returning the canonical lowercase + * hyphenated form. Operates on UTF-8 bytes, not UTF-16 code units, so + * non-ASCII input is measured by byte length — this pre-check is load-bearing, + * since the generated schema would otherwise report a generic `SchemaError`. */ export function parseSnippetUuid( input: string, @@ -129,10 +108,8 @@ export function parseSnippetUuid( } // Tolerant body parse — see `list.handler.ts` for the rationale. The real -// `/v1/snippets/{id}` payload omits `description`, which the generated -// `V1GetASnippetOutput` schema declares as `Union[String, Null]` (required). -// Routing through the typed client surfaces `SchemaError: Missing key …` on -// every non-test response. Same workaround as `linked-project-cache.layer.ts`. +// `/v1/snippets/{id}` payload omits `description`, which the generated schema +// requires, so routing through the typed client fails on real responses. function asRecord(obj: unknown): Record<string, unknown> { return typeof obj === "object" && obj !== null ? (obj as Record<string, unknown>) : {}; } @@ -210,10 +187,9 @@ export const snippetsDownload = Effect.fn("snippets.download")(function* ( ); yield* fetching?.clear() ?? Effect.void; - // TS-only structured output. Expose the full payload so scripted callers - // and agents can read snippet identity (`id`, `name`, `owner`, …) - // alongside `content.sql`, matching the SIDE_EFFECTS.md contract and the - // shape `snippets list --output-format json` uses for its response. + // Exposes the full payload (id, name, owner, ... alongside content.sql) + // for scripted callers — see SIDE_EFFECTS.md; matches the shape + // `snippets list --output-format json` uses. if (output.format === "json" || output.format === "stream-json") { yield* output.success("", asRecord(rawBody)); return; diff --git a/apps/cli/src/commands/snippets/download/download.integration.test.ts b/apps/cli/src/commands/snippets/download/download.integration.test.ts index 44119cc5bf..ece627129d 100644 --- a/apps/cli/src/commands/snippets/download/download.integration.test.ts +++ b/apps/cli/src/commands/snippets/download/download.integration.test.ts @@ -15,8 +15,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { snippetsDownload } from "./download.handler.ts"; const VALID_ID = "0b0d48f6-878b-4190-88d7-2ca33ed800bc"; -// Raw 32-hex form of VALID_ID, uppercase — a form `uuid.Parse` accepts -// (google/uuid v1.6.0) that the old handler rejected before this fix. +// Raw 32-hex form of VALID_ID, uppercase. const UPPER_HEX32_ID = "0B0D48F6878B419088D72CA33ED800BC"; const INVALID_ID = "not-a-uuid"; // length 10 → "invalid UUID length: 10" const TOO_LONG_ID = "0b0d48f6-878b-4190-88d7-2ca33ed800bc-extra"; // length 42 (3 ungrouped: 32, 36, 38, 41) @@ -40,10 +39,9 @@ const SNIPPET_RESPONSE: SnippetResponse = { content: { schema_version: "1.0.0", sql: SQL }, }; -// `goOutput` is intentionally absent: the download handler does not consume -// `OutputFlag` at all — it always prints the raw SQL unconditionally. -// Threading a value through here would suggest a behaviour difference that -// does not exist. +// `goOutput` is absent: the download handler doesn't consume `OutputFlag` at +// all — always prints raw SQL. Threading a value through would suggest a +// behavior difference that doesn't exist. interface SetupOpts { format?: "text" | "json" | "stream-json"; status?: number; @@ -81,10 +79,9 @@ describe("snippets download integration", () => { }).pipe(Effect.provide(layer)); }); - // `--output` is ignored entirely: no read of `OutputFlag`, no - // branching. This regression guards against a future refactor that adds - // branch-on-goOutput logic by mistake — if the flag is consumed, this - // assertion will diverge. + // `--output` is ignored entirely: no read of `OutputFlag`, no branching. + // Guards against a future refactor adding branch-on-goOutput logic by + // mistake — if the flag is consumed, this assertion diverges. it.live("text mode is unaffected by any Go `--output` value (Go parity)", () => { const out = mockOutput({ format: "text" }); const telemetry = mockTelemetryStateTracked(); @@ -141,12 +138,9 @@ describe("snippets download integration", () => { if (Exit.isFailure(exit)) { const dump = JSON.stringify(exit.cause); expect(dump).toContain("SnippetsInvalidIdError"); - // `uuid.Parse` returns `invalid UUID length: 10` for "not-a-uuid" - // (length 10), wrapped as `invalid snippet ID: %w`. expect(dump).toContain("invalid snippet ID: invalid UUID length: 10"); } expect(api.requests).toHaveLength(0); - // Telemetry flush and linked-project caching still fire on this error path. expect(telemetry.flushed).toBe(true); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); @@ -177,7 +171,7 @@ describe("snippets download integration", () => { if (Exit.isFailure(exit)) { const dump = JSON.stringify(exit.cause); expect(dump).toContain("invalid snippet ID: invalid UUID format"); - // The offending value must NOT be embedded in the error message. + // The offending value must not be embedded in the error message. expect(dump).not.toContain(WRONG_FORMAT_ID); } }).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/commands/snippets/download/download.uuid.unit.test.ts b/apps/cli/src/commands/snippets/download/download.uuid.unit.test.ts index 61a0154898..6508ca5ca9 100644 --- a/apps/cli/src/commands/snippets/download/download.uuid.unit.test.ts +++ b/apps/cli/src/commands/snippets/download/download.uuid.unit.test.ts @@ -34,8 +34,6 @@ describe("parseSnippetUuid", () => { expect(parseSnippetUuid("{0b0d48f6-878b-4190-88d7-2ca33ed800bc}")).toEqual({ canonical: "0b0d48f6-878b-4190-88d7-2ca33ed800bc", }); - // The 38th (trailing) character is `!`, not `}` — still parses, because Go's - // `s = s[1:]` only strips the leading brace and never inspects the last byte. expect(parseSnippetUuid("{0b0d48f6-878b-4190-88d7-2ca33ed800bc!")).toEqual({ canonical: "0b0d48f6-878b-4190-88d7-2ca33ed800bc", }); @@ -73,10 +71,9 @@ describe("parseSnippetUuid", () => { }); }); - // `uuid.Parse` dispatches on `len(s)` — UTF-8 BYTES — where a JS - // string's `length` counts UTF-16 code units. Non-ASCII arguments must take - // the byte-length branch and report the byte count. Every expectation - // below is ground truth from go1.26 + google/uuid v1.6.0. + // Dispatch is on byte length, not JS's UTF-16 code-unit length, so + // non-ASCII arguments must take the byte-length branch and report the byte + // count. describe("UTF-8 byte-length dispatch (non-ASCII arguments)", () => { const canonical = "0b0d48f6-878b-4190-88d7-2ca33ed800bc"; @@ -96,15 +93,15 @@ describe("parseSnippetUuid", () => { }); it("slices the urn prefix by byte and %q-quotes it (printable rune prints literally)", () => { - // 2 (é) + 7 + 36 = 45 bytes → urn branch; first 9 BYTES are "érn:uuid". + // 2 (é) + 7 + 36 = 45 bytes → urn branch; first 9 bytes are "érn:uuid". expect(parseSnippetUuid(`érn:uuid${canonical}`)).toEqual({ error: 'invalid urn prefix: "érn:uuid"', }); }); it("renders a rune split by the 9-byte prefix slice as Go's lone \\xNN escape", () => { - // 8 ASCII + é(2 bytes) + 35 = 45 bytes; byte 9 cuts é in half, so the - // reference `%q` formatting shows its orphaned lead byte: `"12345678\xc3"`. + // 8 ASCII + é (2 bytes) + 35 = 45 bytes; byte 9 splits é, so the escaped + // form is "12345678\xc3". expect(parseSnippetUuid(`12345678é${canonical.slice(0, 35)}`)).toEqual({ error: 'invalid urn prefix: "12345678\\xc3"', }); diff --git a/apps/cli/src/commands/snippets/list/list.handler.ts b/apps/cli/src/commands/snippets/list/list.handler.ts index 263d7c50f4..29a97a74bb 100644 --- a/apps/cli/src/commands/snippets/list/list.handler.ts +++ b/apps/cli/src/commands/snippets/list/list.handler.ts @@ -32,13 +32,8 @@ import { renderSnippetsTable, type SnippetRow } from "../snippets.format.ts"; import type { SnippetsListFlags } from "./list.command.ts"; // Tolerant accessors for the API response body. The real `/v1/snippets` -// payload regularly omits optional fields like `description` that the -// generated `V1ListAllSnippetsOutput` schema declares as `Union[String, Null]` -// (present-but-nullable). Routing through the typed client therefore fails -// with a `SchemaError: Missing key …` on any real-world response — see the -// cli-e2e `snippets-download-prints-sql-content-to-stdout` failure that -// prompted the bypass. Same workaround pattern as -// `linked-project-cache.layer.ts` and `suggestUpgrade`. +// payload omits optional fields the generated schema declares required, so +// routing through the typed client fails with `SchemaError: Missing key …`. function readString(obj: unknown, key: string): string { if (typeof obj === "object" && obj !== null && key in obj) { const value = (obj as Record<string, unknown>)[key]; @@ -52,11 +47,9 @@ function asRecord(obj: unknown): Record<string, unknown> { } /** - * Type shape for the snippets-list response, used to drive `-o yaml|toml` - * key casing (see `apps/cli-go/pkg/api/types.gen.go`; re-audit if it - * regenerates). The `description` field is a `nullable.Nullable[string]` — - * yaml.v3 renders it as a `map[bool]string`, and BurntSushi refuses it - * whenever present (CLI-1975). + * Type shape for the snippets-list response, used to drive `-o yaml|toml` key + * casing. `description` (`nullable.Nullable[string]`) renders as + * `map[bool]string` in YAML and is refused outright in TOML. */ const GO_SNIPPET_LIST = goStruct([ ["cursor", goPtr(goString)], @@ -197,10 +190,9 @@ export const snippetsList = Effect.fn("snippets.list")(function* (flags: Snippet const goFmt = Option.getOrUndefined(goOutputFlag); if (goFmt === "json") { - // Round-trip the raw body so a real API `data: []` stays `data: []` - // (and a hypothetical `data: null` would stay null). Go's - // `encoding/json` preserves nil-vs-empty; bypassing the typed client - // means we can faithfully mirror that here too. + // Round-trips the raw body so a real API `data: []` stays `data: []` + // and a hypothetical `data: null` stays null — nil-vs-empty is + // preserved rather than normalized. yield* output.raw(encodeGoJson(rawBody)); return; } @@ -209,9 +201,9 @@ export const snippetsList = Effect.fn("snippets.list")(function* (flags: Snippet return; } if (goFmt === "toml") { - // BurntSushi cannot encode the `nullable.Nullable[string]` description - // field (`map[bool]string`), so Go fails whenever any snippet carries a - // `description` key. Mirror the failure byte-for-byte. + // The established TOML encoder can't represent the nullable + // `description` field, so this fails whenever any snippet carries a + // `description` key — mirroring that established failure exactly. const toml = yield* Effect.try({ try: () => encodeGoToml(rawBody, GO_SNIPPET_LIST), catch: (cause) => diff --git a/apps/cli/src/commands/snippets/list/list.integration.test.ts b/apps/cli/src/commands/snippets/list/list.integration.test.ts index 0d1f950e15..656e7dd190 100644 --- a/apps/cli/src/commands/snippets/list/list.integration.test.ts +++ b/apps/cli/src/commands/snippets/list/list.integration.test.ts @@ -41,9 +41,7 @@ const PIPE_RESPONSE: SnippetsResponse = { data: [ { ...SNIPPET_BASE, - // `strings.ReplaceAll(value, "|", "\\|")` is a markdown-intermediate - // escape that glamour decodes back to literal `|` in the rendered ASCII - // bytes. `renderGlamourTable` bypasses glamour, so we pass raw values — + // `renderGlamourTable` bypasses glamour's markdown-table escaping, so // any `|` in `name` / `owner.username` must appear literally in stdout. name: "name|with|pipes", owner: { id: 7, username: "user|name" }, @@ -121,7 +119,6 @@ describe("snippets list integration", () => { yield* snippetsList({ projectRef: Option.none() }); expect(out.stdoutText).toContain("name|with|pipes"); expect(out.stdoutText).toContain("user|name"); - // No `\|` escape — the intermediate escape is round-tripped by glamour. expect(out.stdoutText).not.toContain("\\|"); }).pipe(Effect.provide(layer)); }); @@ -167,8 +164,7 @@ describe("snippets list integration", () => { const { layer, out } = setup({ goOutput: "json", response: EMPTY_RESPONSE }); return Effect.gen(function* () { yield* snippetsList({ projectRef: Option.none() }); - // The API returns `{"data": []}`; our raw-HTTP bypass means we - // faithfully echo whatever the API sent — no `nullForEmptyArrays` + // Raw-HTTP bypass echoes whatever the API sent — no `nullForEmptyArrays` // coercion (real responses always send `[]`, never null). expect(out.stdoutText).toBe(`{ "data": [] @@ -187,9 +183,8 @@ describe("snippets list integration", () => { }); it.live("Go --output=toml fails like Go when a snippet carries a description", () => { - // BurntSushi refuses the `nullable.Nullable[string]` description field - // (`map[bool]string`) — `snippets list -o toml` fails with this exact - // message whenever any snippet has a `description` key (present-with-value + // The nullable `description` field can't be represented in TOML, so this + // fails whenever any snippet has a `description` key (present-with-value // or explicit null). const { layer } = setup({ goOutput: "toml" }); return Effect.gen(function* () { @@ -206,10 +201,9 @@ describe("snippets list integration", () => { }); it.live("Go --output=toml emits Go-shaped bytes when no snippet has a description", () => { - // In practice the Management API always includes `description` (the - // schema marks it required, value-or-null), so this success branch is - // realistically unreachable in production — Go fails there too. It is - // kept to pin the encoder bytes for the shape where the key is absent. + // The Management API always includes `description`, so this success + // branch is unreachable in production — kept to pin the encoder bytes + // for the shape where the key is absent. const { description: _omitted, ...withoutDescription } = SNIPPET_BASE; const { layer, out } = setup({ goOutput: "toml", @@ -217,7 +211,7 @@ describe("snippets list integration", () => { }); return Effect.gen(function* () { yield* snippetsList({ projectRef: Option.none() }); - // PascalCase Go field names, sub-tables after primitives, 2-space indent. + // PascalCase field names, sub-tables after primitives, 2-space indent. expect(out.stdoutText).toBe(`[[Data]] Favorite = false Id = "${SNIPPET_ID}" @@ -252,7 +246,6 @@ describe("snippets list integration", () => { expect(dump).toContain("--output env flag is not supported"); } expect(api.requests).toHaveLength(0); - // Telemetry flush and linked-project caching still fire on this error path. expect(telemetry.flushed).toBe(true); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/commands/snippets/snippets.e2e.test.ts b/apps/cli/src/commands/snippets/snippets.e2e.test.ts index d14226172d..af3e123009 100644 --- a/apps/cli/src/commands/snippets/snippets.e2e.test.ts +++ b/apps/cli/src/commands/snippets/snippets.e2e.test.ts @@ -7,11 +7,8 @@ const TEST_TOKEN = "sbp_" + "a".repeat(40); describe("supabase snippets", () => { // Golden-path e2e: exercises the real subprocess boundary for the only - // API-free code path in `snippets download` — the UUID pre-check in - // `download.handler.ts`. This validates that the compiled-binary wiring - // (Command.provide, runtime layer, withJsonErrorHandling) correctly - // surfaces the Go-format `invalid snippet ID:` prefix to stdout/stderr - // with exit code 1. + // API-free code path in `snippets download` — the UUID pre-check surfacing + // the `invalid snippet ID:` prefix to stdout/stderr with exit code 1. test( "download with invalid UUID exits 1 with Go-format message", { timeout: E2E_TIMEOUT_MS }, diff --git a/apps/cli/src/commands/snippets/snippets.errors.ts b/apps/cli/src/commands/snippets/snippets.errors.ts index d96bc09d5d..fc2020da74 100644 --- a/apps/cli/src/commands/snippets/snippets.errors.ts +++ b/apps/cli/src/commands/snippets/snippets.errors.ts @@ -39,9 +39,7 @@ export class SnippetsEnvNotSupportedError extends Data.TaggedError("SnippetsEnvN } // `snippets list -o toml` fails whenever a snippet carries a `description`, -// because BurntSushi refuses the `nullable.Nullable[string]` -// (`map[bool]string`) field -// ("failed to output toml: toml: cannot encode a map with non-string key type"). +// since the nullable field can't be represented in TOML. export class SnippetsTomlEncodeError extends Data.TaggedError("SnippetsTomlEncodeError")<{ readonly message: string; }> { @@ -50,8 +48,7 @@ export class SnippetsTomlEncodeError extends Data.TaggedError("SnippetsTomlEncod } } -// Wraps `uuid.Parse` failure in `download.Run`; message preserves Go's -// `invalid snippet ID: <cause>` prefix so callers see the same string. +// Wraps the UUID parse failure with an `invalid snippet ID: <cause>` prefix. export class SnippetsInvalidIdError extends Data.TaggedError("SnippetsInvalidIdError")<{ readonly message: string; }> { diff --git a/apps/cli/src/commands/snippets/snippets.format.ts b/apps/cli/src/commands/snippets/snippets.format.ts index 60a94a838d..de35cee6bb 100644 --- a/apps/cli/src/commands/snippets/snippets.format.ts +++ b/apps/cli/src/commands/snippets/snippets.format.ts @@ -1,22 +1,13 @@ import { renderGlamourTable } from "../../output/glamour-table.ts"; import { formatTimestamp } from "../../command-internal/timestamp.format.ts"; -// --------------------------------------------------------------------------- -// Pure formatter — no Effect / no service dependencies, kept unit-testable. +// `renderGlamourTable` lays out cells directly, bypassing glamour's markdown +// escaping, so any `|` in `name`, `visibility`, or `owner.username` appears +// literally in stdout (same rule as orgs.format.ts). // -// A markdown-table + glamour pipeline wraps each cell in backticks with -// `strings.ReplaceAll(value, "|", "\\|")` applied; glamour then decodes the -// `\|` escape and strips the backticks, so the final ASCII bytes contain raw -// `|` (not `\|`). `renderGlamourTable` lays out cells directly without the -// markdown round-trip, so we pass raw values — any `|` in `name`, -// `visibility`, or `owner.username` appears literally in stdout. (Same rule -// documented in orgs.format.ts.) -// -// Note: API-supplied strings are NOT stripped of ANSI escape sequences or -// other terminal control bytes before rendering. If a future security review -// decides to sanitize, it should land at the renderer -// (`glamour-table.ts`), not per-command. -// --------------------------------------------------------------------------- +// API-supplied strings are not stripped of ANSI/control bytes before +// rendering; a future sanitization pass belongs in the renderer +// (glamour-table.ts), not per-command. const HEADERS = [ "ID", diff --git a/apps/cli/src/commands/ssl-enforcement/get/get.command.ts b/apps/cli/src/commands/ssl-enforcement/get/get.command.ts index f48c5fadd0..58e516c8fd 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/get.command.ts +++ b/apps/cli/src/commands/ssl-enforcement/get/get.command.ts @@ -26,17 +26,12 @@ export const sslEnforcementGetCommand = Command.make("get", config).pipe( Command.withShortDescription("Get SSL enforcement configuration"), Command.withHandler((flags) => Effect.gen(function* () { - // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) - // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an - // invalid `-o` value must win over a missing `--experimental` flag. + // An invalid `-o` value must be rejected before the missing + // `--experimental` check, matching flag-parsing precedence. yield* validateOutputFormat(RESOURCE_OUTPUT_FORMATS); - // Go gates `sslEnforcementCmd` behind `--experimental` in PersistentPreRunE - // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). - // `managementApiRuntimeLayer` eagerly resolves an access token as part - // of building its `CommandPlatformApi` layer, so it must be provided AFTER - // the gate (inline here) rather than via `Command.provide` on the whole - // command — `Command.provide` would build the layer, and fail on a missing - // token, before this generator's first `yield*` ever runs. + // The --experimental gate must run before `managementApiRuntimeLayer` is + // provided (inline here, not via `Command.provide`) — that layer eagerly + // resolves an access token and would fail on a missing one first. yield* requireExperimental; return yield* sslEnforcementGet(flags).pipe( withCommandTelemetry({ flags }), diff --git a/apps/cli/src/commands/ssl-enforcement/get/get.integration.test.ts b/apps/cli/src/commands/ssl-enforcement/get/get.integration.test.ts index 44e750751b..08d74cc978 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/get.integration.test.ts +++ b/apps/cli/src/commands/ssl-enforcement/get/get.integration.test.ts @@ -313,8 +313,8 @@ describe("ssl-enforcement get integration", () => { }); it.live("flushes telemetry even when ref resolution fails (no cache write)", () => { - // Pre-PersistentPostRun-fix regression guard: telemetry must flush whether or not the - // resolver succeeds. The linked-project cache only writes after a ref is resolved. + // Telemetry must flush whether or not the resolver succeeds; the + // linked-project cache only writes after a ref is resolved. const localTempRoot = mkdtempSync(join(tmpdir(), "supabase-ssl-get-int-postrun-")); const telemetry = mockTelemetryStateTracked(); const cache = mockLinkedProjectCacheTracked(); diff --git a/apps/cli/src/commands/ssl-enforcement/ssl-enforcement.experimental-gate.integration.test.ts b/apps/cli/src/commands/ssl-enforcement/ssl-enforcement.experimental-gate.integration.test.ts index 4af6b8c574..83c1f18635 100644 --- a/apps/cli/src/commands/ssl-enforcement/ssl-enforcement.experimental-gate.integration.test.ts +++ b/apps/cli/src/commands/ssl-enforcement/ssl-enforcement.experimental-gate.integration.test.ts @@ -15,11 +15,8 @@ import { import { sslEnforcementCommand } from "./ssl-enforcement.command.ts"; // See postgres-config.experimental-gate.integration.test.ts for the full -// rationale: this proves `--experimental` is wired into the actual -// `.command.ts` handler pipeline AND runs before -// `managementApiRuntimeLayer`'s eager access-token resolution -// (the `IsExperimental` check precedes `IsManagementAPI` in -// `apps/cli-go/cmd/root.go:91-109`). +// rationale: proves `--experimental` is wired into the real command pipeline +// and runs before `managementApiRuntimeLayer`'s eager access-token resolution. const tempRoot = useTempWorkdir("supabase-ssl-enforcement-experimental-int-"); @@ -40,12 +37,9 @@ function setup() { out, api, cliSettings: mockCommandSettings({ workdir: tempRoot.current }), - // The "gate open" case builds the real `managementApiRuntimeLayer` - // inline inside the command; its cliSettings/credentials layers read real - // files under homeDir and ambient env — an ambient SUPABASE_ACCESS_TOKEN, - // SUPABASE_EXPERIMENTAL, or OS keyring entry on the machine running the - // test would make these assertions non-deterministic. Isolate both, keeping - // only the keyring kill-switch set. + // The "gate open" case builds the real `managementApiRuntimeLayer` inline, + // so isolate homeDir/env — a real ambient token or keyring entry would + // make these assertions non-deterministic. runtimeInfo: isolatedHomeLayer(tempRoot.current, { SUPABASE_NO_KEYRING: "1" }), }); const layer = Layer.mergeAll( diff --git a/apps/cli/src/commands/ssl-enforcement/ssl-enforcement.format.ts b/apps/cli/src/commands/ssl-enforcement/ssl-enforcement.format.ts index 7f21e9c8dc..541a262024 100644 --- a/apps/cli/src/commands/ssl-enforcement/ssl-enforcement.format.ts +++ b/apps/cli/src/commands/ssl-enforcement/ssl-enforcement.format.ts @@ -1,17 +1,11 @@ -// Structural shape matches both `V1GetSslEnforcementConfigOutput.Type` and -// `V1UpdateSslEnforcementConfigOutput.Type` — `update.Run` delegates to -// `get.PrintSSLStatus` after a successful PUT, and the two -// response schemas are byte-identical. Keeping the parameter type local -// decouples this formatter from the generated API types and survives any -// future divergence between the two schemas. +// Matches the structural shape of both get and update SSL-enforcement +// response types (byte-identical schemas). Kept local, rather than importing +// the generated type, to decouple this formatter from future API divergence. interface SslEnforcementStatus { readonly currentConfig: { readonly database: boolean }; readonly appliedSuccessfully: boolean; } -/** - * Reproduces `PrintSSLStatus`. - */ export function printSslStatus(response: SslEnforcementStatus): string { if (response.currentConfig.database && response.appliedSuccessfully) { return "SSL is being enforced.\n"; diff --git a/apps/cli/src/commands/ssl-enforcement/ssl-enforcement.go-payload.ts b/apps/cli/src/commands/ssl-enforcement/ssl-enforcement.go-payload.ts index 1cba12c040..d6557d2b9c 100644 --- a/apps/cli/src/commands/ssl-enforcement/ssl-enforcement.go-payload.ts +++ b/apps/cli/src/commands/ssl-enforcement/ssl-enforcement.go-payload.ts @@ -1,9 +1,9 @@ import { type GoType, goBool, goStruct } from "../../command-internal/go-struct-output.encoders.ts"; /** - * Type shape for `api.SslEnforcementResponse` (`apps/cli-go/pkg/api/types.gen.go`). - * Shared by `ssl-enforcement get` and `ssl-enforcement update` for - * `-o yaml` / `-o toml` (CLI-1975). + * Type shape for the SSL enforcement response, used to drive `-o yaml`/`-o + * toml` key casing. Shared by `ssl-enforcement get` and `ssl-enforcement + * update`. */ export const GO_SSL_ENFORCEMENT_RESPONSE: GoType = goStruct([ ["appliedSuccessfully", goBool], diff --git a/apps/cli/src/commands/ssl-enforcement/update/update.command.ts b/apps/cli/src/commands/ssl-enforcement/update/update.command.ts index 290b62da80..d1270bedc0 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/update.command.ts +++ b/apps/cli/src/commands/ssl-enforcement/update/update.command.ts @@ -38,17 +38,12 @@ export const sslEnforcementUpdateCommand = Command.make("update", config).pipe( Command.withShortDescription("Update SSL enforcement configuration"), Command.withHandler((flags) => Effect.gen(function* () { - // Cobra parses flags — rejecting an out-of-enum `-o` (`internal/utils/enum.go:21-27`) - // — before `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an - // invalid `-o` value must win over a missing `--experimental` flag. + // An invalid `-o` value must be rejected before the missing + // `--experimental` check, matching flag-parsing precedence. yield* validateOutputFormat(RESOURCE_OUTPUT_FORMATS); - // Go gates `sslEnforcementCmd` behind `--experimental` in PersistentPreRunE - // (root.go:91-96) BEFORE the `IsManagementAPI` login check (root.go:105-109). - // `managementApiRuntimeLayer` eagerly resolves an access token as part - // of building its `CommandPlatformApi` layer, so it must be provided AFTER - // the gate (inline here) rather than via `Command.provide` on the whole - // command — `Command.provide` would build the layer, and fail on a missing - // token, before this generator's first `yield*` ever runs. + // The --experimental gate must run before `managementApiRuntimeLayer` is + // provided (inline here, not via `Command.provide`) — that layer eagerly + // resolves an access token and would fail on a missing one first. yield* requireExperimental; return yield* sslEnforcementUpdate(flags).pipe( withCommandTelemetry({ flags }), diff --git a/apps/cli/src/commands/ssl-enforcement/update/update.handler.ts b/apps/cli/src/commands/ssl-enforcement/update/update.handler.ts index c6ab086be4..5dac34f54a 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/update.handler.ts +++ b/apps/cli/src/commands/ssl-enforcement/update/update.handler.ts @@ -37,9 +37,9 @@ export const sslEnforcementUpdate = Effect.fn("ssl-enforcement.update")(function const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - // Telemetry flushes on every invocation, including validation failures — matches Go's - // PersistentPostRun semantics. The linked-project cache write happens only after the ref - // has been resolved (it requires `ref` as input), so it wraps the inner sub-effect. + // Telemetry flushes on every invocation, including validation failures. The + // linked-project cache write happens only after the ref is resolved, so it + // wraps the inner sub-effect only. yield* Effect.gen(function* () { if (flags.enableDbSslEnforcement && flags.disableDbSslEnforcement) { return yield* new SslEnforcementMutuallyExclusiveFlagsError(); @@ -55,7 +55,7 @@ export const sslEnforcementUpdate = Effect.fn("ssl-enforcement.update")(function output.format === "text" ? yield* output.task("Updating SSL enforcement config...") : undefined; - // Go only sends the `enforceDbSsl` boolean (`update.go:16`); `--disable-db-ssl-enforcement` + // There's no separate API field for "disable" — `--disable-db-ssl-enforcement` // is the user-facing way to send `database: false`. const response = yield* api.v1 .updateSslEnforcementConfig({ diff --git a/apps/cli/src/commands/ssl-enforcement/update/update.integration.test.ts b/apps/cli/src/commands/ssl-enforcement/update/update.integration.test.ts index 2967dc5c97..cae4f0061e 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/update.integration.test.ts +++ b/apps/cli/src/commands/ssl-enforcement/update/update.integration.test.ts @@ -80,8 +80,6 @@ function setupTracked(opts: SetupOpts = {}) { } describe("ssl-enforcement update integration", () => { - // Flag validation - it.live("fails with SslEnforcementNoEnableDisableFlagError when neither flag is set", () => { const { layer } = setup(); return Effect.gen(function* () { @@ -151,8 +149,6 @@ describe("ssl-enforcement update integration", () => { }).pipe(Effect.provide(layer)); }); - // Request body - it.live("sends requestedConfig.database = true when --enable-db-ssl-enforcement is set", () => { const { layer, api } = setup({ response: SSL_ENFORCED }); return Effect.gen(function* () { @@ -183,8 +179,6 @@ describe("ssl-enforcement update integration", () => { }).pipe(Effect.provide(layer)); }); - // Text output modes (mirroring get scenarios with enable flag) - it.live('prints "SSL is being enforced." when database=true and appliedSuccessfully=true', () => { const { layer, out } = setup({ response: SSL_ENFORCED }); return Effect.gen(function* () { @@ -224,8 +218,6 @@ describe("ssl-enforcement update integration", () => { }, ); - // Go output encoders - it.live("emits Go-compatible env output for --output env (exact bytes)", () => { const { layer, out } = setup({ goOutput: "env", response: SSL_ENFORCED }); return Effect.gen(function* () { @@ -296,8 +288,6 @@ describe("ssl-enforcement update integration", () => { }).pipe(Effect.provide(layer)); }); - // TS output-format modes - it.live("emits a JSON success event when --output-format=json", () => { const { layer, out } = setup({ format: "json", response: SSL_ENFORCED }); return Effect.gen(function* () { @@ -342,8 +332,6 @@ describe("ssl-enforcement update integration", () => { }).pipe(Effect.provide(layer)); }); - // Project ref resolution - it.live("passes the resolved project ref into the updateSslEnforcementConfig URL", () => { const { layer, api } = setup({ response: SSL_ENFORCED }); return Effect.gen(function* () { @@ -441,8 +429,6 @@ describe("ssl-enforcement update integration", () => { }).pipe(Effect.provide(layer)); }); - // Error cases - it.live("fails with SslEnforcementUpdateUnexpectedStatusError on HTTP 503", () => { const { layer } = setup({ status: 503, response: SSL_ENFORCED }); return Effect.gen(function* () { From 4de8426c67cd7add518752608e31244e82cecb87 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:02:36 +0100 Subject: [PATCH 31/56] chore(repo): trim comments, shard 40 (1050 -> 732 comment lines) --- apps/cli/src/shared/runtime/stdin.layer.ts | 69 ++++--------- apps/cli/src/shared/runtime/stdin.service.ts | 34 ++----- .../src/shared/services/services.shared.ts | 22 ++--- .../shared/telemetry/error-actionability.ts | 72 +++++--------- .../error-tag-stability.unit.test.ts | 95 ++++++------------ .../cli/src/shared/telemetry/event-catalog.ts | 16 +-- .../telemetry/failure-metadata.e2e.test.ts | 7 +- apps/cli/src/shared/telemetry/identity.ts | 6 +- .../telemetry/posthog-client.e2e.test.ts | 14 +-- .../telemetry/runtime.layer.unit.test.ts | 13 +-- .../standalone-analytics-config.layer.ts | 17 +--- .../telemetry/tracing.layer.unit.test.ts | 24 ----- apps/cli/src/shared/workers/tar.ts | 50 +++------- apps/cli/src/shared/workers/tar.unit.test.ts | 10 +- apps/cli/src/shared/workers/toml-section.ts | 66 +++++-------- .../shared/workers/toml-section.unit.test.ts | 6 -- .../cli/src/shared/workers/worker-classify.ts | 8 +- apps/cli/src/shared/workers/worker-config.ts | 83 ++++++---------- .../shared/workers/worker-config.unit.test.ts | 51 +++++----- .../cli/src/shared/workers/worker-logs-api.ts | 42 +++----- .../cli/src/shared/workers/worker-logs.sql.ts | 98 +++++++------------ .../workers/worker-logs.sql.unit.test.ts | 27 +---- apps/cli/src/shared/workers/worker-package.ts | 78 ++++++--------- .../workers/worker-package.unit.test.ts | 62 +++++------- 24 files changed, 323 insertions(+), 647 deletions(-) diff --git a/apps/cli/src/shared/runtime/stdin.layer.ts b/apps/cli/src/shared/runtime/stdin.layer.ts index b7891e8861..9371deb579 100644 --- a/apps/cli/src/shared/runtime/stdin.layer.ts +++ b/apps/cli/src/shared/runtime/stdin.layer.ts @@ -16,22 +16,15 @@ import { systemError, type PlatformError } from "effect/PlatformError"; import { Tty } from "./tty.service.ts"; import { Stdin } from "./stdin.service.ts"; -// A parent that put the descriptor it hands down as fd 0 in non-blocking mode hands that mode -// down with it (`O_NONBLOCK` belongs to the shared open file, not to each process's descriptor), -// and an empty read then fails with `EAGAIN` instead of waiting for data. +// fd 0 can be non-blocking (inherited from the parent's open file description), so an empty +// read fails EAGAIN instead of waiting for data. const isEagain = (cause: unknown) => cause instanceof Error && "code" in cause && cause.code === "EAGAIN"; -// Bun's `process.stdin` cannot be throttled: one prompt is enough to start a read of the -// pipe that `pause`, `destroy` and detaching the listener all fail to stop, so an unbounded -// producer (`yes | supabase db push`) turns into an OOM kill. A file stream over fd 0 (the -// path is ignored once `fd` is given) honours backpressure: it reads at most one chunk ahead -// and leaves the rest in the pipe for a child inheriting fd 0, which `autoClose` keeps open -// when the stream is destroyed. Destroying it is what a reader's scope does on the way out: -// left alive, a stream whose listeners are gone would raise its next `EAGAIN` as an uncaught -// error. Clearing `O_NONBLOCK` would clear it for the parent too, so fd 0 is taken as it comes -// and `EAGAIN` is reported as `WouldBlock` for the reader to wait out. -// Ref: https://github.com/supabase/cli/issues/6287 +// Bun's `process.stdin` can't be throttled (pause/destroy/detach don't stop an inherited pipe +// read), so an unbounded producer OOMs. A file stream over fd 0 honors backpressure instead; +// EAGAIN from the non-blocking fd is reported as WouldBlock for the reader to wait out. +// See https://github.com/supabase/cli/issues/6287 const processStdin: Stream.Stream<Uint8Array, PlatformError> = BunStream.fromReadable({ evaluate: () => createReadStream("", { fd: 0, autoClose: false }), onError: (cause) => @@ -44,12 +37,9 @@ const processStdin: Stream.Stream<Uint8Array, PlatformError> = BunStream.fromRea }), }); -// `splitLines` holds a partial line until its terminator arrives, so a producer that never -// sends one (`yes | tr -d '\n' | …`) would grow that buffer for as long as a prompt keeps -// pulling. This bounds it: once more than this many bytes are pending since the last line -// break, the next pull fails instead of reading further, and every prompt from then on takes -// its default. The check runs once per pull, so the buffer runs at most one pull, a chunk or -// two, past this bound before it trips. +// Bounds the unterminated-line buffer `splitLines` holds: a producer that never sends a line +// break (`yes | tr -d '\n' | …`) would otherwise grow it without limit. Once more bytes than +// this are pending, the next pull fails and every later prompt takes its default. const MAX_PENDING_LINE_BYTES = 64 * 1024; const boundPendingLine = (bytes: Stream.Stream<Uint8Array, PlatformError>) => @@ -82,15 +72,9 @@ const makeStdin = Effect.fnUntraced(function* (stdin: Stream.Stream<Uint8Array, const tty = yield* Tty; const textDecoder = new TextDecoder(); - // `WouldBlock` is a non-blocking source with nothing to read yet, not a broken one: put a - // fresh reader over the still-open descriptor and ask again until data or EOF arrives, so the - // wait ends the way a blocking read's would, or with the prompt's timeout, which interrupts - // the retry. A poll, since the throttleable fd reader has no readiness signal to wait on; - // every 10 ms gives a piped prompt's 100 ms window ten looks, and stays fixed because the - // schedule keeps its state until data arrives, so a backoff reached while one prompt waited - // would still be slowing the next one down. Each look keeps a retry frame (a few KB) for the - // reader's lifetime, so a producer that never writes or closes costs a few hundred kilobytes - // a second of waiting, where a blocking read would hang for free. + // WouldBlock means the non-blocking fd has nothing to read yet, not that it's broken: retry + // until data, EOF, or the prompt's timeout interrupts it. Polls at a fixed 10ms rather than + // backing off, so a slow prompt's wait doesn't carry backoff state into the next one. const source = Stream.retry(stdin, ($) => $(Schedule.spaced("10 millis")).pipe( Schedule.while(({ input }) => Predicate.isTagged(input.reason, "WouldBlock")), @@ -114,17 +98,11 @@ const makeStdin = Effect.fnUntraced(function* (stdin: Stream.Stream<Uint8Array, ); }); - // Persistent, lazily-opened line reader shared by every `readLine` call, so a - // command issuing several prompts (config push, seed buckets) reads the *next* piped - // line each time instead of restarting from the top of the pipe. Opening it is - // deferred behind `Effect.cached` and tied to this layer's scope: stdin is not - // touched until the first `readLine`, so a TTY command that only prompts via clack - // never grabs the keyboard (no contention with clack's own stdin capture), and the - // reader outlives individual prompts. `splitLines` preserves interior blank lines so - // answers stay aligned across prompts. A failed read stays failed for the rest of the - // process: what can go wrong with an open fd 0 (closed, hung up, not readable) does not - // mend on its own, so later prompts take their default instead of retrying a dead - // descriptor; the one transient failure, `WouldBlock`, is waited out upstream. + // Persistent, lazily-opened (via `Effect.cached`) line reader shared by every `readLine` + // call, so successive prompts read successive piped lines instead of restarting the pipe. + // Opening is deferred until the first call, so a TTY-only command never grabs stdin from + // clack. A failed read stays failed: fd 0 problems don't self-heal, so later prompts take + // their default instead of retrying a dead descriptor. const nextLine = yield* Effect.cached(lineReader); const readPipedBytes = Effect.gen(function* () { @@ -149,11 +127,8 @@ const makeStdin = Effect.fnUntraced(function* (stdin: Stream.Stream<Uint8Array, return Option.some(bytes); }).pipe(Effect.orElseSucceed(() => Option.none<Uint8Array>())); - // Read the next line (trimmed), bounded by `timeoutMillis`, from the persistent reader - // above: successive calls return successive lines, and a timeout, EOF, or read error all - // collapse to `None`, the prompt's default. The timeout bounds a pipe that stays open - // without sending a line, and a user who never answers, so the prompt takes its default - // instead of waiting for EOF. + // Bounds a pipe that never sends a line, or a user who never answers: a timeout, EOF, or + // read error all collapse to `None`, the prompt's default. const readLine = (timeoutMillis: number): Effect.Effect<Option.Option<string>> => Effect.gen(function* () { const take = yield* nextLine; @@ -163,10 +138,8 @@ const makeStdin = Effect.fnUntraced(function* (stdin: Stream.Stream<Uint8Array, return Option.map(Option.flatten(line), (value) => value.trim()); }); - // Stream piped stdin without collecting it (constant memory). Read errors PROPAGATE on - // the error channel (unlike `readPipedBytes`'s `orElseSucceed(none)` swallow), once - // `WouldBlock` has been waited out above: a consumer writing the bytes to a file must fail - // rather than leave a truncated file behind. + // Streams piped stdin without collecting it. Unlike `readPipedBytes`, read errors PROPAGATE + // so a consumer writing to a file fails rather than leaving a truncated one. const pipedBytesStream = source; return Stdin.of({ diff --git a/apps/cli/src/shared/runtime/stdin.service.ts b/apps/cli/src/shared/runtime/stdin.service.ts index 2015aae6c5..5445a9974a 100644 --- a/apps/cli/src/shared/runtime/stdin.service.ts +++ b/apps/cli/src/shared/runtime/stdin.service.ts @@ -12,34 +12,20 @@ interface StdinShape { readonly isTTY: boolean; readonly readPipedBytes: Effect.Effect<Option.Option<Uint8Array>>; /** - * Piped stdin as a byte stream, for consumers that must avoid buffering the whole - * pipe (e.g. `migration new` seeding a file from a large `pg_dump`). Unlike - * {@link readPipedBytes}, read errors PROPAGATE on the error channel (once a non-blocking - * fd 0 with nothing to read yet has been waited out): a caller writing the bytes to a file - * must fail rather than leave a truncated file behind, so it maps the failure itself. Emits - * nothing for an empty pipe; callers gate on {@link isTTY} themselves (a TTY should not be - * drained). + * Piped stdin as a byte stream, for consumers that must avoid buffering the whole pipe + * (e.g. `migration new` seeding a file from a large `pg_dump`). Unlike {@link readPipedBytes}, + * read errors PROPAGATE so a caller writing the bytes to a file fails rather than leaving a + * truncated one. Emits nothing for an empty pipe; callers gate on {@link isTTY} themselves. */ readonly pipedBytesStream: Stream.Stream<Uint8Array, PlatformError>; readonly readPipedText: Effect.Effect<Option.Option<string>>; /** - * Reads the *next* line from stdin (trimmed), bounded by `timeoutMillis` — callers - * pass 10 minutes on a TTY and 100 ms otherwise. Backed by a single persistent, - * lazily-opened reader, so successive calls return successive lines: a command - * issuing several confirmations answers each from the next piped line. stdin is not - * opened until the first call, so a command that only prompts on a TTY (via clack) - * never grabs the keyboard. A timeout, EOF, or a read error all return `None`, which - * every caller treats as no input and answers with the prompt's default. Unlike - * {@link readPipedText} (a whole-stream collect), this reads line by line, so it - * works for an interactive terminal as well as a pipe. - * - * stdin is pulled a chunk (64 KiB) at a time and only when a prompt needs a line, so a - * producer that outruns the prompts stays in the pipe apart from the chunk or two the reader - * holds for later prompts. A read error ends line reading the way EOF does: that prompt and - * every later one get `None`. So does a line that runs past 64 KiB without a line break - * (`MAX_PENDING_LINE_BYTES` in `stdin.layer.ts`): the reader stops a chunk or two past that - * bound and never reaches whatever follows. A non-blocking fd 0 with nothing to read yet is - * waited on, not treated as a read error. + * Reads the *next* line from stdin (trimmed), bounded by `timeoutMillis` — callers pass 10 + * minutes on a TTY and 100 ms otherwise. Backed by a single persistent, lazily-opened + * reader, so successive calls return successive lines and a command that only prompts on a + * TTY never grabs stdin before it needs to. A timeout, EOF, or a read error all return + * `None`, which every caller treats as the prompt's default. Unlike {@link readPipedText} + * (a whole-stream collect), this reads line by line, so it works on a TTY as well as a pipe. */ readonly readLine: (timeoutMillis: number) => Effect.Effect<Option.Option<string>>; } diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index 8b83783d25..97a6bc0c7c 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -47,10 +47,9 @@ export interface LocalServiceImageOptions { readonly slimCurrentPinOnly?: boolean; } -// Mirrors Go's `utils.ProjectRefPattern` (`apps/cli-go/internal/utils/misc.go`). -// Validating the ref before it reaches the management API path param or the -// tenant gateway hostname keeps a tampered/malformed value from redirecting the -// service-role key to an attacker-controlled host. +// Validates the ref before it reaches the management API path param or the tenant gateway +// hostname, so a tampered/malformed value can't redirect the service-role key to an +// attacker-controlled host. const PROJECT_REF_PATTERN = /^[a-z]{20}$/; interface ServiceImageSpec { @@ -304,9 +303,8 @@ function hasProjectAccessKey<T extends ProjectApiKey>(keys: ReadonlyArray<T>): b const authenticatedRequest = (url: string, accessKey: Redacted.Redacted<string>) => { const key = Redacted.value(accessKey); const request = HttpClientRequest.get(url).pipe(HttpClientRequest.setHeader("apikey", key)); - // New-style `sb_…` keys authenticate via the `apikey` header alone; older JWT - // keys additionally require a bearer token. Mirrors the conditional auth in - // `apps/cli-go/pkg/fetcher/gateway.go` and `command-internal/tenant-versions.ts`. + // New-style `sb_…` keys authenticate via the `apikey` header alone; older JWT keys also + // need a bearer token. return key.startsWith("sb_") ? request : request.pipe(HttpClientRequest.setHeader("Authorization", `Bearer ${key}`)); @@ -441,9 +439,8 @@ export function renderServicesWarning(rows: ReadonlyArray<ServiceVersionRow>): s } /** - * Renders the linked-version mismatch warning for stderr. In text mode the - * `WARNING:` prefix is colorized (matching Go's `utils.Yellow`); machine modes - * keep it plain so the stderr line stays parseable. + * Renders the linked-version mismatch warning for stderr. The `WARNING:` prefix is colorized + * in text mode; machine modes keep it plain so the stderr line stays parseable. */ export function formatServicesWarning(message: string, textMode: boolean): string { const lines = message.split("\n"); @@ -455,9 +452,8 @@ export function formatServicesWarning(message: string, textMode: boolean): strin export function fetchLinkedServiceVersions(input: ServiceFetchConfig) { return Effect.gen(function* () { const exit = yield* Effect.gen(function* () { - // Reject malformed refs before they reach the management API path param or - // the tenant gateway hostname (`https://<ref>.<host>`). The override is - // test-only, so it bypasses the check. + // Malformed refs are rejected before reaching the management API path param or the + // tenant gateway hostname; the test-only override bypasses this check. if ( input.tenantBaseUrlOverride === undefined && !PROJECT_REF_PATTERN.test(input.projectRef) diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index e13c148706..03e97b7e5c 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -2,20 +2,16 @@ import { Cause, Option } from "effect"; import type { CliError as EffectCliError } from "effect/unstable/cli"; /** - * CLI error actionability taxonomy for KPI reporting (CLI-1560). + * CLI error actionability taxonomy for KPI reporting. * - * Classification is declared where each error is defined: every error class in - * `apps/cli/src` exposes a {@link CliErrorActionabilityDeclaration} under the - * {@link ErrorActionabilityId} symbol (enforced by - * `error-actionability-coverage.unit.test.ts`). Errors originating outside the - * CLI workspace (`@supabase/config`, - * `effect` cli/http) are classified by the - * structural adapters at the bottom of this module, which are themselves - * exhaustiveness-checked against those packages' sources. + * Every error class in `apps/cli/src` declares its own + * {@link CliErrorActionabilityDeclaration} under the {@link ErrorActionabilityId} symbol + * (enforced by `error-actionability-coverage.unit.test.ts`). Errors from outside the CLI + * workspace are classified by the structural adapters at the bottom of this module instead. * - * Everything emitted from here is sanitized by construction: kinds, categories, - * suggestion types, and fingerprints use closed enums and source-owned - * identifiers — never raw error text or user-specific data. + * Everything emitted here is sanitized by construction: kinds, categories, suggestion types, + * and fingerprints use closed enums and source-owned identifiers, never raw error text or + * user-specific data. */ export const CliErrorKind = { @@ -405,16 +401,10 @@ export const planLimitGatedActionability: CliErrorActionabilityDeclaration = { }; /** - * Classification policy for errors that carry a Management API status code. - * `upgradeSuggested` is the typed result of the entitlement gate - * (`suggestUpgrade`) threaded through the error constructor — never - * inferred from message text. - * - * A 404 is user-actionable only when the caller knows the endpoint names a - * user-selected resource. List and discovery endpoints can also return 404, - * so the default remains an API-status failure. The entitlement-gate branch - * stays ahead of that opt-in so a confirmed plan-limited 404 still classifies - * as `plan_limit`. + * Classification policy for errors carrying a Management API status code. `upgradeSuggested` + * is the entitlement gate's typed result, never inferred from message text. A 404 counts as + * user-actionable only when the caller confirms the endpoint names a user-selected resource, + * since list/discovery endpoints also 404. */ export function statusCodeActionability( status: number | undefined, @@ -741,18 +731,11 @@ const externalActionabilityByTag: Record<string, ErrorActionabilityAdapter> = { DuplicateRemoteProjectIdError: () => actionability.invalidConfig, InvalidRemoteProjectIdError: () => actionability.invalidConfig, CliConfigWriteError: () => ({ ...actionability.permission, fingerprint_suffix: "filesystem" }), - // A Management API project-config response that fails to map is a platform - // response problem, not a local config-file mistake — the user can't fix - // the payload by editing supabase/config.toml. `@supabase/config` now - // builds a real `suggestion` (upgrade the CLI, then report it) on every - // construction site, so `has_suggestion` flips to true here to match — - // `RerunDebug` is the closest existing bucket (same idiom as - // `internalPanic`/`impossibleState` below), there being no dedicated - // "upgrade the CLI" suggestion type in the closed vocabulary. The - // `caller_misuse` reason (a `toProjectConfig`/`attachApiResponse` argument - // error — the producer's typed field, never message text) is a programming - // error, not an external platform failure: bucketing it as `api_status` - // would corrupt the external-failure KPI with caller bugs. + // A Management API project-config response that fails to map is a platform problem, not + // something the user can fix by editing config.toml, so `has_suggestion` is true (using + // `RerunDebug`, the closest bucket, since there's no dedicated "upgrade the CLI" type). + // `caller_misuse` is a programming error (a bad `toProjectConfig` argument), not an + // external failure, so it's bucketed separately to keep the external-failure KPI clean. ProjectConfigParseError: (error) => error.reason === "caller_misuse" ? { ...actionability.invalidInput, fingerprint_suffix: "request_input" } @@ -767,10 +750,9 @@ const externalActionabilityByTag: Record<string, ErrorActionabilityAdapter> = { // access token / bad configuration); remediation is the token env var. SupabaseApiConfigError: () => actionability.authToken, - // @supabase/api — the generated client's input schema rejected a request - // before it was sent. Treat it as an internal request-construction failure - // unless the command boundary explicitly marked the whole request as - // user-derived; never infer provenance from the schema error message. + // The generated client's input schema rejected a request before it was sent — an internal + // request-construction failure unless the command boundary marked the whole request as + // user-derived. Never infer provenance from the schema error message. SupabaseApiInputError: (error) => readString(error, "source") === "user_input" ? { ...actionability.invalidInput, fingerprint_suffix: "request_input" } @@ -871,10 +853,10 @@ function classifyAtDepth(error: unknown, depth: number): CliErrorActionability { return classifyAtDepth(error["cause"], depth + 1); } - // DownloadError recurses ONLY into local filesystem causes (PlatformError: - // unwritable cache, extraction failure). HTTP causes stay on the wrapper — - // the HttpClientError adapter's 401/403 → auth/permission policy is - // Management-API-specific and must not apply to GitHub/CDN asset downloads. + // DownloadError recurses into local filesystem causes only (PlatformError: unwritable + // cache, extraction failure). HTTP causes stay on the wrapper, since the HttpClientError + // adapter's 401/403 → auth/permission policy is Management-API-specific and must not apply + // to GitHub/CDN asset downloads. if (isErrorRecord(error) && tag === "DownloadError") { const cause = error["cause"]; if (isErrorRecord(cause) && readErrorTag(cause) === "PlatformError") { @@ -926,10 +908,8 @@ export function classifyCliCauseActionability(cause: Cause.Cause<unknown>): CliE } /** - * Fallback for a command that deliberately signalled failure through - * ProcessControl without failing its Effect, when no typed error is available - * to derive a classification from (see `withCommandTelemetry`, - * which classifies the command's own fail-on error class where one exists). + * Fallback for a command that signals failure through ProcessControl without failing its + * Effect, when no typed error is available to classify (see `withCommandTelemetry`). */ export const unknownProcessControlledFailureActionability: CliErrorActionability = toActionability( actionability.unknown, diff --git a/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts b/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts index 872290c88a..eeebacd81b 100644 --- a/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts +++ b/apps/cli/src/shared/telemetry/error-tag-stability.unit.test.ts @@ -4,43 +4,19 @@ import { fileURLToPath, pathToFileURL } from "node:url"; import { describe, expect, it } from "vitest"; /** - * Guards the telemetry identity of every CLI error. + * Guards the telemetry identity of every CLI error: the string passed to + * `Data.TaggedError("...")` becomes `error_fingerprint` on the `cli_command_executed` event, so + * changing it (not renaming the class) silently splits one error's history into two + * fingerprints, undetected until dashboards look wrong. * - * The string passed to `Data.TaggedError("...")` is not cosmetic: it flows - * straight into the `error_fingerprint` property on the `cli_command_executed` - * PostHog event (as `tag:<TagName>`, sometimes with a `:suffix`). Renaming the - * *class* is free -- the tag is looked up independently by convention, not by - * class identity -- but changing the *string literal* silently splits one - * error's history into two fingerprints with no error, no warning, and no - * easy way to notice until repeat-rate/trend dashboards look wrong months - * later. + * Enumerates every production `Data.TaggedError` tag under `apps/cli/src` and + * `packages/config/src`, resolving tags built via template interpolation + * (`mintConfigTargetErrors`) at runtime by constructing each class — see + * {@link collectComputedTagDeclarations}. Compares the set against the committed + * `__fixtures__/error-tags.txt` snapshot. * - * This suite enumerates every production `Data.TaggedError("...")` - * declaration reachable from `apps/cli/src` (including the multi-line form - * the formatter produces for long class names), PLUS: - * - * - the tags `mintConfigTargetErrors` (`command-internal/project-target.ts`) - * computes via template interpolation (`` Data.TaggedError(`${prefix}...`) - * ``), which a source-text regex can never see. Those are collected at - * RUNTIME instead, by importing each known caller module and reading the - * real `_tag` off a constructed instance -- see - * {@link collectComputedTagDeclarations}. - * - the external tags declared by `@supabase/config` - * (`packages/config/src/errors.ts`), which `externalActionabilityByTag` in - * `error-actionability.ts` also treats as telemetry identities. - * - * and compares the resulting set of tag literals against the committed - * snapshot in `__fixtures__/error-tags.txt`. - * - * Known gap (deliberately not closed): the snapshot is a SET of tags, not an - * owner-to-tag mapping, so it cannot detect two classes swapping tags with - * each other in the same change (each tag still exists, just attached to the - * other class). Recording an owner->tag mapping would close that gap, but at - * the cost of failing on ordinary, safe class renames -- which is exactly - * the false positive this suite exists to eliminate. That failure mode is - * real but vanishingly unlikely (it requires two tags to swap in a single - * commit and both survive review), so it is accepted rather than designed - * around. + * Known gap: the snapshot is a set, not an owner-to-tag mapping, so two classes swapping tags + * in one change would pass undetected — accepted to avoid breaking ordinary class renames. */ const cliSrcDir = fileURLToPath(new URL("../..", import.meta.url)); @@ -65,12 +41,9 @@ const TEST_FILE_SUFFIXES = [ ] as const; /** - * The production callers whose tags `mintConfigTargetErrors` computes via - * template interpolation (`command-internal/project-target.ts`'s doc - * comment). Paths are relative to `cliSrcDir`. Every exported class in these - * modules -- minted or plain-literal alike -- is resolved at runtime instead - * of by static regex, so this list is also the source of truth for which - * files {@link collectStaticDeclarations} must skip to avoid double-counting. + * Files whose tags `mintConfigTargetErrors` computes via template interpolation, resolved at + * runtime instead of by static regex. Also the list {@link collectStaticDeclarations} skips to + * avoid double-counting. Paths are relative to `cliSrcDir`. */ const COMPUTED_TAG_SOURCE_FILES: ReadonlyArray<string> = [ "commands/config/pull/pull.errors.ts", @@ -101,11 +74,9 @@ function isProductionSourceFile(filePath: string): boolean { } /** - * Statically regexes every `Data.TaggedError("...")` string-literal - * declaration out of the given production files. Deliberately skips - * `COMPUTED_TAG_SOURCE_FILES` -- their tags (both minted and plain-literal) - * come from {@link collectComputedTagDeclarations} instead, so a file never - * contributes the same declaration through both paths. + * Statically regexes every `Data.TaggedError("...")` string-literal declaration out of the + * given production files, skipping `COMPUTED_TAG_SOURCE_FILES` (those come from + * {@link collectComputedTagDeclarations} instead) so a file never contributes twice. */ function collectStaticDeclarations( filePaths: ReadonlyArray<string>, @@ -128,17 +99,12 @@ function collectStaticDeclarations( } /** - * Runtime half of the computed-tag guard: imports each - * `COMPUTED_TAG_SOURCE_FILES` module and walks `Object.entries` over its - * exports, constructing anything that looks like a `Data.TaggedError` class - * to read its real, fully-interpolated `_tag` off the instance. A source-text - * regex cannot see `` Data.TaggedError(`${prefix}BranchNotFoundError`) `` -- - * this can, because it runs the interpolation instead of parsing around it. + * Runtime half of the computed-tag guard: imports each `COMPUTED_TAG_SOURCE_FILES` module, + * constructs every exported class, and reads its real, fully-interpolated `_tag` off the + * instance — a source-text regex can't see `` Data.TaggedError(`${prefix}...`) ``. * - * Every export in these modules is a `Data.TaggedError`-derived class taking - * a plain args object (see each file), so `new Export({})` is safe: the - * generated base constructor never validates its shape at runtime, it only - * assigns whatever properties are present. + * `new Export({})` is safe because every export here is a `Data.TaggedError`-derived class + * whose generated constructor assigns whatever properties are present without validating them. */ async function collectComputedTagDeclarations(): Promise<Array<TaggedErrorDeclaration>> { const declarations: Array<TaggedErrorDeclaration> = []; @@ -161,9 +127,8 @@ async function collectComputedTagDeclarations(): Promise<Array<TaggedErrorDeclar return declarations; } -/** Single comparator used for both the committed fixture and the live scan, so a - * `localeCompare`-vs-default-sort mismatch can never make an order-sensitive - * comparison lie (see `readSnapshotTags`/regeneration below). */ +/** Shared comparator for the fixture and the live scan, so a `localeCompare`-vs-default-sort + * mismatch can't make the comparison lie. */ function compareTags(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; } @@ -233,14 +198,10 @@ describe("error tag stability", () => { const productionDeclarations = await collectProductionDeclarations(); /** - * Tags that more than one production class intentionally shares. Keyed - * on the TAG -- the actual telemetry identity -- never on today's class - * names: this suite's whole premise is that class names are free to - * rename, so gating "is this collision fine" on the CURRENT names would - * itself break the very first time someone does a legitimate rename of - * one of the two classes below while correctly preserving the tag. Add - * an entry here only when two classes are deliberately meant to report - * as the same fingerprint. + * Tags shared by more than one production class, keyed on the tag itself rather than + * today's class names — keying on names would break the first time either class is + * legitimately renamed. Add an entry only when two classes are meant to share a + * fingerprint. */ const allowedSharedTags: ReadonlySet<string> = new Set([ // shared/functions/download.errors.ts and delete.errors.ts both diff --git a/apps/cli/src/shared/telemetry/event-catalog.ts b/apps/cli/src/shared/telemetry/event-catalog.ts index 1899b6f1b5..513828a59c 100644 --- a/apps/cli/src/shared/telemetry/event-catalog.ts +++ b/apps/cli/src/shared/telemetry/event-catalog.ts @@ -1,9 +1,4 @@ -// CLI telemetry catalog. Mirrors apps/cli-go/internal/telemetry/events.go -// 1:1 so ported commands send byte-identical PostHog payloads. When the Go -// catalog changes, update this file in the same PR. The failure-classification -// properties below (error_kind … workflow) are TS-only: the native shells -// classify failures (CLI-1561) and the Go binary never emits these fields, so -// they are deliberately absent from the Go catalog. +// CLI telemetry catalog: canonical event and property-key constants sent to PostHog. export const EventCommandExecuted = "cli_command_executed"; export const EventProjectLinked = "cli_project_linked"; @@ -14,10 +9,8 @@ export const EventUpgradeSuggested = "cli_upgrade_suggested"; export const PropFeatureKey = "feature_key"; export const PropOrgSlug = "org_slug"; -// TS-only extension to `cli_project_linked` (CLI-2167): `link` accepts a branch -// name/UUID in addition to a project ref (no Go counterpart), so these two -// properties distinguish a branch-name link from a plain ref link. Absent from -// the Go catalog. +// `link` also accepts a branch name/UUID in addition to a project ref; these two properties +// distinguish a branch-name link from a plain ref link. export const PropLinkedVia = "linked_via"; export const PropParentProjectRef = "parent_project_ref"; @@ -55,7 +48,6 @@ export const GroupProject = "project"; export const MaxEnvSignalValueLength = 80; // Env vars whose presence (any non-empty value) is recorded as `true` in env_signals. -// Order matches apps/cli-go/internal/telemetry/events.go:126-167. export const EnvSignalPresenceKeys: ReadonlyArray<string> = [ // AI tools signals "CURSOR_AGENT", @@ -100,7 +92,7 @@ export const EnvSignalPresenceKeys: ReadonlyArray<string> = [ ]; // Env vars whose trimmed values are recorded in env_signals, capped at -// MaxEnvSignalValueLength chars. Order matches events.go:171-178. +// MaxEnvSignalValueLength chars. export const EnvSignalValueKeys: ReadonlyArray<string> = [ "AI_AGENT", "CURSOR_EXTENSION_HOST_ROLE", diff --git a/apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts b/apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts index 7c1f8fdae0..e3f2a246cc 100644 --- a/apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts +++ b/apps/cli/src/shared/telemetry/failure-metadata.e2e.test.ts @@ -47,11 +47,8 @@ describe("failed command telemetry", () => { capturedEvents.length = 0; }); - // Both cases exercise the CLI (the only remaining shell). The - // `branches list` case needs a syntactically valid access token so the - // Management API auth gate builds successfully and the failure happens - // in-handler, during project-ref resolution, rather than at the auth gate - // (which deliberately keeps its existing no-event behavior). + // `branches list` needs a syntactically valid access token so the auth gate builds and the + // failure happens during project-ref resolution instead of at the auth gate itself. test.each([ { args: ["branches", "list"], diff --git a/apps/cli/src/shared/telemetry/identity.ts b/apps/cli/src/shared/telemetry/identity.ts index 6cfc2c2d31..92d30cd199 100644 --- a/apps/cli/src/shared/telemetry/identity.ts +++ b/apps/cli/src/shared/telemetry/identity.ts @@ -81,9 +81,9 @@ export function makeTelemetryIdentity(persisted: string | undefined): TelemetryI } /** - * Logout-only: forget the user AND rotate the device id, severing the link - * between this device and the logged-out user's person graph. A later login - * as a different account then aliases a fresh device. + * Logout-only: forgets the user and rotates the device id, severing the link between this + * device and the logged-out user's person graph. A later login as a different account then + * aliases a fresh device. */ export const resetIdentity = Effect.fnUntraced(function* (configDir: string) { const identity = yield* resolveIdentity(configDir); diff --git a/apps/cli/src/shared/telemetry/posthog-client.e2e.test.ts b/apps/cli/src/shared/telemetry/posthog-client.e2e.test.ts index d786c2fb8e..c7674c3bc3 100644 --- a/apps/cli/src/shared/telemetry/posthog-client.e2e.test.ts +++ b/apps/cli/src/shared/telemetry/posthog-client.e2e.test.ts @@ -2,11 +2,9 @@ import { createServer, type Server, type Socket } from "node:net"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { runSupabase } from "../../../tests/helpers/cli.ts"; -// A TCP blackhole: accepts connections and never responds, so telemetry -// requests connect and then hang until aborted. Asserting on the spawned -// process's wall-clock exit (not scope or shutdown internals) is deliberate: -// pending sockets keep the runtime alive, so only actual process exit proves -// the telemetry exit cap holds end to end. +// A TCP blackhole: accepts connections and never responds, so telemetry requests hang until +// aborted. Asserts on the spawned process's wall-clock exit, since pending sockets keep the +// runtime alive and only actual process exit proves the telemetry exit cap holds end to end. describe("telemetry against a blackholed PostHog endpoint", () => { let server: Server; let host: string; @@ -52,11 +50,9 @@ describe("telemetry against a blackholed PostHog endpoint", () => { expect(exitCode).toBe(0); expect(stdout).toContain("Telemetry is enabled."); expect(stderr).toBe(""); - // Telemetry must have actually reached the blackhole, otherwise the - // timing assertion below passes vacuously with telemetry off. expect(connections).toBeGreaterThanOrEqual(1); - // Healthy runs measure ~2.5s (2s drain cap + spawn overhead); the nearest - // real failure signature is the SDK's 5s default deadline plus startup. + // Healthy runs land near 2.5s (2s drain cap + spawn overhead); the nearest real failure + // signature is the SDK's 5s default deadline plus startup. expect(elapsedMs).toBeLessThan(4_500); }); }); diff --git a/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts b/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts index df866a1966..dda349a2a9 100644 --- a/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts +++ b/apps/cli/src/shared/telemetry/runtime.layer.unit.test.ts @@ -139,11 +139,9 @@ describe("telemetryRuntimeLayer", () => { ); }); - // CLI-1868 (telemetry enable/disable firing cli_command_executed on pre-toggle - // consent) depends on this exact property: `consent` is read from disk once - // at layer-construction time and does not reflect a later on-disk write — - // mirroring Go's PersistentPreRunE snapshot, which a command's own RunE - // (e.g. `telemetry disable`'s SetEnabled) cannot retroactively change. + // `consent` is read from disk once at layer-construction time and does not reflect a later + // on-disk write, so a command that rewrites telemetry.json mid-run doesn't retroactively + // change what that invocation already captured. it.live("captures consent once; a later on-disk write does not change it", () => { const homeDir = makeTempDir(); const configPath = path.join(homeDir, "telemetry.json"); @@ -162,9 +160,8 @@ describe("telemetryRuntimeLayer", () => { const runtime = yield* TelemetryRuntime; expect(runtime.consent).toBe("granted"); - // Simulates `disable`'s handler rewriting the file mid-command, after - // this layer already resolved `consent` — the already-built runtime - // must keep reporting the pre-toggle value. + // Simulates `disable` rewriting telemetry.json mid-command, after this layer already + // resolved `consent`. yield* Effect.sync(() => writeFileSync( configPath, diff --git a/apps/cli/src/shared/telemetry/standalone-analytics-config.layer.ts b/apps/cli/src/shared/telemetry/standalone-analytics-config.layer.ts index 32872dba57..bf8ff64d18 100644 --- a/apps/cli/src/shared/telemetry/standalone-analytics-config.layer.ts +++ b/apps/cli/src/shared/telemetry/standalone-analytics-config.layer.ts @@ -5,19 +5,12 @@ import { runtimeInfoLayer } from "../runtime/runtime-info.layer.ts"; import { ttyLayer } from "../runtime/tty.layer.ts"; /** - * Resolves `CliSettings | RuntimeInfo | Tty` — the services `telemetryRuntimeLayer` - * (and, transitively, `analyticsLayer`/`analyticsLayer`) need beyond the - * `FileSystem`/`Path` platform layer — for callers that build and run an - * `Analytics`-capturing effect OUTSIDE `runCli`'s own composed layer tree - * (`shared/cli/run.ts` already wires the equivalent of this inline for every - * command run via its own `cliSettingsLayerFor`/`cliProjectContextLayerFor` - * helpers). The one caller today is `cli/complete.ts`'s - * `__complete`/`__completeNoDesc` telemetry capture, which fires before - * `runCli` ever bootstraps. + * Resolves `CliSettings | RuntimeInfo | Tty` for callers that build and run an + * `Analytics`-capturing effect outside `runCli`'s own composed layer tree — today, only + * `cli/complete.ts`'s telemetry capture, which fires before `runCli` bootstraps. * - * Still requires the platform layer (`FileSystem`/`Path`, e.g. - * `@effect/platform-bun`'s `BunServices.layer`) to be provided separately by - * the caller, matching `run.ts`'s own top-level `Effect.provide(BunServices.layer)`. + * Still requires the platform layer (`FileSystem`/`Path`) to be provided separately by the + * caller, matching `run.ts`'s own top-level `Effect.provide(BunServices.layer)`. */ export const standaloneAnalyticsConfigLayer = Layer.mergeAll( cliSettingsLayer.pipe(Layer.provide(cliProjectContextLayer), Layer.provide(runtimeInfoLayer)), diff --git a/apps/cli/src/shared/telemetry/tracing.layer.unit.test.ts b/apps/cli/src/shared/telemetry/tracing.layer.unit.test.ts index b56a9317dd..1442384751 100644 --- a/apps/cli/src/shared/telemetry/tracing.layer.unit.test.ts +++ b/apps/cli/src/shared/telemetry/tracing.layer.unit.test.ts @@ -23,10 +23,6 @@ import { } from "../../../tests/helpers/mocks.ts"; import { tracingLayer } from "./tracing.layer.ts"; -// --------------------------------------------------------------------------- -// Filesystem helpers -// --------------------------------------------------------------------------- - const fsLayer = BunServices.layer; function makeTempDir(): string { @@ -38,10 +34,6 @@ function writeConfig(dir: string, config: TelemetryConfig): void { writeFileSync(path.join(dir, "telemetry.json"), JSON.stringify(config)); } -// --------------------------------------------------------------------------- -// Layer builder helpers -// --------------------------------------------------------------------------- - function buildLayer(opts: { home: string; env?: Record<string, string>; stdoutIsTty?: boolean }) { const env: Record<string, string> = { HOME: opts.home, @@ -75,10 +67,6 @@ function buildTracingLayer(opts: { return tracingLayer.pipe(Layer.provide(buildLayer(opts))); } -// --------------------------------------------------------------------------- -// Span factory helper (mirrors ExportableSpan constructor options) -// --------------------------------------------------------------------------- - function makeSpanOptions( overrides: Partial<{ name: string; @@ -98,10 +86,6 @@ function makeSpanOptions( }; } -// --------------------------------------------------------------------------- -// Layer construction & first-run -// --------------------------------------------------------------------------- - describe("tracingLayer – layer construction & first-run", () => { it.live("first-run TTY: creates telemetry.json with consent=granted", () => { const home = makeTempDir(); @@ -196,10 +180,6 @@ describe("tracingLayer – layer construction & first-run", () => { ); }); -// --------------------------------------------------------------------------- -// Span behaviour -// --------------------------------------------------------------------------- - describe("tracingLayer – span behaviour", () => { it.live("span creation attaches global attributes", () => { const home = makeTempDir(); @@ -376,10 +356,6 @@ describe("tracingLayer – span behaviour", () => { }); }); -// --------------------------------------------------------------------------- -// ExportableSpan unit tests -// --------------------------------------------------------------------------- - describe("ExportableSpan unit tests", () => { it.live("child span inherits traceId from parent span", () => { const home = makeTempDir(); diff --git a/apps/cli/src/shared/workers/tar.ts b/apps/cli/src/shared/workers/tar.ts index 449c355da3..276b45114f 100644 --- a/apps/cli/src/shared/workers/tar.ts +++ b/apps/cli/src/shared/workers/tar.ts @@ -1,21 +1,9 @@ /** - * A minimal USTAR writer, for the `.tar.gz` build context `supabase experimental workers - * push` uploads. - * - * Shelling out to `tar` would be shorter, but the CLI ships as a single - * compiled binary to machines where `tar` may be BSD tar, GNU tar, or absent - * (Windows), and each writes a different archive for the same directory. The - * server only ever untars what we send, so producing the bytes here keeps the - * upload identical on every platform and keeps packaging out of the process - * table. - * - * `Bun.Archive` — which this repo already uses to build the pgdata baseline - * marker, `pgDataBaselineMarkerTar` — is not the same tool. It builds - * from path-to-contents pairs and exposes no per-entry metadata: every entry - * comes out as a regular file with mode `0644` and the current wall-clock time, - * so a symlink cannot be stored at all, an executable loses its bit, and the - * same tree packages to different bytes on every run. A single-file marker - * needs none of that; a build context needs all of it. + * A minimal USTAR writer for the `.tar.gz` build context `supabase experimental workers push` + * uploads, needed because shelling out to `tar` isn't portable (BSD tar, GNU tar, and no tar + * at all on Windows produce different bytes for the same directory) and `Bun.Archive` builds + * from path-to-contents pairs with no per-entry metadata, so symlinks and the executable bit + * would be lost. */ import { @@ -51,19 +39,11 @@ export interface TarEntry { const MAX_OCTAL_FIELD = 8 ** 11 - 1; /** - * USTAR stores numbers as zero-padded octal followed by a NUL. - * - * A value too large for the field renders one digit too long and spills into the - * next field, producing an archive that reads back with a plausible but wrong - * size — corruption no reader can detect. Real tars switch to base-256 here; - * this writer refuses instead, because a build context carrying an 8 GiB file is - * already a mistake worth naming rather than silently mangling. - * - * The range is checked, not just the rendered width, because the width check - * alone does not catch a value that is not a whole non-negative number: - * `(-1).toString(8)` is `"-1"` and `NaN.toString(8)` is `"NaN"`, both of which - * pad to exactly `length - 1` characters and slip through while writing a field - * no tar can parse. + * USTAR stores numbers as zero-padded octal followed by a NUL. A value too large for the + * field spills a digit into the next one — corruption no reader can detect — so this refuses + * rather than switching to base-256 like real tars do. The range is checked, not just the + * width, because `(-1)` and `NaN` both render to exactly `length - 1` characters and would + * otherwise slip through. */ function writeOctal(block: Uint8Array, offset: number, length: number, value: number): void { const digits = Math.floor(value); @@ -108,12 +88,10 @@ function byteLength(value: string): number { } /** - * Thrown for a path USTAR cannot represent. A plain `Error` rather than a - * tagged one because `createTar` is a pure synchronous function with no Effect - * semantics of its own; the caller's own error channel is where this surfaces. - * It is still user-actionable — renaming the offending file fixes it — so it - * carries its own classification, and the static identifier keeps the - * fingerprint stable through minification. + * Thrown for a path USTAR cannot represent. A plain `Error`, not a tagged one, because + * `createTar` is a pure synchronous function — the caller's own error channel is where this + * surfaces. Still user-actionable (renaming the file fixes it), so it carries its own + * classification with a static identifier to keep the fingerprint stable through minification. */ export class TarPathTooLongError extends Error { static readonly [ErrorActionabilityFingerprintId] = "TarPathTooLongError"; diff --git a/apps/cli/src/shared/workers/tar.unit.test.ts b/apps/cli/src/shared/workers/tar.unit.test.ts index 31cc74fddd..589d0d2926 100644 --- a/apps/cli/src/shared/workers/tar.unit.test.ts +++ b/apps/cli/src/shared/workers/tar.unit.test.ts @@ -97,8 +97,7 @@ describe("createTar", () => { }); test("refuses a value too large for an octal header field rather than truncating it", () => { - // One past the 11-digit octal ceiling. Encoding it would spill a digit into - // the next field and read back as a plausible but wrong number. + // One past the 11-digit octal ceiling — would spill into the next field if encoded. expect(() => createTar([{ path: "a.txt", contents: new Uint8Array(1), mtime: 8 ** 11 }]), ).toThrow(TarFieldOutOfRangeError); @@ -108,10 +107,9 @@ describe("createTar", () => { ).not.toThrow(); }); - // Each of these renders to exactly the field width once padded, so the width - // check alone waves it through and the header goes out unparseable: GNU tar - // rejects the whole archive, which surfaces server-side after the upload - // rather than here. + // Each of these renders to exactly the field width once padded, so the width check alone + // would wave it through and produce a header GNU tar rejects after the upload rather than + // here. test.each([ ["a pre-epoch mtime", -1], ["an mtime from an invalid date", Number.NaN], diff --git a/apps/cli/src/shared/workers/toml-section.ts b/apps/cli/src/shared/workers/toml-section.ts index b37290e2ea..1f4020f77e 100644 --- a/apps/cli/src/shared/workers/toml-section.ts +++ b/apps/cli/src/shared/workers/toml-section.ts @@ -1,16 +1,9 @@ /** - * Appending one `[section]` to a TOML file. - * - * `supabase/config.toml` belongs to the whole CLI: users hand-edit it, comment - * it, and commit it. Round-tripping through `saveProjectConfig` preserves the - * data but discards every comment and normalizes the formatting the user chose, - * so the write here is textual — render the table, put it at the end, and leave - * every other byte alone. - * - * Append-only by design: locating an existing table means being right about - * multiline strings, the three ways to quote a key, and where one table ends. - * Callers ask the decoded config whether an entry exists instead, so nothing - * here has to find one. + * Appends one `[section]` to a TOML file textually rather than through a parse-and-rewrite + * round trip, since `saveProjectConfig` would discard the user's comments and formatting. + * Append-only by design: locating an existing table correctly means handling multiline + * strings and the three ways to quote a key, so callers ask the decoded config whether an + * entry exists instead. */ /** A TOML bare key needs no quoting; anything else does. */ @@ -28,13 +21,10 @@ const TOML_NAMED_ESCAPES: Record<string, string> = { }; /** - * Escape a string for a TOML basic (double-quoted) string. - * - * Control characters need the same treatment as quotes and backslashes: TOML - * forbids them raw inside a basic string, and a path is allowed to contain them - * on Unix — a directory name with an embedded newline is legal. Writing one - * through verbatim leaves `config.toml` unparseable after the scaffold is - * already on disk. + * Escapes a string for a TOML basic (double-quoted) string. Control characters need the same + * treatment as quotes and backslashes: TOML forbids them raw, and a path may legally contain + * one (an embedded newline in a directory name) — writing it through verbatim would leave + * `config.toml` unparseable after the scaffold is already on disk. */ function quote(value: string): string { let escaped = ""; @@ -54,14 +44,10 @@ function quote(value: string): string { } /** - * Whether `value` is a number this module can render without lying about it. - * - * Bare-rendering is faithful only for a whole, finite, non-negative count — - * which is what `[workers.<name>] instances` is bounded to. Everything else is - * either valid TOML the schema refuses (`1.5`, `-1`, `1e21`) or a token TOML has - * no reading for at all (`String(NaN)` is `NaN`, not `nan`; `String(Infinity)` - * is `Infinity`, not `inf`). Only the second kind fails a re-parse, so the first - * kind has to be stopped here. + * Whether `value` is a whole, non-negative, finite number `[workers.<name>] instances` can + * render without lying: `1.5`/`-1`/`1e21` are valid TOML the schema rejects, while + * `String(NaN)`/`String(Infinity)` render tokens TOML has no reading for at all. Only the + * second kind fails a re-parse, so the first kind must be stopped here. */ export function isRenderableTomlNumber(value: number): boolean { return Number.isSafeInteger(value) && value >= 0; @@ -73,29 +59,21 @@ export function tomlKey(key: string): string { } /** - * `key = "value"`, or `key = value` for a number. - * - * A number has to be rendered bare: quoting it would write a TOML string, and - * the config schema types `[workers.<name>] instances` as a number — so a quoted - * count produces a `config.toml` that no longer loads at all. - * - * Rendering is all this does, and a bare number is only as good as the caller's. - * `1.5` and `-1` render as perfectly valid TOML that the worker schema rejects, - * so `planWorkerEntry`'s re-parse — a syntax check, not a schema one — would - * pass them straight through. {@link isRenderableTomlNumber} is the guard that - * makes that impossible, and it runs before this does. + * `key = "value"`, or `key = value` for a number — quoting a count would write a TOML string, + * and the schema types `[workers.<name>] instances` as a number, so a quoted count would stop + * `config.toml` from loading at all. Rendering doesn't validate: {@link isRenderableTomlNumber} + * is the guard that keeps `1.5`/`-1` from reaching here, since `planWorkerEntry`'s re-parse is + * a syntax check, not a schema one. */ function renderPair(key: string, value: string | number): string { return `${tomlKey(key)} = ${typeof value === "number" ? String(value) : quote(value)}`; } /** - * `text` with a `[header]` table holding `values` appended to the end. - * - * Cannot fail: the caller has already established that no such table exists, so - * there is nothing to reconcile. A file that is empty (or only whitespace) gets - * no leading blank line; an existing one gets exactly one, however it happened - * to be terminated. + * `text` with a `[header]` table holding `values` appended to the end. Cannot fail: the + * caller has already established that no such table exists. An empty (or whitespace-only) + * file gets no leading blank line; an existing one gets exactly one, however it was + * terminated. */ export function appendTomlSection( text: string, diff --git a/apps/cli/src/shared/workers/toml-section.unit.test.ts b/apps/cli/src/shared/workers/toml-section.unit.test.ts index 8cd27d4214..fc4d5e3cbe 100644 --- a/apps/cli/src/shared/workers/toml-section.unit.test.ts +++ b/apps/cli/src/shared/workers/toml-section.unit.test.ts @@ -32,8 +32,6 @@ size = "2gb" ); }); - // However the file happened to be terminated, the new table is separated by - // exactly one blank line. test.each([ ['project_id = "demo"', "no trailing newline"], ['project_id = "demo"\n', "one trailing newline"], @@ -50,8 +48,6 @@ size = "2gb" ); }); - // A path may legally contain a newline on Unix. Writing it through verbatim - // would leave config.toml unparseable, after the directory is already on disk. test("escapes control characters in a written value", () => { const after = appendTomlSection("", "workers.api", { source: "packages/od\nd\tname" }); @@ -65,8 +61,6 @@ size = "2gb" ); }); - // Quoting a count would write a TOML string, and the config schema types - // `instances` as a number — so the rendered file would stop loading entirely. test("writes a number bare rather than quoting it", () => { expect(appendTomlSection("", "workers.api", { size: "2gb", instances: 3 })).toBe( '[workers.api]\nsize = "2gb"\ninstances = 3\n', diff --git a/apps/cli/src/shared/workers/worker-classify.ts b/apps/cli/src/shared/workers/worker-classify.ts index 5c0efb7982..b93f62a143 100644 --- a/apps/cli/src/shared/workers/worker-classify.ts +++ b/apps/cli/src/shared/workers/worker-classify.ts @@ -19,12 +19,10 @@ const MARKERS: ReadonlyArray<{ readonly runtime: WorkerRuntime; readonly files: ReadonlyArray<string>; }> = [ - // An explicit Dockerfile always wins: it is a deliberate signal, not an - // inference. + // An explicit Dockerfile always wins: it's a signal, not an inference. { runtime: "dockerfile", files: ["Dockerfile"] }, - // Deno is checked before plain `package.json` because a Deno project can - // still have one (editor tooling, a stray dependency) while a Node project - // has no `deno.json`. + // Deno is checked before plain `package.json` because a Deno project can still have one + // (editor tooling, a stray dependency) while a Node project has no `deno.json`. { runtime: "deno", files: ["deno.json", "deno.jsonc", "deno.lock"] }, { runtime: "node", files: ["package.json"] }, ]; diff --git a/apps/cli/src/shared/workers/worker-config.ts b/apps/cli/src/shared/workers/worker-config.ts index 8351f9b9c0..db6120e88d 100644 --- a/apps/cli/src/shared/workers/worker-config.ts +++ b/apps/cli/src/shared/workers/worker-config.ts @@ -32,12 +32,9 @@ export interface WorkersSection { } /** - * The worker is already recorded in `config.toml`. - * - * `workers new` creates a worker; changing one that exists is a different - * operation, and the file is the user's to edit. Refusing is also what keeps - * writes here append-only — amending an entry in place is what required knowing - * enough TOML to find and rewrite it safely. + * The worker is already recorded in `config.toml`. `workers new` creates a worker; changing + * one that exists is a different operation, and the file is the user's to edit. Refusing also + * keeps writes append-only, avoiding the need to find and rewrite an entry in place. */ export class WorkerAlreadyConfiguredError extends Data.TaggedError("WorkerAlreadyConfiguredError")<{ readonly detail: string; @@ -49,17 +46,11 @@ export class WorkerAlreadyConfiguredError extends Data.TaggedError("WorkerAlread } /** - * Appending the new table would leave `config.toml` unparseable. - * - * `appendTomlSection` renders one table and puts it at the end, which is only - * valid when the existing file is valid TOML that does not already seal the - * `workers` key. A config whose `[workers]` is an inline table (`workers = {}`) - * is the case in point: TOML inline tables cannot be extended, so appending - * `[workers.api]` produces a file nothing can read. - * - * Rather than enumerate the representations that break, the plan is parsed - * before it is returned. Anything that does not round-trip is refused while the - * refusal is still free — `new` calls this before it writes the scaffold. + * Appending the new table would leave `config.toml` unparseable — e.g. a `[workers]` that's + * already an inline table (`workers = {}`) can't be extended, so appending `[workers.api]` + * produces a file nothing can read. Rather than enumerate every representation that breaks, + * the plan is parsed before it's returned and anything that doesn't round-trip is refused, + * before `new` writes the scaffold. */ export class WorkerConfigWriteUnsafeError extends Data.TaggedError("WorkerConfigWriteUnsafeError")<{ readonly detail: string; @@ -74,18 +65,12 @@ const stringOrUndefined = (value: unknown): string | undefined => typeof value === "string" && value !== "" ? value : undefined; /** - * As {@link stringOrUndefined}, but an explicitly empty string survives. - * - * For `exposure`, "recorded but unusable" must not read as "not recorded". - * Absent means the `public` default, so folding `exposure = ""` into `undefined` - * hands a config that plainly tried to say something to the most open setting - * there is — the exact silent-widening `push`'s `resolveExposure` exists to - * refuse. Kept verbatim so it reaches that check like any other value the CLI - * does not recognize. - * - * `runtime`, `size` and `source` keep the collapsing reader: their fallbacks are - * a marker-file guess, a default size and the conventional directory, none of - * which widens anything. + * As {@link stringOrUndefined}, but an explicitly empty string survives. For `exposure`, + * "recorded but unusable" must not read as "not recorded": absent means the `public` default, + * so folding `exposure = ""` into `undefined` would silently widen a config that plainly + * tried to say something to the most open setting there is. `runtime`, `size`, and `source` + * keep the collapsing reader, since their fallbacks (a marker-file guess, a default size, the + * conventional directory) don't widen anything. */ const recordedStringOrUndefined = (value: unknown): string | undefined => typeof value === "string" ? value : undefined; @@ -107,11 +92,9 @@ const instanceCountOrUndefined = (value: unknown): number | undefined => * object is dropped rather than read as a worker named after it. */ export function readWorkersSection(workers: unknown): WorkersSection { - // Null-prototype, so a worker legitimately named `constructor`, `toString` or - // `hasOwnProperty` reads as absent when it is absent. A plain `{}` answers - // every one of those lookups with something inherited from - // `Object.prototype`, which is enough to make `workers new constructor` write - // its starter files and then refuse to record them. + // Null-prototype, so a worker named `constructor`, `toString`, or `hasOwnProperty` reads as + // absent when it is: a plain `{}` would answer those lookups from `Object.prototype`, + // enough to make `workers new constructor` write its starter files and refuse to record them. const entries: Record<string, WorkerEntry> = Object.create(null); if (!isRecord(workers)) { @@ -125,9 +108,8 @@ export function readWorkersSection(workers: unknown): WorkersSection { entries[key] = { runtime: stringOrUndefined(value["runtime"]), size: stringOrUndefined(value["size"]), - // Left as whatever string was written, empty included: `push` is what - // names the accepted values, and dropping an unrecognized one here would - // silently deploy a worker at the default exposure instead. + // Left as written, empty included: `push` names the accepted values, and dropping an + // unrecognized one here would silently deploy at the default exposure instead. exposure: recordedStringOrUndefined(value["exposure"]), instances: instanceCountOrUndefined(value["instances"]), source: stringOrUndefined(value["source"]), @@ -144,11 +126,10 @@ export interface WorkerEntryWrite { } /** - * Render `config.toml` with `[workers.<name>]` appended, without writing it. - * - * Split from the write so callers can find out an entry already exists before - * they scaffold anything: `new` writes the starter files first, and a failure - * after that would leave a directory nothing records. + * Renders `config.toml` with `[workers.<name>]` appended, without writing it. Split from the + * write so callers can find out an entry already exists before they scaffold anything: `new` + * writes the starter files first, and a failure after that would leave a directory nothing + * records. */ export const planWorkerEntry = Effect.fnUntraced(function* (options: { readonly configPath: string; @@ -160,9 +141,8 @@ export const planWorkerEntry = Effect.fnUntraced(function* (options: { }) { const fs = yield* FileSystem.FileSystem; - // Append-only, so an entry that is already there cannot be amended. The - // decoded config is the authority on whether one exists — a question the - // parser has answered, and one no amount of regex over the file text answers + // Append-only, so an entry that already exists cannot be amended. The decoded config is + // the authority on whether one exists — regex over the file text can't answer that // reliably for a dotted or inline entry. if (options.existingWorkers[options.name] !== undefined) { return yield* Effect.fail( @@ -173,10 +153,9 @@ export const planWorkerEntry = Effect.fnUntraced(function* (options: { ); } - // Before rendering, because the re-parse below cannot catch this. A number - // like `1.5` or `-1` renders as valid TOML that only the *schema* rejects, so - // it would sail through a syntax check and land in the user's config as a - // `[workers]` section the loader then refuses. + // Before rendering, since the re-parse below is a syntax check only: `1.5` or `-1` render + // as valid TOML that only the schema rejects, so they'd sail through and land in the + // user's config as a `[workers]` section the loader then refuses. const unrenderable = Object.entries(options.patch).find( ([, value]) => typeof value === "number" && !isRenderableTomlNumber(value), ); @@ -194,9 +173,9 @@ export const planWorkerEntry = Effect.fnUntraced(function* (options: { const header = `workers.${tomlKey(options.name)}`; const next = appendTomlSection(text, header, options.patch); - // The rendered file has to parse, and the new table has to be readable back - // out of it. Appending text is a syntactic operation on a file this code did - // not write, so the only honest check is to read the result. + // The rendered file has to parse and the new table has to be readable back out of it — + // appending is a syntactic operation on a file this code didn't write, so reading the + // result back is the only honest check. const parsed = yield* Effect.try({ try: () => SmolToml.parse(next), catch: (cause) => diff --git a/apps/cli/src/shared/workers/worker-config.unit.test.ts b/apps/cli/src/shared/workers/worker-config.unit.test.ts index 5c4c3c9b4c..032b9f49d7 100644 --- a/apps/cli/src/shared/workers/worker-config.unit.test.ts +++ b/apps/cli/src/shared/workers/worker-config.unit.test.ts @@ -59,8 +59,8 @@ describe("readWorkersSection", () => { }); }); - // `push` has to send a count with every deploy, so a value the API would - // reject is dropped here and the default used instead. + // `push` sends a count with every deploy, so a value the API would reject is dropped here + // and the default used instead. test.each([ ["a float", 1.5], ["a negative", -1], @@ -75,10 +75,9 @@ describe("readWorkersSection", () => { expect(readWorkersSection({ api: { instances: 0 } }).workers["api"]?.instances).toBe(0); }); - // Unlike the instance count, an unrecognized exposure is kept and carried to - // `push`, which names the values it accepts. Dropping it here would deploy the - // worker at the default exposure — public — which is the opposite of what a - // misspelled `private` was asking for. + // Unlike the instance count, an unrecognized exposure is kept and carried to `push`, which + // names the values it accepts — dropping it here would deploy at the default (public) + // exposure, the opposite of what a misspelled `private` was asking for. test("keeps an exposure it does not recognize, for push to refuse by name", () => { expect(readWorkersSection({ api: { exposure: "privat" } }).workers["api"]?.exposure).toBe( "privat", @@ -110,9 +109,9 @@ describe("planWorkerEntry + commitWorkerEntry", () => { const writeWorkerEntry = (options: Parameters<typeof planWorkerEntry>[0]) => planWorkerEntry(options).pipe(Effect.flatMap(commitWorkerEntry)); - // The re-parse below is a syntax check, not a schema one: `instances = 1.5` - // is perfectly valid TOML that the worker schema rejects, so it would reach - // the user's config and only fail later, when the loader refuses the file. + // The re-parse below is a syntax check, not a schema one: `instances = 1.5` is valid TOML + // the worker schema rejects, so without it the value would reach the user's config and only + // fail later, when the loader refuses the file. test.each([ ["a fraction", 1.5], ["a negative count", -1], @@ -176,8 +175,6 @@ describe("planWorkerEntry + commitWorkerEntry", () => { ); }); - // `new` creates a worker; changing one that exists is a `config.toml` edit and - // the file is the user's. Refusing is also what keeps writes append-only. test("refuses a worker that is already configured, leaving the file alone", async () => { const before = '# hand-written\n[workers.api]\nruntime = "node" # mine\n'; writeFileSync(configPath, before); @@ -195,10 +192,9 @@ describe("planWorkerEntry + commitWorkerEntry", () => { expect(readFileSync(configPath, "utf8")).toBe(before); }); - // How the entry is written — dotted, inline or a table — does not matter. The - // decoded config says it exists, which is the whole question, and answering it - // from the parser rather than the file text is what removed the need to know - // any TOML beyond how to render a value. + // How the entry is written — dotted, inline, or a table — doesn't matter: the decoded + // config already says it exists, so nothing here needs to understand TOML beyond + // rendering a value. test.each([ ["dotted keys", 'workers.api.runtime = "node"\n'], ["an inline table", 'workers = { api = { runtime = "node" } }\n'], @@ -220,10 +216,9 @@ describe("planWorkerEntry + commitWorkerEntry", () => { expect(readFileSync(configPath, "utf8")).toBe(before); }); - // An inline `[workers]` is sealed: TOML forbids extending it, so appending - // `[workers.api]` renders a file nothing can parse. The name is absent from - // the decoded section, so the already-configured check cannot catch this — - // reading the rendered plan back is what does. + // An inline `[workers]` is sealed: TOML forbids extending it, so appending `[workers.api]` + // renders a file nothing can parse. The name is absent from the decoded section, so the + // already-configured check can't catch this — reading the rendered plan back is what does. test.each([ ["an empty inline workers table", "workers = {}\n"], [ @@ -246,10 +241,9 @@ describe("planWorkerEntry + commitWorkerEntry", () => { expect(readFileSync(configPath, "utf8")).toBe(before); }); - // The backstop is not limited to the inline case: a config.toml that does not - // parse to begin with cannot be appended to safely either, and finding that - // out after the scaffold is written is exactly what the plan/commit split - // exists to avoid. + // The backstop isn't limited to the inline case: a config.toml that doesn't parse to begin + // with can't be appended to safely either, and finding that out after the scaffold is + // written is exactly what the plan/commit split exists to avoid. test("refuses a config.toml that does not parse, leaving the file alone", async () => { const before = "this is not = = toml\n"; writeFileSync(configPath, before); @@ -267,9 +261,8 @@ describe("planWorkerEntry + commitWorkerEntry", () => { expect(readFileSync(configPath, "utf8")).toBe(before); }); - // Why rendering is separate from writing: `new` writes the starter files before - // it records anything, so a failure that could only surface at the write would - // leave a scaffold on disk that nothing records. + // Why rendering is separate from writing: `new` writes the starter files before recording + // anything, so a failure at the write would otherwise leave an unrecorded scaffold on disk. test("renders without writing, and only writes when committed", async () => { writeFileSync(configPath, 'project_id = "demo"\n'); @@ -291,9 +284,9 @@ describe("planWorkerEntry + commitWorkerEntry", () => { }); describe("readWorkersSection blank values", () => { - // Absent means the `public` default, so a blank `exposure` must not read as - // absent — that would silently widen a worker whose config tried to say - // something. `push` refuses the value instead. + // Absent means the `public` default, so a blank `exposure` must not read as absent — that + // would silently widen a worker whose config tried to say something. `push` refuses it + // instead. test("keeps an explicitly blank exposure so push can refuse it", () => { const section = readWorkersSection({ api: { runtime: "node", exposure: "" } }); diff --git a/apps/cli/src/shared/workers/worker-logs-api.ts b/apps/cli/src/shared/workers/worker-logs-api.ts index 6611fd415b..80829e7370 100644 --- a/apps/cli/src/shared/workers/worker-logs-api.ts +++ b/apps/cli/src/shared/workers/worker-logs-api.ts @@ -41,21 +41,15 @@ export interface WorkerLogEntry { } /** - * The row shape the projection in `workerLogsQuery` produces. - * - * `stream` stays a plain string and `log_attributes` an open record because the - * log contract is additive-only: a new stream or a new attribute must render, - * not fail the whole read. A closed `Schema.Literal` union here would break the - * command the next time a stream is added. + * The row shape the projection in `workerLogsQuery` produces. `stream` stays a plain string + * and `log_attributes` an open record because the log contract is additive-only: a new stream + * or attribute must render, not fail the whole read. */ /** - * The widest instant a JavaScript `Date` can hold, either side of the epoch. - * - * `ts_ms` feeds `new Date(...).toISOString()` when a payload is built, which is - * unconditional — text runs construct it too. Outside this range that call - * throws `RangeError`, turning a recoverable bad row into a defect, so the bound - * belongs on the schema where it fails through `decodeBody` as an unreadable - * response instead. + * The widest instant a JavaScript `Date` can hold, either side of the epoch. `ts_ms` feeds + * `new Date(...).toISOString()` unconditionally when a payload is built; outside this range + * that throws `RangeError`, turning a bad row into a defect — so the bound lives on the + * schema, where it fails through `decodeBody` as an unreadable response instead. */ const MAX_DATE_MS = 8_640_000_000_000_000; @@ -79,17 +73,10 @@ const StructuredLogError = Schema.Struct({ /** * The response envelope, declared here rather than reusing the generated - * `V1GetProjectLogsOutput`. - * - * The generated schema is `optionalKey` on both fields but allows neither to be - * `null` — while the endpoint sends exactly `{"result":[...],"error":null}` on - * success and `{"result":null,"error":"..."}` on failure, because - * `getAnalyticsResponse` normalises the unused half to an explicit `null`. Decoding - * a real response against the generated schema therefore always fails. - * - * `error` stays `Unknown` so the string and structured forms are both accepted and - * narrowed at the point of use; the generated struct also marks fields required - * that real bodies omit. + * `V1GetProjectLogsOutput`: that schema forbids `null` on either field, while the endpoint + * always sends one of them as an explicit `null` (`getAnalyticsResponse` normalizes the + * unused half), so decoding a real response against it always fails. `error` stays `Unknown` + * so both the string and structured forms are accepted and narrowed at the point of use. */ const LogsResponse = Schema.Struct({ result: Schema.optionalKey(Schema.NullOr(Schema.Array(Schema.Unknown))), @@ -208,9 +195,8 @@ export const fetchWorkerLogs = Effect.fnUntraced(function* ( }); } - // The query orders `desc` to make `limit` mean "the most recent N". Sorting - // here rather than trusting that order: guest lines are ingested late and out - // of order, and once `--follow` merges overlapping windows the server's order - // stops being meaningful at all. + // The query orders `desc` to make `limit` mean "the most recent N", but sorting here rather + // than trusting that order: guest lines are ingested late and out of order, and once + // `--follow` merges overlapping windows the server's order stops being meaningful at all. return entries.sort((left, right) => left.timestampMs - right.timestampMs); }); diff --git a/apps/cli/src/shared/workers/worker-logs.sql.ts b/apps/cli/src/shared/workers/worker-logs.sql.ts index 9072fa9b62..96aedeb68b 100644 --- a/apps/cli/src/shared/workers/worker-logs.sql.ts +++ b/apps/cli/src/shared/workers/worker-logs.sql.ts @@ -8,12 +8,9 @@ */ /** - * The three streams that share the Workers Logflare source, keyed by the word the - * `--kind` flag exposes. - * - * `worker_guest_logs` is an internal name; `app` is what a user means. The - * mapping lives here rather than in the command so the flag and the query cannot - * drift apart. + * The three streams that share the Workers Logflare source, keyed by the word the `--kind` + * flag exposes (`worker_guest_logs` is internal; `app` is what a user means). Lives here + * rather than in the command so the flag and the query cannot drift apart. */ export const WORKER_LOG_STREAMS = { app: "worker_guest_logs", @@ -24,12 +21,9 @@ export const WORKER_LOG_STREAMS = { export type WorkerLogKindChoice = keyof typeof WORKER_LOG_STREAMS; /** - * The `--kind` words, in the order help lists them. - * - * Lives beside {@link WORKER_LOG_STREAMS} and is checked against it, so the flag - * cannot offer a word the query has no stream for. Without that link an added - * choice would index the map as `undefined` and reach the SQL as an empty - * stream name rather than failing to compile. + * The `--kind` words, in the order help lists them. Checked against {@link WORKER_LOG_STREAMS} + * so the flag can't offer a word the query has no stream for — without that link, an added + * choice would index the map as `undefined` and reach the SQL as an empty stream name. */ export const WORKER_LOG_KINDS = [ "app", @@ -52,36 +46,26 @@ const WORKER_LOG_NAME_ATTRIBUTE = "worker"; const WORKER_LOG_STREAM_ATTRIBUTE = "source"; /** - * The server clamps a span of *more than* 24 hours, so the default window sits - * just under the boundary rather than on it. - * - * Being clamped is worse than being rejected: the server rewrites `end` to - * `start + 24h`, so an over-wide request silently returns an *older* slice than - * the one asked for. + * The server clamps a span of more than 24 hours, so the default window sits just under the + * boundary. Being clamped is worse than being rejected: the server rewrites `end` to + * `start + 24h`, so an over-wide request silently returns an older slice than the one asked for. */ export const WORKER_LOG_WINDOW_MINUTES = 23 * 60 + 59; /** - * How often `--follow` re-queries. - * - * **Set by the rate limit, not by responsiveness.** The v1 analytics endpoints - * allow 10 requests per 60 seconds, so the two-second poll a live tail suggests - * would 429 within the first ten seconds. Six seconds is the arithmetic floor; - * ten leaves room for the initial history query, the deployed-worker check, and a - * retry inside the same window. + * How often `--follow` re-queries — set by the rate limit, not by responsiveness. The v1 + * analytics endpoints allow 10 requests per 60 seconds, so a two-second poll would 429 within + * the first ten seconds; ten seconds leaves room for the initial history query, the + * deployed-worker check, and a retry inside the same window. */ export const WORKER_LOG_POLL_SECONDS = 10; /** - * How far behind the newest line seen the next window starts. - * - * Guest lines are relayed CloudWatch -> subscription filter -> Lambda -> Logflare - * and arrive **late and out of order**, so a cursor sitting exactly on the newest - * timestamp drops every straggler permanently. The window is deliberately - * re-asked for ground it has already covered; `id` dedupe absorbs the overlap. - * - * Wider than one poll interval, so a line delayed by a full cycle is still - * inside the next window. + * How far behind the newest line seen the next window starts. Guest lines are relayed + * CloudWatch -> subscription filter -> Lambda -> Logflare and arrive late and out of order, so + * a cursor sitting exactly on the newest timestamp would drop every straggler permanently — + * the window re-asks for ground already covered, and `id` dedupe absorbs the overlap. Wider + * than one poll interval, so a line delayed a full cycle still lands inside the next window. */ const WORKER_LOG_CURSOR_GRACE_SECONDS = 60; @@ -97,12 +81,9 @@ export function isoLogTimestamp(date: Date): string { } /** - * A closed window ending at `now`. - * - * Both bounds, always. Sending only a start yields a **one-minute** window - * server-side (the lone bound is minute-rounded and the other derived from it), - * and sending neither is an outright error — so there is no valid single-bound - * call to make. + * A closed window ending at `now`. Both bounds, always: sending only a start yields a + * one-minute window server-side (the lone bound is minute-rounded and the other derived from + * it), and sending neither is an outright error, so there's no valid single-bound call. */ export function logWindow(now: Date): { readonly start: string; readonly end: string } { return { @@ -112,14 +93,11 @@ export function logWindow(now: Date): { readonly start: string; readonly end: st } /** - * The window for one `--follow` poll: from just before the newest line seen, up - * to now. - * - * Clamped to the same sub-24h span as {@link logWindow}. That matters when a tail - * is left running past a laptop suspend: without the clamp the resumed poll would - * ask for a wider span, and the server answers an over-wide request by rewriting - * `end` to `start + 24h` — returning an *older* slice rather than a truncated one, - * so a resumed tail would silently start replaying yesterday. + * The window for one `--follow` poll: from just before the newest line seen, up to now. + * Clamped to the same sub-24h span as {@link logWindow} — a tail left running past a laptop + * suspend would otherwise resume with a wider span, and the server answers that by rewriting + * `end` to `start + 24h`, returning an older slice rather than a truncated one, so a resumed + * tail would silently start replaying yesterday. */ export function followWindow( now: Date, @@ -147,23 +125,13 @@ function quote(value: string): string { } /** - * The logs query for one worker. - * - * Two things about the projection are load-bearing: - * - * - **The filter is `log_attributes`, not the `source` column.** Worker rows carry - * an empty top-level `source`, because the Workers Logflare source is not - * enrolled as a category in the generic logs path — so `where source = - * 'worker_guest_logs'` matches nothing. The stream survives only in - * `log_attributes['source']`. - * - **The `in (...)` list is a tenancy guard, not a convenience.** With `source` - * empty there is nothing else keeping a non-worker row that happens to carry a - * `worker` attribute out of the result. - * - * `toUnixTimestamp64Milli` rather than a formatter: ClickHouse's `%M` is the - * *month name*, and bare `toString(timestamp)` yields - * `2026-08-31 14:45:32.576000000` — space-separated, nine decimals, no zone. - * Epoch milliseconds have no such trap and sort as a number. + * The logs query for one worker. Two things about the projection are load-bearing: the filter + * is `log_attributes`, not the `source` column, since worker rows carry an empty top-level + * `source` (the stream survives only in `log_attributes['source']`); and the `in (...)` list + * is a tenancy guard, not a convenience — with `source` empty, it's the only thing keeping a + * non-worker row with a `worker` attribute out of the result. `toUnixTimestamp64Milli` rather + * than a formatter, since ClickHouse's `%M` is the month name and bare `toString(timestamp)` + * has no zone. */ export function workerLogsQuery(options: { readonly name: string; diff --git a/apps/cli/src/shared/workers/worker-logs.sql.unit.test.ts b/apps/cli/src/shared/workers/worker-logs.sql.unit.test.ts index 7af64a4127..f7d58b3b8e 100644 --- a/apps/cli/src/shared/workers/worker-logs.sql.unit.test.ts +++ b/apps/cli/src/shared/workers/worker-logs.sql.unit.test.ts @@ -17,8 +17,6 @@ describe("workerLogsQuery", () => { expect(sql).toContain("log_attributes['worker'] = 'api'"); expect(sql).toContain("log_attributes['source'] in ("); - // The load-bearing negative: worker rows carry an empty top-level `source`, - // so a predicate on that column matches nothing at all. expect(sql).not.toMatch(/(?:^|\s)where source =/); expect(sql).not.toMatch(/(?:^|\s)and source =/); }); @@ -26,8 +24,6 @@ describe("workerLogsQuery", () => { it("constrains to the known streams even when none was requested", () => { const sql = workerLogsQuery({ name: "api", streams: ALL_WORKER_LOG_STREAMS, tail: 10 }); - // With `source` empty this list is the only thing keeping a non-worker row - // that happens to carry a `worker` attribute out of the results. expect(sql).toContain("'worker_guest_logs'"); expect(sql).toContain("'worker_ingress_logs'"); expect(sql).toContain("'worker_api_logs'"); @@ -48,8 +44,6 @@ describe("workerLogsQuery", () => { const sql = workerLogsQuery({ name: "api", streams: ALL_WORKER_LOG_STREAMS, tail: 1 }); expect(sql).toContain("toUnixTimestamp64Milli(timestamp) as ts_ms"); - // `%M` is ClickHouse's month name, and bare toString has no zone — neither - // belongs in this query. expect(sql).not.toContain("formatDateTime"); expect(sql).not.toContain("toString(timestamp)"); }); @@ -62,8 +56,8 @@ describe("workerLogsQuery", () => { }); it("escapes a quote in the worker name", () => { - // Unreachable in practice — the handler validates first, see below — so this - // pins the backstop rather than the guard. + // Unreachable in practice (the handler validates first) — this pins the backstop, not + // the guard. const sql = workerLogsQuery({ name: "a'b", streams: ALL_WORKER_LOG_STREAMS, tail: 1 }); expect(sql).toContain("log_attributes['worker'] = 'a''b'"); @@ -84,8 +78,6 @@ describe("logWindow", () => { it("always returns both bounds", () => { const window = logWindow(new Date("2026-08-31T12:00:00.000Z")); - // A lone bound yields a one-minute window server-side, and sending neither is - // an outright error, so there is no valid single-bound call. expect(window.start).toBeDefined(); expect(window.end).toBeDefined(); }); @@ -95,8 +87,6 @@ describe("logWindow", () => { const window = logWindow(now); const spanMs = Date.parse(window.end) - Date.parse(window.start); - // Being clamped is worse than being rejected: the server rewrites `end` to - // `start + 24h`, returning an older slice than the one asked for. expect(spanMs).toBeLessThan(24 * 60 * 60 * 1000); expect(WORKER_LOG_WINDOW_MINUTES).toBeLessThan(24 * 60); }); @@ -109,9 +99,8 @@ describe("logWindow", () => { }); /** - * The grace and the clamp are written as literals rather than read from the - * module. Deriving the expectation from the constant under test would keep these - * green through exactly the change they exist to catch. + * The grace and clamp are written as literals rather than read from the module, so a change + * to the constant under test doesn't silently keep these green. */ describe("followWindow", () => { const now = new Date("2026-08-31T12:00:00.000Z"); @@ -120,8 +109,6 @@ describe("followWindow", () => { const cursor = Date.parse("2026-08-31T11:59:30.000Z"); const window = followWindow(now, cursor); - // Guest lines are relayed late and out of order, so a window starting exactly - // on the cursor drops every straggler permanently. expect(window.start).toBe("2026-08-31T11:58:30.000Z"); expect(Date.parse(window.start)).toBe(cursor - 60_000); }); @@ -134,10 +121,6 @@ describe("followWindow", () => { }); it("clamps a cursor left behind by a suspend to the 24 hour span", () => { - // The case this clamp exists for: a tail left running across a laptop - // suspend resumes with a cursor days old. Unclamped, the server answers an - // over-wide request by rewriting `end` to `start + 24h` — returning an older - // slice rather than a truncated one, so the tail replays yesterday. const staleCursor = Date.parse("2026-08-28T09:00:00.000Z"); const window = followWindow(now, staleCursor); @@ -154,8 +137,6 @@ describe("followWindow", () => { describe("isoLogTimestamp", () => { it("emits a Z suffix and no numeric offset", () => { - // The v1 DTO validates with `z.string().datetime()`, which requires the Z and - // rejects `+00:00`. const formatted = isoLogTimestamp(new Date("2026-08-31T12:00:00.000Z")); expect(formatted).toBe("2026-08-31T12:00:00.000Z"); diff --git a/apps/cli/src/shared/workers/worker-package.ts b/apps/cli/src/shared/workers/worker-package.ts index 9049197e35..ab36d93dd4 100644 --- a/apps/cli/src/shared/workers/worker-package.ts +++ b/apps/cli/src/shared/workers/worker-package.ts @@ -6,16 +6,12 @@ import { WorkerSourceEscapingLinkError } from "./workers.errors.ts"; import { createTar, type TarEntry, TarFieldOutOfRangeError, TarPathTooLongError } from "./tar.ts"; /** - * Package a worker's source directory into the `.tar.gz` build context the - * Workers API's upload slot expects. - * - * Nothing is excluded. For a `dockerfile` worker the archive is the build - * context, so it has to be what the user's own `Dockerfile` expects to find; - * for a catalog runtime the server synthesizes `FROM <base>` + `COPY` with no - * install step of its own, so an installed `node_modules/` is a dependency of - * the deploy rather than noise in it. The packaged size is reported back so a - * directory that has grown past what anyone meant to upload is visible before - * the upload rather than after it. + * Packages a worker's source directory into the `.tar.gz` build context the Workers API's + * upload slot expects. Nothing is excluded: for a `dockerfile` worker the archive is the + * build context the user's own `Dockerfile` expects, and for a catalog runtime the server + * synthesizes `FROM <base>` + `COPY` with no install step, so `node_modules/` is a deploy + * dependency rather than noise. Packaged size is reported back so growth is visible before + * the upload rather than after. */ interface PackagedWorker { @@ -24,13 +20,11 @@ interface PackagedWorker { } /** - * Where a symlink points, relative to the packaged tree — or `undefined` when it - * points outside it. - * - * A link is stored rather than followed, so the target has to be packaged too - * for the link to mean anything on the other end. Targets are also rewritten - * relative to the link's own directory: an absolute one is a path on this - * machine and would not resolve anywhere else. + * Where a symlink points, relative to the packaged tree, or `undefined` when it points + * outside it. A link is stored rather than followed, so the target has to be packaged too for + * the link to mean anything on the other end. Targets are rewritten relative to the link's + * own directory, since an absolute one is a path on this machine that wouldn't resolve + * anywhere else. */ function confinedLinkTarget(input: { readonly root: string; @@ -46,13 +40,11 @@ function confinedLinkTarget(input: { } /** - * Seconds since the epoch, as a USTAR octal field can hold them. - * - * A filesystem timestamp is not always a sane one. A pre-1970 mtime is negative - * — a botched `touch` and some archive extractors both produce them — and a - * corrupt one decodes to an `Invalid Date` whose `getTime()` is `NaN`. Neither - * is representable, and neither is worth failing a deploy over, so both collapse - * to the epoch rather than reaching `writeOctal`'s range check. + * Seconds since the epoch, as a USTAR octal field can hold them. A filesystem timestamp + * isn't always a sane one: a pre-1970 mtime is negative (a botched `touch` or some archive + * extractors produce them), and a corrupt one decodes to an `Invalid Date` with `NaN`. Neither + * is worth failing a deploy over, so both collapse to the epoch instead of reaching + * `writeOctal`'s range check. */ function tarMtime(modified: Option.Option<Date>): number { if (Option.isNone(modified)) { @@ -63,12 +55,10 @@ function tarMtime(modified: Option.Option<Date>): number { } /** - * Every entry under `root`, as tar entries. - * - * Filesystem errors propagate rather than being skipped: an entry missing from - * the archive means deploying an application with a hole in it, reported as a - * success. A directory the walk cannot read, a file it cannot open and an entry - * that vanishes mid-walk are all that case. + * Every entry under `root`, as tar entries. Filesystem errors propagate rather than being + * skipped: an entry missing from the archive means deploying an application with a hole in + * it, reported as a success — an unreadable directory, an unopenable file, or an entry that + * vanishes mid-walk are all that case. */ const collectEntries = ( root: string, @@ -89,12 +79,10 @@ const collectEntries = ( const relativePath = relativeDir === "" ? name : `${relativeDir}/${name}`; const absolutePath = `${root}/${relativePath}`; - // `readLink` succeeds only for symlinks, so it stands in for the `lstat` - // this FileSystem service does not expose (the same probe - // `sql-files-glob.ts` uses). Storing the link rather than following - // it is what keeps a pnpm-installed `node_modules` from being inlined file - // by file, keeps a broken link from vanishing, and stops a link pointing at - // an ancestor from being walked into. + // `readLink` succeeds only for symlinks, standing in for the `lstat` this FileSystem + // service doesn't expose. Storing the link rather than following it keeps a + // pnpm-installed `node_modules` from being inlined file by file, keeps a broken link + // from vanishing, and stops a link pointing at an ancestor from being walked into. const linkTarget = yield* fs.readLink(absolutePath).pipe(Effect.option); if (Option.isSome(linkTarget)) { const confined = confinedLinkTarget({ @@ -137,10 +125,9 @@ const collectEntries = ( } const contents = yield* fs.readFile(absolutePath); - // The executable bit is the only permission that changes what the image - // does; everything else is normalized so the same tree packages - // identically on every machine. `mode` is a plain number here, unlike the - // `Option`-wrapped `mtime` above. + // The executable bit is the only permission that changes what the image does; + // everything else is normalized so the same tree packages identically on every + // machine. `mode` is a plain number here, unlike the `Option`-wrapped `mtime` above. const executable = (info.mode & 0o111) !== 0; entries.push({ path: relativePath, @@ -156,12 +143,11 @@ const collectEntries = ( export const packageWorkerDirectory = Effect.fnUntraced(function* (dir: string) { const entries = yield* collectEntries(dir, ""); - // `createTar` throws for anything USTAR cannot represent: a path component - // over 100 bytes, or a size past the 8 GiB an octal field holds. Both are - // user-actionable, and both declare themselves so, which only takes effect if - // they reach the failure channel — `withJsonErrorHandling` catches failures - // and not defects, so a defect exits `--output-format json` with no - // structured error at all. + // `createTar` throws for anything USTAR can't represent: a path over 100 bytes, or a size + // past the 8 GiB an octal field holds. Both are user-actionable, but that classification + // only takes effect if they reach the failure channel — `withJsonErrorHandling` catches + // failures, not defects, so a defect would exit `--output-format json` with no structured + // error at all. const archive = yield* Effect.try({ try: () => gzipSync(createTar(entries)), catch: (cause) => { diff --git a/apps/cli/src/shared/workers/worker-package.unit.test.ts b/apps/cli/src/shared/workers/worker-package.unit.test.ts index 7e53609a95..d8e72451e2 100644 --- a/apps/cli/src/shared/workers/worker-package.unit.test.ts +++ b/apps/cli/src/shared/workers/worker-package.unit.test.ts @@ -20,11 +20,9 @@ import { afterEach, beforeEach, describe, expect, test } from "vitest"; import { formatBytes, packageWorkerDirectory } from "./worker-package.ts"; /** - * Whether the current user can still read `path` after it was chmod-ed shut. - * - * Root ignores the permission bits, and CI sometimes runs as root, so the - * permission-denied tests below assert the opposite outcome instead of skipping - * — either way the behaviour under test is pinned. + * Whether the current user can still read `path` after it was chmod-ed shut. Root ignores + * permission bits, and CI sometimes runs as root, so the permission-denied tests below assert + * the opposite outcome instead of skipping — either way the behavior under test is pinned. */ function readableAsCurrentUser(path: string): boolean { try { @@ -108,9 +106,8 @@ describe("packageWorkerDirectory", () => { expect(result.fileCount).toBe(3); }); - // Anything pnpm installs is symlink-dense, so following links would inline - // every dependency's real contents — and a link pointing at an ancestor would - // be walked into until the OS refused. + // Anything pnpm installs is symlink-dense, so following links would inline every + // dependency's contents, and a link pointing at an ancestor would be walked into forever. test("stores symlinks as links rather than following them", async () => { writeFileSync(join(dir, "target.txt"), "hello"); symlinkSync("target.txt", join(dir, "link.txt")); @@ -122,9 +119,9 @@ describe("packageWorkerDirectory", () => { expect(link?.link).toBe("target.txt"); }); - // Broken, but pointing at a name inside the tree: whether the target exists is - // the server's problem once the archive is extracted, and dropping the link - // would change the tree the build sees. + // Broken, but pointing at a name inside the tree: whether the target exists is the + // server's problem after extraction, and dropping the link would change the tree the build + // sees. test("keeps a broken symlink instead of dropping it", async () => { symlinkSync("nowhere-at-all.txt", join(dir, "broken.txt")); @@ -133,9 +130,8 @@ describe("packageWorkerDirectory", () => { expect(entries.find((entry) => entry.path === "broken.txt")?.type).toBe("2"); }); - // The archive is the whole of what the server gets, so a link out of it - // arrives dangling however valid it is here. Refused while the user is still - // at the terminal, rather than surfacing as a remote build failure. + // The archive is all the server gets, so a link pointing outside it would arrive dangling + // however valid it is here — refused locally rather than surfacing as a remote build failure. test.each([ ["a relative escape", "../../outside.txt"], ["an absolute escape", "/nowhere-at-all"], @@ -176,11 +172,9 @@ describe("packageWorkerDirectory", () => { expect(entries.find((entry) => entry.path === "sub/up")?.type).toBe("2"); }); - // A pre-1970 mtime is negative, and a negative number is not representable in - // a USTAR octal field: `(-1).toString(8)` renders to exactly the field width, - // so it would sail past the width check and ship a header GNU tar rejects - // after the upload. A botched `touch` is not worth failing a deploy over, so - // the timestamp collapses to the epoch instead. + // A pre-1970 mtime is negative and not representable in a USTAR octal field — + // `(-1).toString(8)` renders to exactly the field width, sailing past the width check and + // shipping a header GNU tar rejects. A botched `touch` isn't worth failing a deploy over. test("packages a file with a pre-epoch mtime, timestamped at the epoch", async () => { const file = join(dir, "a.txt"); writeFileSync(file, "a"); @@ -189,9 +183,8 @@ describe("packageWorkerDirectory", () => { const result = await pack(dir); const entry = readEntries(result.archive).find((candidate) => candidate.path === "a.txt"); - // Some filesystems refuse a pre-epoch timestamp and clamp it on the way in, - // in which case there is nothing to collapse — either way the field has to - // be a plain octal number the archive can carry. + // Some filesystems clamp a pre-epoch timestamp on the way in, leaving nothing to + // collapse — either way the field must be a plain octal number the archive can carry. const stored = statSync(file).mtimeMs; expect(entry?.mtime).toBe(stored < 0 ? "00000000000" : expectedOctalMtime(stored)); }); @@ -203,9 +196,8 @@ describe("packageWorkerDirectory", () => { expect(result.fileCount).toBe(0); }); - // Archiving an unreadable file as zero bytes would report success for a deploy - // carrying an empty file. Failing is the only honest answer: the archive is the - // application. + // Archiving an unreadable file as zero bytes would report success for a deploy carrying an + // empty file — failing is the only honest answer, since the archive is the application. test("fails rather than archiving a file it cannot read as empty", async () => { const unreadable = join(dir, "secret.txt"); writeFileSync(unreadable, "important"); @@ -243,9 +235,9 @@ describe("packageWorkerDirectory", () => { }); }); -// `createTar` throws for a name USTAR cannot represent. Called directly inside -// the generator that became a defect, which `withJsonErrorHandling` does not -// catch — so `--output-format json` would have died with no structured error. +// `createTar` throws for a name USTAR can't represent, which becomes a defect +// `withJsonErrorHandling` doesn't catch — `--output-format json` would die with no structured +// error. describe("packageWorkerDirectory tar limits", () => { let dir: string; @@ -266,19 +258,17 @@ describe("packageWorkerDirectory tar limits", () => { ); expect(Exit.isFailure(exit)).toBe(true); - // A failure, not a defect: the difference is whether the JSON error handler - // ever sees it. `Exit.isFailure` alone does not say which, since a defect - // exits that way too — the cause is what tells them apart. + // A failure, not a defect — `Exit.isFailure` alone doesn't distinguish them since a + // defect exits that way too; the cause is what tells them apart. expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(false); expect(Exit.isFailure(exit) && Cause.hasFails(exit.cause)).toBe(true); expect(JSON.stringify(exit)).toContain("TarPathTooLong"); }); - // The other half of the same rule. `TarFieldOutOfRangeError` declares itself - // user-actionable too, and that declaration can only take effect if the error - // reaches the failure channel rather than being rethrown as a defect. An 8 GiB - // file trips it through the size field; a far-future mtime is the same check - // for the price of a `utimes` call. + // The other half of the same rule: `TarFieldOutOfRangeError`'s user-actionable + // classification only takes effect if it reaches the failure channel rather than being + // rethrown as a defect. A far-future mtime trips the same check as an 8 GiB file, for the + // price of a `utimes` call. test("reports an out-of-range header field as a failure rather than a defect", async () => { const file = join(dir, "a.txt"); writeFileSync(file, "contents"); From 0374a15d6e58a69b8e2ab71760f7854c5550e44c Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:03:24 +0100 Subject: [PATCH 32/56] chore(repo): fix stale comment pointers and tighten long headers (final pass 1) --- .../src/command-internal/db-config.service.ts | 6 +----- .../src/command-internal/docker-lifecycle.ts | 6 ++---- apps/cli/src/command-internal/linked-state.ts | 18 ++++++----------- .../command-internal/start-secrets-cleanup.ts | 20 +++++++------------ .../commands/db/advisors/advisors.command.ts | 2 +- apps/cli/src/commands/db/diff/diff.command.ts | 2 +- .../schema/declarative/declarative.errors.ts | 5 ----- .../src/commands/storage/storage.errors.ts | 4 +--- .../cli/src/shared/workers/worker-logs-api.ts | 10 +++++----- 9 files changed, 24 insertions(+), 49 deletions(-) diff --git a/apps/cli/src/command-internal/db-config.service.ts b/apps/cli/src/command-internal/db-config.service.ts index 76cab7ed47..18de77b180 100644 --- a/apps/cli/src/command-internal/db-config.service.ts +++ b/apps/cli/src/command-internal/db-config.service.ts @@ -72,11 +72,7 @@ interface DbConfigResolverShape { ) => Effect.Effect<Option.Option<PgConnInput>, DbConfigError>; } -/** - * Resolves a Postgres connection from the `--db-url` / `--local` / `--linked` - * flags. Shared cross-command infra: - * `db reset` / `db dump` will reuse it as they are ported. - */ +/** Resolves a Postgres connection from the `--db-url` / `--local` / `--linked` flags. */ export class DbConfigResolver extends Context.Service<DbConfigResolver, DbConfigResolverShape>()( "supabase/cli/DbConfigResolver", ) {} diff --git a/apps/cli/src/command-internal/docker-lifecycle.ts b/apps/cli/src/command-internal/docker-lifecycle.ts index 8976f0560f..fc9bb16d86 100644 --- a/apps/cli/src/command-internal/docker-lifecycle.ts +++ b/apps/cli/src/command-internal/docker-lifecycle.ts @@ -213,8 +213,7 @@ export const inspectContainerState = (spawner: Spawner, containerId: string) => }); }), ); - // Concurrency is required, not cosmetic — see the matching comment in - // `listContainersByLabel` above. + // Concurrency is required, not cosmetic; see `spawnDockerPsLines` above. const [exitCode, stdout, stderr] = yield* Effect.all( [ child.exitCode.pipe(Effect.map(Number)), @@ -300,8 +299,7 @@ export const listVolumesByLabel = (spawner: Spawner, projectIdFilter: string) => }), ), ); - // Concurrency is required, not cosmetic — see the matching comment in - // `listContainersByLabel` above. + // Concurrency is required, not cosmetic; see `spawnDockerPsLines` above. const [exitCode, stdout, stderr] = yield* Effect.all( [ child.exitCode.pipe(Effect.map(Number)), diff --git a/apps/cli/src/command-internal/linked-state.ts b/apps/cli/src/command-internal/linked-state.ts index fd994c53f0..81f52cde6b 100644 --- a/apps/cli/src/command-internal/linked-state.ts +++ b/apps/cli/src/command-internal/linked-state.ts @@ -32,19 +32,13 @@ export type LinkedState = }; /** - * Soft "currently linked ref" lookup: env `SUPABASE_PROJECT_ID`, else the - * `<workdir>/supabase/.temp/project-ref` file; never prompts, never fails. - * Deliberately independent of `ProjectRefResolver`, so this stays usable from - * a runtime (e.g. `status`'s) that never wires that resolver up. + * Soft lookup of the currently linked ref: env `SUPABASE_PROJECT_ID`, else the + * `<workdir>/supabase/.temp/project-ref` file. Never prompts or fails, and does not depend on + * `ProjectRefResolver`, so runtimes like `status` can use it without wiring that resolver up. * - * Both candidates are validated against `PROJECT_REF_PATTERN`: this value is - * emitted verbatim by `status -o json`/`--output-format json`, and - * `readProjectRefFile` follows symlinks, so an unvalidated candidate could - * exfiltrate a symlinked secret file's contents. A non-matching candidate is - * treated as absent rather than reaching any output channel. - * - * Reports which candidate won, since an env override and the workdir's own - * cache file carry different trust rules. + * Candidates must match `PROJECT_REF_PATTERN`: the value is echoed verbatim by `status -o json` + * and the file read follows symlinks, so an unvalidated value could leak a secret file's contents. + * Reports which source won, since the env override and the cache file carry different trust. */ const resolveSoftLinkedRef = Effect.fnUntraced(function* () { const cliSettings = yield* CommandSettings; diff --git a/apps/cli/src/command-internal/start-secrets-cleanup.ts b/apps/cli/src/command-internal/start-secrets-cleanup.ts index 4bc78693a5..d12847188c 100644 --- a/apps/cli/src/command-internal/start-secrets-cleanup.ts +++ b/apps/cli/src/command-internal/start-secrets-cleanup.ts @@ -6,20 +6,14 @@ import { Effect } from "effect"; import type { ContainerIdName } from "./docker-lifecycle.ts"; /** - * Best-effort removal of per-container staged-secret directories: plaintext secret/env - * material some `start` services stage on host disk. Used by `start`'s rollback and `stop`. - * Only Edge Runtime still stages anything here; an unstaged directory is a harmless no-op. + * Best-effort removal of the per-container staged-secret directories some `start` services put + * on host disk (today only Edge Runtime). Used by `start`'s rollback and by `stop`; never fails. * - * Each container's directory is resolved under its own workdir label, not the caller's - * `fallbackWorkdir` (used only when a container's label is empty) — otherwise a - * `--project-id`/`--all` teardown could orphan a different project's secrets. - * - * `containers` must be exactly what Docker reported for the just-completed teardown, never - * reconstructed or a pre-teardown snapshot — a container that failed to be removed must keep - * its secrets, and this also avoids ever deleting the whole `start-secrets/` parent. - * - * Never fails. `container.name` is external `docker ps` metadata, so the resolved candidate - * must be a direct child of the staging root before deletion — this also covers an empty name. + * Each directory is resolved under the container's own workdir label (`fallbackWorkdir` only + * when the label is empty) so a `--project-id`/`--all` teardown can't orphan another project's + * secrets, and it must be a direct child of the staging root because `container.name` is + * 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( containers: ReadonlyArray<ContainerIdName>, diff --git a/apps/cli/src/commands/db/advisors/advisors.command.ts b/apps/cli/src/commands/db/advisors/advisors.command.ts index 5abc8b5b1e..4c2dd468ba 100644 --- a/apps/cli/src/commands/db/advisors/advisors.command.ts +++ b/apps/cli/src/commands/db/advisors/advisors.command.ts @@ -20,7 +20,7 @@ const config = { Flag.withDescription("Checks the local database for issues."), Flag.withDefault(false), ), - // TS-only override of the linked project ref — see push.command.ts. + // Overrides the linked project ref; the same flag exists on `config push`. projectRef: Flag.string("project-ref").pipe( Flag.withDescription("Project ref of the Supabase project."), Flag.optional, diff --git a/apps/cli/src/commands/db/diff/diff.command.ts b/apps/cli/src/commands/db/diff/diff.command.ts index 4c0bfacba7..3fff3ac3d8 100644 --- a/apps/cli/src/commands/db/diff/diff.command.ts +++ b/apps/cli/src/commands/db/diff/diff.command.ts @@ -71,7 +71,7 @@ const config = { Flag.withDescription("Diffs local migration files against the local database."), Flag.optional, ), - // TS-only override of the linked project ref — see push.command.ts. + // Overrides the linked project ref; the same flag exists on `config push`. projectRef: Flag.string("project-ref").pipe( Flag.withDescription("Project ref of the Supabase project."), Flag.optional, diff --git a/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts b/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts index 7ba8e65309..8c3f4541a7 100644 --- a/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts +++ b/apps/cli/src/commands/db/schema/declarative/declarative.errors.ts @@ -138,8 +138,3 @@ export function readErrorSuggestion(error: unknown): string | undefined { const { suggestion } = error as { suggestion: unknown }; return typeof suggestion === "string" ? suggestion : undefined; } - -/** - * Materializing the declarative export on disk failed: `"failed to clean declarative schema - * directory: " + err` or `"unsafe declarative export path: " + path`. - */ diff --git a/apps/cli/src/commands/storage/storage.errors.ts b/apps/cli/src/commands/storage/storage.errors.ts index 1b63a54e8c..1070eb9898 100644 --- a/apps/cli/src/commands/storage/storage.errors.ts +++ b/apps/cli/src/commands/storage/storage.errors.ts @@ -152,9 +152,7 @@ export class StorageFileError extends Data.TaggedError("StorageFileError")<{ } } -/** - * Both `--linked` and `--local` set — mutually exclusive. - */ +/** Conflicting target flags: `--linked` with `--local`, or `--project-ref` with `--local`. */ export class StorageMutuallyExclusiveFlagsError extends Data.TaggedError( "StorageMutuallyExclusiveFlagsError", )<{ diff --git a/apps/cli/src/shared/workers/worker-logs-api.ts b/apps/cli/src/shared/workers/worker-logs-api.ts index 80829e7370..c2a5b13e64 100644 --- a/apps/cli/src/shared/workers/worker-logs-api.ts +++ b/apps/cli/src/shared/workers/worker-logs-api.ts @@ -40,11 +40,6 @@ export interface WorkerLogEntry { readonly attributes: Readonly<Record<string, string>>; } -/** - * The row shape the projection in `workerLogsQuery` produces. `stream` stays a plain string - * and `log_attributes` an open record because the log contract is additive-only: a new stream - * or attribute must render, not fail the whole read. - */ /** * The widest instant a JavaScript `Date` can hold, either side of the epoch. `ts_ms` feeds * `new Date(...).toISOString()` unconditionally when a payload is built; outside this range @@ -53,6 +48,11 @@ export interface WorkerLogEntry { */ const MAX_DATE_MS = 8_640_000_000_000_000; +/** + * The row shape the projection in `workerLogsQuery` produces. `stream` stays a plain string + * and `log_attributes` an open record because the log contract is additive-only: a new stream + * or attribute must render, not fail the whole read. + */ const WorkerLogRow = Schema.Struct({ id: Schema.String, ts_ms: Schema.Number.pipe( From 75d6322b8cbf997955b1920677a2200effc37184 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:03:49 +0100 Subject: [PATCH 33/56] chore(repo): trim comments, shard 42 (845 -> 597 comment lines) --- apps/cli/tests/helpers/mocks.ts | 54 +++----- apps/cli/tests/helpers/npm-registry.ts | 43 +++---- apps/cli/tests/helpers/stack-e2e-cleanup.ts | 12 +- apps/cli/tests/helpers/storage.ts | 29 ++--- apps/cli/tests/helpers/workers.ts | 30 ++--- apps/cli/tests/smoke-test-linux.ts | 10 +- apps/cli/tests/smoke-test-macos.ts | 15 +-- apps/cli/tests/smoke-test-windows.ts | 9 -- apps/cli/vitest.config.ts | 15 +-- packages/api/scripts/download-openapi.ts | 39 +++--- packages/api/scripts/generate.ts | 57 +++------ .../generated-output-sync.unit.test.ts | 22 ++-- packages/api/src/effect.unit.test.ts | 9 +- .../src/generated-contract-sync.unit.test.ts | 10 +- packages/api/src/internal/client.ts | 29 ++--- packages/api/src/internal/client.unit.test.ts | 11 +- packages/cli-test-helpers/src/harness.ts | 21 ++-- .../scripts/build-artifacts.unit.test.ts | 8 +- packages/config/scripts/build.ts | 81 ++++-------- .../config/scripts/json-schema-postprocess.ts | 43 +++---- packages/config/scripts/release-plan.ts | 38 +++--- .../scripts/semantic-release-path-filter.ts | 78 +++++------- .../semantic-release-path-filter.unit.test.ts | 5 +- packages/config/src/auth/email.ts | 13 +- packages/config/src/auth/providers.ts | 11 +- packages/config/src/auth/sms.ts | 15 +-- packages/config/src/base.ts | 17 +-- packages/config/src/bun.ts | 10 +- packages/config/src/config-diff.ts | 118 ++++++------------ 29 files changed, 292 insertions(+), 560 deletions(-) diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index b1fb6955ac..00ea574274 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -37,10 +37,6 @@ import { CurrentAnalyticsContext } from "../../src/shared/telemetry/analytics-co import { TelemetryRuntime } from "../../src/shared/telemetry/runtime.service.ts"; import { makeTelemetryIdentity } from "../../src/shared/telemetry/identity.ts"; -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - type OutputMessage = { type: "intro" | "outro" | "info" | "warn" | "error" | "success" | "fail"; message: string; @@ -59,22 +55,15 @@ type OutputEvent = { [key: string]: unknown; }; -// Default home for mocks that need *some* path value. Unique per process (never -// created on disk here) so a test that accidentally combines this default with a -// real FileSystem layer can never pick up stale files written by earlier test -// runs or manual CLI invocations — the failure mode the previous fixed literal -// `/tmp/supabase-cli-test-home` allowed. Tests that really read or write files -// under homeDir must pass their own per-test temp dir instead (see -// `useTempWorkdir` in `command-mocks.ts`). +// Default home for mocks that need *some* path value; unique per process and never +// created on disk, so a test combining it with a real FileSystem layer can't collide +// with another run's files. Tests that read/write real files under homeDir should use +// `useTempWorkdir` in `command-mocks.ts` instead. const defaultTestHomeDir = join( tmpdir(), `supabase-cli-test-home-${process.pid.toString(36)}-${Math.random().toString(36).slice(2, 8)}`, ); -// --------------------------------------------------------------------------- -// Stateless mocks -// --------------------------------------------------------------------------- - export function mockBrowser(): Layer.Layer<Browser> { return Layer.succeed(Browser, { open: () => Effect.void, @@ -105,9 +94,8 @@ export function mockStdin(isTTY: boolean, pipedInput?: string | Uint8Array): Lay ? Option.some(new TextDecoder().decode(pipedBytes.value)) : Option.none<string>(); - // Split the piped input into lines, dropping the trailing empty element left by a - // final newline (production `Stream.splitLines` emits no line after the terminating - // newline); interior blank lines are preserved. + // Drops the trailing empty element a final newline leaves behind, matching + // production `Stream.splitLines`; interior blank lines are preserved. const lines = Option.isSome(pipedText) ? pipedText.value.split(/\r?\n/u) : []; if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); let lineIndex = 0; @@ -119,9 +107,8 @@ export function mockStdin(isTTY: boolean, pipedInput?: string | Uint8Array): Lay ? Stream.fromIterable([pipedBytes.value]) : Stream.empty, readPipedText: Effect.succeed(pipedText), - // Dispenses the piped lines one per call (trimmed), then None once exhausted — - // the timeout is irrelevant to a fixed mock. Mirrors the production persistent - // reader so a command issuing several prompts reads successive lines. + // Ignores any timeout argument; dispenses piped lines one per call (trimmed), + // then None once exhausted. readLine: () => Effect.sync(() => { if (lineIndex >= lines.length) { @@ -213,10 +200,6 @@ export function mockProcessControl( }; } -// --------------------------------------------------------------------------- -// Stateful mock factories -// --------------------------------------------------------------------------- - export function mockCredentials(opts: { existingToken?: string } = {}) { let savedToken: string | undefined; let deleteWasCalled = false; @@ -407,12 +390,12 @@ export function mockOutput( ) => { callCount++; promptTextCalls.push({ message, opts: options }); - // Exercise the validate callback to cover both branches (line 140) + // Runs the validate callback so both branches get coverage. if (options?.validate) { - options.validate(""); // truthy branch: returns error message - options.validate("123456"); // falsy branch: returns undefined + options.validate(""); // returns an error message + options.validate("123456"); // returns undefined } - // Fail on the verification prompt (2nd call), not the "Press Enter" prompt (1st call) + // Fails the verification prompt (2nd call), not the "Press Enter" prompt (1st call). if (opts.promptTextFail && callCount > 1) { return Effect.fail( new NonInteractiveError({ @@ -535,13 +518,8 @@ export function mockApi( } /** - * `withCommandTelemetry` threads `flags`/`command`/etc. through - * `CurrentAnalyticsContext`, not the direct `capture()` call args. The plain - * `mockAnalytics()` below deliberately doesn't merge that context (most - * callers don't need it); tests asserting on context-carried properties - * (telemetry `flags` maps, `groups`) use this variant instead. - * Shape-compatible with `mockAnalytics()`'s return so it's a drop-in - * override wherever a setup helper accepts one. + * Like `mockAnalytics()`, but merges `CurrentAnalyticsContext` into captured event + * properties. Use it when asserting on context-carried fields (`flags`, `groups`). */ export function mockContextualAnalytics(): ReturnType<typeof mockAnalytics> { const captured: Array<{ event: string; properties: Record<string, unknown> }> = []; @@ -670,10 +648,6 @@ export function mockTelemetryRuntime( ); } -// --------------------------------------------------------------------------- -// Environment helpers -// --------------------------------------------------------------------------- - function applyProcessEnv(values: Readonly<Record<string, string | undefined>>) { const snapshot = { ...process.env }; diff --git a/apps/cli/tests/helpers/npm-registry.ts b/apps/cli/tests/helpers/npm-registry.ts index 97cb0e1132..0d2f30a4af 100644 --- a/apps/cli/tests/helpers/npm-registry.ts +++ b/apps/cli/tests/helpers/npm-registry.ts @@ -57,9 +57,7 @@ async function startVerdaccio( try { const res = await fetch(`${url}/-/ping`); if (res.ok) return { url, [Symbol.asyncDispose]: async () => proc.kill() }; - } catch { - // not ready yet - } + } catch {} await Bun.sleep(500); } @@ -140,9 +138,7 @@ async function dumpInstalledTree(testDir: string, ext: string): Promise<void> { const bp = path.join(binDir, b); console.log(` bin/${b}: ${await describePath(bp)}`); } - } catch { - // no bin/ - } + } catch {} } } @@ -159,9 +155,7 @@ async function findPlatformBinary(testDir: string, ext: string): Promise<string try { await stat(candidate); return candidate; - } catch { - // not this one - } + } catch {} } return null; } @@ -234,10 +228,9 @@ export async function runNpmTest( const configPath = path.join(tmp.path, "config.yaml"); const storageDir = path.join(tmp.path, "storage"); - // Verdaccio config: store our published tarballs locally. The umbrella - // package is shim-only at runtime and should resolve only our own - // `@supabase/cli-*` optional dependencies from this registry; the public npm - // uplink is retained for npm installer internals and any incidental tooling. + // Stores our published tarballs; the umbrella package's `@supabase/cli-*` + // optional deps resolve here, while the public npm uplink stays available + // for npm installer internals and other tooling. await writeFile( configPath, `storage: ${storageDir} @@ -265,9 +258,8 @@ listen: 0.0.0.0:${PORT} ); // pnpm publish delegates to npm internals, which only honor per-registry auth - // configured in an .npmrc — `NPM_CONFIG_TOKEN` is not consulted. Write a temp - // .npmrc with `_authToken` for the verdaccio host and point npm at it via - // `npm_config_userconfig` so every publish call sees credentials. + // in an .npmrc (not `NPM_CONFIG_TOKEN`); write one and point npm at it via + // `npm_config_userconfig`. const publishNpmrc = path.join(tmp.path, "publish.npmrc"); await writeFile(publishNpmrc, `//localhost:${PORT}/:_authToken=dummy\n`); const publishEnv = { ...process.env, npm_config_userconfig: publishNpmrc }; @@ -298,14 +290,12 @@ listen: 0.0.0.0:${PORT} } } - // Inspect what Verdaccio actually received — directly answers whether - // `publishConfig.executableFiles` is being applied to the published tarball. + // Confirms `publishConfig.executableFiles` made it into the published tarball. console.log("\nVerdaccio tarball contents (bin entries only):"); for (const pkg of platformPackages) { await inspectVerdaccioTarball(storageDir, pkg); } - // Build and publish umbrella package const cliDir = path.join(root, "apps", "cli"); console.log("\nBuilding umbrella package shim..."); await $`pnpm build:shim`.cwd(cliDir).quiet(); @@ -336,7 +326,6 @@ listen: 0.0.0.0:${PORT} console.log(` <no umbrella tarball storage at ${umbrellaStorage}>`); } - // Create test project const testDir = path.join(tmp.path, "test-project"); await mkdir(testDir); await writeFile( @@ -348,17 +337,14 @@ listen: 0.0.0.0:${PORT} `registry=${registry.url}\n//localhost:${PORT}/:_authToken=dummy\n`, ); - // Install. Pass --registry explicitly: in some environments (notably ones - // where pnpm has set `npm_config_*` env vars) those override the project - // .npmrc, and `npm install supabase` silently fetches from registry.npmjs.org - // instead — the test then accidentally exercises the published 2.x CLI rather - // than the umbrella we just packed. The CLI flag wins over both env vars and - // .npmrc, so it is the only resolution path that is actually safe here. + // Pass --registry explicitly: pnpm-set `npm_config_*` env vars can override the + // project .npmrc and silently fetch from registry.npmjs.org instead, installing + // the published CLI rather than the umbrella just packed. The CLI flag wins over + // both env vars and .npmrc. const installSpec = tag === "latest" ? umbrellaName : `${umbrellaName}@${tag}`; console.log(`\nInstalling ${installSpec}...`); await $`npm install --registry ${registry.url} ${installSpec}`.cwd(testDir); - // Verify console.log("\nVerifying..."); const ext = process.platform === "win32" ? ".cmd" : ""; const binPath = path.join(testDir, "node_modules", ".bin", `supabase${ext}`); @@ -375,8 +361,7 @@ listen: 0.0.0.0:${PORT} console.log(` stdout=${JSON.stringify(versionResult.stdout)}`); console.log(` stderr=${JSON.stringify(versionResult.stderr)}`); - // Isolate "shim broken" vs "platform binary broken" by trying the - // platform binary directly. + // Isolate "shim broken" from "platform binary broken". const platformBin = await findPlatformBinary(testDir, ext); if (platformBin) { console.log(`\n[verify] retrying via platform binary: ${platformBin}`); diff --git a/apps/cli/tests/helpers/stack-e2e-cleanup.ts b/apps/cli/tests/helpers/stack-e2e-cleanup.ts index e7ff18ff32..bab2076695 100644 --- a/apps/cli/tests/helpers/stack-e2e-cleanup.ts +++ b/apps/cli/tests/helpers/stack-e2e-cleanup.ts @@ -475,15 +475,9 @@ export function createStackE2eCleanupManager( } } - // Cleanup of leaked stack resources is best-effort: assertions in the - // test itself have already passed by the time `drain()` runs, and CI - // runners are ephemeral so a leaked temp dir doesn't affect - // correctness. Surface the details so developers can still see them - // locally, but don't fail the test (in particular: `functions dev` - // leaves root-owned files from edge-runtime's docker container that - // the runner user cannot unlink, and the docker fallback may itself - // fail in environments where the daemon is unreachable from the - // sandbox). + // Cleanup failures are best-effort and don't fail the test: assertions already + // ran, CI runners are ephemeral, and cases like `functions dev`'s root-owned + // docker files can make cleanup itself impossible. if (failures.length > 0) { console.warn( `[stack-e2e-cleanup] ${failures.length} resource(s) could not be cleaned up:\n${failures.join("\n")}`, diff --git a/apps/cli/tests/helpers/storage.ts b/apps/cli/tests/helpers/storage.ts index 467fb81d7c..30d3bd0885 100644 --- a/apps/cli/tests/helpers/storage.ts +++ b/apps/cli/tests/helpers/storage.ts @@ -27,10 +27,9 @@ import { /** * One Storage gateway / Management API mock route. * - * Routes are matched in registration order and **consumed** once matched (like - * gock mocks), so pagination and recursive flows register one route per expected - * call. `when` further narrows a match by the parsed request body (e.g. paging by - * `offset`). `persist` keeps a route matchable across calls (e.g. api-keys). + * Routes match in registration order and are consumed once matched, so pagination + * or recursive flows need one route per expected call unless `persist` is set. + * `when` narrows a match by the parsed request body. */ export interface StorageRoute { readonly method: string; @@ -89,11 +88,10 @@ export interface SetupStorageOptions { } /** - * Builds the layer + recorded state for a `storage` command integration test. - * Mirrors the seed-buckets setup: a recording `HttpClient` for the Storage - * gateway, a config.toml on disk, a project-ref resolver, a lazy Management API - * factory (api-keys), tracked telemetry + linked-project cache, and the - * `--local` scoped-global flag value. + * Builds the layer and recorded state for a `storage` command integration test: + * a recording `HttpClient` for the Storage gateway, an on-disk `config.toml`, + * project-ref resolution, tracked telemetry/linked-project cache, and a lazy + * Management API factory for api-keys. */ export function setupStorage(workdir: string, opts: SetupStorageOptions) { if (opts.toml !== undefined) { @@ -178,9 +176,8 @@ export function setupStorage(workdir: string, opts: SetupStorageOptions) { resolveForLink: () => opts.linkedFails === true ? Effect.fail(notLinked()) : Effect.succeed(projectRefRef), resolveOptional: () => Effect.succeed(Option.some(projectRefRef)), - // Gives an explicit `--project-ref` flag top precedence, same as Go's - // `flags.LoadProjectRef` — short-circuits BEFORE `linkedFails`, so a test - // can prove the flag resolves a ref even for an "unlinked" workdir. + // An explicit `--project-ref` flag takes precedence and short-circuits before + // `linkedFails`, so a test can resolve a ref even for an "unlinked" workdir. loadProjectRef: (flagValue: Option.Option<string>) => Option.isSome(flagValue) && flagValue.value.length > 0 ? Effect.succeed(flagValue.value) @@ -216,15 +213,15 @@ export function setupStorage(workdir: string, opts: SetupStorageOptions) { make: CommandPlatformApi.pipe(Effect.provide(managementApi.layer)), }), Layer.succeed(YesFlag, opts.yes ?? false), - // `storage rm` confirms deletions via `promptYesNo`; model an - // interactive user answering via `confirm` (other storage commands ignore it). + // `storage rm` confirms deletions via `promptYesNo`, answered by `confirm`; + // other storage commands ignore it. mockTty({ stdinIsTty: opts.stdinIsTty ?? true, stdoutIsTty: false }), mockStdin( opts.stdinIsTty ?? true, opts.pipedAnswers ? `${opts.pipedAnswers.join("\n")}\n` : undefined, ), - // `cp` resolves relative local paths against the original cwd (Go's - // `utils.CurrentDirAbs`); point it at the temp workdir for tests. + // `cp` resolves relative local paths against the original cwd; point it at + // the temp workdir for tests. mockRuntimeInfo({ cwd: workdir }), // `resolveYes` scans the raw argv for an explicit `--yes=false`. Layer.succeed(CliArgs, { args: opts.cliArgs ?? [] }), diff --git a/apps/cli/tests/helpers/workers.ts b/apps/cli/tests/helpers/workers.ts index 6d167500fe..1a0b14c95a 100644 --- a/apps/cli/tests/helpers/workers.ts +++ b/apps/cli/tests/helpers/workers.ts @@ -22,10 +22,9 @@ import { mockOutput, mockProcessControl, mockRuntimeInfo, mockTty } from "./mock /** * Shared scaffolding for the `supabase experimental workers` command integration tests. * - * Every worker command reads a real `supabase/config.toml` and a real worker - * directory, so these tests run against a per-test temp project rather than a - * mocked filesystem — the config-writing and packaging behaviour is most of - * what is worth asserting. Only the network is faked. + * Every worker command reads a real `supabase/config.toml` and worker directory, + * so these tests run against a per-test temp project instead of a mocked + * filesystem; only the network is faked. */ export const WORKERS_PROJECT_REF = "abcdefghijklmnopqrst"; @@ -34,10 +33,8 @@ export interface RecordedRequest { readonly method: string; readonly url: string; /** - * Query parameters, which `url` does not carry. - * - * `HttpClientRequest` keeps `urlParams` beside the URL rather than appended to - * it, so a test asserting what a GET actually asked for has to read this. The + * Query parameters, which `url` does not carry — `HttpClientRequest` keeps them + * separate, so a test asserting what a GET asked for must read this. The * analytics logs endpoint puts the whole SQL query here. */ readonly urlParams: Readonly<Record<string, string>>; @@ -218,12 +215,8 @@ export const workerLogsRoute = () => `/v1/projects/${WORKERS_PROJECT_REF}/analyt /** * One row as the logs endpoint returns it, matching the projection in - * `workerLogsQuery`. - * - * Shaped from rows captured off a real project (see - * `scratch/FINDINGS-worker-logs.md`), which is why `log_attributes` values are - * all strings: the column is a `Map(String, String)`, so `status` really does - * arrive as `"200"`. + * `workerLogsQuery`. `log_attributes` values are all strings — the column is a + * `Map(String, String)`, so `status` arrives as `"200"`. */ export function workerLogRow(options: { readonly id?: string; @@ -265,8 +258,7 @@ export function workerIngressLogRow(options: { ...(options.tsMs === undefined ? {} : { tsMs: options.tsMs }), ...(options.worker === undefined ? {} : { worker: options.worker }), stream: "worker_ingress_logs", - // Only method and path — status and duration are deliberately absent, as - // they are on the wire. + // Only method and path go in the message, matching the wire format. message: `${method} ${path}`, attributes: { method, @@ -357,9 +349,9 @@ export interface WorkersSetupOptions { readonly format?: "text" | "json" | "stream-json"; readonly interactive?: boolean; /** - * Whether stdin is a terminal. Defaults to `interactive`, so a text-mode test - * can prompt; set it false to model a piped stdin with a TTY stdout, which is - * what `printf 'api\n' | supabase experimental workers delete api` looks like. + * Whether stdin is a terminal; defaults to `interactive`. Set false to model a + * piped stdin with a TTY stdout, as in `printf 'api\n' | supabase experimental + * workers delete api`. */ readonly stdinIsTty?: boolean; readonly linked?: boolean; diff --git a/apps/cli/tests/smoke-test-linux.ts b/apps/cli/tests/smoke-test-linux.ts index 4cf88235de..bc569257d2 100644 --- a/apps/cli/tests/smoke-test-linux.ts +++ b/apps/cli/tests/smoke-test-linux.ts @@ -30,14 +30,12 @@ interface TestResult { const results: TestResult[] = []; -// --- Native --- - console.log(`\n${"=".repeat(60)}`); console.log("Native binary tests"); console.log("=".repeat(60)); { - const arch = process.arch; // "x64" or "arm64" + const arch = process.arch; const name = `native-linux-${arch}`; const binPath = path.join(root, "packages", `cli-linux-${arch}`, "bin", "supabase"); @@ -56,8 +54,6 @@ console.log("=".repeat(60)); } } -// --- Docker --- - console.log(`\n${"=".repeat(60)}`); console.log("Docker-based Linux package tests"); console.log("=".repeat(60)); @@ -159,8 +155,6 @@ if (!hasDocker) { } } -// --- npm --- - console.log(`\n${"=".repeat(60)}`); console.log("npm (Verdaccio) test"); console.log("=".repeat(60)); @@ -173,8 +167,6 @@ try { results.push({ name: "npm", status: "fail" }); } -// --- Summary --- - console.log(`\n${"=".repeat(60)}`); console.log("Linux Smoke Test Summary"); console.log("=".repeat(60)); diff --git a/apps/cli/tests/smoke-test-macos.ts b/apps/cli/tests/smoke-test-macos.ts index 5dc50b3ade..b55d6f84e4 100644 --- a/apps/cli/tests/smoke-test-macos.ts +++ b/apps/cli/tests/smoke-test-macos.ts @@ -30,14 +30,12 @@ interface TestResult { const results: TestResult[] = []; -// --- Native --- - console.log(`\n${"=".repeat(60)}`); console.log("Native binary tests"); console.log("=".repeat(60)); { - const arch = process.arch; // "arm64" or "x64" + const arch = process.arch; const name = `native-darwin-${arch}`; const binPath = path.join(root, "packages", `cli-darwin-${arch}`, "bin", "supabase"); @@ -56,10 +54,8 @@ console.log("=".repeat(60)); } } -// --- Native signature --- - { - const arch = process.arch; // "arm64" or "x64" + const arch = process.arch; const binDir = path.join(root, "packages", `cli-darwin-${arch}`, "bin"); const binaries = ["supabase"]; if (existsSync(path.join(binDir, "supabase-go"))) { @@ -81,8 +77,6 @@ console.log("=".repeat(60)); } } -// --- npm --- - console.log(`\n${"=".repeat(60)}`); console.log("npm (Verdaccio) test"); console.log("=".repeat(60)); @@ -95,8 +89,6 @@ try { results.push({ name: "npm", status: "fail" }); } -// --- Brew --- - console.log(`\n${"=".repeat(60)}`); console.log("Homebrew test"); console.log("=".repeat(60)); @@ -110,7 +102,6 @@ if (!hasBrew) { console.log("[brew] SKIP — brew not found"); } else { try { - // Generate the formula with local file:// URLs console.log("Generating Homebrew formula..."); await $`bun run apps/cli/scripts/update-homebrew.ts --version ${version} --local`.cwd(root); @@ -146,8 +137,6 @@ if (!hasBrew) { } } -// --- Summary --- - console.log(`\n${"=".repeat(60)}`); console.log("macOS Smoke Test Summary"); console.log("=".repeat(60)); diff --git a/apps/cli/tests/smoke-test-windows.ts b/apps/cli/tests/smoke-test-windows.ts index 8b4c26d63c..1785264abd 100644 --- a/apps/cli/tests/smoke-test-windows.ts +++ b/apps/cli/tests/smoke-test-windows.ts @@ -32,8 +32,6 @@ interface TestResult { const results: TestResult[] = []; -// --- Native --- - console.log(`\n${"=".repeat(60)}`); console.log("Native binary tests"); console.log("=".repeat(60)); @@ -59,8 +57,6 @@ const arch = process.arch === "arm64" ? "arm64" : "x64"; } } -// --- Release tarball --- - console.log(`\n${"=".repeat(60)}`); console.log("Release tarball test"); console.log("=".repeat(60)); @@ -91,8 +87,6 @@ console.log("=".repeat(60)); } } -// --- Scoop --- - console.log(`\n${"=".repeat(60)}`); console.log("Scoop test"); console.log("=".repeat(60)); @@ -108,7 +102,6 @@ if (!hasScoop) { const manifest = path.join(root, "dist", "supabase.json"); try { - // Generate the manifest with local file:/// URLs console.log("Generating Scoop manifest..."); await $`bun run apps/cli/scripts/update-scoop.ts --version ${version} --local`.cwd(root); @@ -133,8 +126,6 @@ if (!hasScoop) { } } -// --- Summary --- - console.log(`\n${"=".repeat(60)}`); console.log("Windows Smoke Test Summary"); console.log("=".repeat(60)); diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index 272881864c..ba36ff0776 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -16,16 +16,11 @@ function dockerfileTextPlugin() { }; } -// Workspace packages such as @supabase/config publish a `bun` export -// condition pointing at their TypeScript source (see -// packages/config/package.json's `exports` map); without it, Vite's resolver -// falls through to the `default` condition and loads the built `dist/*.js` -// output instead — which is stale, or missing entirely on a fresh clone -// before the package has been built. Extending (not replacing) Vite's -// default condition lists keeps every other package's exports resolution -// unchanged. Required on every inline `test.projects` entry below too: -// Vitest builds a separate Vite config per project and does not inherit -// these from the root config (see PR #6366 finding 0). +// Workspace packages such as @supabase/config publish a `bun` export condition +// pointing at their TypeScript source; without it, Vite falls through to `default` +// and loads the built `dist/*.js`, which can be stale or missing. Each inline +// `test.projects` entry below needs this too, since Vitest builds a separate Vite +// config per project and does not inherit it from the root config. const workspacePackageResolve = { conditions: [...defaultClientConditions, "bun"] }; const workspacePackageSsrResolve = { conditions: [...defaultServerConditions, "bun"] }; diff --git a/packages/api/scripts/download-openapi.ts b/packages/api/scripts/download-openapi.ts index 521dc83929..fd368bc06d 100644 --- a/packages/api/scripts/download-openapi.ts +++ b/packages/api/scripts/download-openapi.ts @@ -217,13 +217,10 @@ export function applyOpenApiOverrides( continue; } if (override.op === "remove") { - // Deliberate deviation from RFC 6902 (mirroring the existing "add" - // deviation below, which throws when the target key already exists): - // silently ignore removal of a pointer that doesn't exist. This file - // applies to documents that differ between environments — staging's - // /api/v2-json is currently served by two backend variants that - // disagree about whether the webhook paths exist — so a strict - // remove would fail on most staging runs. + // Silently ignores removing a pointer that doesn't exist (a deviation from + // RFC 6902): staging's /api/v2-json is served by two backend variants that + // disagree about whether the webhook paths exist, so a strict remove would + // fail on most staging runs. removeJsonPointerValue(document, override.path); continue; } @@ -390,12 +387,9 @@ export function mergeOpenApiDocuments( } } - // The merged document carries only the keys the generator consumes. - // Upstream extras (`servers`, `tags`, `components.securitySchemes`, …) - // must not reach the snapshot even transiently: generate.ts rewrites the - // file without them, so if they were written here a crash between the two - // steps would leave a plausible-looking openapi.json that disagrees with - // every healthy regeneration. + // Carries only the keys the generator consumes; upstream extras (`servers`, + // `tags`, `components.securitySchemes`, …) are dropped so a crash before + // generate.ts finishes can't leave a stale-looking but inconsistent openapi.json. return { openapi: openapiVersion, info: { title: "Supabase API", version: infoVersion }, @@ -404,10 +398,9 @@ export function mergeOpenApiDocuments( }; } -// Runs AFTER overrides are applied — this ordering is load-bearing. Prod's -// v2 document currently has 20 webhook operations sharing just 2 duplicated -// operationIds, and the overrides remove those paths. Validating before -// overrides were applied would abort every production regeneration. +// Must run after overrides are applied: some upstream webhook operations share +// duplicate operationIds that the overrides remove, so validating first would +// abort regeneration. export function assertMergedOpenApiDocument(document: OpenApiDocument): void { const operationClaims = new Map<string, Array<string>>(); @@ -461,9 +454,8 @@ export function assertMergedOpenApiDocument(document: OpenApiDocument): void { export async function downloadOpenApiSpec(): Promise<void> { const envBaseUrl = process.env.SUPABASE_API_URL; - // The sidecar is consulted only when the environment does not override it, - // so an explicit SUPABASE_API_URL works even when the pin is absent or - // malformed. + // The pin is consulted only when the environment doesn't override it, so an + // explicit SUPABASE_API_URL works even when the pin is absent or malformed. const pinnedBaseUrl = envBaseUrl === undefined ? await loadPinnedBaseUrl() : undefined; const baseUrl = resolveOpenApiBaseUrl({ envBaseUrl, pinnedBaseUrl }); console.log(`Resolved OpenAPI base URL: ${baseUrl}`); @@ -476,10 +468,9 @@ export async function downloadOpenApiSpec(): Promise<void> { console.log(`Fetching ${version} OpenAPI document from ${url}`); const response = await fetch(url); - // Hard-fail on a missing document instead of tolerating it: a 404 on - // /api/v2-json would silently delete the whole v2 namespace from the - // generated client, and the hourly regeneration sync would auto-merge - // that deletion without anyone noticing. + // Hard-fails on a missing document instead of tolerating it: a 404 would + // silently drop the whole v2 namespace, and the hourly regeneration sync + // would auto-merge that deletion unnoticed. if (!response.ok) { throw new Error(`Failed to download OpenAPI spec from ${url}: ${response.status}`); } diff --git a/packages/api/scripts/generate.ts b/packages/api/scripts/generate.ts index da1d57370f..252dbf6305 100644 --- a/packages/api/scripts/generate.ts +++ b/packages/api/scripts/generate.ts @@ -206,37 +206,20 @@ function identifier(value: string): string { return camel[0] ? camel[0].toUpperCase() + camel.slice(1) : camel; } -// OpenAPI 3.0 treats `format: "uuid"` as a hint, not validation. Without a -// concrete `pattern`, the resulting Effect schema's UUID branch has no check, -// so a 20-letter project ref matches both branches of `oneOf [project-ref, uuid]` -// unions (e.g. `branch_id_or_ref`) and validation fails at "Expected exactly one -// member to match". Add the canonical RFC 4122 pattern so the branches become -// mutually exclusive. Mirrored by an inline patch in `contracts.ts` (search -// "Patched: OpenAPI's `format: \"uuid\"`") that survives ad-hoc edits between -// regenerations. +// OpenAPI's `format: "uuid"` is a hint, not validation, so a project-ref string +// can match both branches of a `oneOf [project-ref, uuid]` union unless a +// concrete pattern is added; kept in sync with contracts.ts's inline uuid patch. const UUID_PATTERN = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"; -// Keys that we want to strip from a schema node because they describe -// documentation / example values rather than the value's shape. JSON Schema's -// `default` is a primitive (or array/object) literal used for documentation — -// not part of the type contract — so we drop it during sanitization to keep -// the generated Effect schema lean. +// Keys stripped from a schema node because they describe documentation/example +// values, not the value's shape. const SCHEMA_METADATA_KEYS = new Set(["default", "example", "examples"]); -// Recurses into a schema. The `inPropertiesMap` flag tracks whether the current -// object is the value of a JSON Schema `properties: {...}` map — in that -// context, keys are user-defined property NAMES (which may legitimately be -// literally `"default"`, `"example"`, etc.) and we must NOT strip them. -// -// Without this distinction, an OpenAPI schema like -// { properties: { default: { oneOf: [...] } } } -// would have the `default` field silently dropped during generation, producing -// a TypeScript schema that omits the property. This bit the SAML SSO -// attribute-mapping codegen (each key has `name?`, `names?`, `array?`, and -// `default?: any` per OpenAPI spec; the `default?: any` field was silently -// stripped because of this). The union may be encoded as either `oneOf` or -// `anyOf` by different OpenAPI producers. +// `inPropertiesMap` tracks whether the current object is the value of a +// `properties: {...}` map; there, keys are user-defined property names (which may +// legitimately be `"default"`, `"example"`, etc.) and must not be stripped, or a +// schema like `{ properties: { default: {...} } }` loses that property entirely. function isArbitraryJsonDefault(schema: OpenApiSchema): boolean { const members = schema.oneOf ?? schema.anyOf; if (members?.length !== 4) { @@ -304,11 +287,9 @@ export function sanitizeOpenApiSchema( sanitized.pattern = UUID_PATTERN; } - // The spec's `date-time` patterns reject timestamps the Management API - // itself emits: most are Z-anchored, and even the most permissive variant - // rejects offset-less values and the lowercase `t`/`z` RFC 3339 §5.6 allows - // (supabase/cli#6115). Keeping any of them means owning a guess about every - // shape the API may serialize, so keep `format` and drop the pattern. + // The spec's `date-time` pattern rejects timestamps the Management API actually + // emits (offset-less values, lowercase `t`/`z`); keep `format` but drop the + // pattern rather than guessing every shape the API may serialize. if (sanitized.type === "string" && sanitized.format === "date-time") { delete sanitized.pattern; } @@ -636,10 +617,9 @@ export function operationVersionFromPath(path: string): string { } // Strips a leading version prefix (e.g. `v1`/`V2`) from a camelized operation -// name, lowercasing the character that follows it, so `v2GetProjectConfig` -// becomes `getProjectConfig`. Operation names without a version prefix are -// returned unchanged — the path, not the operationId, is the authority on -// version. +// name and lowercases the next character, so `v2GetProjectConfig` becomes +// `getProjectConfig`. Names without a prefix are returned unchanged — the path, +// not the operationId, is the authority on version. export function operationMethodName(operationName: string): string { const match = /^([vV]\d+)(.*)$/u.exec(operationName); if (!match) { @@ -655,10 +635,9 @@ export function operationMethodName(operationName: string): string { return `${first}${methodBase.slice(1)}`; } -// A version prefix on the operationId is optional, but when present it must -// agree with the path-derived version — otherwise the generated namespace -// (from the path) and the SDK method name (from the operationId) would imply -// different API versions for the same operation. +// A version prefix on the operationId is optional, but when present must agree +// with the path-derived version, or the generated namespace and the SDK method +// name would imply different API versions for the same operation. function assertOperationVersionAgreement(operation: { readonly operationId: string; readonly operationName: string; diff --git a/packages/api/scripts/generated-output-sync.unit.test.ts b/packages/api/scripts/generated-output-sync.unit.test.ts index 3428fc974b..355380b25c 100644 --- a/packages/api/scripts/generated-output-sync.unit.test.ts +++ b/packages/api/scripts/generated-output-sync.unit.test.ts @@ -6,14 +6,11 @@ import { describe, expect, test } from "vitest"; import { extractOperations, loadSpec, renderContracts, renderEffectClient } from "./generate.ts"; -// Full-fidelity drift guard: re-renders every generated file from the -// committed openapi.json snapshot, formats the result through the same oxfmt -// the pipeline uses, and requires byte equality with the committed files. -// Unlike the operation-level bijection test in src/generated-contract-sync, -// this catches hand edits to schema definitions, parameter lists, request -// bodies, response types, and the executor switch — anything short of -// editing the snapshot and the generated output consistently, which the -// hourly upstream sync then catches. +// Full-fidelity drift guard: re-renders every generated file from the committed +// snapshot, formats it with the same oxfmt the pipeline uses, and requires byte +// equality. Catches hand edits to schema definitions, parameter lists, request +// bodies, response types, and the executor switch, which the operation-level +// bijection test in src/generated-contract-sync doesn't. const scriptDir = path.dirname(fileURLToPath(import.meta.url)); const packageDir = path.join(scriptDir, ".."); @@ -24,11 +21,10 @@ const generatedDir = path.join(packageDir, "src", "generated"); const oxfmtBin = path.join(repoDir, "node_modules", ".bin", "oxfmt"); function formatWithOxfmt(source: string, fileName: string): string { - // oxfmt runs in file mode (also what the pipeline's fmt:fix runs) rather - // than through stdin/stdout: Bun on Linux truncates a child's piped stdout - // at ~219 KB, and these renders are 600+ KB. The temp directory lives - // inside the package so oxfmt resolves the same configuration, but not - // under node_modules, which oxfmt skips by default. + // Runs oxfmt in file mode, not via stdin/stdout: Bun on Linux truncates a + // child's piped stdout at ~219 KB, and these renders are 600+ KB. The temp + // dir lives inside the package (same oxfmt config) but outside node_modules, + // which oxfmt skips by default. const tempDir = mkdtempSync(path.join(packageDir, ".generated-output-sync-")); try { const tempFile = path.join(tempDir, fileName); diff --git a/packages/api/src/effect.unit.test.ts b/packages/api/src/effect.unit.test.ts index dc54c794a5..b495314bab 100644 --- a/packages/api/src/effect.unit.test.ts +++ b/packages/api/src/effect.unit.test.ts @@ -60,11 +60,9 @@ const config = { } as const; describe("SSO provider contracts", () => { - // The provider payload the Management API actually returns: no `saml.id` and - // no `domains[].id` — neither field exists in the spec (or in the Go CLI's - // `api.ListProvidersResponse`). Every SSO subcommand decodes one of these - // five schemas, so a stale required-key here breaks the whole command family - // (supabase/cli#5475, #5589, #6051). + // The provider payload the Management API actually returns: no `saml.id` or + // `domains[].id`. Every SSO subcommand decodes one of these five schemas, so a + // stale required key here breaks the whole command family. const SPARSE_PROVIDER = { id: "0b0d48f6-878b-4190-88d7-2ca33ed800bc", saml: { @@ -107,7 +105,6 @@ describe("SSO provider contracts", () => { domains: [{ ...SPARSE_PROVIDER.domains[0], id: "9484591c-a203-4500-bea7-d0aaa845e2f5" }], }; - // Go's structs have no such fields, so `encoding/json` never echoes them. for (const schema of SINGLE_PROVIDER_SCHEMAS) { const decoded = Schema.decodeUnknownSync(schema)(withLegacyIds); expect(decoded.saml).not.toHaveProperty("id"); diff --git a/packages/api/src/generated-contract-sync.unit.test.ts b/packages/api/src/generated-contract-sync.unit.test.ts index 1dc1641a04..b103cbdf2b 100644 --- a/packages/api/src/generated-contract-sync.unit.test.ts +++ b/packages/api/src/generated-contract-sync.unit.test.ts @@ -169,12 +169,10 @@ describe("generated client drift against the committed openapi.json snapshot", ( expect(versionsFromSnapshot).toContain("v2"); }); - // A byte-for-byte `JSON.stringify(parsed, null, 2) + "\n"` reproduction of - // the committed file does not hold: oxfmt collapses short arrays (e.g. - // `"tags": ["Environments"]`) onto a single line after generation, so a - // naive re-stringify diverges purely on formatting, not content. This - // instead checks that the committed bytes parse deterministically and keep - // the single trailing newline `scripts/generate.ts` writes. + // A byte-for-byte `JSON.stringify(parsed, null, 2) + "\n"` reproduction of the + // committed file doesn't hold, since oxfmt collapses short arrays onto one line + // after generation. This instead checks that the bytes parse deterministically + // and keep a single trailing newline. test("parses the committed snapshot deterministically and keeps a single trailing newline", () => { const reparsed = JSON.parse(readFileSync(openApiJsonPath, "utf8")) as OpenApiDocumentShape; expect(reparsed).toEqual(openApiDocument); diff --git a/packages/api/src/internal/client.ts b/packages/api/src/internal/client.ts index 6aa2928b20..e55466a4a6 100644 --- a/packages/api/src/internal/client.ts +++ b/packages/api/src/internal/client.ts @@ -56,12 +56,11 @@ export interface SupabaseApiClientShape { input: OperationInput<Id>, ) => Effect.Effect<OperationOutput<Id>, SupabaseApiError>; /** - * Execute an operation but return the raw HTTP response without decoding the - * output schema or filtering on status. Use this when the response body - * cannot satisfy the strict generated schema (e.g. cli-e2e replay fixtures - * embed a `__PROJECT_REF__` placeholder that violates `ref`'s 20-char - * pattern), so the caller can parse the body leniently. Request building — - * URL, auth, headers, body serialization — is identical to `execute`. + * Executes an operation but returns the raw HTTP response, without decoding + * the output schema or filtering on status. Use this when the response body + * can't satisfy the strict generated schema (e.g. a replay fixture embeds a + * placeholder value that violates a field's pattern). Request building is + * identical to `execute`. */ readonly executeRaw: <Id extends OperationId>( definition: OperationDefinition<Id>, @@ -86,12 +85,10 @@ export class SupabaseApiConfigError extends Error { export type SupabaseApiInputErrorSource = "generated_client" | "user_input"; /** - * The generated client's input schema rejected the request input before any - * request was sent. This defaults to `generated_client` because a schema - * rejection can be caused by a request assembled incorrectly by its caller; - * command boundaries may opt a confirmed user-derived request into - * `user_input` without inspecting the schema error message. The original - * schema failure is preserved as `cause`. + * The generated client's input schema rejected the request before it was sent. + * Defaults to `generated_client` since a rejection is usually a caller assembly + * bug; command boundaries can reclassify a confirmed user-derived request as + * `user_input`. The original schema failure is preserved as `cause`. */ export class SupabaseApiInputError extends Error { readonly _tag = "SupabaseApiInputError"; @@ -374,11 +371,9 @@ function asBinaryRequestBody(value: unknown): Effect.Effect<Uint8Array, HttpBody return Effect.succeed(new TextEncoder().encode(String(revealed))); } -// Serialize JSON bodies with alphabetically-sorted keys (recursively) to match -// Go's `encoding/json`, which emits oapi-codegen's alphabetically-declared -// struct fields and sorts map keys. Without this, multi-field request bodies -// serialize in OpenAPI-spec field order and diverge from the Go CLI on the -// wire (only single/already-sorted bodies happen to match). +// Serializes JSON bodies with keys sorted alphabetically (recursively) so the +// wire format matches recorded replay fixtures; the spec's field order would +// otherwise diverge from them for multi-field bodies. function sortJsonKeysDeep(value: unknown): unknown { if (Array.isArray(value)) { return value.map(sortJsonKeysDeep); diff --git a/packages/api/src/internal/client.unit.test.ts b/packages/api/src/internal/client.unit.test.ts index 893dd619b4..803aa4128b 100644 --- a/packages/api/src/internal/client.unit.test.ts +++ b/packages/api/src/internal/client.unit.test.ts @@ -468,9 +468,8 @@ describe("makeSupabaseApiClient", () => { ]); }); - // Both payloads are the shapes reported against 2.112.0, where the spec's - // Z-anchored pattern rejected them and broke `link` and `branches list` - // outright (supabase/cli#6115). + // Regression payloads: the spec's Z-anchored pattern used to reject these and + // break `link` and `branches list` outright. test("decodes timestamps with a numeric UTC offset", async () => { const apiKeys = await Effect.runPromise( makeSupabaseApiClient(config).pipe( @@ -723,10 +722,8 @@ describe("makeSupabaseApiClient", () => { makeSupabaseApiClient(config).pipe( Effect.flatMap((client) => client.execute<"v1DiffABranch">(operationDefinitions.v1DiffABranch, { - // 20-letter project ref. Used to be "branch-ref" but the UUID - // branch of the oneOf union now has an actual pattern check, so - // free-form strings like "branch-ref" no longer match either - // branch. + // Must satisfy the oneOf union's project-ref/uuid pattern; a free-form + // string like "branch-ref" doesn't match either branch. branch_id_or_ref: "abcdefghijklmnopqrst", }), ), diff --git a/packages/cli-test-helpers/src/harness.ts b/packages/cli-test-helpers/src/harness.ts index 7a74c40921..7e4fa5c416 100644 --- a/packages/cli-test-helpers/src/harness.ts +++ b/packages/cli-test-helpers/src/harness.ts @@ -17,8 +17,8 @@ export interface HarnessOptions { accessToken: string; /** Working directory for the subprocess. Defaults to a fresh temp dir. */ cwd?: string; - /** Set as SUPABASE_PROJECT_ID in the subprocess env. Storage commands read - * this via viper (no --project-ref flag) for config validation in --local mode. */ + /** Set as SUPABASE_PROJECT_ID in the subprocess env. Storage commands validate + * against it (no --project-ref flag) for config validation in --local mode. */ projectId?: string; /** Profile `project_host` — the domain the CLI derives per-project hosts from * (storage `<ref>.<host>`, db `db.<ref>.<host>`, etc.). Defaults to "localhost" @@ -133,20 +133,19 @@ export async function exec( // tests don't touch the developer's real ~/.supabase. SUPABASE_HOME: harness.options.cwd ?? tmpdir(), ...(harness.options.projectId ? { SUPABASE_PROJECT_ID: harness.options.projectId } : {}), - // When a test writes a pooler-url file the Go CLI takes the pooler path in - // ParseDatabaseConfig. Setting a non-empty password avoids the initPoolerLogin - // API call so the only network traffic is the actual Management API call - // under test. Safe to set globally: it is only used when pooler-url exists. + // Setting a non-empty password avoids the interactive pooler login flow when a + // pooler-url file is present, so the only network traffic is the Management + // API call under test. Safe to set globally: only used when pooler-url exists. SUPABASE_DB_PASSWORD: "test-placeholder-password", ...(built.binaryOverride ? { SUPABASE_CLI_BINARY_OVERRIDE: built.binaryOverride } : {}), ...opts?.env, }; - // The CLI selects its API base URL through the profile system rather than - // SUPABASE_API_URL: `CommandSettings` accepts a built-in profile name first and - // a YAML file path second, and the proxied commands' Go binary reads the same - // file via viper's SUPABASE_PROFILE when the value isn't a built-in name. Write - // a temporary profile file pointing at the replay server so both paths reach it. + // The CLI resolves its API base URL through the profile system, not + // SUPABASE_API_URL: `CommandSettings` accepts a built-in profile name or a YAML + // file path, and the still-proxied Go binary reads the same file via + // SUPABASE_PROFILE. Write a temp profile file pointing at the replay server so + // both paths reach it. const profilePath = join(tmpdir(), `cli-e2e-profile-${randomUUID()}.yaml`); const url = harness.options.apiUrl; writeFileSync( diff --git a/packages/config/scripts/build-artifacts.unit.test.ts b/packages/config/scripts/build-artifacts.unit.test.ts index 479e6a6e46..28ed07aeff 100644 --- a/packages/config/scripts/build-artifacts.unit.test.ts +++ b/packages/config/scripts/build-artifacts.unit.test.ts @@ -7,11 +7,9 @@ import { import { CLI_CONFIG_SCHEMA_URL, PROJECT_CONFIG_SCHEMA_URL } from "../src/schema-metadata.ts"; import { collapseNonFiniteNumberUnions, withSchemaMetadata } from "./json-schema-postprocess.ts"; -// CLI-2234 group 6c: regression coverage for the exact post-processing -// `scripts/build.ts` applies to both `dist/schema.json` and -// `dist/project-schema.json` — generated in-memory here (no real build), via -// the same pure functions the build script itself calls, against the real -// `CliConfigSchema`/`ProjectConfigSchema`. +// Regression coverage for the post-processing `scripts/build.ts` applies to +// both `dist/schema.json` and `dist/project-schema.json`, generated in-memory +// here via the same pure functions the build script itself calls. function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null; diff --git a/packages/config/scripts/build.ts b/packages/config/scripts/build.ts index 8c6e63d5fa..a9d7a1cb80 100644 --- a/packages/config/scripts/build.ts +++ b/packages/config/scripts/build.ts @@ -41,14 +41,11 @@ function readStringAt(root: unknown, path: ReadonlyArray<string>): string { } /** - * Runs a schema's rendered JSON Schema document through - * {@link collapseNonFiniteNumberUnions} and {@link withSchemaMetadata}, then - * writes it via {@link renderJsonSchema}. `collapseNonFiniteNumberUnions` - * returns `unknown` (it's a generic JSON-tree walk with no static shape - * guarantee); this narrows it back to an object via `isRecord` rather than an - * `as` cast — both `toCliConfigJsonSchema()`/`toProjectConfigJsonSchema()` - * always render a top-level object, so a non-object result here would mean - * the collapse walk itself is broken, worth failing loudly on. + * Runs a schema document through {@link collapseNonFiniteNumberUnions} and + * {@link withSchemaMetadata}, then writes it via {@link renderJsonSchema}. + * Narrows the collapse step's `unknown` result with `isRecord` instead of an + * `as` cast, since a non-object result here would mean the collapse walk + * itself is broken. */ async function renderCollapsedJsonSchema( outputPath: string, @@ -94,28 +91,19 @@ async function renderJsonSchema(outputPath: string, json: Record<string, unknown } /** - * Proves the package.json `sideEffects: false` claim (a deferred CLI-2230 - * review item) against the real compiled `dist/index.js`, rather than merely - * asserting it. Bundles a probe importing ONLY `CliConfigSchema` for a - * browser-ish target: `project-schema.ts`'s import-time invariant guard (and - * the rest of the `./project-config/registry*.ts` graph it pulls in) must be - * droppable even though it contains real side-effecting statements — that's - * exactly what `sideEffects: false` authorizes a bundler to do, and exactly - * what this asserts actually happened. A second, positive-control probe - * (bundling `projectConfigMappingRows` from `dist/internal.js`) proves the - * registry-only marker this test looks for is actually detectable by this - * exact bundling method in the first place, before trusting its absence from - * the first probe as meaningful. + * Proves `sideEffects: false` against the real compiled `dist/index.js`, not by + * assertion alone: bundles a probe importing only `CliConfigSchema` for a + * browser target, since its registry graph has real side effects but must still + * tree-shake away. A positive-control probe confirms the test's marker is + * actually detectable by this method before trusting its absence as meaningful. */ async function verifyTreeShaking(): Promise<void> { const distIndexPath = await realpath(path.join(packageRoot, "dist", "index.js")); const distInternalPath = await realpath(path.join(packageRoot, "dist", "internal.js")); // `mkdtemp` can return a path through a symlinked prefix (e.g. macOS's - // `/var` -> `/private/var`) that Bun's bundler resolves to its canonical - // form internally when computing the probe entry's own directory — compute - // the relative specifier against that same canonical form, or a - // `path.relative` mismatch silently produces a specifier with one too many - // `../` segments. + // `/var` -> `/private/var`) that Bun's bundler resolves canonically; compute + // the relative specifier against that same canonical form, or `path.relative` + // produces a specifier with one too many `../` segments. const probeDir = await realpath( await mkdtemp(path.join(tmpdir(), "supabase-config-tree-shake-")), ); @@ -145,16 +133,12 @@ async function verifyTreeShaking(): Promise<void> { return output.text(); } - // Only appears in `src/project-config/registry*.ts` (verified by - // grepping `dist/`) — a real API attribute path segment, never used by - // `CliConfigSchema`'s own field names (`base.ts`/`api.ts` use `schemas`, - // not `db_schema`). + // Only appears in `src/project-config/registry*.ts`; `CliConfigSchema`'s own + // field names (`base.ts`/`api.ts`) use `schemas`, not `db_schema`. const REGISTRY_ONLY_MARKER = "db_schema"; - // Derived at runtime from the actual `CliConfigSchema` annotation it - // names, rather than hardcoded prose that could silently drift from the - // real description text — `api.enabled`'s `description`, read off the - // real rendered JSON Schema document (the same source `dist/schema.json` - // is built from). + // Derived at runtime from `CliConfigSchema`'s own `api.enabled` description, + // read off the real rendered JSON Schema document, rather than hardcoded + // prose that could drift from it. const SCHEMA_MARKER = readStringAt(toCliConfigJsonSchema(), [ "properties", "api", @@ -244,28 +228,11 @@ function buildSmokeTestScript(): string { } /** - * The real CLI-2232/CLI-2234 acceptance check: packs the actual publish - * tarball (`npm pack`, governed by `files`/`.npmignore` — the exact thing - * `npm publish` would ship) and installs it into a fresh, isolated consumer - * project, then imports every entrypoint and JSON artifact through a real - * `node` process. This catches `files`/`exports` drift the previous - * workspace-link Node smoke test missed entirely (a workspace `pnpm` link - * resolves straight to this package's own directory, bypassing `files` - * filtering altogether). - * - * Deliberately extracts the tarball directly (`tar`) rather than `npm install - * <tarball>`: the latter would additionally try to resolve - * `@supabase/config`'s own dependency tree (`effect`'s own `fast-check`/ - * `msgpackr`, `@effect/platform-node`'s `undici`/`mime`, …) from the npm - * registry over the network on every build. Every runtime dependency this - * smoke test actually needs is already resolved locally by pnpm — symlinking - * those real, already-resolved package directories in below — the same - * directories `packages/config/node_modules/*` itself points at — mirrors - * exactly how pnpm links every other workspace in this monorepo (Node - * resolves each symlink to its real path before walking further ancestor - * `node_modules` directories, so each linked package's own transitive deps, - * already resolved alongside it in the pnpm store, are found the same way). - * This keeps the check hermetic, fast, and network-free. + * Packs the actual publish tarball (`npm pack`, respecting `files`/`.npmignore`) + * and installs it into a fresh consumer project, then imports every entrypoint + * through real `node` — catching drift a workspace `pnpm` link would miss. + * Extracts with `tar` (not `npm install <tarball>`) and symlinks pnpm's + * already-resolved deps in, keeping the check hermetic and network-free. */ async function runPackAndInstallSmokeTest(): Promise<void> { const npmPath = Bun.which("npm"); @@ -361,7 +328,7 @@ function collectDistTargets(node: ExportsNode, into: Set<string>): void { } } -/** CLI-2234: every `types`/`default`/JSON-artifact target the exports map declares must exist once the build finishes. */ +/** Every `types`/`default`/JSON-artifact target the exports map declares must exist once the build finishes. */ async function verifyExportsMapTargetsExist(): Promise<void> { const packageJson = JSON.parse(await Bun.file(path.join(packageRoot, "package.json")).text()) as { readonly exports: ExportsMap; diff --git a/packages/config/scripts/json-schema-postprocess.ts b/packages/config/scripts/json-schema-postprocess.ts index c51dbe96e0..ce2fc444ca 100644 --- a/packages/config/scripts/json-schema-postprocess.ts +++ b/packages/config/scripts/json-schema-postprocess.ts @@ -3,9 +3,6 @@ import { SchemaAST } from "effect"; /** * Pure JSON Schema post-processing used by `build.ts`'s `renderJsonSchema` on * both generated artifacts (`dist/schema.json`, `dist/project-schema.json`). - * Extracted to its own module (rather than inlined in `build.ts`) so - * `json-schema-postprocess.unit.test.ts` can exercise it directly against an - * in-memory document, without spawning the real build. */ function isRecord(value: unknown): value is Record<string, unknown> { @@ -36,16 +33,11 @@ interface RecoveredAnnotations { } /** - * `description`/`default` for every `Schema.Number` leaf reachable from - * `ast`, keyed by dotted property path (`"*"` for a record/array element) — - * the only two annotations Effect's `Schema.toJsonSchemaDocument` silently - * drops when it splits a plain `Schema.Number` into the `anyOf` union - * {@link collapseNonFiniteNumberUnions} collapses back down (verified - * empirically against `api.max_rows`, which carries both). A `.check()`ed - * number (e.g. `workers.*.instances`'s `isInt()`) renders as a plain - * `"type": "integer"` node instead of this union, so it never reaches this - * map's consumer in the first place — collected here regardless, since this - * walk narrows number leaves only through `SchemaAST.isNumber`. + * `description`/`default` for every `Schema.Number` leaf reachable from `ast`, + * keyed by dotted property path (`"*"` for a record/array element) — the only + * two annotations `Schema.toJsonSchemaDocument` drops when it splits a plain + * `Schema.Number` into the `anyOf` union {@link collapseNonFiniteNumberUnions} + * collapses back down. */ function collectNumberLeafAnnotations( ast: SchemaAST.AST, @@ -190,10 +182,9 @@ function collapseSchemaNode( result["$defs"] = Object.fromEntries( Object.entries(defs).map(([name, child]) => [ name, - // `$defs` members don't correspond to a reachable property path off - // `ast` (they're keyed by ref name, not position) — pass `path` - // through unchanged. Neither generated document actually emits - // `$defs` today (no shared/recursive substructure), so this is inert. + // `$defs` members are keyed by ref name, not position, so `path` passes + // through unchanged; neither generated document currently emits `$defs`, + // so this is inert. collapseSchemaNode(child, path, annotationsByPath), ]), ); @@ -204,12 +195,10 @@ function collapseSchemaNode( /** * Collapses every `anyOf: [{ type: "number", ... }, { type: "string", enum: - * [subset of "Infinity"/"-Infinity"/"NaN"] }]` node anywhere in `document` - * down to the plain `{ type: "number", ... }` branch, re-attaching that - * leaf's `description`/`default` from `rootAst` when the union node itself - * doesn't already carry them (Effect's `Schema.toJsonSchemaDocument` drops - * both when it renders a plain `Schema.Number` as this non-finite-safe - * union). `rootAst` must be the same schema `document` was rendered from. + * [subset of "Infinity"/"-Infinity"/"NaN"] }]` node in `document` down to the + * plain `{ type: "number", ... }` branch, re-attaching `description`/`default` + * from `rootAst` when the union node doesn't already carry them. `rootAst` must + * be the same schema `document` was rendered from. */ export function collapseNonFiniteNumberUnions(document: unknown, rootAst: SchemaAST.AST): unknown { const annotationsByPath = collectNumberLeafAnnotations(rootAst); @@ -218,11 +207,9 @@ export function collapseNonFiniteNumberUnions(document: unknown, rootAst: Schema /** * Injects `$id`/`title`/`description` right after `$schema`, ahead of the - * rest of the document's own keys — used by `build.ts` on both generated - * artifacts (CLI-2234). `metadata` is authoritative: any `$id`/`title`/ - * `description` already present on the incoming `document` is discarded - * rather than allowed to win over the caller-supplied values through the - * trailing `...rest` spread. + * document's own keys. `metadata` is authoritative: any `$id`/`title`/ + * `description` already on `document` is discarded rather than allowed to win + * via the trailing `...rest` spread. */ export function withSchemaMetadata( document: Record<string, unknown>, diff --git a/packages/config/scripts/release-plan.ts b/packages/config/scripts/release-plan.ts index 53ce9f7031..4bf3d2b049 100644 --- a/packages/config/scripts/release-plan.ts +++ b/packages/config/scripts/release-plan.ts @@ -1,18 +1,16 @@ /** - * Computes the `@supabase/config` release plan via semantic-release's - * dry-run JS API (CLI-2233) — the version-computation half of an otherwise - * independent release pipeline for this package. Actual publishing (npm - * publish, tag push, GitHub release) happens in later, separate workflow - * steps; see `.github/workflows/release-config.yml`. + * Computes the `@supabase/config` release plan via semantic-release's dry-run + * JS API — the version-computation half of an otherwise independent release + * pipeline; actual publishing happens in later, separate workflow steps (see + * `.github/workflows/release-config.yml`). * - * Commit analysis and release-notes generation are scoped to this package's - * own history via `./semantic-release-path-filter.ts` (see that file for - * why a plain `@semantic-release/commit-analyzer` run over the whole - * monorepo history would be wrong here). + * Commit analysis and release-notes generation are scoped to this package's own + * history via `./semantic-release-path-filter.ts` (see that file for why a + * monorepo-wide run would be wrong here). * - * Always exits 0 once semantic-release itself completes, whether or not a - * release is due — a non-zero exit means this script itself failed to run - * the plan, not that no release was found. + * Always exits 0 once semantic-release completes, whether or not a release is + * due — a non-zero exit means this script itself failed to run the plan, not + * that no release was found. */ import { appendFile } from "node:fs/promises"; @@ -88,11 +86,10 @@ async function computeReleasePlan(isPrivate: boolean): Promise<ReleasePlan> { } // With no config-v* tag on the branch, semantic-release would cut 1.0.0 - // analyzed from the ENTIRE monorepo history — the release notes (the human - // approver's artifact and the public GH release body) would be a changelog - // of every commit that ever touched packages/config/. Refuse until a + // analyzed from the entire monorepo history, so the release notes would be a + // changelog of every commit that ever touched packages/config/. Refuse until a // baseline tag exists (see AGENTS.md "One-time setup"); the escape hatch is - // for a deliberate, eyes-open first cut. + // for an intentional first cut. if (!result.lastRelease.gitTag && !process.env.CONFIG_RELEASE_ALLOW_NO_BASELINE) { throw new Error( "no config-v* baseline tag found on this branch: semantic-release would release " + @@ -136,11 +133,10 @@ async function appendStepSummary(markdown: string): Promise<void> { } /** - * A fence long enough that no backtick run inside `content` can close it — - * the notes are commit-message-derived (squash-merge messages sourced from PR - * titles/bodies, including external contributors'), and this summary is the - * approver's evidence: rendering them as live markdown would let a crafted - * commit message forge parts of it. + * A fence long enough that no backtick run inside `content` can close it. + * Notes are commit-message-derived (PR titles/bodies, including external + * contributors'), so rendering them as live markdown would let a crafted + * commit message forge parts of this summary. */ function fenceFor(content: string): string { const longestRun = Math.max(0, ...[...content.matchAll(/`+/g)].map((match) => match[0].length)); diff --git a/packages/config/scripts/semantic-release-path-filter.ts b/packages/config/scripts/semantic-release-path-filter.ts index b0b008c165..0e2678a51c 100644 --- a/packages/config/scripts/semantic-release-path-filter.ts +++ b/packages/config/scripts/semantic-release-path-filter.ts @@ -1,17 +1,15 @@ /** * An in-repo replacement for the unmaintained `semantic-release-monorepo` - * wrapper (CLI-2233). This repo runs `@supabase/config`'s release from the - * monorepo root's git history: without filtering, a `fix:` commit anywhere - * in `apps/cli` (or any other workspace) would be analyzed as if it touched - * `packages/config/` and falsely trigger a config release. + * wrapper. This repo runs `@supabase/config`'s release from the monorepo + * root's git history: without filtering, a `fix:` commit anywhere else in the + * repo would be analyzed as if it touched `packages/config/` and falsely + * trigger a release. * - * {@link filterCommitsToPackage} narrows `context.commits` down to the ones - * whose diff actually touches a path under {@link PACKAGE_PATH_PREFIX}; - * {@link analyzeCommits}/{@link generateNotes} apply that filter and then - * delegate to the real `@semantic-release/commit-analyzer` and - * `@semantic-release/release-notes-generator` plugins, so this package still - * gets the standard Angular commit-analysis and changelog rendering — just - * scoped to its own history. + * {@link filterCommitsToPackage} narrows `context.commits` to the ones whose + * diff touches a path under {@link PACKAGE_PATH_PREFIX}; {@link analyzeCommits}/ + * {@link generateNotes} apply that filter and delegate to the real + * commit-analyzer/release-notes-generator plugins, so this package still gets + * standard Angular commit analysis, just scoped to its own history. */ import process from "node:process"; @@ -20,14 +18,11 @@ import type { AnalyzeCommitsContext, GenerateNotesContext } from "semantic-relea /** * `@semantic-release/commit-analyzer` and `@semantic-release/release-notes-generator` - * ship no `.d.ts` of their own (only `semantic-release` itself does), and - * TypeScript refuses a `declare module` augmentation for a specifier that - * already resolves to a real, untyped file (TS2665) from anywhere but a - * genuinely global, import/export-free `.d.ts` — not an option for a - * single-file plugin. `require()`'s return type is an explicit `any` (not an - * implicit one, so this doesn't trip `noImplicitAny`), and its result is - * narrowed into these two locally declared structural interfaces via typed - * `const` bindings immediately below — no `any`/`as` leaks past that point. + * ship no `.d.ts` of their own, and TypeScript refuses a `declare module` + * augmentation for an already-resolved untyped specifier (TS2665) from anywhere + * but a global, import/export-free `.d.ts` — not an option here. `require()`'s + * return is an explicit `any`, narrowed into these two structural interfaces via + * typed `const` bindings below, so no `any`/`as` leaks past this point. */ type PluginConfig = Record<string, unknown>; @@ -51,24 +46,15 @@ const releaseNotesGenerator: ReleaseNotesGeneratorPlugin = require("@semantic-re export const PACKAGE_PATH_PREFIX = "packages/config/"; /** - * Resolves which of `commits` touch a path under {@link PACKAGE_PATH_PREFIX}, - * using ONE batched `git diff-tree --stdin -r --root --name-only -z` - * subprocess rather than one per commit — the first release analyzes the - * repo's entire history (thousands of commits). - * - * Output format (verified empirically against this repo, including a merge - * commit): with `-z`, every element — each echoed input hash and each of its - * changed paths (repo-root-relative) — is NUL-terminated, with no other - * separators. `-z` matters for correctness, not just parsing convenience: - * without it, `core.quotePath` (default true) C-quotes any path with - * non-ASCII or special bytes (`"packages/config/caf\303\251.ts"`), which - * would silently fail the prefix match and drop a genuinely releasable - * commit. A merge commit prints nothing at all here (no hash, no paths) - * because `-m` is deliberately omitted: this trunk is squash-merged, so a - * merge commit carries no analyzable change of its own, and dropping it out - * of the result is intended, not a parsing gap. A root commit would print - * nothing too — `--root` closes that gap by diffing it against the empty - * tree (merge behavior is unaffected). + * Resolves which of `commits` touch a path under {@link PACKAGE_PATH_PREFIX} + * using one batched `git diff-tree --stdin -r --root --name-only -z` + * subprocess, since the first release analyzes the repo's entire history. With + * `-z`, every echoed hash and changed path is NUL-terminated with no other + * separators, avoiding `core.quotePath`'s C-quoting of non-ASCII paths (which + * would silently break the prefix match). A merge commit prints nothing since + * `-m` is omitted for this squash-merged trunk, so it's correctly excluded + * rather than mis-parsed; `--root` makes a root commit diff against the empty + * tree instead of also printing nothing. */ export async function filterCommitsToPackage<T extends { hash: string }>( commits: readonly T[], @@ -79,12 +65,11 @@ export async function filterCommitsToPackage<T extends { hash: string }>( } const hashes = commits.map((commit) => commit.hash); - // `git diff-tree --stdin` echoes each commit's FULL object ID, and the - // header recognition below matches echoed elements against the input set — - // an abbreviated input hash would never match its own echo, silently - // dropping that commit's paths (a missed release, not an error). Refuse - // anything but full OIDs up front. (semantic-release always supplies full - // hashes; this guards other callers.) + // `git diff-tree --stdin` echoes each commit's full object ID, and the header + // recognition below matches echoed elements against the input set; an + // abbreviated hash would never match its own echo, silently dropping that + // commit's paths. Refuse anything but full OIDs up front — semantic-release + // always supplies them, this guards other callers. const invalidHashes = hashes.filter((hash) => !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(hash)); if (invalidHashes.length > 0) { throw new Error( @@ -101,10 +86,9 @@ export async function filterCommitsToPackage<T extends { hash: string }>( stdout: "pipe", stderr: "pipe", }); - // Start draining stdout/stderr before writing stdin: Bun buffers subprocess - // output eagerly so this can't deadlock today, but the classic full-pipe - // deadlock (git blocked writing stdout while we're blocked writing stdin) - // is one runtime port away — don't rely on the buffering behavior. + // Starts draining stdout/stderr before writing stdin: Bun buffers subprocess + // output eagerly so this can't deadlock today, but a future runtime change + // could reintroduce the classic full-pipe deadlock, so don't rely on that. const stdoutText = new Response(proc.stdout).text(); const stderrText = new Response(proc.stderr).text(); try { diff --git a/packages/config/scripts/semantic-release-path-filter.unit.test.ts b/packages/config/scripts/semantic-release-path-filter.unit.test.ts index 3afa18d4c3..f79a9a61ec 100644 --- a/packages/config/scripts/semantic-release-path-filter.unit.test.ts +++ b/packages/config/scripts/semantic-release-path-filter.unit.test.ts @@ -69,9 +69,8 @@ function fakeCommit(hash: string, message: string): Commit { } // AnalyzeCommitsContext pulls in every field of semantic-release's -// VerifyConditionsContext/BaseContext, but analyzeCommits() (ours and the -// real commit-analyzer it delegates to) only reads commits/cwd/logger.log; -// the rest is dummy filler required purely to satisfy the type. +// VerifyConditionsContext/BaseContext, but `analyzeCommits()` only reads +// `commits`/`cwd`/`logger.log`; the rest is dummy filler to satisfy the type. function fakeAnalyzeCommitsContext(commits: Commit[], cwd: string): AnalyzeCommitsContext { return { commits, diff --git a/packages/config/src/auth/email.ts b/packages/config/src/auth/email.ts index 4b17fb0a6f..342d77e12f 100644 --- a/packages/config/src/auth/email.ts +++ b/packages/config/src/auth/email.ts @@ -26,15 +26,10 @@ const defaultSubject = ""; const defaultContentPath = ""; /** - * Go's `Auth.Email.Template`/`Notification` are genuine `map[string]emailTemplate`/ - * `map[string]notification` (`apps/cli-go/pkg/config/auth.go:247-248`) — open maps with no key - * restriction at all; `(e *email) validate(fsys)` (`pkg/config/config.go:1293-1313`) iterates - * every entry regardless of name. The known template/notification names below are the ones the - * Studio/docs UI and `config push`'s Management API sync recognise, but an unrecognized key like - * `[auth.email.template.custom]` is still a legitimate config shape Go accepts — it's simply - * never synced anywhere, same as an unmodeled `auth.external` provider. Record keys therefore - * accept any string, matching Go; only decode failures on a KNOWN/matching name would be a - * Go-parity bug. + * `auth.email.template`/`notification` are open maps with no key restriction: an + * unrecognized key like `auth.email.template.custom` is a legitimate config + * shape, just never synced by Studio or `config push`. Record keys accept any + * string. */ const templateName = Schema.String; const notificationName = Schema.String; diff --git a/packages/config/src/auth/providers.ts b/packages/config/src/auth/providers.ts index 375fb48df9..fd665fc3b5 100644 --- a/packages/config/src/auth/providers.ts +++ b/packages/config/src/auth/providers.ts @@ -119,13 +119,10 @@ const provider = (providerConfig: { const defaultExternal = {}; /** - * Go's deprecated `linkedin`/`slack` provider ids (`pkg/config/config.go:1418- - * 1423`) are intentionally NOT modeled here — only their `_oidc` replacements - * (`linkedin_oidc`, `slack_oidc`) are, matching Go's `(e external) validate()`, - * which unconditionally deletes the deprecated keys before anything decodes - * them. `io.ts`'s `normalizeDeprecatedExternalProviders` strips a config's - * `linkedin`/`slack` table (warning on stderr when it was `enabled`, same as - * Go) before this schema ever sees it. + * The deprecated `linkedin`/`slack` provider ids aren't modeled here — only + * their `_oidc` replacements (`linkedin_oidc`, `slack_oidc`) are. `io.ts`'s + * `normalizeDeprecatedExternalProviders` strips a config's `linkedin`/`slack` + * table (warning on stderr if it was enabled) before this schema ever sees it. */ export const external = Schema.Struct({ apple: provider({ diff --git a/packages/config/src/auth/sms.ts b/packages/config/src/auth/sms.ts index ac1e5f3212..439ad8ee58 100644 --- a/packages/config/src/auth/sms.ts +++ b/packages/config/src/auth/sms.ts @@ -73,17 +73,10 @@ function missing(provider: string, field: string) { } /** - * Go's `(s *sms) validate()` (`apps/cli-go/pkg/config/config.go:1348-1410`): a boolean `switch` - * that inspects providers in a FIXED priority order — twilio, twilio_verify, messagebird, - * textlocal, vonage — and validates ONLY the first one whose `enabled` is true, matching Go's - * `switch` short-circuit semantics. A later enabled-but-incomplete provider is never even looked - * at. This replaces five independent per-provider `requiredWhenEnabled` checks (one per provider - * sub-struct) that used to validate EVERY enabled provider table regardless of priority — a real - * Go-parity gap, since a stale secondary `[auth.sms.*]` block Go silently ignores could make this - * schema reject a config Go accepts. `s.EnableSignup`'s own switch case (`config.go:1408-1410`, a - * WARN-only "no SMS provider enabled" notice with no throwing equivalent) isn't reproduced here, - * matching this package's established precedent of not porting WARN-only branches (e.g. the - * `auth.captcha.secret`/`assertEnvLoaded` case). + * Validates providers in a fixed priority order — twilio, twilio_verify, + * messagebird, textlocal, vonage — checking only the first one whose `enabled` + * is true; a later enabled-but-incomplete provider is never inspected, so a + * stale secondary `[auth.sms.*]` block doesn't cause a rejection. */ function validateSmsProviderSwitch(value: SmsProviderSwitchInput) { if (value.twilio.enabled) { diff --git a/packages/config/src/base.ts b/packages/config/src/base.ts index 494d60051a..348d565b49 100644 --- a/packages/config/src/base.ts +++ b/packages/config/src/base.ts @@ -61,17 +61,12 @@ const remoteCliConfigBlock = Schema.Struct({ /** * Exported separately (not inlined into {@link CliConfigSchema}) so * `packages/config/src/io.ts` can decode it on its own with - * `disableChecks: true`. Go's `Config.Validate` only ever checks - * `remotes.*.project_id` format for every remote block - * (`apps/cli-go/pkg/config/config.go:996-1001`, "Since remote config is merged - * to base, we only need to validate the project_id field") — every other - * business-rule check (`Auth.External.validate()`, `Auth.Sms.validate()`, - * etc.) runs exactly once, against the merged effective config - * (`config.go:1136-1152`), never iterated over `c.Remotes[*]`. Decoding this - * schema normally (checks enabled) would apply those same business-rule - * `.check()`s — embedded in `auth`/`db`/etc. — to every remote regardless of - * selection, rejecting configs Go accepts (e.g. an unselected - * `[remotes.prod.auth.external.github] enabled = true` stub with no secret). + * `disableChecks: true`. Only the merged effective config gets full + * business-rule validation, never each remote block individually; decoding + * this schema normally would apply those `.check()`s (embedded in + * `auth`/`db`/etc.) to every remote regardless of selection, rejecting a valid + * but unselected `[remotes.prod.auth.external.github] enabled = true` stub with + * no secret. */ export const RemotesSchema = Schema.Record(Schema.String, remoteCliConfigBlock).annotate({ default: {}, diff --git a/packages/config/src/bun.ts b/packages/config/src/bun.ts index dffb36ef32..c439566c1e 100644 --- a/packages/config/src/bun.ts +++ b/packages/config/src/bun.ts @@ -14,10 +14,8 @@ export const loadCliProjectEnvironment = cliConfigIo.loadCliProjectEnvironment; export const saveCliConfig = cliConfigIo.saveCliConfig; export const inferFunctionsManifest = cliConfigIo.inferFunctionsManifest; export type { CliConfigIo } from "./promise-facade.ts"; -// Re-exports every pure symbol from `.` (types, schema, errors, etc.) so -// `./io` consumers can name `LoadedCliConfig`/`CliProjectPaths`/etc. without -// a second import from `@supabase/config` — `index.ts`'s own graph is pure, -// so this doesn't drag anything platform-specific into it. No name -// collisions with the seven facade functions above (verified against -// `index.ts`'s export surface). +// Re-exports every pure symbol from `.` (types, schema, errors, etc.) so `./io` +// consumers can name them without a second import from `@supabase/config`; +// `index.ts`'s own graph is pure, so this doesn't drag in anything +// platform-specific. export * from "./index.ts"; diff --git a/packages/config/src/config-diff.ts b/packages/config/src/config-diff.ts index 8731b9b741..234471b09d 100644 --- a/packages/config/src/config-diff.ts +++ b/packages/config/src/config-diff.ts @@ -17,8 +17,8 @@ import { getDefaultCliConfig } from "./sparse.ts"; * response, resolving the target, and rendering output are the caller's job * (`supabase config diff`, and `config pull` after it). See ADR 0022. * - * Both operands are convergence projections from CLI-2230's normalizers (ADR - * 0021): the local operand is derived here from the loaded `{config, + * Both operands are convergence projections from this package's normalizers + * (ADR 0021): the local operand is derived here from the loaded `{config, * document}` pair via `fromConfigDocument` (raw-presence-masked, * canonicalized, secrets omitted), and the caller builds `remote` with * `fromApiProjectConfig(response)`. The comparable surface is the mapping @@ -87,56 +87,32 @@ export interface ConfigChangeSet { /** * Comparable non-secret paths the file declares but the local projection * dropped — declared state no actor's write path can express, because the - * feature it belongs to is switched off (ADR 0021's CLI-2314 addendum: - * siblings of a disabled container's sentinel — e.g. - * `auth.oauth_server.authorization_url_path`, disabled - * `storage.analytics`/`storage.vector`'s quota fields — an unselected SMS - * provider's credentials, …). Not every reason a path lands here is - * self-contained, either: `auth.rate_limit.email_sent` can be pruned - * because a DIFFERENT path, `auth.email.smtp`, is undeclared. These were - * never compared on the local side, so — like `masked` — a clean `changes` - * list is only a partial claim; callers must surface this rather than let - * a declared value silently vanish from the comparison. + * owning feature is switched off (see ADR 0021's addendum for examples). The + * reason isn't always self-contained: a path can be pruned because a + * different, sibling path is undeclared. Never compared on the local side, + * so — like `masked` — a clean `changes` list is only a partial claim; + * callers must surface this. */ readonly unmanaged: ReadonlyArray<ReadonlyArray<string>>; readonly counts: ConfigChangeCounts; /** * Which {@link ConfigAbsencePolicy} `options.local` selected for this call — * `"absent-is-hands-off"` when `options.local.document` was supplied, - * `"absent-is-default"` when it was omitted. See that type's docstring for - * the danger matrix this distinction protects against, and - * {@link DiffProjectConfigOptions.local}'s docstring for the specific - * classification cliff omitting `document` falls off. + * `"absent-is-default"` otherwise. See that type's docstring for the danger + * matrix this protects against, and {@link DiffProjectConfigOptions.local} + * for the classification cliff omitting `document` falls off. */ readonly absencePolicy: ConfigAbsencePolicy; } export interface DiffProjectConfigOptions { /** - * The loaded local config: the `{config, document}` pair - * `fromConfigDocument` accepts (pass the loaded config WITH its raw - * document so raw-presence masking applies — ADR 0021's remedy), plus the - * loader's `valueOrigins` when env-var attribution is wanted. The local - * projection and the declared-key set are both derived from this one value, - * so they can never come from different loads. `LoadedCliConfig` is - * structurally assignable. Note `fromConfigDocument` runs inside - * `diffProjectConfig`, so a document the registry cannot canonicalize - * throws `ProjectConfigParseError` from here. - * - * **Omitting `document` is a cliff, not a gentle degradation.** Internally, - * `diffProjectConfig` computes `const declaredRoot = options.local.document - * ?? {}` — a caller that leaves `document` out entirely doesn't just lose - * `applyRawPresenceMask`'s fixed-list masking, EVERY path's `declared` flag - * becomes `false` universally (`isDeclaredAtPath` walks an empty object). - * That means `local_only` can never fire (it requires `declared === true` - * with no remote value) and `unmanaged`/`masked` are always empty arrays - * (both filter on `isDeclaredAtPath(declaredRoot, ...)` too) — not merely - * "less masking coverage". This selects {@link ConfigAbsencePolicy} - * `"absent-is-default"` (see that type's docstring for the danger matrix) - * and is exactly the calling shape a caller like Studio would use if it - * called `diffProjectConfig({local: {config}, remote})` without ever - * loading/passing a raw document — the whole reason `ConfigAbsencePolicy` - * exists to be named. + * The loaded local config: the `{config, document}` pair `fromConfigDocument` + * accepts. Omitting `document` is a cliff, not gentle degradation: every + * path's `declared` flag becomes `false`, so `local_only` never fires and + * `unmanaged`/`masked` are always empty, and this selects + * {@link ConfigAbsencePolicy} `"absent-is-default"` instead of + * `"absent-is-hands-off"`. */ readonly local: CliConfigWithRawPresence & { readonly valueOrigins?: ReadonlyArray<CliConfigValueOrigin> | undefined; @@ -251,13 +227,11 @@ export type ConfigArrayEquality = "set" | "sequence"; /** * Type-aware value equality. Scalars tolerate string/number and - * string/boolean representation skew. Arrays default to SEQUENCE semantics — - * element order is meaningful unless the field's registry row opts into - * `"set"` (whether an array is a set or a sequence is per-field wire - * knowledge: `api.schemas`' first entry is PostgREST's default schema and - * `api.extra_search_path` is a literal `search_path`, while - * `auth.additional_redirect_urls` is membership-only). Defaulting to - * sequence over-reports rather than under-reports drift. + * string/boolean representation skew. Arrays default to sequence semantics — + * order matters unless the field's registry row opts into `"set"` (e.g. + * `auth.additional_redirect_urls` is membership-only, while `api.schemas`' + * order matters). Defaulting to sequence over-reports rather than + * under-reports drift. */ export function isEqualConfigValue( a: unknown, @@ -266,9 +240,8 @@ export function isEqualConfigValue( ): boolean { if (Array.isArray(a) && Array.isArray(b)) { if (arrayEquality === "set") { - // TRUE set semantics: membership only. Duplicates carry no meaning for - // a set-mode field (a repeated redirect URL is the same allow list), - // so they must not register as drift. + // Set semantics: membership only. Duplicates carry no meaning for a + // set-mode field, so they must not register as drift. const left = new Set(a.map(canonicalArrayElement)); const right = new Set(b.map(canonicalArrayElement)); return left.size === right.size && [...left].every((element) => right.has(element)); @@ -366,11 +339,10 @@ function hasAncestorPathKey(path: ReadonlyArray<string>, set: ReadonlySet<string // The default config's own convergence projection — the first `remote_only` // suppression baseline tier. Lazy so importing this module never pays for a -// full schema decode + projection up front. Deliberately calls -// `fromConfigDocument` on a bare `EffectiveConfig` — an internal, -// intentional `"absent-is-default"` use (`ConfigAbsencePolicy`): the -// suppression tiers below need a fully schema-materialized baseline to -// compare a silent remote report against, not a raw-presence-masked one. +// full schema decode up front. Calls `fromConfigDocument` on a bare +// `EffectiveConfig`, an intentional internal `"absent-is-default"` use: the +// suppression tiers below need a fully schema-materialized baseline, not a +// raw-presence-masked one. let defaultProjectionMemo: ProjectConfig | undefined; function defaultProjection(): ProjectConfig { defaultProjectionMemo ??= fromConfigDocument(getDefaultCliConfig()); @@ -402,12 +374,11 @@ export function diffProjectConfig(options: DiffProjectConfigOptions): ConfigChan } // Declared comparable paths the local projection dropped: push cannot - // communicate them, so they were never compared on the local side. - // `comparableProjectConfigPaths` already excludes secret rows, so this - // never overlaps `masked`. Hoisted above the classification loop and - // excluded there before any classification branch runs — ADR 0022: an - // unmanaged path can never classify either, so it must never reach a - // branch that would compare it against the remote report. + // communicate them, so they were never compared locally. + // `comparableProjectConfigPaths` already excludes secret rows, so this never + // overlaps `masked`. Hoisted above the classification loop and excluded + // there first — an unmanaged path (ADR 0022) must never reach a branch that + // compares it against the remote report. const unmanaged = comparableProjectConfigPaths .filter( (path) => isDeclaredAtPath(declaredRoot, path) && valueAtPath(local, path) === undefined, @@ -432,9 +403,9 @@ export function diffProjectConfig(options: DiffProjectConfigOptions): ConfigChan continue; } // A declared value differing from the remote is an update; an - // undeclared one is remote-side drift against the (materialized) - // default the local projection carries — which stays populated on the - // change so consumers can see what a push would write. + // undeclared one is remote-side drift against the local projection's + // materialized default, which stays populated on the change so + // consumers can see what a push would write. changes.push({ path, class: declared ? "update" : "remote_only", @@ -447,20 +418,11 @@ export function diffProjectConfig(options: DiffProjectConfigOptions): ConfigChan } if (remoteValue !== undefined) { - // The local projection is silent because the file doesn't declare this - // path — a declared-but-unmanaged path never reaches this branch, since - // the loop above already excluded it (ADR 0022). Suppress the remote - // value unconditionally when the row is `platformRendered` (the - // platform authors this value itself; no local default exists to - // compare against), or when it matches the unconfigured baseline: the - // default config's own projection, then the raw default config - // (push-gated containers, e.g. network restrictions' allow-all), then - // the registry row's declared `unconfiguredValue` (the platform's - // report of an unconfigured feature, e.g. `sessions_timebox: 0` - // canonicalized to `"0s"`). With no baseline at any tier the value is - // reported — "unconfigured" is never inferred from type-level zero - // values, since canonicalization can turn a platform zero into a - // non-zero shape. + // Suppresses a silent-local, declared remote value when the row is + // `platformRendered`, or when it matches the unconfigured baseline: the + // default projection, the raw default config, then the registry row's + // `unconfiguredValue`, tried in order. With no baseline match the value + // is reported — never inferred as unconfigured from a type-level zero. const baseline = valueAtPath(defaultProjection(), path) ?? valueAtPath(getDefaultCliConfig(), path) ?? From 9dbaf0560fe6baa63badbd6f012beb8c55b66011 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:04:12 +0100 Subject: [PATCH 34/56] chore(repo): trim comments, shard 27 (957 -> 359 comment lines) --- .../commands/gen/gen.signing-keys-config.ts | 329 +++--------------- .../gen/signing-key/signing-key.e2e.test.ts | 9 +- .../gen/signing-key/signing-key.handler.ts | 44 +-- .../signing-key.integration.test.ts | 61 +--- .../src/commands/gen/types/types.e2e.test.ts | 10 +- .../src/commands/gen/types/types.errors.ts | 16 +- .../src/commands/gen/types/types.handler.ts | 132 +++---- .../gen/types/types.integration.test.ts | 72 ---- .../src/commands/gen/types/types.layers.ts | 22 +- .../gen/types/types.layers.unit.test.ts | 15 +- .../src/commands/gen/types/types.unit.test.ts | 6 +- apps/cli/src/commands/init/init.errors.ts | 20 +- apps/cli/src/commands/init/init.handler.ts | 19 +- .../commands/init/init.integration.test.ts | 29 +- .../inspect/db/blocking/blocking.query.ts | 7 +- apps/cli/src/commands/inspect/db/db.layers.ts | 13 +- .../inspect/db/db.layers.unit.test.ts | 7 - .../commands/inspect/db/inspect-db-command.ts | 17 +- .../inspect/db/inspect-db.e2e.test.ts | 16 +- .../db/inspect-query.integration.test.ts | 14 - .../src/commands/inspect/db/inspect-query.ts | 109 ++---- .../inspect/db/inspect-query.unit.test.ts | 15 +- .../commands/inspect/db/inspect-schemas.ts | 9 +- .../db/inspect-specs.integration.test.ts | 7 - .../db/table-stats/table-stats.query.ts | 7 +- .../db/vacuum-stats/vacuum-stats.query.ts | 7 +- 26 files changed, 206 insertions(+), 806 deletions(-) diff --git a/apps/cli/src/commands/gen/gen.signing-keys-config.ts b/apps/cli/src/commands/gen/gen.signing-keys-config.ts index 91d68f724e..9d2898617e 100644 --- a/apps/cli/src/commands/gen/gen.signing-keys-config.ts +++ b/apps/cli/src/commands/gen/gen.signing-keys-config.ts @@ -6,18 +6,9 @@ import { goJsonKindName } from "../../command-internal/go-json.ts"; import { resolveProjectEnvironmentValues } from "../../command-internal/project-environment.ts"; /** - * Shared `[auth].signing_keys_path` config-loading logic for the `gen` command - * family — used by both `gen signing-key` (`signing-key.handler.ts`, generating - * or appending a key) and `gen bearer-jwt` (`bearer-jwt.handler.ts`, resolving - * a key to sign with). Per `apps/cli/CLAUDE.md`'s "hoist before you duplicate" - * rule: this logic is used by ≥2 commands in the same command family, so it - * lives at the family root (`commands/gen/`) rather than being inlined - * in either sibling. - * - * Error TYPES are intentionally NOT shared — each caller passes its own - * tagged-error constructors (mirroring `sso.saml.ts`'s `readMetadataFile` - * pattern), so `gen signing-key` and `gen bearer-jwt` keep independent error - * hierarchies while sharing the actual file-resolution/read/decode logic. + * Shared `[auth].signing_keys_path` config-loading logic for `gen signing-key` and `gen + * bearer-jwt`. Each caller passes its own tagged-error constructors, so the two commands keep + * independent error hierarchies while sharing file resolution, reading, and decoding. */ export type StoredSigningKeyJwk = Readonly<Record<string, unknown>>; @@ -26,17 +17,8 @@ interface GenSigningKeysConfigPaths { /** CWD-relative `supabase/config.toml` (or the resolved config file's own display path). */ readonly configDisplayPath: string; /** - * `[auth].enabled` from the resolved config (default `true`). The - * `[auth].signing_keys_path` file is only read when auth is enabled — - * every caller that reaches this file's read through the shared config - * load is subject to the SAME gate. With `auth.enabled = false` and a - * configured `signing_keys_path` file, `gen signing-key --append` never - * reads that file's real content at all — it appends to (and a - * subsequent write clobbers) the default single-key array instead, - * discarding whatever was actually on disk. Both `gen bearer-jwt`'s - * `getSigningKey` ({@link resolveBearerJwtSigningKey}) and `gen - * signing-key` ({@link genSigningKey}) branch on this field for - * exactly that reason. + * `[auth].enabled` from the resolved config (default `true`). The `signing_keys_path` file is + * only read when this is `true` — see {@link resolveBearerJwtSigningKey} and {@link genSigningKey}. */ readonly authEnabled: boolean; /** `Option.some` when `[auth].signing_keys_path` is configured (non-empty). */ @@ -47,27 +29,14 @@ interface GenSigningKeysConfigPaths { } /** - * `typeof value === "object"` is also `true` for a JSON array — without excluding - * `Array.isArray(value)`, a `signing_keys_path` entry shaped like `[]` (or any nested - * array) would pass this check and be accepted as a JWK-shaped record. An - * array-shaped element must be rejected with `"json: cannot unmarshal array - * into Go value of type config.JWK"`: `[[], {"kty":"EC","kid":"k2"}]` fails - * decoding outright, it does not partially accept `k2` the way this check - * would without the array exclusion. + * Excludes arrays: `typeof value === "object"` is also `true` for `[]`, but an array-shaped + * `signing_keys_path` entry must be rejected, not accepted as a JWK-shaped record. */ function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); } -/** - * `goJsonKindName` (`go-json.ts`) is deliberately scoped to - * scalars only — every one of its existing call sites already excludes null/array/ - * object before reaching it. This file's per-field JWK checks below DO need to name a - * bare JSON object (`key_ops`'s elements, or a nested value under any field, can be an - * object — a `--payload`-style `{}` inside `key_ops` must report - * `"...into Go struct field JWK.key_ops of type string"` with kind `object`), - * so this is a local superset rather than a change to that shared, narrower contract. - */ +/** Extends `goJsonKindName` to also name a bare object, needed when a JWK field holds `{}`. */ function jwkFieldKindName(value: unknown): string { if (value !== null && typeof value === "object" && !Array.isArray(value)) { return "object"; @@ -75,51 +44,19 @@ function jwkFieldKindName(value: unknown): string { return goJsonKindName(value); } -/** - * Established `encoding/json` struct-field type-mismatch text: `"json: cannot - * unmarshal <kind> into Go struct field JWK.<field> of type <goType>"` — for - * every field this file reads: `kty`/`kid`/`use`/`alg`/`n`/`e`/`d`/`p`/`q`/ - * `dp`/`dq`/`qi`/`crv`/`x`/`y` (`goType: "string"`), `key_ops` as a whole - * (`goType: "[]string"`) vs. one of its elements (`goType: "string"`, the same - * as any other string field), and `ext` (`goType: "bool"`). - */ +/** Reproduces `encoding/json`'s struct-field type-mismatch text: `"json: cannot unmarshal <kind> into Go struct field JWK.<field> of type <goType>"`. */ function jwkStructFieldTypeMismatch(field: string, value: unknown, goType: string): string { return `json: cannot unmarshal ${jwkFieldKindName(value)} into Go struct field JWK.${field} of type ${goType}`; } -/** - * A JSON `null` for any `config.JWK` field is a documented `encoding/json` - * no-op — same as a `null` for the whole JWK (see `bearer-jwt.signing-key.ts`'s - * `resolveSigningKeyFromStdinJwk` doc comment) — so `null` must NOT be treated as a type - * mismatch here, only as "absent". - */ +/** `null` is treated as absent, not a type mismatch, matching `encoding/json`'s zero-value semantics for a null field. */ function isAbsentJwkField(value: unknown): boolean { return value === undefined || value === null; } /** - * `encoding/json`-style struct-field matching is case-insensitive — the - * decoder "match[es] incoming object keys to the keys used by Marshal - * (either the struct field name or its tag), preferring an exact match but - * also accepting a case-insensitive match" — and `config.JWK` gets NO - * field-specific exemption from this: every field this file reads (`kty`, - * `kid`, `use`, `key_ops`, `alg`, `ext`, `n`, `e`, `d`, `p`, `q`, `dp`, `dq`, - * `qi`, `crv`, `x`, `y`), including `alg` despite its extra - * `encoding.TextUnmarshaler` hook — `{"KTY":"EC","ALG":"ES256"}` decodes - * identically to the all-lowercase spelling. A plain `record[field]` index - * (JS property access is always exact-case) would otherwise treat - * `"KTY"`/`"ALG"`/etc. as absent instead of decoding them, incorrectly - * rejecting keys the established decoder accepts. - * - * When MULTIPLE case-variant spellings of the same field are present, the - * decoder processes JSON keys strictly in SOURCE order and overwrites the - * struct field on each match, so whichever case-variant key comes LAST in - * the object wins: `{"KTY":"EC","kty":"RSA"}` decodes to `kty:"RSA"`, and - * `{"kty":"EC","KTY":"RSA"}` also decodes to `kty:"RSA"` (the later key, - * regardless of casing, always wins). This only GENERALIZES `JSON.parse`'s - * own already-relied-on last-value-wins behavior for a same-case duplicate - * key to cross-case duplicates too — it never changes the answer for an - * object with no case-variant duplicates. + * Looks up a JWK field case-insensitively, matching `encoding/json`'s struct-field matching. + * When multiple case-variant keys are present, the last one in source order wins. */ export function resolveJwkFieldValue(record: Record<string, unknown>, field: string): unknown { let value: unknown; @@ -134,18 +71,8 @@ export function resolveJwkFieldValue(record: Record<string, unknown>, field: str } /** - * Reads an optional STRING field, throwing the established struct-field - * type-mismatch text (see {@link jwkStructFieldTypeMismatch}) when the field - * is PRESENT with a non-string value — e.g. `{"kid":123}` or `{"ext":"true"}` - * — rather than silently treating a malformed field as absent. Decoding into - * `config.JWK` fails outright on any such field, so a mistyped optional - * field must never let a caller mint a token as if the field had simply - * been omitted. Hoisted here (rather than living only in - * `bearer-jwt.signing-key.ts`) because {@link assertNoMalformedDuplicateJwkField} — used - * by BOTH `gen signing-key` and `gen bearer-jwt` via {@link readSigningKeysFile} — - * needs the exact same per-field check. Looks the field up case-insensitively - * via {@link resolveJwkFieldValue} to match the established case-insensitive - * struct-field matching. + * Reads an optional string field, throwing {@link jwkStructFieldTypeMismatch} when the field + * is present with a non-string value rather than treating it as absent. */ export function readOptionalString( record: Record<string, unknown>, @@ -162,24 +89,9 @@ export function readOptionalString( } /** - * Reads the optional `key_ops` STRING ARRAY field, throwing the established - * struct-field type-mismatch text (see {@link jwkStructFieldTypeMismatch}) - * when the field is PRESENT but is not an array (`goType: "[]string"`) or - * contains a non-string, non-null element (`goType: "string"`, each element - * is decoded individually into the slice's element type) — same "never - * silently treat malformed as absent" rule as {@link readOptionalString}. - * See that function's doc comment for why this is exported from the family - * root rather than kept local to `bearer-jwt.signing-key.ts`. - * - * A `null` ELEMENT (e.g. `"key_ops":["sign",null]`) is its own separate - * zero-value case, one level deeper than {@link isAbsentJwkField}'s "the - * whole field is absent": a `null` slice element decodes into `[]string` as - * that element's zero value (`""`), not a type mismatch — - * `json.Unmarshal(["sign", null], &[]string{})` yields `["sign", ""]` with - * no error — and `key_ops` is never even READ by signing (it only inspects - * `kty`/`Algorithm`/the key-material fields), so a JWK with a `null` - * `key_ops` element still signs successfully, where this function - * previously rejected it outright. + * Reads the optional `key_ops` field, throwing {@link jwkStructFieldTypeMismatch} when present + * but not an array or containing a non-string, non-null element. A `null` element decodes to + * `""` (its zero value) instead, matching `encoding/json`'s slice-element decoding. */ export function readOptionalStringArray( record: Record<string, unknown>, @@ -203,13 +115,7 @@ export function readOptionalStringArray( }); } -/** - * Reads the optional `ext` BOOLEAN field (`*bool`), throwing the established - * struct-field type-mismatch text (see {@link jwkStructFieldTypeMismatch}) when the - * field is PRESENT with a non-boolean value — e.g. `{"ext":"true"}` or `{"ext":1}` — - * same "never silently treat malformed as absent" rule as {@link readOptionalString}. - * See that function's doc comment for why this is exported from the family root. - */ +/** Reads the optional `ext` boolean field, throwing {@link jwkStructFieldTypeMismatch} when present with a non-boolean value. */ export function readOptionalBoolean( record: Record<string, unknown>, field: string, @@ -224,13 +130,7 @@ export function readOptionalBoolean( return value; } -/** - * Every plain-`string` `config.JWK` field EXCEPT `alg` — `alg`'s own `config.Algorithm` - * type additionally implements `encoding.TextUnmarshaler` (the RS256/ES256 allowlist, - * `pkg/config/auth.go:80-86`), which changes ITS duplicate-key mechanics enough that - * {@link assertNoMalformedDuplicateJwkField} checks it separately — see that function's - * doc comment. - */ +/** Every plain string JWK field except `alg`, which has its own allowlist validation and is checked separately by {@link assertNoMalformedDuplicateJwkField}. */ const JWK_PLAIN_STRING_FIELDS = [ "kty", "kid", @@ -249,23 +149,15 @@ const JWK_PLAIN_STRING_FIELDS = [ ] as const; /** - * Advances past exactly one JSON value starting at `text[start]` (after any leading - * whitespace), returning the index of the first character after that value — a minimal - * span-only JSON tokenizer (it never builds a JS value) shared by - * {@link splitJsonArrayElementTexts} and `findTopLevelObjectFieldOccurrences`. Tracks - * string-literal state (including `\"` escapes) so structural characters (`{}[]:,`) - * inside a string never affect nesting depth — a naive brace/bracket counter would - * otherwise mis-parse a value like `"a,b}c"`. Only ever called on text that has ALREADY - * parsed successfully as a whole via `JSON.parse` (a duplicate key is a semantic oddity - * `JSON.parse` tolerates, not a syntax error), so this can assume well-formed JSON - * grammar throughout — the `i === start` guards below are defense-in-depth against a - * hang, not a correctness requirement for well-formed input. + * Advances past one JSON value starting at `text[start]`, returning the index just past it. + * Tracks string-literal state so structural characters inside a string never affect nesting + * depth. Assumes `text` is already valid JSON (called only after a successful `JSON.parse`). */ function skipJsonValue(text: string, start: number): number { let i = start; while (i < text.length && /\s/.test(text[i] ?? "")) i++; const skipString = () => { - i++; // opening quote + i++; while (i < text.length) { const c = text[i]; if (c === "\\") { @@ -303,15 +195,9 @@ function skipJsonValue(text: string, start: number): number { } /** - * Splits a JSON *array* literal's own top-level elements into their exact source - * substrings — respecting nested strings/objects/arrays so a comma or bracket inside a - * nested value never splits an element early — WITHOUT ever re-serializing them through - * `JSON.stringify` (which could reorder/reformat, and can't reproduce a source-only - * artifact like a duplicate key at all). {@link readSigningKeysFile} uses this to - * recover each `signing_keys_path` entry's OWN untouched text for - * {@link assertNoMalformedDuplicateJwkField} — `JSON.parse`, which the array as a WHOLE - * already went through for the ordinary shape checks in that function, has by that - * point already discarded the very duplicate-key evidence that check exists to find. + * Splits a JSON array literal into each top-level element's exact source substring, without + * re-serializing through `JSON.stringify` (which would erase a duplicate key) — used by + * {@link readSigningKeysFile} for {@link assertNoMalformedDuplicateJwkField}. */ function splitJsonArrayElementTexts(arrayText: string): ReadonlyArray<string> { const result: Array<string> = []; @@ -337,20 +223,9 @@ function splitJsonArrayElementTexts(arrayText: string): ReadonlyArray<string> { } /** - * Returns every top-level field of a JSON *object* literal, keyed by the field name - * LOWERCASED, with ALL occurrences' raw source substrings preserved in true source - * order — including duplicates `JSON.parse` would silently collapse down to just the - * last one, AND case-variant "duplicates" of the same `config.JWK` field (e.g. `{"KID": - * 1,"kid":"k"}`) that a same-case-only grouping would otherwise miss entirely: - * `encoding/json`-style matching resolves struct fields case-insensitively (see - * {@link resolveJwkFieldValue}'s doc comment), so `KID` and `kid` here both feed the - * SAME struct field and must be checked together, in true relative source order, for - * {@link assertNoMalformedDuplicateJwkField} to catch a malformed earlier occurrence - * regardless of which case variant it used (`{"KID":123,"kid":"validkid"}` still - * errors `"...JWK.kid..."`, even though `kid`'s own final, valid occurrence comes - * later). Grouping by lowercase here — rather than post-hoc merging per-key arrays - * after the fact — keeps every occurrence in exactly the order it appeared in the - * source, regardless of which case variant it used. + * Returns every top-level field of a JSON object literal, keyed by lowercased name, with every + * occurrence's raw source text in source order — including case-variant duplicates (`KID`/`kid`) + * that `JSON.parse` would otherwise collapse to just the last one. */ function findTopLevelObjectFieldOccurrences( objectText: string, @@ -387,51 +262,9 @@ function findTopLevelObjectFieldOccurrences( } /** - * Detects a JWK-shaped object literal's raw source text having a KNOWN `config.JWK` - * field repeated with an EARLIER occurrence that must be rejected, even when the - * LAST occurrence — the only one `JSON.parse` actually keeps, per plain JS - * object-literal semantics — is perfectly valid on its own. `JSON.parse` collapsing - * `{"kid":1,"kid":"k"}` down to `{kid: "k"}` erases the very evidence - * `readOptionalString`/etc. would need to catch the earlier `1`. - * - * Two genuinely different mechanics depending on the field: - * - * - **Plain `string`/`[]string`/`*bool` fields** (every field here except `alg`): - * `encoding/json`-style decoding processes duplicate occurrences in source order and - * ALWAYS continues past a type-mismatched occurrence to try the next one (a later - * valid occurrence DOES get written into the struct) — but the decode's own - * returned error is always the FIRST mismatch found, regardless of what a later - * occurrence does. Net effect: if ANY occurrence of a plain field mismatches, - * decoding errors, full stop — which is exactly what iterating every occurrence - * through the same {@link readOptionalString}/{@link readOptionalStringArray}/ - * {@link readOptionalBoolean} the merged value already goes through, in source - * order, reproduces (first thrown wins, same as the established first-saved - * error). - * - **`alg`**: `config.Algorithm` additionally implements `encoding.TextUnmarshaler` - * (the RS256/ES256 allowlist). Once an EARLIER occurrence's `UnmarshalText` itself - * returns a non-nil error (i.e. a validly-typed but disallowed string, like `"HS256"`), - * the decoder never even ATTEMPTS a later occurrence of `alg`: - * `json.Unmarshal` of `{"alg":"HS256","alg":"ES256"}` into a `config.JWK`-shaped struct - * still errors `"must be one of [RS256 ES256]"`, and the field never advances past the - * first, disallowed value, even though `"ES256"` alone would have been fine and is the - * value `JSON.parse` alone would have kept. A bare JSON-type mismatch on `alg` (e.g. a - * number) behaves like the plain-field case above instead — the outer struct-field - * type check runs BEFORE `UnmarshalText` is ever reached, and does not block later - * occurrences the way `UnmarshalText`'s OWN error does. So `alg` needs both checks, in - * order, per occurrence: {@link readOptionalString} (type) then - * {@link assertDecodableJwkAlgorithm} (allowlist) — first thrown wins, matching - * the established first-saved-error behavior exactly for this field too. - * - * Checks known fields in a fixed order (not the object's own source order) — an accepted - * gap already documented on `bearer-jwt.signing-key.ts`'s `normalizeStoredJwk` for the - * analogous "multiple simultaneously-malformed DISTINCT fields" case, which this - * inherits: every genuinely malformed duplicate is still rejected, just not always - * attributed to the established first field when more than one is wrong at once. - * - * A no-op (never throws, never even builds `findTopLevelObjectFieldOccurrences`'s full - * map unnecessarily) for an object with no duplicated known field — the overwhelmingly - * common case, where every value `readOptionalString`/etc. would need to inspect is - * exactly the one they already inspect via the merged value downstream. + * Rejects an earlier malformed duplicate JWK field even though `JSON.parse` keeps only + * the last occurrence, matching `encoding/json`'s first-mismatch-wins duplicate-key + * handling; `alg` also fails on an earlier disallowed value even if a later one is valid. */ export function assertNoMalformedDuplicateJwkField(objectText: string): void { const occurrences = findTopLevelObjectFieldOccurrences(objectText); @@ -477,19 +310,9 @@ export const resolveSigningKeysConfigPaths = Effect.fnUntraced(function* <E>( onConfigParseError: (message: string) => E, ) { const path = yield* Path.Path; - // The dotenv cascade must run BEFORE `loadFromFile` ever decodes `env(...)` - // TOML references — and that cascade reaches `.env.<SUPABASE_ENV>[.local]` - // files AND the project-root directory (`<workdir>/.env`), not just - // `supabase/.env`/`.env.local`. `loadCliConfig`'s OWN internal env - // resolution (used whenever `options.projectEnv` is omitted, - // `@supabase/config`'s `loadCliProjectEnvironment`) only covers that narrower - // `supabase/`-dir, env-agnostic half — so `[auth].signing_keys_path = - // "env(KEYS_PATH)"` with `KEYS_PATH` set only in - // `.env.development`/`<workdir>/.env` would otherwise stay literally - // unexpanded here even though the established behavior resolves and signs - // with it fine. Fills the exact same gap `local-project-context.ts`'s - // `loadLocalProjectContext` already fills for `stop`/`status`, via the - // same two-step resolution. + // Loads the dotenv cascade explicitly before `loadCliConfig` decodes `env(...)` TOML + // references — `loadCliConfig`'s own internal env resolution covers only + // `supabase/.env[.local]`, not `.env.<SUPABASE_ENV>[.local]` or `<workdir>/.env`. const projectEnv = yield* loadCliProjectEnvironment({ cwd, baseEnv: process.env, @@ -505,21 +328,9 @@ export const resolveSigningKeysConfigPaths = Effect.fnUntraced(function* <E>( const loaded = yield* loadCliConfig(cwd, { cliProjectEnv: projectEnv !== null ? { ...projectEnv, values: projectEnvValues } : undefined, goViperCompat: true, - // `cwd` here is the ALREADY-resolved `CommandSettings.workdir` (the - // ancestor climb already ran once to produce it — see - // `command-settings.layer.ts`'s `resolveWorkdir`). Without `search: false`, this - // call would climb AGAIN from `cwd`, which diverges from the established - // behavior whenever an explicit `--workdir` points at a subdirectory - // below another project's root: the established behavior changes - // directly into that exact subdirectory (no climb once - // `--workdir`/`SUPABASE_WORKDIR` is set) and finds no - // `supabase/config.toml` there, while this call would otherwise still - // find the ANCESTOR project's config — regressing to the ancestor's - // `signing_keys_path` leaking into the picker prompt. - // `tomlOnly: true`: there is no concept of a JSON project config file, so - // a stray `supabase/config.json` must never win over `config.toml` here - // either (`local-project-context.ts` establishes this exact pair - // of options for the same underlying reason). + // `cwd` is already resolved (`CommandSettings.workdir`); `search: false` avoids climbing + // again, which would otherwise find an ancestor project's config when `--workdir` points + // below another project's root. `tomlOnly: true` because there is no JSON config format. search: false, tomlOnly: true, }).pipe( @@ -535,9 +346,7 @@ export const resolveSigningKeysConfigPaths = Effect.fnUntraced(function* <E>( } satisfies GenSigningKeysConfigPaths; } - // The CWD-relative `supabase/config.toml` is displayed, never an absolute - // path. `@supabase/config` always resolves `loaded.path` to an absolute - // path, so relativize it back against the project root. + // Display the config path relative to the project root; `loaded.path` is always absolute. const projectRoot = path.dirname(path.dirname(loaded.path)); const configDisplayPath = path.relative(projectRoot, loaded.path); const authEnabled = loaded.config.auth.enabled; @@ -565,24 +374,9 @@ export const resolveSigningKeysConfigPaths = Effect.fnUntraced(function* <E>( }); /** - * Reads and JSON-decodes a `[auth].signing_keys_path` file at `actualPath` into an array of - * JWK-shaped records. Established error wrapping (`"failed to read signing keys: %w"` / - * `"failed to decode signing keys: %w"`) — the "expected a JSON array [of - * objects]" shape check matches this package's own pre-existing `gen - * signing-key` behavior (not a literal error string; decode failures come - * from `encoding/json`-style type-mismatch errors, which `readJwkArray`'s two - * checks approximate). - * - * The `alg` allowlist check and the duplicate-field check below ARE literal - * established error strings (or reproductions of the established - * struct-field type-mismatch text), unlike the shape checks above: decoding - * straight into `[]config.JWK` runs the full `encoding/json` struct decode — - * including `config.Algorithm.UnmarshalText` (the RS256/ES256 allowlist) — - * for every element, wrapped here as `"failed to decode signing keys: failed - * to parse response body: %w"`. {@link assertNoMalformedDuplicateJwkField} - * closes the gap where an element has a duplicate top-level field whose - * earlier occurrence `JSON.parse` alone would have discarded before either - * check ever saw it. + * Reads and JSON-decodes a `[auth].signing_keys_path` file into an array of JWK-shaped + * records, validating the `alg` allowlist and rejecting malformed duplicate fields (see + * {@link assertNoMalformedDuplicateJwkField}) with the established error text. */ export const readSigningKeysFile = Effect.fnUntraced(function* <E1, E2>( actualPath: string, @@ -594,17 +388,9 @@ export const readSigningKeysFile = Effect.fnUntraced(function* <E1, E2>( .readFileString(actualPath) .pipe(Effect.mapError((cause) => onReadError(`failed to read signing keys: ${String(cause)}`))); const decoded = yield* Effect.try({ - // Decoding is a single `json.Decoder.Decode`-style call, which reads - // exactly ONE JSON value and never checks for trailing bytes — content - // after that first value (even further syntactically-valid JSON, e.g. a - // `signing_keys_path` file containing `"[validKey] []"`) is silently - // ignored, not an error. Plain `JSON.parse` requires the ENTIRE string to - // be exactly one value and throws on anything left over, so parse only - // the first value's own source span — reusing the same - // {@link skipJsonValue} span-scanner {@link splitJsonArrayElementTexts} - // already uses below — to match the established decode-once-ignore-the-rest - // behavior: signing still succeeds with `validKey` from a - // `signing_keys_path` file containing `[validKey] []`. + // Parses only the first JSON value's span and ignores trailing content, since plain + // `JSON.parse` would otherwise error on trailing bytes that a single-value decode + // should silently ignore. try: () => JSON.parse(raw.slice(0, skipJsonValue(raw, 0))), catch: (cause) => onDecodeError(`failed to decode signing keys: ${String(cause)}`), }); @@ -613,23 +399,9 @@ export const readSigningKeysFile = Effect.fnUntraced(function* <E1, E2>( onDecodeError("failed to decode signing keys: expected a JSON array"), ); } - // A bare `null` ARRAY ELEMENT (as opposed to `isAbsentJwkField`'s "a FIELD is - // absent") is an `encoding/json`-style zero-value case, not a type mismatch: a - // `null` decoded into `config.JWK` (a struct, not a pointer) leaves every field at - // its zero value, same as `bearer-jwt.signing-key.ts`'s `resolveSigningKeyFromStdinJwk` - // already documents for a pasted `null` JWK. So `null` must normalize to `{}` - // (an empty record — {@link readOptionalString}/etc. treat every field as absent) - // rather than fail this shape check outright, regardless of WHERE in the array it - // appears. With `signing_keys_path` decoding to `[validKey, null]`, config - // validation succeeds (`generateAPIKeys` signs with `SigningKeys[0]`, which - // is `validKey`), and a non-TTY `gen bearer-jwt` can still select - // `validKey` by kid or blank-input fallback — a `[null, validKey]` - // ordering is different (already adjudicated on this PR): - // `SigningKeys[0]` there is the null-decoded zero-value JWK, so - // `generateAPIKeys` itself fails signing before selection is ever reached - // — but that later, ALREADY-REJECTED failure is the established downstream - // signing behavior, not a reason for this decode step to reject either - // ordering up front. + // A `null` array element normalizes to `{}` (every field absent) rather than being + // rejected here, matching `encoding/json`'s zero-value decoding of a `null` struct element. + // Downstream signing may still fail on an all-absent key; this step never rejects it. for (const item of decoded) { if (item !== null && !isRecord(item)) { return yield* Effect.fail( @@ -645,10 +417,7 @@ export const readSigningKeysFile = Effect.fnUntraced(function* <E1, E2>( const record = item === null ? {} : item; const elementText = elementTexts[index]; try { - // Case-insensitive lookup (`resolveJwkFieldValue`) — the `alg` - // allowlist check (`config.Algorithm.UnmarshalText`) runs at - // JSON-decode time regardless of the key's casing; see that - // function's doc comment. + // The `alg` allowlist check runs case-insensitively, matching decode-time validation. const alg = resolveJwkFieldValue(record, "alg"); assertDecodableJwkAlgorithm(typeof alg === "string" ? alg : undefined); if (elementText !== undefined) { diff --git a/apps/cli/src/commands/gen/signing-key/signing-key.e2e.test.ts b/apps/cli/src/commands/gen/signing-key/signing-key.e2e.test.ts index 156db3b9da..4243678cbf 100644 --- a/apps/cli/src/commands/gen/signing-key/signing-key.e2e.test.ts +++ b/apps/cli/src/commands/gen/signing-key/signing-key.e2e.test.ts @@ -8,12 +8,9 @@ import { runSupabase } from "../../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; /** - * Golden-path e2e for CLI-1865: exercises the real compiled-binary boundary — - * `signing-key.command.ts`'s actual production runtime layer, not the mocked - * `Stdin` the integration suite provides via `Layer.succeed`. A missing - * `stdinLayer` in that composition only surfaces as a "Service not found" defect - * at this boundary (see the CLAUDE.md invariant 5). Per-branch - * prompt/format coverage lives in the integration suite. + * Golden-path e2e exercising the real compiled-binary boundary: the actual production runtime + * layer, not the mocked `Stdin` the integration suite provides. Per-branch prompt/format + * coverage lives in the integration suite. */ describe("supabase gen signing-key", () => { let projectDir: string; diff --git a/apps/cli/src/commands/gen/signing-key/signing-key.handler.ts b/apps/cli/src/commands/gen/signing-key/signing-key.handler.ts index 5ddb827178..338155a624 100644 --- a/apps/cli/src/commands/gen/signing-key/signing-key.handler.ts +++ b/apps/cli/src/commands/gen/signing-key/signing-key.handler.ts @@ -144,19 +144,9 @@ const generatePrivateKey = Effect.fnUntraced(function* (algorithm: SigningAlgori } satisfies SigningKeyJwk; }); -// `gen signing-key` goes through the exact same config load and validation -// pipeline as `gen bearer-jwt` — there is no separate, ungated code path for -// this command. The `[auth].signing_keys_path` file is only read when auth -// is enabled, so with auth disabled the signing keys never advance past the -// default single-key array — meaning `--append` appends to (and a -// subsequent overwrite clobbers) that phantom default set, NOT the file's -// real content: with `auth.enabled = false` and a configured -// `signing_keys_path` pointing at a file containing a real custom key, `gen signing-key -// --append` overwrote the file with the default ES256 key plus the newly generated one, -// discarding the original entry entirely — surprising, but this is the -// established behavior, so this must gate the read on `paths.authEnabled` -// exactly like `gen bearer-jwt`'s own `resolveBearerJwtSigningKey` -// already does. +// Only reads the `signing_keys_path` file when auth is enabled; otherwise `--append` appends +// to (and an overwrite clobbers) the default single-key array instead of the file's real +// content. Surprising but established behavior — matches `gen bearer-jwt`'s own gate. const loadSigningKeysConfig = Effect.fnUntraced(function* (cwd: string) { const paths = yield* resolveSigningKeysConfigPaths( cwd, @@ -221,12 +211,8 @@ export const genSigningKey = Effect.fn("gen.signing-key")(function* (flags: GenS const warnText = (text: string) => styleIfTty(tty.stdoutIsTty, "yellow", text); return yield* Effect.gen(function* () { - // The project `.env` files are loaded before the overwrite prompt reads - // the yes flag, so a `SUPABASE_YES` set only in `supabase/.env` must - // auto-confirm here too. Resolved inside this block (not above it) so a - // malformed/unreadable `.env` still flushes telemetry below — telemetry - // must attach before the config load runs, so the capture still fires - // even when that load fails. + // Loaded here (not above) so a malformed `.env` still flushes telemetry: `SUPABASE_YES` + // in `supabase/.env` must be able to auto-confirm the overwrite prompt below. const projectEnv = yield* loadProjectEnv(fs, path, cliSettings.workdir); const yes = yield* resolveYesWithProjectEnv(projectEnv); // The configured signing-keys file is validated before any key is @@ -247,24 +233,16 @@ export const genSigningKey = Effect.fn("gen.signing-key")(function* (flags: GenS const nextKeys = flags.append ? [...configured.value.existingKeys, key] : yield* Effect.gen(function* () { - // `promptYesNo` silently returns the default (true) for any non-text - // `--output-format`, but this command has no structured json/stream-json output - // (SIDE_EFFECTS.md) — that combination only arises from a real interactive TTY - // explicitly requesting machine output. Fail closed rather than silently - // overwriting irrecoverable key material. + // Fail closed instead of relying on `promptYesNo`'s default-true fallback: this + // command has no structured json/stream-json output, so silently overwriting key + // material on a machine-output TTY would be worse than erroring. const confirmed = !yes && tty.stdinIsTty && output.format !== "text" ? false : yield* promptYesNo( - // `promptYesNo` checks `output.format !== "text"` BEFORE it checks - // TTY, so a non-TTY (piped or empty) invocation under `json`/`stream-json` - // would otherwise hit that check first and return the default without - // ever reading stdin. The confirmation prompt has no concept of output - // format at all — it always reads piped stdin — so a piped `y`/`n` answer - // must be honored here the same as in text mode. Present a text-shaped - // view of `output` to reach that read; `raw`/`promptConfirm` write the - // prompt to stderr under every `Output` layer, so this never touches the - // machine-readable stdout payload. + // Presents a text-shaped `output` so `promptYesNo` reads piped stdin instead + // of short-circuiting on `output.format !== "text"`; the prompt itself always + // writes to stderr, so this never touches the machine-readable stdout payload. output.format === "text" ? output : { ...output, format: "text" }, yes, `Do you want to overwrite the existing ${emphasize(configured.value.displayPath)} file?`, diff --git a/apps/cli/src/commands/gen/signing-key/signing-key.integration.test.ts b/apps/cli/src/commands/gen/signing-key/signing-key.integration.test.ts index ec15ab6fc0..55b6f476a5 100644 --- a/apps/cli/src/commands/gen/signing-key/signing-key.integration.test.ts +++ b/apps/cli/src/commands/gen/signing-key/signing-key.integration.test.ts @@ -40,17 +40,14 @@ interface SetupOptions { readonly yes?: boolean; readonly promptConfirmResponses?: ReadonlyArray<boolean>; readonly trackTelemetry?: boolean; - // Exit code returned by the mocked `git check-ignore` subprocess. `0` means the path is - // ignored, any non-zero code means it is not. Only consumed by the gitignore-warning branch. + // Exit code for the mocked `git check-ignore` subprocess: `0` means ignored. readonly gitCheckIgnoreExitCode?: number; - // Piped (non-TTY) stdin answer for the overwrite prompt (CLI-1865). + // Piped (non-TTY) stdin answer for the overwrite prompt. readonly pipedAnswer?: string; // Raw argv for `resolveYes`'s explicit `--yes=false` detection. readonly cliArgs?: ReadonlyArray<string>; } -// `git check-ignore` is invoked via ChildProcessSpawner. Mock it with a controlled exit code so -// the gitignore-warning branch is exercised in-process without depending on a real `git` binary. function mockGitCheckIgnore(exitCode: number) { return Layer.succeed( ChildProcessSpawner.ChildProcessSpawner, @@ -115,8 +112,7 @@ async function writeJsonConfig(contents: string) { await writeFile(join(tempRoot.current, "supabase", "config.json"), contents); } -// `findGitRoot` walks up looking for a real `.git` entry, so the gitignore branch needs one to -// exist; the `git check-ignore` call itself is mocked via `gitCheckIgnoreExitCode`. +// `findGitRoot` needs a real `.git` entry; `git check-ignore` itself is mocked separately. async function initGitDir() { await mkdir(join(tempRoot.current, ".git"), { recursive: true }); } @@ -210,13 +206,6 @@ describe("gen signing-key integration", () => { () => { const { layer, out } = setup(); return Effect.gen(function* () { - // Config loading has no concept of a JSON project config file — a - // stray `supabase/config.json` with no `config.toml` present must be - // treated exactly like no config at all, never as a substitute config source - // (`gen.signing-keys-config.ts`'s `loadCliConfig(..., { tomlOnly: true })`). - // The CWD-relative `supabase/config.toml` is printed in this - // "absent config" case; the hint must stay relative and must never leak the absolute - // temp-dir path either. yield* Effect.tryPromise(() => writeJsonConfig("{}\n")); yield* genSigningKey({ algorithm: "ES256", append: false }); @@ -254,9 +243,6 @@ describe("gen signing-key integration", () => { }, ); - // The overwrite prompt reads piped stdin even in non-TTY mode and honors an - // explicit "n" — before this fix, TS returned `true` unconditionally without reading stdin at - // all, so `echo n | supabase gen signing-key` silently overwrote instead of canceling. it.live("cancels the overwrite when a piped non-tty answer of 'n' is read", () => { const { layer, out } = setup({ stdinIsTty: false, pipedAnswer: "n" }); return Effect.gen(function* () { @@ -279,7 +265,6 @@ describe("gen signing-key integration", () => { readFile(join(tempRoot.current, "supabase", "signing_keys.json"), "utf8"), ); expect(JSON.parse(saved)).toEqual([]); - // The non-TTY prompt echoes the piped answer back to stderr after the label. expect(out.stderrText).toContain("[Y/n] n\n"); }).pipe(Effect.provide(layer)); }); @@ -352,12 +337,6 @@ describe("gen signing-key integration", () => { }).pipe(Effect.provide(layer)); }); - // The configured `signing_keys_path` file is only read/decoded when auth - // is enabled — `gen signing-key` goes through the exact same config load - // pipeline as `gen bearer-jwt` (both call it), so it is subject to the - // same gate: with `auth.enabled = false`, a malformed signing-keys file is - // never read at all, so the command succeeds instead of failing on a - // decode error. it.live("does not fail on a malformed signing keys file when [auth] enabled is false", () => { const { layer } = setup(); return Effect.gen(function* () { @@ -373,13 +352,6 @@ describe("gen signing-key integration", () => { }).pipe(Effect.provide(layer)); }); - // Same finding, the more surprising half: the signing keys never advance - // past the default single-key array when auth is disabled, so `--append` - // appends to (and the resulting write clobbers) that phantom default set, - // NOT the file's real content: appending under `auth.enabled = false` - // against a file containing a genuine custom key overwrote it with the - // default ES256 key plus the newly generated one, discarding the original - // entry entirely. it.live( "appends to (and overwrites with) the built-in default key, ignoring the real file content, when [auth] enabled is false", () => { @@ -475,7 +447,6 @@ describe("gen signing-key integration", () => { const saved = yield* Effect.tryPromise(() => readFile(absoluteKeysPath, "utf8")); const parsed = JSON.parse(saved) as ReadonlyArray<Record<string, unknown>>; expect(parsed).toHaveLength(1); - // An absolute configured path is displayed verbatim, matching Go. expect(out.stderrText).toContain(absoluteKeysPath); }).pipe(Effect.provide(layer)); }); @@ -574,9 +545,6 @@ describe("gen signing-key integration", () => { }).pipe(Effect.provide(layer)); }); - // This command has no structured json/stream-json output (SIDE_EFFECTS.md), so a real TTY - // requesting machine output is an unsupported combination — fail closed on this destructive, - // irreversible overwrite rather than silently defaulting to yes with no prompt at all. it.live( "declines the overwrite without prompting on a tty when --output-format is not text", () => { @@ -605,11 +573,6 @@ describe("gen signing-key integration", () => { }, ); - // CLI-1865 follow-up: `promptYesNo` checks `output.format !== "text"` BEFORE it - // checks TTY, so a non-TTY invocation under `json`/`stream-json` must not fall into that - // early return — this command has no structured json/stream-json payload, so a piped - // answer must be honored the same as text mode. Before this fix, a piped "n" here was - // silently ignored and the file was overwritten with the default (true). it.live("honors a piped non-tty 'n' even when --output-format is json", () => { const { layer, out } = setup({ format: "json", stdinIsTty: false, pipedAnswer: "n" }); return Effect.gen(function* () { @@ -654,10 +617,6 @@ describe("gen signing-key integration", () => { }); it.live("honors SUPABASE_YES and overwrites even when a piped 'n' is present", () => { - // Go reads `viper.GetBool("YES")` (incl. the SUPABASE_YES env var) BEFORE scanning - // stdin (`console.go:71`), so `SUPABASE_YES=1 printf 'n\n' | supabase gen signing-key` - // auto-confirms and overwrites rather than consuming the piped `n`. The handler - // resolves `yes` via `resolveYes`, not the raw --yes flag. const prev = process.env["SUPABASE_YES"]; process.env["SUPABASE_YES"] = "1"; const { layer } = setup({ stdinIsTty: false, pipedAnswer: "n" }); @@ -690,14 +649,7 @@ describe("gen signing-key integration", () => { it.live( "auto-confirms from SUPABASE_YES in the project .env, even with a piped 'n' (CLI-1878)", () => { - // SUPABASE_YES lives only in supabase/.env, not the shell. The project - // `.env` files load before the overwrite prompt reads the yes flag, so - // the overwrite auto-confirms and the piped `n` is never consumed — - // same precedence as the shell-env case above. - // - // Defensively clear a shell SUPABASE_YES: this test must prove the project-.env source - // specifically, not accidentally pass because a prior test in this file left the shell - // env set (the sibling shell-env tests above save/restore theirs). + // Clears any shell SUPABASE_YES so this proves the project-.env source specifically. const prev = process.env["SUPABASE_YES"]; delete process.env["SUPABASE_YES"]; const { layer } = setup({ stdinIsTty: false, pipedAnswer: "n" }); @@ -776,11 +728,6 @@ describe("gen signing-key integration", () => { }); it.live("flushes telemetry state even when the project .env is malformed (Codex review)", () => { - // Go attaches the telemetry service in root's `PersistentPreRunE` (cmd/root.go:131-155), - // before this command's own `RunE` runs `flags.LoadConfig` (signingkeys.go:99), so - // `service.Capture` still fires even when that project-.env load fails. The project-env - // resolution here must live inside the `Effect.ensuring(telemetryState.flush)`-wrapped - // block for the same reason — locks in that fix. const { layer, telemetry } = setup({ trackTelemetry: true }); return Effect.gen(function* () { yield* Effect.tryPromise(() => diff --git a/apps/cli/src/commands/gen/types/types.e2e.test.ts b/apps/cli/src/commands/gen/types/types.e2e.test.ts index d6f2e336a5..3d29d7b82e 100644 --- a/apps/cli/src/commands/gen/types/types.e2e.test.ts +++ b/apps/cli/src/commands/gen/types/types.e2e.test.ts @@ -19,9 +19,8 @@ type TypegenLang = (typeof TYPEGEN_LANGS)[number]; const LOCAL_POSTGRES_IMAGE = getRegistryImageUrl(dockerfileServiceImage("pg")); const LOCAL_POSTGRES_TIMEOUT_MS = 120_000; const TYPEGEN_TIMEOUT_MS = 90_000; -// Image resolution happens inside the test bodies, ahead of the startup and -// per-language windows the test timeouts already budget — so each timeout has -// to include its own image setup allowance on top. +// Image resolution runs inside the test body, so its timeout must add on top of the +// startup and per-language windows the test already budgets. const LOCAL_IMAGE_BUDGET_MS = LOCAL_POSTGRES_TIMEOUT_MS + TYPEGEN_TIMEOUT_MS; const REMOTE_E2E_FLAG = "SUPABASE_TYPEGEN_E2E_REMOTE"; const REMOTE_PROJECT_REF_ENV = "SUPABASE_TEST_PROJECT_REF"; @@ -218,9 +217,8 @@ async function ensurePgmetaImage(deadline?: number) { async function startLocalPostgres(input: { readonly projectId: string; readonly dbPort: number }) { const containerName = localDbContainerId(input.projectId); const networkName = localNetworkId(input.projectId); - // One shared window (already counted in the local test's timeout), with - // pg-meta's slice reserved up front: Postgres may spend the window only up - // to the point that still leaves pg-meta the default budget. + // Reserves pg-meta's slice of the shared window up front so Postgres pull time can't + // starve it. const imageDeadline = resolveDeadline(LOCAL_IMAGE_BUDGET_MS); const postgresImage = await ensureImage(LOCAL_POSTGRES_IMAGE, imageDeadline - RESOLVE_BUDGET_MS); await ensurePgmetaImage(imageDeadline); diff --git a/apps/cli/src/commands/gen/types/types.errors.ts b/apps/cli/src/commands/gen/types/types.errors.ts index bcacc044fd..3cd63213f2 100644 --- a/apps/cli/src/commands/gen/types/types.errors.ts +++ b/apps/cli/src/commands/gen/types/types.errors.ts @@ -61,11 +61,8 @@ export class GenTypesWorkdirError extends Data.TaggedError("GenTypesWorkdirError } /** - * `loadCliConfig` failed to parse `supabase/config.toml`/`config.json`, or - * found two `[remotes.*]` blocks declaring the same `project_id`. Mirrors - * `ConfigDiffLoadConfigError`'s parse-error/duplicate-remote handling - * (`config diff`'s `loadLocalConfig`) so a malformed config reports its own - * parse failure instead of the raw `CliConfigParseError`/ + * `loadCliConfig` failed to parse the config, or found two `[remotes.*]` blocks with the same + * `project_id`. Reports a clean parse failure instead of the raw `CliConfigParseError`/ * `DuplicateRemoteProjectIdError` tag leaking through as the message. */ export class GenTypesParseConfigError extends Data.TaggedError("GenTypesParseConfigError")<{ @@ -77,12 +74,9 @@ export class GenTypesParseConfigError extends Data.TaggedError("GenTypesParseCon } /** - * An explicit `--workdir`/`SUPABASE_WORKDIR` holds no project config, on a - * path that would otherwise load one (`--linked`/`--project-id`/`--db-url`, - * or the linked fallback) — raised instead of silently falling back to the - * embedded default schemas, which would drop a declared `[api].schemas` and - * write a public-only types file at exit 0. A DEFAULTED workdir keeps the - * established tolerant fallback. + * Raised when an explicit `--workdir`/`SUPABASE_WORKDIR` holds no project config on a path that + * would otherwise load one, instead of silently falling back to the embedded default schemas + * (which would drop a declared `[api].schemas`). A defaulted workdir keeps the tolerant fallback. */ export class GenTypesMissingProjectConfigError extends Data.TaggedError( "GenTypesMissingProjectConfigError", diff --git a/apps/cli/src/commands/gen/types/types.handler.ts b/apps/cli/src/commands/gen/types/types.handler.ts index 30ff8c23d4..68212fe214 100644 --- a/apps/cli/src/commands/gen/types/types.handler.ts +++ b/apps/cli/src/commands/gen/types/types.handler.ts @@ -87,11 +87,8 @@ const mapBranchDatabaseConfigError = mapHttpError({ `unexpected preview branch database config status ${status}: ${body}`, }); -// A 404 from `GET /v1/projects/{ref}` means the ref is a preview branch rather -// than a project, so fall back to the branch config endpoint. Mirror the link -// handler, which treats *any* 404 as the branch case (`link.handler.ts:46-50`); -// do not narrow on the response body, since the Management API's 404 wording -// is not guaranteed. +// A 404 from `GET /v1/projects/{ref}` means the ref is a preview branch, not a project — fall +// back to the branch config endpoint. Don't narrow on the response body; its wording isn't guaranteed. function isProjectNotFound(cause: unknown) { return cause instanceof GenTypesUnexpectedStatusError && cause.status === 404; } @@ -107,9 +104,7 @@ type GenTypesMutexFlag = | "swift-access-control" | "query-timeout"; -// Four mutually-exclusive flag groups. Validation runs in lexicographically -// sorted group-key order and reports only the first violated group, so they -// are listed here in that sorted order — e.g. `--db-url X +// Validation reports only the first violated group, in this listed order — e.g. `--db-url X // --postgrest-v9-compat --project-id Y` reports the postgrest group, not the // local/linked/project-id/db-url group. const GEN_TYPES_MUTEX_GROUPS: ReadonlyArray<ReadonlyArray<GenTypesMutexFlag>> = [ @@ -120,15 +115,9 @@ const GEN_TYPES_MUTEX_GROUPS: ReadonlyArray<ReadonlyArray<GenTypesMutexFlag>> = ]; /** - * Every value-taking (non-boolean) flag reachable when `gen types` parses: - * the command's own (`types.command.ts`) plus the root's persistent value - * flags — these tell `pflagArgvScan` which bare tokens consume the next argv - * token as their value. `--local`, `--linked`, and `--postgrest-v9-compat` - * are this command's only boolean flags and are deliberately excluded; - * booleans never consume a following token. `--schema`'s `-s` shorthand - * (Go `cmd/gen.go:155` `StringSliceVarP`) is covered via the shorthand map - * so a genuine `-s public` invocation — and a bare `-s` consuming the next - * flag-shaped token as pflag does — is seen exactly as pflag sees it. + * Every value-taking flag `gen types` parses, telling `pflagArgvScan` which bare tokens + * consume the next argv token as their value. Boolean flags (`--local`, `--linked`, + * `--postgrest-v9-compat`) are excluded since they never consume a following token. */ const GEN_TYPES_SCAN_SPEC = { valueFlagNames: new Set([ @@ -162,10 +151,9 @@ function collectByteStream(stream: Stream.Stream<Uint8Array, unknown>) { ).pipe(Effect.map((text) => text + decoder.decode())); } -// Keep these two sets in sync with the value-bearing flags on the root command -// (command-internal/global-flags.ts) and the `gen types` command (types.command.ts). -// They let `findPositionalLanguage` skip a flag's value so it is not -// mistaken for the legacy positional language argument (e.g. `gen types typescript`). +// Keep in sync with the value-bearing flags on the root command and `gen types` itself. +// Lets `findPositionalLanguage` skip a flag's value so it isn't mistaken for the legacy +// positional language argument (e.g. `gen types typescript`). const LONG_FLAGS_WITH_VALUES = new Set([ "db-url", "project-id", @@ -245,49 +233,34 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) const dbConfig = yield* DbConfigResolver; const sslProbe = yield* PgDeltaSslProbe; - // "Set" follows cobra's `pflag.Changed` semantics — whether the flag was - // passed at all — not the resulting value: `--linked=false` still counts - // as set. Scanning raw argv keeps detection aligned with pflag's semantics - // rather than with whatever the TS parser produced — e.g. a bare - // `-s --linked --local` is pflag's `-s` consuming `--linked` as its - // (oddly named, but valid) schema value, leaving only `--local` changed, - // while the Effect parser reads `--linked` as its own boolean flag. + // "Set" means the flag appeared in argv at all (pflag's `Changed` semantics), not its parsed + // value — `--linked=false` still counts. Argv is scanned directly since a token like + // `-s --linked` consumes `--linked` as `-s`'s value, not as its own boolean flag. const scan = pflagArgvScan(rawArgs, GEN_TYPES_COMMAND_PATH, GEN_TYPES_SCAN_SPEC); const occurrences = scan.occurrences; - // `--query-timeout` is parsed at flag-parse time (pflag's `DurationVar`), - // before the telemetry context is installed — so an invalid duration wins - // over every guard below, and unlike them, its rejection is never - // followed by a telemetry flush. + // Parsed before the telemetry context is installed, so an invalid `--query-timeout` wins + // over every guard below and, unlike them, is never followed by a telemetry flush. const queryTimeoutSeconds = yield* parseQueryTimeoutSeconds(flags.queryTimeout); - // flags.schema is already CSV-parsed and validated by `Flag.mapTryCatch(parseSchemaFlags)` - // in types.command.ts — use it directly. const schemas = flags.schema; const lang = flags.lang; const swiftAccessControl = flags.swiftAccessControl; - // Resolved against `cliSettings.workdir`, the root every config load in - // this handler uses. const toRelativeConfigPath = (path: string) => relativeConfigPath(cliSettings.workdir, path); - // `projectRef` is only ever passed for the `--linked`/`--project-id` paths - // below (so a matching `[remotes.*]` overlay is merged in the SAME load); - // omitted for the `--local`/`--db-url` paths, matching `loadCliConfig`'s - // own optional `projectRef`. + // `projectRef` is passed only for the `--linked`/`--project-id` paths, so a matching + // `[remotes.*]` overlay is merged in the same load; omitted for `--local`/`--db-url`. const loadConfig = (projectRef?: string) => loadCliConfig(cliSettings.workdir, { ...(projectRef === undefined ? {} : { projectRef }), goViperCompat: true, search: shouldSearchAncestors(cliSettings), }).pipe( - // `cause.path` names the file that actually failed to parse — `loadCliConfig` - // probes `supabase/config.json` before falling back to `supabase/config.toml` - // (`findCliProjectPaths`), so hardcoding the `.toml` name here would mislabel a - // broken `config.json`. Caught regardless of `explicitWorkdir` — a malformed - // config is a parse failure, not the "no project here" case - // `requireProjectConfigWhenExplicit` handles, so it must run before that - // flatMap ever sees the (by-then-already-failed) load. + // `cause.path` names the actual failed file; `loadCliConfig` probes `config.json` + // before falling back to `config.toml`, so hardcoding `.toml` here would mislabel it. + // Caught before `requireProjectConfigWhenExplicit`, since a parse failure is distinct + // from the "no project here" case that guard handles. Effect.catchTag( "CliConfigParseError", (cause) => @@ -302,10 +275,9 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) Effect.flatMap(requireProjectConfigWhenExplicit), ); - // CLI-2285: an explicit --workdir that holds no project must not silently - // resolve to the embedded default schemas (dropping a declared [api].schemas - // and writing a public-only types file, exit 0). A DEFAULTED workdir keeps - // today's tolerant fallback. + // An explicit --workdir that holds no project must not silently resolve to the embedded + // default schemas (dropping a declared [api].schemas and writing a public-only file at exit + // 0). A defaulted workdir keeps the tolerant fallback. const requireProjectConfigWhenExplicit = (loaded: LoadedCliConfig | null) => loaded === null && cliSettings.explicitWorkdir ? Effect.gen(function* () { @@ -464,12 +436,9 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) Effect.gen(function* () { yield* output.raw(`Connecting to ${target.host} ${target.port}\n`, "stderr"); - // Each entry is a "KEY=VALUE" string, passed as a `--env - // KEY=VALUE` argument rather than a `--env-file`: env-files - // split on newlines, so they cannot carry the multi-line PEM CA - // bundle, and a value containing a newline could inject an extra - // variable. Passing argv elements keeps each entry as exactly - // one variable regardless of its contents. + // Passed as `--env KEY=VALUE` args rather than `--env-file`: env-files split on + // newlines and can't carry the multi-line PEM CA bundle without injecting an + // extra variable. const env = [ `PG_META_DB_URL=${target.url}`, `PG_CONN_TIMEOUT_SECS=${queryTimeoutSeconds}`, @@ -480,9 +449,8 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) `PG_META_GENERATE_TYPES_DETECT_ONE_TO_ONE_RELATIONSHIPS=${String(!input.postgrestV9Compat)}`, ]; - // Emitted to stderr when the probe runs with certificate - // verification disabled. Our wire-level SSLRequest probe never - // verifies certificates, so honour the same env var here too. + // The SSL probe never verifies certificates on its own, so honor the same env var + // here too when warning about disabled verification. if (process.env["SUPABASE_CA_SKIP_VERIFY"] === "true") { yield* output.raw( "WARNING: TLS certificate verification disabled for SSL probe (SUPABASE_CA_SKIP_VERIFY=true)\n", @@ -497,8 +465,7 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) // After the TLS probe, so an unreachable database fails before any image pull. const pgmetaImage = yield* resolvedImage; - // `--network-id` overrides any base network mode (even the - // "host" mode used for --db-url), so honour the override here too. + // `--network-id` overrides any base network mode, including "host" for --db-url. const networkMode = Option.isSome(networkId) ? networkId.value : input.networkMode; const args = [ "run", @@ -567,9 +534,8 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) const assertLocalDbRunning = (projectId: string) => Effect.scoped( Effect.gen(function* () { - // We only need the exit code and stderr (Go uses Docker's ContainerInspect API, - // which reads no stdout). Discard stdout so the inspect JSON can never fill the - // pipe buffer and deadlock the unconsumed stream. + // Only the exit code and stderr matter; discard stdout so the inspect JSON can't + // fill the pipe buffer and deadlock the unconsumed stream. const child = yield* spawnContainerCli( spawner, ["container", "inspect", localDbContainerId(projectId)], @@ -600,19 +566,14 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) ); yield* Effect.gen(function* () { - // The resolved `--workdir`/`SUPABASE_WORKDIR` must exist and be a - // directory before the command's own guard or flag-group validation — - // the query-timeout parse failure above still precedes it (parsed at - // flag-parse time, before the telemetry context). + // Validated before the command's own guard or flag-group validation; the query-timeout + // parse failure above still precedes even this, since it happens at flag-parse time. yield* validateWorkdirIsDirectory(cliSettings.workdir, fs).pipe( Effect.mapError((error) => new GenTypesWorkdirError({ message: error.message })), ); - // The command's own guard runs next, then flag-group validation — so - // this guard's error wins when both apply (e.g. `--local --linked - // --postgrest-v9-compat`). Both run AFTER the telemetry context is - // already installed, unlike the query-timeout parse failure above, so - // every return in this block must stay inside the + // This guard runs before flag-group validation, so its error wins when both apply. Both + // run after the telemetry context is installed, so every return here must stay inside the // `Effect.ensuring(telemetryState.flush)` below. if (flags.postgrestV9Compat && Option.isNone(flags.dbUrl)) { // Established error text, including the "must used" typo — do not @@ -630,12 +591,9 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) return yield* Effect.fail(new Error("use --lang flag to specify the typegen language")); } - // Cobra's mutual exclusion keys off pflag `Changed` — a flag counts as - // set once passed explicitly, regardless of value, so `--linked=false` - // still trips its groups. `project-id` and `db-url` are read straight off - // the parsed flags: neither has a boolean-vs-default ambiguity, and - // reconciling them against the scan is unnecessary here — every guard - // test drives the handler with argv that matches its flags. + // A flag counts as set once passed explicitly, regardless of value (`--linked=false` + // still trips its group). `project-id`/`db-url` are read straight off parsed flags since + // they have no boolean-vs-default ambiguity. const changedMutexFlags: Record<GenTypesMutexFlag, boolean> = { local: occurrences.has("local"), linked: occurrences.has("linked"), @@ -663,10 +621,8 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) ); const paths = tempPaths(path, cliSettings.workdir); - // Go resolves Config.Api.Image from the rest-version file only when - // Db.MajorVersion > 14, then forces v9 compat when that image tag contains "v9" - // (pkg/config/config.go:657-666, internal/gen/types/types.go:69). Gate and trim - // identically so we don't force v9 on older databases. + // Only forces v9 compat from the rest-version file's image tag when the database's + // major version is > 14, so older databases aren't forced into v9 mode. const restVersion = config.majorVersion > 14 ? (yield* fs @@ -704,11 +660,9 @@ export const genTypes = Effect.fn("gen.types")(function* (flags: GenTypesFlags) } if (Option.isSome(flags.dbUrl)) { - // Mirrors the `--linked`/`--project-id` branches below: an explicit - // `--schema` makes the config load's only output (the schema fallback) - // unused, so skip it entirely — an explicit workdir with no project - // must not fail a `--db-url --schema ...` invocation that never needed - // the config in the first place. + // Skips the config load entirely when `--schema` is explicit, since the load's only + // output here is the schema fallback — a `--db-url --schema ...` invocation must not + // fail just because the workdir has no project config. const loaded = schemas.length > 0 ? null : yield* loadConfig(); const direct = yield* parseDatabaseUrl(flags.dbUrl.value); const includedSchemas = ( diff --git a/apps/cli/src/commands/gen/types/types.integration.test.ts b/apps/cli/src/commands/gen/types/types.integration.test.ts index 143d471984..d838d2c4dd 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -764,13 +764,6 @@ describe("gen types", () => { it.live( "fails instead of picking up an ancestor project's configured api schemas when --workdir names a subdirectory with no config of its own", () => { - // CLI-2285 regression: an explicit --workdir is authoritative and must - // never let the schema-resolution config load climb past it — the - // ancestor (root) genuinely declares [api].schemas and the - // subdirectory genuinely has no supabase/ of its own. Silently falling - // back to the built-in "public" default (dropping the ancestor's - // schemas without a hint) would write a wrong types file at exit 0, so - // this now hard-fails before any network call instead. const root = mkdtempSync(join(tmpdir(), "supabase-gen-types-ancestor-")); writeConfig( root, @@ -806,10 +799,6 @@ describe("gen types", () => { it.live( "a defaulted workdir still picks up an ancestor project's configured api schemas from a subdirectory", () => { - // Complements the regression above: a DEFAULTED (unset) --workdir must - // keep climbing so the ancestor's declared [api].schemas still resolves - // from a config-less subdirectory — proving the hard-fail fix above - // didn't break the legitimate default-climb case. const root = mkdtempSync(join(tmpdir(), "supabase-gen-types-ancestor-")); writeConfig( root, @@ -844,12 +833,6 @@ describe("gen types", () => { Effect.tryPromise({ try: () => withSslProbeServer(async (port) => { - // The --db-url branch's config load exists only to fall back to - // a declared [api].schemas when --schema is absent — with an - // explicit --schema that load's result is unused, so it's - // skipped entirely, and a config-less explicit --workdir (here, - // a subdirectory of an unrelated ancestor project) must not - // fail an invocation that never needed the config. const docker = captureDockerRun(); const root = mkdtempSync(join(tmpdir(), "supabase-gen-types-ancestor-")); writeConfig( @@ -875,8 +858,6 @@ describe("gen types", () => { ).pipe(Effect.provide(layer)), ); - // The explicit --schema wins, not the (unreachable) ancestor's - // declared schema. expect(docker.env.has("PG_META_GENERATE_TYPES_INCLUDED_SCHEMAS=public")).toBe(true); }), catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), @@ -913,11 +894,6 @@ describe("gen types", () => { it.live( "surfaces a real error message when supabase/config.toml is malformed, not the raw CliConfigParseError tag", () => { - // CLI-2285 Round 3: `loadConfigForRef` now catches `CliConfigParseError` - // and maps it to `GenTypesParseConfigError` with a real message. - // Before this fix the raw `CliConfigParseError` tag propagated unmapped, - // so an assertion that only checked `Exit.isFailure` would not catch a - // re-regression of that leak — the message content itself is the point. const workdir = mkdtempSync(join(tmpdir(), "supabase-gen-types-malformed-")); writeConfig(workdir, 'project_id = "unterminated\n'); const { layer, api } = setup({ @@ -971,27 +947,15 @@ describe("gen types", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - // cobra sorts the violating-flag set alphabetically (sort.Strings) — - // "linked" before "local" — regardless of check order. expect(String(exit.cause)).toContain( "if any flags in the group [local linked project-id db-url] are set none of the others can be; [linked local] were all set", ); } - // The root's `PersistentPreRunE` has already installed the telemetry - // context by the time cobra validates flag groups (`cmd/root.go:93-163`, - // `command.go:1000-1010`), so a mutex rejection still flushes telemetry. expect(telemetry.flushed).toBe(true); }); }); it.live("does not misdetect a mutex flag consumed as -s's value (pflag consumption)", () => { - // `-s` is a pflag string-slice shorthand: a bare `-s` consumes the very - // next argv token unconditionally, even a flag-shaped one — pflag hands - // `-s` the (oddly named, but valid) value `"--linked"`, leaving only - // `--local` Changed. Simulates what the real Effect parser produces for - // this argv (both `local` and `linked` parse as independently true, - // since its tokenizer is unaware of pflag's value consumption); only the - // pflag-faithful scan can tell them apart. // `childExitCode: 1` fails the local target's `container inspect`, keeping the // downstream failure deterministic before the real SSL probe can reach whatever // is listening on the local db port. @@ -1102,8 +1066,6 @@ describe("gen types", () => { "--postgrest-v9-compat must used together with --db-url", ); } - // The guard runs after the telemetry context is already installed, so - // this restored guard must still flush telemetry on rejection. expect(telemetry.flushed).toBe(true); }); }); @@ -1160,8 +1122,6 @@ describe("gen types", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - // pflag's `Changed` is true once a flag is passed explicitly, even as - // `--linked=false`, so cobra still trips the mutex group. expect(String(exit.cause)).toContain( "if any flags in the group [linked project-id postgrest-v9-compat] are set none of the others can be; [linked project-id] were all set", ); @@ -1182,16 +1142,9 @@ describe("gen types", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - // Go rejects the duration at flag-parse time, before PreRunE and the - // mutex groups, so the parse error wins over the linked/query-timeout - // mutex violation. expect(String(exit.cause)).toContain('invalid duration "bogus"'); expect(String(exit.cause)).not.toContain("if any flags in the group"); } - // pflag's `DurationVar` rejects this at flag-parse time, before the - // root's `PersistentPreRunE` ever installs the telemetry context - // (`cmd/root.go:93-163`) — unlike the guards below, this rejection must - // NOT flush telemetry. expect(telemetry.flushed).toBe(false); }); }); @@ -1206,9 +1159,6 @@ describe("gen types", () => { return Effect.gen(function* () { yield* genTypes(defaultFlags({ queryTimeout: "20s" })).pipe(Effect.provide(layer)); - // Go neither errors nor warns here — only one flag of the - // linked/project-id/query-timeout mutex group is set, and the remote - // TypeScript path simply never reads the timeout. expect(out.stderrText).not.toContain("--query-timeout"); expect(api.requests).toContainEqual({ method: "generateTypescriptTypes", @@ -1256,11 +1206,6 @@ describe("gen types", () => { ).pipe(Effect.provide(layer)), ); - // Unlike an explicit --linked/--project-id, the implicit fallback never - // sets the "linked"/"project-id" mutex keys, so --query-timeout and - // --swift-access-control clear every guard here and reach pg-meta — the - // SIDE_EFFECTS.md defaults-invariant note is scoped to the explicit - // paths for exactly this reason. expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); expect(docker.env.has("PG_QUERY_TIMEOUT_SECS=20")).toBe(true); expect(docker.env.has("PG_CONN_TIMEOUT_SECS=20")).toBe(true); @@ -1282,8 +1227,6 @@ describe("gen types", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - // Go runs the command's PreRunE before cobra's flag-group validation - // (spf13/cobra command.go:1000-1010), so the PreRunE error wins. expect(String(exit.cause)).toContain( "--postgrest-v9-compat must used together with --db-url", ); @@ -1326,9 +1269,6 @@ describe("gen types", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - // Cobra validates the groups in lexicographically sorted key order, so - // the linked/project-id/postgrest-v9-compat group reports before the - // local/linked/project-id/db-url group even though both are violated. expect(String(exit.cause)).toContain( "if any flags in the group [linked project-id postgrest-v9-compat] are set none of the others can be; [postgrest-v9-compat project-id] were all set", ); @@ -1370,8 +1310,6 @@ describe("gen types", () => { onSpawn: docker.onSpawn, }); - // Go has no "--swift-access-control requires --lang swift" guard — - // the value is always forwarded to pg-meta regardless of language. await Effect.runPromise( genTypes( defaultFlags({ local: true, lang: "python", swiftAccessControl: "public" }), @@ -1499,8 +1437,6 @@ describe("gen types", () => { ).toBe(true); expect(dbConfig.resolves).toHaveLength(1); expect(dbConfig.resolves[0]?.connType).toBe("linked"); - // --project-id is an ad-hoc remote ref: the resolver must not inherit - // the workdir's ambient password / saved pooler URL. expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(true); const linkedProjectRef = dbConfig.resolves[0]?.linkedProjectRef; expect( @@ -1544,7 +1480,6 @@ describe("gen types", () => { expect(dbConfig.resolves).toHaveLength(1); expect(dbConfig.resolves[0]?.connType).toBe("linked"); - // --linked is the workdir's own project: keep workdir-scoped credentials. expect(dbConfig.resolves[0]?.adHocProjectRef).toBe(false); }), catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), @@ -2513,8 +2448,6 @@ describe("gen types", () => { const { layer, api, dbConfig } = setup({ args: ["gen", "types", "--lang", "python", "--project-id", VALID_REF], childStdout: ["class PublicMovies(BaseModel):"], - // The Management API's 404 wording is not guaranteed; a generic body - // must still route to the branch config endpoint. getProject: () => Effect.fail(statusApiError(404, `{"message":"Not found"}`)), getABranchConfig: ({ branch_id_or_ref }) => Effect.succeed({ @@ -2656,8 +2589,6 @@ describe("gen types", () => { ); expect(child.spawned[2]?.args).toContain(resolvePgmetaImage()); expect(child.spawned[2]?.args.slice(-2)).toEqual(["node", "dist/server/server.js"]); - // The local/db-url paths have no project ref, so they must not - // populate the linked-project cache. expect(linkedProjectCache.cached).toBe(false); }), catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), @@ -3426,9 +3357,6 @@ describe("gen types", () => { }), ); - // The SSL probe does not special-case `--debug`: a successful probe - // returns true regardless, so the bundle is passed to pgmeta regardless of - // the flag. it.live("passes the CA bundle env var in --debug mode when TLS is supported", () => Effect.tryPromise({ try: () => diff --git a/apps/cli/src/commands/gen/types/types.layers.ts b/apps/cli/src/commands/gen/types/types.layers.ts index de5ab84d39..55e75f2191 100644 --- a/apps/cli/src/commands/gen/types/types.layers.ts +++ b/apps/cli/src/commands/gen/types/types.layers.ts @@ -23,10 +23,9 @@ import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.lay import { CommandRuntime } from "../../../shared/runtime/command-runtime.service.ts"; /** - * `gen types --local` and `--db-url` do not use the Management API, so this - * runtime deliberately avoids `managementApiRuntimeLayer`: that layer - * eagerly builds the platform API client and requires an access token before - * the handler can choose the local/db-url branch. + * Avoids `managementApiRuntimeLayer`, which eagerly builds the platform API client and + * requires an access token before the handler can choose the local/db-url branch — `gen types + * --local`/`--db-url` don't use the Management API. */ export const genTypesRuntimeLayer = (() => { const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); @@ -35,9 +34,8 @@ export const genTypesRuntimeLayer = (() => { Layer.provide(cliSettings), Layer.provide(debugLoggerLayer), ); - // `identityStitchLayer` (one per-command identity stitcher) is provided by - // the SAME reference to the platform-API factory and the linked-project cache so - // memoisation gives both a single `stitchAttempted` guard. + // The same `identityStitchLayer` reference is provided to the platform-API factory and the + // linked-project cache so memoisation gives both a single `stitchAttempted` guard. const platformApiFactory = commandPlatformApiFactoryLayer.pipe( Layer.provide(credentials), Layer.provide(cliSettings), @@ -65,14 +63,8 @@ export const genTypesRuntimeLayer = (() => { ), pgDeltaSslProbeLayer, telemetryStateLayer, - // The one per-command identity stitcher, exposed at top level so - // `withCommandTelemetry` can read - // `stitchedDistinctId()` and attribute the cli_command_executed event to the - // gotrue id. The SAME reference is provided to platformApiFactory / - // linkedProjectCache above, so memoisation gives both a single - // `stitchAttempted` guard — aliasing/persisting at most once. Its - // Analytics / TelemetryRuntime / FileSystem / Path deps are ambient (root - // runtime). Mirrors advisors.layers.ts / lint.layers.ts. + // Exposed at top level so `withCommandTelemetry` can read `stitchedDistinctId()` and + // attribute the cli_command_executed event to the gotrue id. identityStitchLayer, commandRuntimeLayer(["gen", "types"]), ); diff --git a/apps/cli/src/commands/gen/types/types.layers.unit.test.ts b/apps/cli/src/commands/gen/types/types.layers.unit.test.ts index 7e237542e9..4d66bf6959 100644 --- a/apps/cli/src/commands/gen/types/types.layers.unit.test.ts +++ b/apps/cli/src/commands/gen/types/types.layers.unit.test.ts @@ -1,13 +1,6 @@ /** - * Layer-exposure test for `genTypesRuntimeLayer`. - * - * Verifies that `IdentityStitch` is exposed at the top level of the - * runtime layer so that `withCommandTelemetry` can read - * `stitchedDistinctId()` via `Effect.serviceOption(IdentityStitch)` and - * attribute the `cli_command_executed` event to the gotrue id. - * - * See `db/lint/lint.layers.unit.test.ts` for the canonical pattern and a - * detailed explanation of the bug this guards against. + * Layer-exposure test for `genTypesRuntimeLayer`. See `db/lint/lint.layers.unit.test.ts` for + * the canonical pattern. */ import { describe, expect, it } from "@effect/vitest"; @@ -80,8 +73,8 @@ function ambientStubs() { return Layer.mergeAll( BunServices.layer, - // The runtime layer under test builds the REAL commandSettingsLayer against - // the real filesystem — see isolatedHomeLayer's docs. + // Builds the real commandSettingsLayer against the real filesystem — see + // isolatedHomeLayer's docs. isolatedHomeLayer(tempRoot.current), mockTty(), mockProcessControl().layer, diff --git a/apps/cli/src/commands/gen/types/types.unit.test.ts b/apps/cli/src/commands/gen/types/types.unit.test.ts index f9e945d6f6..ea1d39d67c 100644 --- a/apps/cli/src/commands/gen/types/types.unit.test.ts +++ b/apps/cli/src/commands/gen/types/types.unit.test.ts @@ -171,8 +171,6 @@ describe("resolvePgmetaImage", () => { }); it("honors SUPABASE_INTERNAL_IMAGE_REGISTRY for a non docker.io registry (e.g. ghcr.io)", () => { - // Regression: setup-cli exports `ghcr.io` on shared CI runners to dodge ECR - // rate limits, but gen types used to ignore it and still pull from ECR. const image = withEnv("SUPABASE_INTERNAL_IMAGE_REGISTRY", "ghcr.io", () => resolvePgmetaImage("1.2.3"), ); @@ -205,9 +203,7 @@ describe("resolvePgmetaImage", () => { describe("schema and id helpers", () => { it("normalizes comma separated and repeated schema flags", () => { - // pflag's StringSlice parses each value via encoding/csv with NO - // trimming, and an empty value yields no field. Whitespace is preserved - // verbatim. + // pflag's StringSlice parses via encoding/csv with no trimming; an empty value yields no field. expect(parseSchemaFlags(["public, auth", " storage ", ""])).toEqual([ "public", " auth", diff --git a/apps/cli/src/commands/init/init.errors.ts b/apps/cli/src/commands/init/init.errors.ts index 7fb6e5cd16..c9d7b03fef 100644 --- a/apps/cli/src/commands/init/init.errors.ts +++ b/apps/cli/src/commands/init/init.errors.ts @@ -7,16 +7,8 @@ import { } from "../../shared/telemetry/error-actionability.ts"; /** - * `supabase/config.toml` already exists and `--force` was not set. Reproduces - * the wrapped `O_EXCL` open error from `utils.InitConfig` - * (`apps/cli-go/internal/utils/config.go:243-246`) — a `*os.PathError` passed - * through verbatim, so the message is platform-specific: - * `failed to create config file: open supabase/config.toml: file exists` on - * Linux/macOS (POSIX `EEXIST` text), and - * `failed to create config file: open supabase\config.toml: The file exists.` - * on Windows (`filepath.Join` separator + `ERROR_FILE_EXISTS` errno text) — - * plus the `utils.CmdSuggestion` set (platform-independent, since - * `errors.Is(err, os.ErrExist)` matches `ERROR_FILE_EXISTS` too). + * `supabase/config.toml` already exists and `--force` was not set. The message reproduces the + * platform's raw file-exists error text: `file exists` (POSIX) or `The file exists.` (Windows). */ export class InitConfigExistsError extends Data.TaggedError("InitConfigExistsError")<{ readonly message: string; @@ -28,11 +20,9 @@ export class InitConfigExistsError extends Data.TaggedError("InitConfigExistsErr } /** - * `--use-orioledb` without `--experimental`. Reproduces cobra's - * `MarkFlagRequired("experimental")` PreRun error, byte-for-byte - * (`required flag(s) "experimental" not set`). No suggestion — `recoverAndExit` - * appends the generic `--debug` troubleshooting hint, which the text output - * layer's `fail` already adds when `suggestion` is unset. + * `--use-orioledb` without `--experimental`. Reproduces the established required-flag error + * text verbatim: `required flag(s) "experimental" not set`. No suggestion — the text output + * layer's `fail` already appends the generic `--debug` hint when unset. */ export class InitExperimentalRequiredError extends Data.TaggedError( "InitExperimentalRequiredError", diff --git a/apps/cli/src/commands/init/init.handler.ts b/apps/cli/src/commands/init/init.handler.ts index f736107600..f5a2a9d0c3 100644 --- a/apps/cli/src/commands/init/init.handler.ts +++ b/apps/cli/src/commands/init/init.handler.ts @@ -14,8 +14,6 @@ export const init = Effect.fn("init")(function* (flags: InitFlags) { const workdir = yield* WorkdirFlag; if (flags.useOrioledb && !experimental) { - // Go marks `experimental` required in PreRun (`cmd/init.go:32-36`), so cobra's - // `ValidateRequiredFlags` fails with its standard required-flag message. return yield* Effect.fail( new InitExperimentalRequiredError({ message: `required flag(s) "experimental" not set`, @@ -28,24 +26,17 @@ export const init = Effect.fn("init")(function* (flags: InitFlags) { force: flags.force, useOrioledb: flags.useOrioledb, interactive: flags.interactive, - // `--yes` OR `SUPABASE_YES` (`viper.GetBool("YES")`, root.go:318-320): - // auto-accepts the `-i` IDE prompts with the established stderr echo - // instead of prompting anyway (CLI-1974). + // `--yes`/`SUPABASE_YES` auto-accepts the `-i` IDE prompts with the established stderr + // echo instead of prompting. yes: yield* resolveYes, withVscodeSettings: flags.withVscodeWorkspace || flags.withVscodeSettings, withIntellijSettings: flags.withIntellijSettings, }); if (!result.created) { - // The message embeds the `*os.PathError` from the `O_EXCL` open of - // `utils.ConfigPath`, which is *relative* — so the path in the message is - // always `supabase/config.toml` regardless of cwd or `--workdir`. The - // rendering is platform-specific: `ConfigPath` is built with - // `filepath.Join` (`utils/misc.go:82`), so Windows prints a backslash, - // and the `O_EXCL` open fails there with `ERROR_FILE_EXISTS`, which - // `syscall.Errno.Error()` renders as `The file exists.` — vs the POSIX - // `EEXIST` text (`file exists`) on Linux/macOS. The Windows literal - // follows from the same code path via documented Go/Win32 semantics. + // The path in this message is always the relative `supabase/config.toml`, regardless of + // cwd or `--workdir`. Windows uses a backslash and "The file exists."; POSIX uses a + // forward slash and "file exists". const message = runtimeInfo.platform === "win32" ? "failed to create config file: open supabase\\config.toml: The file exists." diff --git a/apps/cli/src/commands/init/init.integration.test.ts b/apps/cli/src/commands/init/init.integration.test.ts index f9bef438f6..ccf35d63e7 100644 --- a/apps/cli/src/commands/init/init.integration.test.ts +++ b/apps/cli/src/commands/init/init.integration.test.ts @@ -63,10 +63,9 @@ function findFailure(exit: Exit.Exit<unknown, unknown>): Record<string, unknown> } /** - * Renders a handler failure exactly like the real CLI does — `normalizeCause` - * followed by the production text output layer's `fail` — and returns the - * captured stderr writes (ANSI-stripped). This locks the composed two-line - * stderr contract documented in SIDE_EFFECTS.md, not just the error fields. + * Renders a handler failure exactly like the real CLI does and returns the captured stderr + * writes (ANSI-stripped). Locks the composed stderr contract from SIDE_EFFECTS.md, not just + * the error fields. */ function renderFailureToStderr(exit: Exit.Exit<unknown, unknown>) { return Effect.gen(function* () { @@ -141,15 +140,11 @@ describe("init", () => { withIntellijSettings: false, }).pipe(Effect.provide(layer), Effect.exit); - // `experimental` is marked required in PreRun, so the user sees - // cobra's standard message. No suggestion — the text output layer - // appends the generic `--debug` troubleshooting hint instead. const error = findFailure(exit); expect(error["_tag"]).toBe("InitExperimentalRequiredError"); expect(error["message"]).toBe(`required flag(s) "experimental" not set`); expect(error["suggestion"]).toBeUndefined(); - // Composed stderr byte-matches `recoverAndExit`'s output. expect(yield* renderFailureToStderr(exit)).toEqual([ `required flag(s) "experimental" not set\n`, "Try rerunning the command with --debug to troubleshoot the error.\n", @@ -177,8 +172,6 @@ describe("init", () => { yield* init(initFlags).pipe(Effect.provide(layer)); const exit = yield* init(initFlags).pipe(Effect.provide(layer), Effect.exit); - // Byte-matches the wrapped `O_EXCL` `*os.PathError` from - // `utils.InitConfig` (`config.go:243-246`) plus its CmdSuggestion. const error = findFailure(exit); expect(error["_tag"]).toBe("InitConfigExistsError"); expect(error["message"]).toBe( @@ -188,7 +181,6 @@ describe("init", () => { "Run supabase init --force to overwrite existing config file.", ); - // Composed stderr byte-matches `recoverAndExit`'s output (Linux/macOS). expect(yield* renderFailureToStderr(exit)).toEqual([ "failed to create config file: open supabase/config.toml: file exists\n", "Run supabase init --force to overwrite existing config file.\n", @@ -216,11 +208,6 @@ describe("init", () => { yield* init(initFlags).pipe(Effect.provide(layer)); const exit = yield* init(initFlags).pipe(Effect.provide(layer), Effect.exit); - // On Windows, `utils.ConfigPath` is built with `filepath.Join` - // (`utils/misc.go:82`) — backslash separator — and the `O_EXCL` open - // fails with `ERROR_FILE_EXISTS`, rendered by `syscall.Errno.Error()` as - // `The file exists.`. The suggestion is unchanged because - // `errors.Is(err, os.ErrExist)` matches on Windows too. const error = findFailure(exit); expect(error["_tag"]).toBe("InitConfigExistsError"); expect(error["message"]).toBe( @@ -230,7 +217,6 @@ describe("init", () => { "Run supabase init --force to overwrite existing config file.", ); - // Composed stderr byte-matches `recoverAndExit`'s output (Windows). expect(yield* renderFailureToStderr(exit)).toEqual([ "failed to create config file: open supabase\\config.toml: The file exists.\n", "Run supabase init --force to overwrite existing config file.\n", @@ -298,12 +284,6 @@ describe("init", () => { ); }); - // --------------------------------------------------------------------------- - // `-i` + `--yes`/`SUPABASE_YES` — `PromptForIDESettings` goes through - // `PromptYesNo`, so the global YES auto-accepts the VS Code question with the - // `[Y/n] y` stderr echo instead of prompting anyway (CLI-1974). - // --------------------------------------------------------------------------- - const BASE_INIT_FLAGS = { useOrioledb: false, force: false, @@ -322,7 +302,6 @@ describe("init", () => { expect(out.promptConfirmCalls).toHaveLength(0); expect(out.stderrText).toContain("Generate VS Code settings for Deno? [Y/n] y\n"); - // Go returns after writing VS Code settings — IntelliJ is never asked. expect(out.stderrText).not.toContain("IntelliJ"); expect( yield* Effect.tryPromise(() => readFile(join(tempDir, ".vscode", "settings.json"), "utf8")), @@ -359,8 +338,6 @@ describe("init", () => { }); it.live("init -i --yes writes VS Code settings even when stdout is piped (Go parity)", () => { - // Go gates the IDE prompts on `-i` + a TTY stdin only (`cmd/init.go:40`); with - // YES set no clack UI is rendered, so a piped stdout must not skip the write. const tempDir = makeTempDir(); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/inspect/db/blocking/blocking.query.ts b/apps/cli/src/commands/inspect/db/blocking/blocking.query.ts index 5ab51e8b6d..7e9e3feadf 100644 --- a/apps/cli/src/commands/inspect/db/blocking/blocking.query.ts +++ b/apps/cli/src/commands/inspect/db/blocking/blocking.query.ts @@ -23,10 +23,9 @@ JOIN pg_catalog.pg_stat_activity ka WHERE NOT bl.granted`; /** - * `inspect db blocking` — queries holding locks and the queries waiting on them. - * Both statement columns are whitespace-collapsed; the row format - * backtick-wraps every column EXCEPT `blocked_statement` (col 5), so - * `blocking_statement` (col 2) uses the backtick variant and col 5 stays bare. + * `inspect db blocking` — queries holding locks and the queries waiting on them. Both statement + * columns are whitespace-collapsed; only `blocking_statement` (col 2) is backtick-wrapped — + * `blocked_statement` (col 5) stays bare. */ export const blockingSpec: InspectQuerySpec = { name: "blocking", diff --git a/apps/cli/src/commands/inspect/db/db.layers.ts b/apps/cli/src/commands/inspect/db/db.layers.ts index b5052ba660..16b1f5f760 100644 --- a/apps/cli/src/commands/inspect/db/db.layers.ts +++ b/apps/cli/src/commands/inspect/db/db.layers.ts @@ -4,16 +4,9 @@ import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.lay import { inspectBaseLayer } from "../inspect.layers.ts"; /** - * The command-runtime path for a single `inspect db <leaf>` subcommand. - * - * The `leaf` is the invoked command's own name (e.g. `"locks"`, or a - * deprecated alias like `"cache-hit"`) and is appended to `["inspect", "db"]`. This - * path is what `withCommandTelemetry` records as the PostHog - * `cli_command_executed` `command` property: the inspect tree is a real 3-level - * hierarchy, so each of the 25 leaves emits a distinct command name. A shared - * `["inspect", "db"]` path would collapse them all into one event, so each leaf must - * pass its own name — and a deprecated alias records the alias the user typed, not - * the backend command it delegates to. + * The command-runtime path for a single `inspect db <leaf>` subcommand, appended to + * `["inspect", "db"]` so each of the 25 leaves — and each deprecated alias — records its own + * name in `cli_command_executed`, not a shared "inspect db" event. */ export const inspectDbCommandPath = (leaf: string): ReadonlyArray<string> => [ "inspect", diff --git a/apps/cli/src/commands/inspect/db/db.layers.unit.test.ts b/apps/cli/src/commands/inspect/db/db.layers.unit.test.ts index 9045dff4a2..66e7303579 100644 --- a/apps/cli/src/commands/inspect/db/db.layers.unit.test.ts +++ b/apps/cli/src/commands/inspect/db/db.layers.unit.test.ts @@ -3,19 +3,12 @@ import { describe, expect, it } from "vitest"; import { inspectDbCommandPath } from "./db.layers.ts"; describe("inspectDbCommandPath", () => { - // The inspect tree is a real 3-level hierarchy, so each leaf's own name is - // distinct and `cli_command_executed` records the full path. The command-runtime - // path must append the leaf to `["inspect", "db"]` rather than - // collapsing all 25 subcommands into a single `inspect db` event. it("appends a native leaf to the inspect db path", () => { expect(inspectDbCommandPath("locks")).toEqual(["inspect", "db", "locks"]); expect(inspectDbCommandPath("vacuum-stats")).toEqual(["inspect", "db", "vacuum-stats"]); }); it("records a deprecated alias under its own name, not the backend command", () => { - // `cache-hit` delegates to the db-stats backend but is its own command; - // the recorded path reflects the alias the user typed, so the path must - // carry the alias, never `db-stats`. expect(inspectDbCommandPath("cache-hit")).toEqual(["inspect", "db", "cache-hit"]); expect(inspectDbCommandPath("index-usage")).toEqual(["inspect", "db", "index-usage"]); }); diff --git a/apps/cli/src/commands/inspect/db/inspect-db-command.ts b/apps/cli/src/commands/inspect/db/inspect-db-command.ts index 19c2c85100..068827c405 100644 --- a/apps/cli/src/commands/inspect/db/inspect-db-command.ts +++ b/apps/cli/src/commands/inspect/db/inspect-db-command.ts @@ -6,10 +6,8 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import type { InspectConnectionFlags } from "./inspect-query.ts"; /** - * The `inspect` persistent flag set, inherited by every `inspect db` subcommand. - * Shared verbatim across all 25 commands - * so the flag names and descriptions live in one place. `Command.make` reads this - * immutable descriptor without mutating it, so a single instance is safe to reuse. + * The `inspect` persistent flag set, inherited by every `inspect db` subcommand. Shared + * verbatim across all 25 commands so flag names and descriptions live in one place. */ export const INSPECT_DB_FLAGS = { dbUrl: Flag.string("db-url").pipe( @@ -34,10 +32,9 @@ export const INSPECT_DB_FLAGS = { } as const; /** - * Wraps an `inspect db` handler with the standard command-level pipeline: legacy - * telemetry instrumentation (the Go-shape `cli_command_executed` event, with the - * three connection flags) and the machine-format JSON error envelope. Shared by - * all 25 command files so the wiring is defined once. + * Wraps an `inspect db` handler with the standard command-level pipeline: telemetry + * instrumentation with the three connection flags, and the machine-format JSON error envelope. + * Shared by all 25 command files so the wiring is defined once. */ export function inspectDbCommandHandler<E, R>( handler: (flags: InspectConnectionFlags) => Effect.Effect<void, E, R>, @@ -51,9 +48,7 @@ export function inspectDbCommandHandler<E, R>( local: flags.local, "project-ref": flags.projectRef, }, - // TS-only flag with no Go telemetry-safety baseline; Go's nearest - // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most - // others) are unmarked, so it stays redacted. + // `project-ref` stays redacted; it carries no telemetry-safety allowlist entry. }), withJsonErrorHandling, ); diff --git a/apps/cli/src/commands/inspect/db/inspect-db.e2e.test.ts b/apps/cli/src/commands/inspect/db/inspect-db.e2e.test.ts index 9c75539809..382ddb7503 100644 --- a/apps/cli/src/commands/inspect/db/inspect-db.e2e.test.ts +++ b/apps/cli/src/commands/inspect/db/inspect-db.e2e.test.ts @@ -4,16 +4,13 @@ import { makeTempHome, runSupabase } from "../../../../tests/helpers/cli.ts"; const E2E_TIMEOUT_MS = 30_000; -// A definitely-closed local port: resolution succeeds (the `--db-url` is parsed -// directly, no config.toml / running stack needed), then the native handler dials -// and fails fast with a connection error. This exercises the real subprocess path -// — flag parse → resolution → native query run — without -// depending on a live database in CI. +// A definitely-closed local port: `--db-url` resolves without a config.toml or running +// stack, then the native handler dials and fails fast — exercising the real subprocess path +// without depending on a live database in CI. const DEAD_DB_URL = "postgres://postgres:postgres@127.0.0.1:1/postgres"; -// `--agent no` forces text-mode output deterministically: the CLI otherwise -// auto-selects a machine format (JSON on stdout) when it detects a coding-agent -// environment, which would route the error away from stderr. +// `--agent no` forces text-mode output; otherwise the CLI may auto-select a machine format +// (JSON on stdout) in a detected coding-agent environment, routing the error away from stderr. const TEXT_MODE = "--agent"; const TEXT_MODE_VALUE = "no"; @@ -28,8 +25,6 @@ describe("supabase inspect db", () => { { home: home.dir, env: { HOME: home.dir } }, ); expect(exitCode).toBe(1); - // The native handler writes the connection diagnostic to stderr - // and then surfaces the connection failure. expect(stderr).toContain("Connecting to remote database..."); expect(stderr).toMatch(/failed to connect to postgres|connection refused|ECONNREFUSED/i); }, @@ -46,7 +41,6 @@ describe("supabase inspect db", () => { ); expect(exitCode).toBe(1); expect(stderr).toContain('Command "cache-hit" is deprecated, use "db-stats" instead.'); - // The deprecation line precedes the connection diagnostic/error. const deprecationIndex = stderr.indexOf('Command "cache-hit" is deprecated'); const connectingIndex = stderr.indexOf("Connecting to remote database..."); expect(deprecationIndex).toBeGreaterThanOrEqual(0); diff --git a/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts b/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts index 8ac37589fd..7a17788349 100644 --- a/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts +++ b/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts @@ -179,16 +179,13 @@ describe("inspect db query runner", () => { const { layer, out, connection } = setup({ rows: [DB_STATS_ROW] }); return Effect.gen(function* () { yield* inspectDbDbStats(flags()); - // The query ran with the embedded SQL and both params (escaped schemas + db name). expect(connection.querySql).toBe(dbStatsSpec.sql); expect(connection.queryParams?.[1]).toBe("postgres"); expect(Array.isArray(connection.queryParams?.[0])).toBe(true); - // stdout is byte-exact with the rendered glamour table. const expected = renderGlamourTable(dbStatsSpec.headers, [ dbStatsSpec.project(DB_STATS_ROW, { conn: LOCAL_CONN, isLocal: true }), ]); expect(out.stdoutText).toBe(expected); - // The leading Name column is the resolved database name. expect(out.stdoutText).toContain("postgres"); expect(out.stdoutText).toContain("8192 kB"); expect(out.stdoutText).toContain("WAL Size"); @@ -206,9 +203,6 @@ describe("inspect db query runner", () => { }); it.live("renders an empty backtick-wrapped cell as two literal backticks (role-stats)", () => { - // Every role-stats cell wraps in `` `…` ``; the postgres - // row has no custom config, so glamour emits an empty code span as the two - // literal backtick characters, including the resulting column width. const ROLE_ROW = { role_name: "postgres", active_connections: 3, @@ -278,7 +272,6 @@ describe("inspect db query runner", () => { const { layer, resolver } = setup({ rows: [DB_STATS_ROW] }); return Effect.gen(function* () { yield* inspectDbDbStats(flags()); - // `--linked` is the default; the runner derives connType="linked" from absence. expect(resolver.resolveInput?.connType).toBe("linked"); expect(Option.isNone(resolver.resolveInput?.dbUrl ?? Option.some("x"))).toBe(true); }).pipe(Effect.provide(layer)); @@ -319,9 +312,6 @@ describe("inspect db query runner", () => { }); it.live("--local=false is Changed and routes to local (not linked)", () => { - // `--local=false` is explicitly passed regardless of value; value-based - // detection would miss it and fall through to the linked default. This test guards - // that regression. const { layer, resolver } = setup({ rows: [DB_STATS_ROW], cliArgs: ["--local=false"] }); return Effect.gen(function* () { yield* inspectDbDbStats(flags({ local: false })); @@ -330,8 +320,6 @@ describe("inspect db query runner", () => { }); it.live("--linked --local=false raises the mutual-exclusion error", () => { - // Both flags are Changed (one explicit false, one true) → cobra raises the - // mutual-exclusion error regardless of their boolean values. const { layer } = setup({ cliArgs: ["--linked", "--local=false"] }); return Effect.gen(function* () { const exit = yield* Effect.exit(inspectDbDbStats(flags({ linked: true, local: false }))); @@ -365,8 +353,6 @@ describe("inspect db query runner", () => { const { layer, resolver } = setup({ rows: [DB_STATS_ROW] }); return Effect.gen(function* () { yield* inspectDbDbStats(flags({ projectRef: Option.some(FLAG_REF) })); - // `inspect db` never caches the ref — the resolver call it threads the flag - // into is the strongest observable this harness offers. expect(resolver.resolveInput?.connType).toBe("linked"); expect(resolver.resolveInput?.linkedProjectRef).toEqual(Option.some(FLAG_REF)); }).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/commands/inspect/db/inspect-query.ts b/apps/cli/src/commands/inspect/db/inspect-query.ts index 88c27b1764..8a9349dade 100644 --- a/apps/cli/src/commands/inspect/db/inspect-query.ts +++ b/apps/cli/src/commands/inspect/db/inspect-query.ts @@ -16,11 +16,9 @@ import { resolveDbTargetFlags } from "../../../command-internal/db-target-flags. import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; /** - * The connection selector flags every `inspect db` subcommand inherits from the - * `inspect` persistent flag set: - * `--db-url` / `--linked` / `--local`, mutually exclusive. `--linked` is the - * default; the runner derives that default from the absence of the others - * while keeping the exclusivity check keyed off the raw (explicitly-set) flags. + * The connection selector flags every `inspect db` subcommand inherits: `--db-url` / `--linked` + * / `--local`, mutually exclusive, with `--linked` as the default. Exclusivity is keyed off the + * raw explicitly-set flags, not the resolved boolean values. */ export interface InspectConnectionFlags { readonly dbUrl: Option.Option<string>; @@ -31,12 +29,9 @@ export interface InspectConnectionFlags { } /** - * A single `inspect db` subcommand: the SQL it runs, the query parameters, the - * markdown table headers, and how each result row projects to clean table cells. - * - * `project` reproduces the per-column formatting (via the cell formatters below) - * minus backtick code-spans and `\|` pipe escaping, since `renderGlamourTable` - * takes already-clean cell strings. + * A single `inspect db` subcommand: the SQL it runs, the query parameters, table headers, and + * how each result row projects to clean table cells (already stripped of backtick code-spans + * and pipe escaping, since `renderGlamourTable` takes already-clean strings). */ export interface InspectQuerySpec { /** The subcommand's own name, e.g. `"db-stats"`. */ @@ -52,12 +47,9 @@ export interface InspectQuerySpec { } /** - * Raised when more than one of `--db-url` / `--linked` / `--local` is explicitly - * set. The message matches the established mutually-exclusive-flags text. - * - * Not reusing `test db`'s identical error type: hoisting it would drag that - * command's test surface into scope for a single shared string. Revisit if a - * third consumer appears. + * Raised when more than one of `--db-url` / `--linked` / `--local` is explicitly set, with the + * established mutually-exclusive-flags message. Not reused from `test db`'s identical error + * type: hoisting would drag that command's test surface into scope for one shared string. */ export class InspectMutuallyExclusiveFlagsError extends Data.TaggedError( "InspectMutuallyExclusiveFlagsError", @@ -67,22 +59,10 @@ export class InspectMutuallyExclusiveFlagsError extends Data.TaggedError( } } -// --------------------------------------------------------------------------- -// Cell formatters — pure, exported, unit-tested. They branch on `typeof` -// rather than casting, so an unexpected driver type degrades to a string -// instead of throwing. -// --------------------------------------------------------------------------- - /** - * The backtick-wrapped `` `…` `` text cell — the shape of almost every `inspect - * db` string column. - * - * Glamour's `AsciiStyle` strips the backticks from a non-empty inline code span, - * so a populated cell renders as its bare value. But an EMPTY code span (`` `` ``) - * is not a valid token, so glamour passes the two backtick characters through - * literally. We therefore render an empty/null value as the two literal backticks - * (so the cell contributes width 2, matching a populated one). The - * few UNWRAPPED columns (no code span) use `inspectPlainText`. + * The backtick-wrapped `` `…` `` text cell. An empty code span isn't a valid glamour token, so + * empty/null values render as the two literal backticks too, keeping the same column width as + * a populated cell. Unwrapped columns use `inspectPlainText` instead. */ export function inspectText(value: unknown): string { const text = value === null || value === undefined ? "" : String(value); @@ -90,10 +70,9 @@ export function inspectText(value: unknown): string { } /** - * The UNWRAPPED text cell (no backtick code span): an empty/null value - * renders as the empty string. Only the `vacuum_stats` timestamp columns - * (`Last_vacuum`/`Last_autovacuum`/`Last_analyze`/`Last_autoanalyze`) are written - * bare; every other string column is wrapped (use `inspectText`). + * The unwrapped text cell (no backtick code span): an empty/null value renders as the empty + * string. Only the `vacuum_stats` timestamp columns are written bare; every other string + * column is wrapped (use `inspectText`). */ export function inspectPlainText(value: unknown): string { if (value === null || value === undefined) return ""; @@ -131,28 +110,21 @@ export function inspectFloat1(value: unknown): string { } /** - * A statement/query cell (locks, blocking, outliers, calls): collapse every run - * of whitespace to a single space. Pipes are left as-is here since - * `renderGlamourTable` takes literal cells. - * - * Note: `long-running-queries.query` is NOT normalized, so its spec uses - * `inspectText`, not this. + * A statement/query cell (locks, blocking, outliers, calls): collapses every whitespace run to + * a single space, leaving pipes as-is since `renderGlamourTable` takes literal cells. + * `long-running-queries.query` isn't normalized this way and uses `inspectText` instead. */ export function inspectStmt(value: unknown): string { if (value === null || value === undefined) return ""; - // Collapse runs of `[\t\n\f\r ]` and replace each vertical tab individually - // with a single space — the exact character set this must match, since - // JS's `\s` differs (it includes `\v` AND Unicode spaces like nbsp, U+2028) - // and a naive `/\s+/g` would over-collapse runs this must leave alone. + // Matches `[\t\n\f\r ]+|\v` exactly, not `\s+`: JS's `\s` also matches nbsp/U+2028, which + // must not collapse here. return String(value).replace(/[\t\n\f\r ]+|\v/g, " "); } /** - * A whitespace-collapsed statement cell that is ALSO wrapped in backticks - * (used for `calls`/`outliers`'s `query` column and `blocking`'s - * `blocking_statement` — unlike `locks` and `blocking`'s `blocked_statement`, - * which stay bare). Same empty-code-span rule as `inspectText`: an - * empty value surfaces as the two literal backticks. + * A whitespace-collapsed statement cell also wrapped in backticks (used for `calls`/`outliers`'s + * `query` column and `blocking`'s `blocking_statement`). Same empty-code-span rule as + * `inspectText`: an empty value surfaces as the two literal backticks. */ export function inspectBacktickStmt(value: unknown): string { const stmt = inspectStmt(value); @@ -160,12 +132,9 @@ export function inspectBacktickStmt(value: unknown): string { } /** - * Runs an `inspect db` subcommand's query and renders the result. - * - * The shared shape: resolve the connection, connect (which prints "Connecting - * to <local|remote> database..." to stderr), run the query, then render the - * table. In `json`/`stream-json` mode the raw driver rows are emitted as a - * structured result instead. + * Runs an `inspect db` subcommand's query and renders the result: resolve the connection, + * connect (printing "Connecting to <local|remote> database..." to stderr), run the query, then + * render the table — or, in `json`/`stream-json` mode, emit the raw driver rows instead. */ export const runInspectQuery = Effect.fnUntraced(function* ( spec: InspectQuerySpec, @@ -177,10 +146,8 @@ export const runInspectQuery = Effect.fnUntraced(function* ( const dbConn = yield* DbConnection; const cliArgs = yield* CliArgs; - // Mutual exclusivity is keyed off raw argv (which flags were explicitly - // passed), not the parsed boolean value. `--local=false` was explicitly - // passed even though its value is false; value-based detection would miss - // it and route to linked incorrectly. + // Mutual exclusivity is keyed off raw argv (which flags were explicitly passed), not the + // parsed boolean value — `--local=false` counts as explicitly set. const target = resolveDbTargetFlags(cliArgs.args); if (target.setFlags.length > 1) { return yield* Effect.fail( @@ -190,9 +157,8 @@ export const runInspectQuery = Effect.fnUntraced(function* ( ); } - // `--linked` is the default, so absence of `--db-url`/`--local` resolves - // to the linked project. Exclusivity above is already keyed off the raw flags, - // so deriving the connType here does not re-trigger it. + // `--linked` is the default; exclusivity above is already keyed off the raw flags, so + // deriving the connType here does not re-trigger it. const connType = target.connType ?? "linked"; // `--project-ref` never implies `--linked` and must not be silently @@ -216,10 +182,8 @@ export const runInspectQuery = Effect.fnUntraced(function* ( const rows = yield* Effect.scoped( Effect.gen(function* () { - // "Connecting to <local|remote> database..." is written to stderr before - // dialing. stdout is reserved for the rendered table (the machine - // payload in json modes), so this diagnostic always goes to stderr - // regardless of output mode. + // Always written to stderr, even in json/stream-json modes, since stdout is reserved + // for the rendered table / machine payload. yield* output.raw( `Connecting to ${cfg.isLocal ? "local" : "remote"} database...\n`, "stderr", @@ -249,12 +213,9 @@ export function inspectDeprecationNotice(alias: string, target: string): string } /** - * Builds an `inspect db <name>` handler from its spec. Each active subcommand and - * each deprecated alias gets its own `Effect.fn` trace span (`legacy.inspect.db.<name>`) - * and flushes telemetry on completion (success or failure) — - * callers must NOT add a second `Effect.ensuring(flush)` at - * the command level. Deprecated aliases pass `deprecation`, the exact stderr - * line (build it with `inspectDeprecationNotice`) emitted before the query runs. + * Builds an `inspect db <name>` handler from its spec, with its own `Effect.fn` trace span and + * telemetry flush on completion — callers must not add a second `Effect.ensuring(flush)`. + * `deprecation` (see {@link inspectDeprecationNotice}) is the stderr line emitted first. */ export function makeInspectDbHandler( spec: InspectQuerySpec, diff --git a/apps/cli/src/commands/inspect/db/inspect-query.unit.test.ts b/apps/cli/src/commands/inspect/db/inspect-query.unit.test.ts index 915d6a231b..1aa7fd70a2 100644 --- a/apps/cli/src/commands/inspect/db/inspect-query.unit.test.ts +++ b/apps/cli/src/commands/inspect/db/inspect-query.unit.test.ts @@ -17,9 +17,6 @@ describe("inspectText (backtick-wrapped `%s`)", () => { expect(inspectText(42)).toBe("42"); }); it("renders an empty/null value as the two literal backticks of an empty code span", () => { - // The cell wraps as `` `…` ``; an empty code span isn't a valid token, so - // glamour emits the two backtick characters literally (e.g. role-stats - // `custom_config` for the `postgres` row). expect(inspectText("")).toBe("``"); expect(inspectText(null)).toBe("``"); expect(inspectText(undefined)).toBe("``"); @@ -28,8 +25,6 @@ describe("inspectText (backtick-wrapped `%s`)", () => { describe("inspectPlainText (unwrapped `%s`)", () => { it("passes strings through and renders null/undefined as empty", () => { - // The unwrapped columns (vacuum_stats timestamps) have no code span, so an - // empty value stays empty rather than `` `` ``. expect(inspectPlainText("2024-01-01 00:00")).toBe("2024-01-01 00:00"); expect(inspectPlainText("")).toBe(""); expect(inspectPlainText(null)).toBe(""); @@ -98,11 +93,7 @@ describe("inspectStmt (whitespace-collapsed %s)", () => { expect(inspectStmt("a | b")).toBe("a | b"); }); it("replaces each vertical tab individually (Go's RE2 `\\s` excludes `\\v`)", () => { - // Vertical tabs are replaced individually rather than collapsed as part of - // a run; consecutive vertical tabs therefore collapse to one space each, - // not a single space. expect(inspectStmt("a\v\vb")).toBe("a b"); - // A space-then-vtab is a space run then an individual vtab → two spaces. expect(inspectStmt("a \vb")).toBe("a b"); }); it("leaves a non-breaking space untouched (not in Go's `\\s`)", () => { @@ -112,8 +103,6 @@ describe("inspectStmt (whitespace-collapsed %s)", () => { describe("inspectBacktickStmt (backtick-wrapped, whitespace-collapsed `%s`)", () => { it("collapses whitespace like inspectStmt for a non-empty statement", () => { - // calls/outliers wrap the query in `` `…` ``, so a populated - // statement renders bare with its runs collapsed. expect(inspectBacktickStmt("SELECT\n 1")).toBe("SELECT 1"); }); it("renders an empty/null statement as the two literal backticks", () => { @@ -149,9 +138,7 @@ describe("vacuumStatsSpec rowcount projection", () => { }); it("replaces the first `-1` substring within the padded to_char output", () => { - // `to_char(reltuples, '9G999G999G999')` right-justifies in a fixed width, so a - // -1 reltuples comes back space-padded. Only the first `-1` substring is - // rewritten; the projection must match byte-for-byte. + // `to_char(reltuples, '9G999G999G999')` right-justifies, so a -1 value comes back padded. const cells = vacuumStatsSpec.project(row(" -1"), cfg); expect(cells[5]).toBe(" No stats"); }); diff --git a/apps/cli/src/commands/inspect/db/inspect-schemas.ts b/apps/cli/src/commands/inspect/db/inspect-schemas.ts index 97b83fbb38..c3e5025530 100644 --- a/apps/cli/src/commands/inspect/db/inspect-schemas.ts +++ b/apps/cli/src/commands/inspect/db/inspect-schemas.ts @@ -1,10 +1,7 @@ /** - * Internal Postgres schemas the `inspect db` queries exclude, and the LIKE-escape - * helper that turns them into `LIKE ANY($1)` exclusion patterns. - * - * The order is preserved verbatim because the escaped array is passed straight - * through to `LIKE ANY($1)`, where order is observable in nothing but is kept - * identical to avoid any drift from the established list. + * Internal Postgres schemas the `inspect db` queries exclude, and the LIKE-escape helper that + * turns them into `LIKE ANY($1)` exclusion patterns. Order has no functional effect but is + * kept identical to the established list to avoid any drift. */ export const INTERNAL_SCHEMAS: ReadonlyArray<string> = [ "information_schema", diff --git a/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts b/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts index f2808f17f6..7e67fc8ea3 100644 --- a/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts +++ b/apps/cli/src/commands/inspect/db/inspect-specs.integration.test.ts @@ -261,10 +261,8 @@ describe("inspect db specs (per-subcommand correctness)", () => { return Effect.gen(function* () { yield* runInspectQuery(testCase.spec, localFlags, "native"); - // The embedded SQL is sent verbatim. expect(ctx.querySql).toBe(testCase.spec.sql); - // Query parameters match the subcommand's shape. if (testCase.params === "none") { expect(ctx.queryParams).toEqual([]); } else { @@ -278,7 +276,6 @@ describe("inspect db specs (per-subcommand correctness)", () => { } } - // The rendered table contains the headers and the projected cells. for (const header of testCase.spec.headers) { expect(ctx.out.stdoutText).toContain(header); } @@ -292,10 +289,6 @@ describe("inspect db specs (per-subcommand correctness)", () => { }); } - // Cell-level truth for blocking's per-column backtick-wrapping: col 2 - // (`blocking_statement`) is backtick-wrapped, so a null/empty value renders as - // the two literal backticks (glamour's empty-code-span rule), while col 5 - // (`blocked_statement`) stays bare, so an empty value renders as "". it("projects a null blocking_statement to the two-backtick empty code span, keeping blocked_statement bare", () => { const row: Record<string, unknown> = { blocked_pid: 1, diff --git a/apps/cli/src/commands/inspect/db/table-stats/table-stats.query.ts b/apps/cli/src/commands/inspect/db/table-stats/table-stats.query.ts index 1d8cf8edd8..e1f849130d 100644 --- a/apps/cli/src/commands/inspect/db/table-stats/table-stats.query.ts +++ b/apps/cli/src/commands/inspect/db/table-stats/table-stats.query.ts @@ -30,10 +30,9 @@ LEFT JOIN ( ORDER BY ts.total_size_bytes DESC`; /** - * `inspect db table-stats` — combined table size, index size, and row count. - * Also the routed query for the deprecated `table-sizes` / `table-index-sizes` / - * `total-table-sizes` aliases (but NOT `table-record-counts`, which routes to - * index-stats — preserved in that alias's handler). + * `inspect db table-stats` — combined table size, index size, and row count. Also the routed + * query for the deprecated `table-sizes` / `table-index-sizes` / `total-table-sizes` aliases + * (not `table-record-counts`, which routes to index-stats instead). */ export const tableStatsSpec: InspectQuerySpec = { name: "table-stats", diff --git a/apps/cli/src/commands/inspect/db/vacuum-stats/vacuum-stats.query.ts b/apps/cli/src/commands/inspect/db/vacuum-stats/vacuum-stats.query.ts index b2b20864e8..009efb3be7 100644 --- a/apps/cli/src/commands/inspect/db/vacuum-stats/vacuum-stats.query.ts +++ b/apps/cli/src/commands/inspect/db/vacuum-stats/vacuum-stats.query.ts @@ -66,10 +66,9 @@ ORDER BY 1`; /** - * `inspect db vacuum-stats` — per-table vacuum statistics. The query - * returns 11 columns but only 9 are rendered (`autovacuum_threshold` - * and `autoanalyze_threshold` are dropped). The `rowcount` cell has a one-shot `-1` → `No - * stats` replacement. + * `inspect db vacuum-stats` — per-table vacuum statistics. The query returns 11 columns but + * only 9 are rendered (`autovacuum_threshold`/`autoanalyze_threshold` are dropped). The + * `rowcount` cell has a one-shot `-1` → `No stats` replacement. */ export const vacuumStatsSpec: InspectQuerySpec = { name: "vacuum-stats", From 134ab929eff18d74d73ff157db4c90398dd79397 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:04:44 +0100 Subject: [PATCH 35/56] chore(repo): trim comments, shard 34 (1422 -> 719 comment lines) --- .../start/services/logflare.service.ts | 70 +- .../start/services/mailpit.service.ts | 44 +- .../start/services/pg-meta.service.ts | 31 +- .../start/services/postgrest.service.ts | 39 +- .../start/services/realtime.service.ts | 17 +- .../start/services/storage.service.ts | 69 +- .../commands/start/services/studio.service.ts | 81 +- .../services/studio.service.unit.test.ts | 10 - .../start/services/supavisor.service.ts | 87 +- .../commands/start/services/vector.service.ts | 159 +--- apps/cli/src/commands/start/start.command.ts | 28 +- .../commands/start/start.command.unit.test.ts | 8 - apps/cli/src/commands/start/start.e2e.test.ts | 15 +- apps/cli/src/commands/start/start.exclude.ts | 28 +- apps/cli/src/commands/start/start.gates.ts | 76 +- apps/cli/src/commands/start/start.handler.ts | 838 ++++++------------ 16 files changed, 448 insertions(+), 1152 deletions(-) diff --git a/apps/cli/src/commands/start/services/logflare.service.ts b/apps/cli/src/commands/start/services/logflare.service.ts index cde982129e..5385edcf19 100644 --- a/apps/cli/src/commands/start/services/logflare.service.ts +++ b/apps/cli/src/commands/start/services/logflare.service.ts @@ -1,18 +1,12 @@ /** - * Logflare container spec builder, gated on `config.analytics.enabled` — the - * gate itself is `start.handler.ts`'s job (a later task), not this module's; - * this file only builds the `docker create` spec. + * Builds the `docker create` spec for the Logflare/analytics container. Gating on + * `config.analytics.enabled` is the caller's responsibility. * - * Two things make this the fullest worked example among the three real - * services ported in this file group: - * - * - A custom `Entrypoint`/`Cmd` pair that writes and runs its own `run.sh`, - * because the image's own entrypoint conflicts with the healthcheck due to - * a 15-second sleep - * (https://github.com/Logflare/logflare/blob/staging/run.sh#L35). - * - A `config.analytics.backend` branch (`postgres` vs `bigquery`) that - * appends different env vars (and, for BigQuery only, a bind mount for the - * GCP service-account JSON). + * Writes and execs its own `run.sh` because the image's own entrypoint conflicts with the + * healthcheck due to a 15-second startup sleep + * (https://github.com/Logflare/logflare/blob/staging/run.sh#L35). Branches on + * `config.analytics.backend` (`postgres` vs `bigquery`) for env vars and, for BigQuery, a bind + * mount for the GCP service-account JSON. */ import { join } from "node:path"; @@ -26,37 +20,21 @@ import { usesSlimImageRuntime } from "../../../shared/services/slim-images.ts"; const LOGFLARE_CONTAINER_SUFFIX = "analytics"; /** - * The superuser role — the DB user Logflare's own Ecto connection - * authenticates as. Distinct from - * {@link LogflareContainerSpecInput.dbUser} (`"postgres"`), which is - * used only for the Postgres-backend `POSTGRES_BACKEND_URL` below — two - * different DB users really are used for two different env vars in this one - * block. + * The DB user Logflare's own Ecto connection authenticates as, distinct from + * {@link LogflareContainerSpecInput.dbUser} (used only for the Postgres-backend + * `POSTGRES_BACKEND_URL`). */ const LOGFLARE_DB_USERNAME = "supabase_admin"; -/** - * The analytics API key's only possible value. It's never decoded from - * `config.toml`, so this can never actually vary; hardcoding it here - * (rather than threading it through as an input) reflects that. - */ +/** The analytics API key's only possible value; never decoded from `config.toml`, so it's hardcoded rather than threaded through as an input. */ const LOGFLARE_API_KEY = "api-key"; /** - * The Logflare entrypoint script: the image's own entrypoint conflicts with - * the container healthcheck due to a 15-second sleep, so this writes its own - * `run.sh` and runs that instead. - * - * Deliberate design choice, do not "fix" this in a later cleanup (issue - * #6088): `migrate && start`, so a failed migrate exits the container and - * the `unless-stopped` restart policy retries until the db is ready — - * running Logflare against an unmigrated database lets Oban die on the - * missing `public.oban_jobs` table instead. - * - * `run.sh` stays PID 1 on purpose: a plain `exec` of `beam.smp` still burned - * Docker's 10s SIGTERM grace (upstream hang). Forward TERM, wait 3s, then - * KILL. Interrupted `wait` is >128; a second `wait` recovers the BEAM's - * status unless it was already reaped (127). + * Writes and execs a custom `run.sh`: the image's own entrypoint conflicts with the container + * healthcheck due to a 15-second startup sleep. Runs `migrate && start` so a failed migration + * exits the container and the restart policy retries until the db is ready, instead of running + * Logflare against an unmigrated database. Stays PID 1, trapping TERM to forward it, wait 3s, + * then KILL — a plain `exec` of `beam.smp` was still hitting Docker's 10s SIGTERM grace. */ const LOGFLARE_ENTRYPOINT_SCRIPT = "cat <<'EOF' > run.sh && exec sh run.sh\n" + @@ -89,10 +67,8 @@ export interface LogflareContainerSpecInput { /** `config.analytics.gcp_project_number` — only read when {@link backend} is `"bigquery"`. */ readonly gcpProjectNumber: string; /** - * `config.analytics.gcp_jwt_path` — only read when {@link backend} is - * `"bigquery"`. Joined onto {@link workdir} UNCONDITIONALLY — an empty - * string still produces a (degenerate) bind mount of `workdir` itself, - * the behavior when the field is unset. + * `config.analytics.gcp_jwt_path`, only read when {@link backend} is `"bigquery"`. Always + * joined onto {@link workdir}; an unset field falls back to a bind mount of `workdir` itself. */ readonly gcpJwtPath: string; /** The process working directory, used to resolve {@link gcpJwtPath} to a host path. */ @@ -102,9 +78,8 @@ export interface LogflareContainerSpecInput { /** Hardcoded `5432`. */ readonly dbPort: number; /** - * Hardcoded `"postgres"` — used only for the Postgres-backend - * `POSTGRES_BACKEND_URL` env var, NOT for `DB_USERNAME` (see - * {@link LOGFLARE_DB_USERNAME}'s doc comment). + * Hardcoded `"postgres"`, used only for the Postgres-backend `POSTGRES_BACKEND_URL` env var — + * not for `DB_USERNAME` (see {@link LOGFLARE_DB_USERNAME}). */ readonly dbUser: string; /** `config.db.password`. */ @@ -126,9 +101,8 @@ export function buildLogflareContainerSpec(input: LogflareContainerSpecInput): S LOGFLARE_PRIVATE_ACCESS_TOKEN: LOGFLARE_API_KEY, LOGFLARE_LOG_LEVEL: "warn", LOGFLARE_NODE_HOST: "127.0.0.1", - // The literal env VALUE includes the single quotes — this is set directly - // on the container env, never through a shell, so the quotes are not - // stripped anywhere. + // The single quotes are part of the literal value; this is set directly on the container + // env, not through a shell, so they are never stripped. LOGFLARE_FEATURE_FLAG_OVERRIDE: "'multibackend=true'", RELEASE_COOKIE: "cookie", }; diff --git a/apps/cli/src/commands/start/services/mailpit.service.ts b/apps/cli/src/commands/start/services/mailpit.service.ts index 327c9c20ee..09105dd135 100644 --- a/apps/cli/src/commands/start/services/mailpit.service.ts +++ b/apps/cli/src/commands/start/services/mailpit.service.ts @@ -1,52 +1,24 @@ -/** - * Mailpit container spec builder, gated on `config.inbucket.enabled` — the - * gate itself is `start.handler.ts`'s job (a later task), not this module's; - * this file only builds the `docker create` spec. - * - * The simplest of the container-bring-up services in this port: no hostname - * override, no entrypoint/cmd override, no binds, and exactly one - * unconditional env var. The only real branching is which of the two optional - * ports (SMTP, POP3) get published alongside the always-on web UI port. - */ - import { serviceContainerName } from "../../../command-internal/docker-ids.ts"; import type { StartContainerSpec } from "../../../command-internal/db-bootstrap/docker-create-args.ts"; /** - * Also this service's `containerSuffix` in `SERVICE_CATALOG` - * (`service-catalog.ts`). Mailpit keeps the internal "inbucket" name - * (the product it replaced) for the container/alias/id, even though the - * user-facing service and config section are "Mailpit"/`config.inbucket`. + * This service's `containerSuffix` in `SERVICE_CATALOG`. Kept as "inbucket" (the product Mailpit + * replaced) even though the user-facing service and config section are "Mailpit"/`config.inbucket`. */ const MAILPIT_CONTAINER_SUFFIX = "inbucket"; export interface MailpitContainerSpecInput { - /** - * The already-resolved `config.inbucket.image`. Not part of the decoded - * `@supabase/config` schema; resolution is the caller's responsibility, - * same as every other service in this port. - */ + /** The already-resolved `config.inbucket.image`; resolution is the caller's responsibility. */ readonly image: string; /** The project id, used to derive this container's own name via {@link serviceContainerName}. */ readonly projectId: string; - /** - * `container.HostConfig.NetworkMode`'s target — resolved once per `start` - * run, not per-container (see `StartContainerSpec.networkId`'s doc - * comment in `docker-create-args.ts`). - */ + /** `container.HostConfig.NetworkMode`'s target; resolved once per `start` run, not per-container. */ readonly networkId: string; /** `config.inbucket.port` — always published as `8025/tcp`. */ readonly port: number; - /** - * `config.inbucket.smtp_port` — published as `1025/tcp` only when set and - * non-zero. `@supabase/config` has no default for this key, so an absent - * value decodes to `undefined` — the "unset" case. - */ + /** `config.inbucket.smtp_port`; published as `1025/tcp` only when set (not `undefined`) and non-zero. */ readonly smtpPort?: number; - /** - * `config.inbucket.pop3_port` — published as `1110/tcp` only when set and - * non-zero. - */ + /** `config.inbucket.pop3_port`; published as `1110/tcp` only when set (not `undefined`) and non-zero. */ readonly pop3Port?: number; } @@ -66,7 +38,7 @@ export function buildMailpitContainerSpec(input: MailpitContainerSpecInput): Sta image: input.image, containerName: serviceContainerName(MAILPIT_CONTAINER_SUFFIX, input.projectId), env: { - // Disable reverse DNS lookups in Mailpit to avoid slow/delayed DNS resolution. + // Disables reverse DNS lookups to avoid slow/delayed DNS resolution. MP_SMTP_DISABLE_RDNS: "true", }, binds: [], @@ -76,7 +48,7 @@ export function buildMailpitContainerSpec(input: MailpitContainerSpecInput): Sta intervalSeconds: 10, timeoutSeconds: 2, retries: 3, - // StartPeriod taken from upstream Dockerfile. + // Matches Mailpit's own upstream healthcheck start period. startPeriodSeconds: 10, }, restartPolicy: "unless-stopped", diff --git a/apps/cli/src/commands/start/services/pg-meta.service.ts b/apps/cli/src/commands/start/services/pg-meta.service.ts index f44d37cd57..c229444814 100644 --- a/apps/cli/src/commands/start/services/pg-meta.service.ts +++ b/apps/cli/src/commands/start/services/pg-meta.service.ts @@ -1,26 +1,15 @@ /** - * pg-meta container spec builder, gated on `config.studio.enabled` (pg-meta - * has no `enabled` flag of its own — it only exists to back Studio's schema - * browser) and - * `!isContainerExcluded(config.studio.pgmeta_image, excluded)` — see - * `service-catalog.ts`'s `pgMeta` entry (`excludeKey: "postgres-meta"`, - * gated on `studio.enabled`). Gating and image resolution/pre-pull are the - * caller's job (a future `start.handler.ts`); this module only assembles the - * container spec once the caller has already decided to start it, matching - * `docker-create-args.ts`'s "image already resolved/pulled" contract. - * - * No separately-tested pure env function the way Studio has - * (`buildStudioEnv`): pg-meta's env is 6 straight `KEY=value` - * assignments with no derived formatting or conditional logic, so - * {@link buildPgMetaContainerSpec} is the only exported entry point. + * pg-meta container spec builder. Gated on `config.studio.enabled` (pg-meta has no `enabled` + * flag of its own — it exists only to back Studio's schema browser) and image exclusion; gating + * and image resolution/pre-pull are the caller's responsibility. */ import type { StartContainerSpec } from "../../../command-internal/db-bootstrap/docker-create-args.ts"; -/** The hardcoded pg-meta listen port (`PG_META_PORT=8080`) — never configurable. */ +/** pg-meta's listen port; not configurable. */ const PG_META_PORT = 8080; -/** The pg-meta network alias — a fixed, non-configurable constant. */ +/** pg-meta's fixed network alias. */ const PG_META_NETWORK_ALIASES = ["pg_meta"]; export interface PgMetaContainerInput { @@ -28,10 +17,7 @@ export interface PgMetaContainerInput { readonly image: string; /** `serviceContainerName("pg_meta", projectId)`. */ readonly containerName: string; - /** - * The local Postgres container's own hostname on the shared Docker - * network. - */ + /** The local Postgres container's own hostname on the shared Docker network. */ readonly dbHost: string; /** Hardcoded `5432`. */ readonly dbPort: number; @@ -45,10 +31,7 @@ export interface PgMetaContainerInput { readonly networkId: string; } -/** - * Assembles pg-meta's {@link StartContainerSpec}. Pure — no Effect or - * I/O — matching `docker-create-args.ts`'s own builder shape. - */ +/** Builds pg-meta's {@link StartContainerSpec}. */ export function buildPgMetaContainerSpec(input: PgMetaContainerInput): StartContainerSpec { return { image: input.image, diff --git a/apps/cli/src/commands/start/services/postgrest.service.ts b/apps/cli/src/commands/start/services/postgrest.service.ts index f3d9e3c0fa..11eeab5ea3 100644 --- a/apps/cli/src/commands/start/services/postgrest.service.ts +++ b/apps/cli/src/commands/start/services/postgrest.service.ts @@ -1,19 +1,10 @@ /** - * PostgREST container spec builder. + * Builds the `docker create` spec for the PostgREST container. Gated on `config.api.enabled` by + * the caller. * - * Enabled gate: `config.api.enabled`. Gating (this field, plus - * `!isContainerExcluded`) is the caller's responsibility — see - * `start.services.ts`'s `postgrest` catalog entry (`enabledGate: - * "api.enabled"`). - * - * No `Healthcheck` field at all: PostgREST does not expose a shell for - * health checks. PostgREST readiness is instead checked at runtime via an - * HTTP HEAD through the local Kong gateway - * (`checkHttpReady`/`POSTGREST_READY_PATH`, - * `../../../shared/db-bootstrap/health-check.ts`) — this builder correctly - * omits `healthcheck` so `buildStartContainerCreateArgs` never emits a - * `--health-*` flag for this container, matching `docker-create-args.ts`'s - * own documented PostgREST exception. + * Omits `healthcheck`: PostgREST has no shell to run a Docker health check command, so its + * readiness is checked at runtime via an HTTP HEAD through the local Kong gateway instead + * (`checkHttpReady`/`POSTGREST_READY_PATH`). */ import type { CliConfig } from "@supabase/config"; @@ -34,21 +25,16 @@ export interface PostgrestEnvInput { readonly maxRows: CliConfig["api"]["max_rows"]; /** The `db` container's own Docker name (`serviceContainerName("db", projectId)`). */ readonly dbHost: string; - /** See `startInternalDbPassword` (`../../../shared/db-bootstrap/internal-db-connection.ts`). */ + /** See {@link startInternalDbPassword}. */ readonly dbPassword: string; /** - * `resolveLocalJwks`'s resolved JWKS JSON string — feeds - * `PGRST_JWT_SECRET` directly (despite the env var's name, PostgREST is - * fed the JWKS document, not the raw `auth.jwt_secret`). + * The resolved JWKS JSON string, fed into `PGRST_JWT_SECRET` — despite the name, PostgREST + * receives the JWKS document, not the raw `auth.jwt_secret`. */ readonly jwks: string; } -/** - * Pure env-var builder, split out from - * {@link buildPostgrestContainerSpec} so the full env set is - * unit-testable without constructing a whole container spec. - */ +/** Builds the env vars for the PostgREST container. */ export function buildPostgrestEnv(input: PostgrestEnvInput): Record<string, string> { return { PGRST_DB_URI: startInternalDbUrl("authenticator", input.dbHost, input.dbPassword), @@ -62,11 +48,11 @@ export function buildPostgrestEnv(input: PostgrestEnvInput): Record<string, stri } export interface PostgrestContainerSpecInput { - /** The sanitized project id — see `serviceContainerName`'s callers. */ + /** The sanitized project id. */ readonly projectId: string; - /** `container.HostConfig.NetworkMode`/`network.NetworkingConfig` target — the `--network-id` override or `utils.NetId`. */ + /** `container.HostConfig.NetworkMode`'s target; resolved once per `start` run, not per-container. */ readonly networkId: string; - /** `utils.Config.Api.Image`, already resolved/pulled by the caller (`image-prepull.ts`). */ + /** `config.api.image`, already resolved/pulled by the caller. */ readonly image: string; readonly schemas: CliConfig["api"]["schemas"]; readonly extraSearchPath: CliConfig["api"]["extra_search_path"]; @@ -99,7 +85,6 @@ export function buildPostgrestContainerSpec( binds: [], restartPolicy: "unless-stopped", networkId: input.networkId, - // The PostgREST network alias. networkAliases: ["rest"], labels: {}, }; diff --git a/apps/cli/src/commands/start/services/realtime.service.ts b/apps/cli/src/commands/start/services/realtime.service.ts index 6889dde155..8dafa26358 100644 --- a/apps/cli/src/commands/start/services/realtime.service.ts +++ b/apps/cli/src/commands/start/services/realtime.service.ts @@ -1,12 +1,6 @@ /** - * Builds the Realtime container spec. - * - * Enabled gate: `config.realtime.enabled` — independent of - * `config.api.enabled` (PostgREST's own gate); the two are never conflated. - * Gating (this field, plus `!isContainerExcluded`) is the caller's - * responsibility — see `start.services.ts`'s `realtime` catalog entry - * (`enabledGate: "realtime.enabled"`) — this module only builds the - * container spec once called. + * Builds the `docker create` spec for the Realtime container. Gated on `config.realtime.enabled` + * by the caller, independent of PostgREST's `config.api.enabled`. */ import type { CliConfig } from "@supabase/config"; @@ -22,11 +16,11 @@ import { usesSlimImageRuntime } from "../../../shared/services/slim-images.ts"; import { startInternalDbPassword } from "../../../command-internal/db-bootstrap/internal-db-connection.ts"; export interface RealtimeContainerSpecInput { - /** The sanitized project id — see `serviceContainerName`'s callers. */ + /** The sanitized project id. */ readonly projectId: string; - /** `container.HostConfig.NetworkMode`/`network.NetworkingConfig` target — the `--network-id` override or `utils.NetId`. */ + /** `container.HostConfig.NetworkMode`'s target; resolved once per `start` run, not per-container. */ readonly networkId: string; - /** `utils.Config.Realtime.Image`, already resolved/pulled by the caller (`image-prepull.ts`). */ + /** `config.realtime.image`, already resolved/pulled by the caller. */ readonly image: string; readonly ipVersion: CliConfig["realtime"]["ip_version"]; readonly maxHeaderLength: CliConfig["realtime"]["max_header_length"]; @@ -81,7 +75,6 @@ export function buildRealtimeContainerSpec(input: RealtimeContainerSpecInput): S }, restartPolicy: "unless-stopped", networkId: input.networkId, - // Network aliases: `realtime` plus the tenant id. networkAliases: ["realtime", REALTIME_TENANT_ID], labels: {}, }; diff --git a/apps/cli/src/commands/start/services/storage.service.ts b/apps/cli/src/commands/start/services/storage.service.ts index 5709812157..8a4e6ccf88 100644 --- a/apps/cli/src/commands/start/services/storage.service.ts +++ b/apps/cli/src/commands/start/services/storage.service.ts @@ -1,37 +1,6 @@ /** * Storage container spec builder, plus the vector-bucket env helper - * `appendStorageVectorEnv`. - * - * Enabled gate: `isStorageEnabled` — `config.storage.enabled && - * !isContainerExcluded(storageImage, excluded)`. Gating itself is the - * caller's responsibility (`start.services.ts`'s `storage` catalog entry, - * `enabledGate: "storage.enabled"`); this module only builds the container - * spec once called. - * - * `imageTransformationEnabled` (below) is a COMPOUND value (`storage.enabled` - * is already implied by the fact Storage itself is starting) && - * `config.storage.image_transformation?.enabled && - * !isContainerExcluded(imgproxyImage, excluded)`, NOT the bare - * `config.storage.image_transformation?.enabled` field alone. The caller - * must compute this compound boolean exactly once and pass the SAME value - * both here (`ENABLE_IMAGE_TRANSFORMATION`) and to the decision of whether - * to actually start the ImgProxy container - * (`imgproxy.service.ts`'s `buildImgproxyContainerSpec` precondition) - * — the two must never disagree. - * - * `s3ProtocolEnabled` is `config.storage.s3_protocol.enabled` directly (no - * exclusion factor — S3 protocol support is a Storage feature flag, not a - * separate container). The embedded default config template always sets - * `[storage.s3_protocol]\nenabled = true` and is merged in as the base layer - * before any real config loads, so this section is always present in - * practice. `@supabase/config`'s schema mirrors this by decoding - * `storage.s3_protocol` unconditionally (never `optionalKey`, unlike - * `image_transformation`), so the raw decoded boolean needs no extra - * PRESENCE check — but the caller is still responsible for applying - * `SUPABASE_STORAGE_S3_PROTOCOL_ENABLED`/`SUPABASE_STORAGE_VECTOR_ENABLED` - * (the same override mechanism `imageTransformationEnabled`'s own compound - * gate already accounts for) before passing - * `s3ProtocolEnabled`/`vectorBucketsEnabled` in. + * `appendStorageVectorEnv`. Gated on `config.storage.enabled` by the caller. */ import type { CliConfig } from "@supabase/config"; @@ -53,15 +22,12 @@ const STORAGE_DOCKER_PATH = "/mnt"; export interface StorageVectorEnvInput { /** The `db` container's own Docker name (`serviceContainerName("db", projectId)`). */ readonly dbHost: string; - /** See `startInternalDbPassword` (`../../../shared/db-bootstrap/internal-db-connection.ts`). */ + /** See {@link startInternalDbPassword}. */ readonly dbPassword: string; readonly projectEnvValues?: Readonly<Record<string, string>>; } -/** - * Only called when `config.storage.vector.enabled` — note the TOML key is - * `[storage.vector]`, not `vector_buckets`. - */ +/** Called only when `config.storage.vector.enabled` — the TOML key is `[storage.vector]`, not `vector_buckets`. */ export function appendStorageVectorEnv( env: Readonly<Record<string, string>>, input: StorageVectorEnvInput, @@ -90,10 +56,8 @@ export function appendStorageVectorEnv( export interface StorageEnvInput { /** - * The storage target-migration pin, resolved from a version-pin file, not - * from `@supabase/config`'s schema. Out of scope for this builder (like - * `image`); the caller resolves it and typically passes `""` when the - * file is absent. + * The storage target-migration pin, resolved by the caller from a version-pin file (not from + * `@supabase/config`'s schema); typically `""` when the file is absent. */ readonly targetMigration: string; /** `LocalConfigValues.anonKey`. */ @@ -106,7 +70,7 @@ export interface StorageEnvInput { readonly jwks: string; /** The `db` container's own Docker name (`serviceContainerName("db", projectId)`). */ readonly dbHost: string; - /** See `startInternalDbPassword` (`../../../shared/db-bootstrap/internal-db-connection.ts`). */ + /** See {@link startInternalDbPassword}. */ readonly dbPassword: string; /** `config.storage.file_size_limit`, e.g. `"50MiB"` — converted to a byte count via `ramInBytes`. */ readonly fileSizeLimit: CliConfig["storage"]["file_size_limit"]; @@ -116,22 +80,22 @@ export interface StorageEnvInput { readonly s3AccessKeyId: string; /** `LocalConfigValues.storageS3SecretAccessKey`. */ readonly s3SecretAccessKey: string; - /** The compound image-transformation-enabled boolean — see this file's header for why this is NOT the bare config field. */ + /** + * The compound image-transformation-enabled boolean (`image_transformation?.enabled` plus + * image exclusion), not the bare config field. Must equal the value used to decide whether the + * ImgProxy container itself starts. + */ readonly imageTransformationEnabled: boolean; /** The ImgProxy container's own Docker name (`serviceContainerName("imgproxy", projectId)`). */ readonly imgproxyHost: string; - /** `config.storage.s3_protocol.enabled` — see this file's header for why no extra presence check is needed. */ + /** `config.storage.s3_protocol.enabled`; the schema decodes this key unconditionally, so no separate presence check is needed. */ readonly s3ProtocolEnabled: boolean; /** `config.storage.vector.enabled`. */ readonly vectorBucketsEnabled: boolean; readonly projectEnvValues?: Readonly<Record<string, string>>; } -/** - * Pure env-var builder, split out from {@link buildStorageContainerSpec} - * so the full env set — including the conditional vector-bucket branch — is - * unit-testable without constructing a whole container spec. - */ +/** Builds the env vars for the Storage container, including the conditional vector-bucket branch. */ export function buildStorageEnv(input: StorageEnvInput): Record<string, string> { const env: Record<string, string> = { DB_MIGRATIONS_FREEZE_AT: input.targetMigration, @@ -171,11 +135,11 @@ export function buildStorageEnv(input: StorageEnvInput): Record<string, string> } export interface StorageContainerSpecInput { - /** The sanitized project id — see `serviceContainerName`'s callers. */ + /** The sanitized project id. */ readonly projectId: string; - /** `container.HostConfig.NetworkMode`/`network.NetworkingConfig` target — the `--network-id` override or `utils.NetId`. */ + /** `container.HostConfig.NetworkMode`'s target; resolved once per `start` run, not per-container. */ readonly networkId: string; - /** `utils.Config.Storage.Image`, already resolved/pulled by the caller (`image-prepull.ts`). */ + /** `config.storage.image`, already resolved/pulled by the caller. */ readonly image: string; readonly targetMigration: string; readonly fileSizeLimit: CliConfig["storage"]["file_size_limit"]; @@ -243,7 +207,6 @@ export function buildStorageContainerSpec(input: StorageContainerSpecInput): Sta }, restartPolicy: "unless-stopped", networkId: input.networkId, - // The Storage network alias. networkAliases: ["storage"], labels: {}, }; diff --git a/apps/cli/src/commands/start/services/studio.service.ts b/apps/cli/src/commands/start/services/studio.service.ts index 00bcc5b60c..e44ab39f1c 100644 --- a/apps/cli/src/commands/start/services/studio.service.ts +++ b/apps/cli/src/commands/start/services/studio.service.ts @@ -1,23 +1,12 @@ /** - * Studio env + container spec builder, gated on `config.studio.enabled` and - * `!isContainerExcluded(config.studio.image, excluded)` — see - * `service-catalog.ts`'s `studio` entry (`excludeKey: "studio"`, gated - * on `studio.enabled`, depends on pg-meta being healthy/running). Gating, - * image resolution/pre-pull, and edge-function bind-mount resolution - * (`serve.PopulatePerFunctionConfigs`, out of scope here — see - * {@link StudioContainerInput.functionBinds}) are the caller's job (a - * future `start.handler.ts`). + * Studio env + container spec builder. Gating, image resolution, and edge-function bind-mount + * resolution are the caller's responsibility. * - * `workdir`/`containerSnippetsPath`: the host snippets path is - * `filepath.Join(workdir, "supabase", "snippets")` — a plain top-level - * `<project>/supabase/snippets` directory, NOT under `supabase/.temp/` (that - * directory is reserved for the link-state cache, see - * `temp-paths.ts`; snippets are user content Studio's SQL Editor - * reads/writes, meant to persist, not a cache). `containerSnippetsPath` is - * that host path translated to its in-container mount form (see - * {@link toDockerMountPath}), computed once by - * {@link buildStudioContainerSpec} and threaded into both the bind mount - * and {@link buildStudioEnv}'s `SNIPPETS_MANAGEMENT_FOLDER`. + * The host snippets path (`<workdir>/supabase/snippets`) is a plain top-level directory, not + * under `supabase/.temp/` (reserved for the link-state cache) — snippets are persistent user + * content Studio's SQL Editor reads/writes, not a cache. {@link buildStudioContainerSpec} computes + * its in-container mount form once and reuses it for both the bind mount and + * {@link buildStudioEnv}'s `SNIPPETS_MANAGEMENT_FOLDER`. */ import { join } from "node:path"; @@ -31,28 +20,15 @@ const STUDIO_CONTAINER_PORT = 3000; /** The Studio network alias — a fixed, non-configurable constant. */ const STUDIO_NETWORK_ALIASES = ["studio"]; -/** - * The default analytics API key — never decoded from `config.toml`, never - * overridable — so this is always the value regardless of the resolved - * project config, exactly like `local-config-values.ts`'s other - * hardcoded, schema-absent constants (`DEFAULT_DB_PASSWORD`, the S3 - * credential triple). - */ +/** The analytics API key; never decoded from `config.toml` or overridable, so it's always this value. */ const LOGFLARE_PRIVATE_ACCESS_TOKEN = "api-key"; export interface BuildStudioEnvInput { /** The db password — becomes `POSTGRES_PASSWORD`. */ readonly dbPassword: string; - /** - * `CommandSettings.workdir`, the already-resolved absolute project root — - * `EDGE_FUNCTIONS_MANAGEMENT_FOLDER` is resolved against it - * (`<workdir>/supabase/functions`). - */ + /** The already-resolved absolute project root; `EDGE_FUNCTIONS_MANAGEMENT_FOLDER` is resolved against it (`<workdir>/supabase/functions`). */ readonly workdir: string; - /** - * Becomes `SNIPPETS_MANAGEMENT_FOLDER` verbatim. See this module's doc - * comment for what it is and how it's derived. - */ + /** Becomes `SNIPPETS_MANAGEMENT_FOLDER` verbatim; see this module's header for how it's derived. */ readonly containerSnippetsPath: string; /** `CURRENT_CLI_VERSION`. */ readonly cliVersion: string; @@ -63,14 +39,9 @@ export interface BuildStudioEnvInput { /** Logflare's own container name — `LOGFLARE_URL=http://<name>:4000`. */ readonly logflareContainerName: string; /** - * `config.studio.api_url`, post-`SUPABASE_STUDIO_API_URL`-override AND - * post-config-validation's host-rewrite (`resolveStudioApiUrl`) — - * `SUPABASE_PUBLIC_URL`. Distinct from `LocalConfigValues.studioUrl` - * (the `http://<hostname>:<port>` value `status` reports). This is NOT - * simply the raw `studio.api_url` field: under a default config its host - * (`127.0.0.1`) matches the local hostname, so it's rewritten to - * `Config.Api.ExternalUrl` (the Kong URL) before `start` ever reads it — - * the caller must apply that same rewrite before passing this field in. + * Becomes `SUPABASE_PUBLIC_URL`. Not the raw `config.studio.api_url` field: under a default + * config its host (`127.0.0.1`) is rewritten to the Kong URL before `start` reads it, and the + * caller must apply that rewrite before passing this field in. */ readonly studioApiUrl: string; /** `resolveLocalConfigValues(...).jwtSecret` — `AUTH_JWT_SECRET`. */ @@ -87,10 +58,7 @@ export interface BuildStudioEnvInput { readonly s3AccessKeyId: string; /** `resolveLocalConfigValues(...).storageS3SecretAccessKey` — `S3_PROTOCOL_ACCESS_KEY_SECRET`. */ readonly s3SecretAccessKey: string; - /** - * `config.studio.openai_api_key`, decrypted/resolved by the caller — - * `OPENAI_API_KEY`. `undefined` maps to `""`, the unset-secret case. - */ + /** `config.studio.openai_api_key`, resolved by the caller; `undefined` maps to `""` (unset). */ readonly openaiApiKey: string | undefined; /** `config.api.schemas` — `PGRST_DB_SCHEMAS`, comma-joined. */ readonly apiSchemas: ReadonlyArray<string>; @@ -105,11 +73,8 @@ export interface BuildStudioEnvInput { } /** - * Builds Studio's container env. Returns a `KEY -> value` map — - * {@link StartContainerSpec.env}'s own shape, chosen so secret values - * never round-trip through this process's own `docker create` argv (see that - * field's doc comment in `docker-create-args.ts`). Pure — no Effect or I/O — - * so every env var mapping is unit-testable in isolation. + * Builds Studio's container env as a `KEY -> value` map, matching {@link StartContainerSpec.env}'s + * shape, so secret values never round-trip through this process's own `docker create` argv. */ export function buildStudioEnv(input: BuildStudioEnvInput): Record<string, string> { return { @@ -153,23 +118,15 @@ export interface StudioContainerInput { /** `config.studio.port` — the host port published to `3000/tcp`. */ readonly port: number; /** - * The per-enabled-Edge-Function module bind mounts resolved from each - * `supabase/functions/<slug>`'s deploy config - * (`serve.PopulatePerFunctionConfigs`). Out of scope for now (a separate, - * large functions-deploy-config-parsing concern); pass `[]` until a - * future edge-functions-serve integration supplies these. + * Per-enabled-Edge-Function module bind mounts, resolved from each `supabase/functions/<slug>`'s + * deploy config. Pass `[]` until an edge-functions-serve integration supplies these. */ readonly functionBinds: ReadonlyArray<string>; /** Every value {@link buildStudioEnv} needs, minus the path this builder derives itself. */ readonly env: Omit<BuildStudioEnvInput, "containerSnippetsPath">; } -/** - * Assembles Studio's {@link StartContainerSpec}, including the snippets - * bind mount and its Docker-path form, derived from `workdir` once and - * reused for both the bind and {@link buildStudioEnv}'s - * `SNIPPETS_MANAGEMENT_FOLDER`. - */ +/** Builds Studio's {@link StartContainerSpec}, including the snippets bind mount. */ export function buildStudioContainerSpec(input: StudioContainerInput): StartContainerSpec { const hostSnippetsPath = join(input.env.workdir, "supabase", "snippets"); const containerSnippetsPath = toDockerMountPath(hostSnippetsPath); diff --git a/apps/cli/src/commands/start/services/studio.service.unit.test.ts b/apps/cli/src/commands/start/services/studio.service.unit.test.ts index c3263f4dc0..a792a5fdfe 100644 --- a/apps/cli/src/commands/start/services/studio.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/studio.service.unit.test.ts @@ -34,7 +34,6 @@ describe("buildStudioEnv", () => { test("mirrors Go's TestBuildStudioEnv fixture", () => { const env = buildStudioEnv(baseEnvInput); - // The exact 8 assertions this fixture makes. expect(env["SUPABASE_ANON_KEY"]).toBe("anon-key"); expect(env["SUPABASE_SERVICE_KEY"]).toBe("service-role-key"); expect(env["SUPABASE_PUBLISHABLE_KEY"]).toBe("sb_publishable_test"); @@ -44,8 +43,6 @@ describe("buildStudioEnv", () => { expect(env["SUPABASE_URL"]).toBe("http://test-kong:8000"); expect(env["STUDIO_PG_META_URL"]).toBe("http://test-pgmeta:8080"); - // Every other key `buildStudioEnv` emits, covered here for full - // parity. expect(env).toEqual({ CURRENT_CLI_VERSION: "test-version", STUDIO_PG_META_URL: "http://test-pgmeta:8080", @@ -75,8 +72,6 @@ describe("buildStudioEnv", () => { }); test("LOGFLARE_PRIVATE_ACCESS_TOKEN is always Go's hardcoded 'api-key', regardless of input", () => { - // `analytics.api_key` isn't a `config.toml`-configurable field — - // there is no input field for it at all. const env = buildStudioEnv(baseEnvInput); expect(env["LOGFLARE_PRIVATE_ACCESS_TOKEN"]).toBe("api-key"); }); @@ -140,10 +135,6 @@ describe("buildStudioContainerSpec", () => { retries: 3, }); - // pg-meta URL wiring: a distinct `pgMetaContainerName` (resolved by the - // caller via `serviceContainerName("pg_meta", projectId)`) flows - // through to STUDIO_PG_META_URL exactly like it does in - // `buildStudioEnv`. expect(spec.env["STUDIO_PG_META_URL"]).toBe("http://test-pgmeta:8080"); }); @@ -157,7 +148,6 @@ describe("buildStudioContainerSpec", () => { "/project/supabase/functions/hello:/home/deno/functions/hello:ro", "/project/supabase/snippets:/project/supabase/snippets:rw", ]); - // The snippets bind's container-side path also backs SNIPPETS_MANAGEMENT_FOLDER. expect(spec.env["SNIPPETS_MANAGEMENT_FOLDER"]).toBe("/project/supabase/snippets"); }); diff --git a/apps/cli/src/commands/start/services/supavisor.service.ts b/apps/cli/src/commands/start/services/supavisor.service.ts index 4de311e334..ddb57b91a0 100644 --- a/apps/cli/src/commands/start/services/supavisor.service.ts +++ b/apps/cli/src/commands/start/services/supavisor.service.ts @@ -1,41 +1,13 @@ /** - * Supavisor/pooler container spec builder, gated on - * `config.db.pooler.enabled` — the gate itself is `start.handler.ts`'s job - * (a later task), not this module's; this file only builds the - * `docker create` spec (plus the pure tenant-provisioning script it embeds - * — see below). + * Builds the `docker create` spec for the Supavisor/pooler container. Gated on + * `config.db.pooler.enabled` by the caller. * - * IMPORTANT — how the Supavisor tenant is actually provisioned: it is NOT a - * post-start `docker exec`. The rendered `pooler.exs` (via - * `renderStartPoolerExs`, already ported in `../lib/template-render.ts`) - * is built BEFORE the container is created, then baked directly into the - * container's own startup `Cmd` - * (`/bin/sh -c "/app/bin/migrate && /app/bin/supavisor eval '<script>' && - * /app/bin/server"`) — overriding the image's default `CMD` while keeping - * its own `ENTRYPOINT` (no `Entrypoint` field is set here at all, matching - * `docker-create-args.ts`'s documented Pooler precedent). There is no - * separate post-start step: tenant creation runs once, as part of the - * container's first boot, inside the same shell invocation that also runs - * `/app/bin/migrate`. - * - * A literal shell-embed of `<script>` (which carries the DB password) would - * only be safe with an architecture that calls the Docker Engine API - * directly, so that `Cmd` string never becomes a subprocess's own argv. - * THIS PORT SHELLS OUT to a real `docker create`, where that would leak, so - * it deliberately diverges: the rendered script travels via - * {@link StartContainerSpec.secretFiles} instead (an in-memory tar - * entry, mode `0644`, streamed via `docker cp - <id>:/` into the container at - * {@link SUPAVISOR_POOLER_TENANT_CONTAINER_PATH}) — Supavisor itself - * runs fully as root in its image, so it is unaffected by the non-root read - * issue that motivates `0644` for Kong/Postgres (see - * `copyStartSecretFilesIntoContainer`'s doc comment); the file mode is - * simply widened here for consistency with the other secrets, and - * {@link buildSupavisorStartCmd} only ever references that FIXED path - * — never the secret content itself (CWE-214/522). See that function's doc - * comment for the resulting quoting nuance. {@link buildSupavisorStartCmd} - * is exported separately (rather than inlined) so a later orchestrator can - * unit-test or reuse the exact shell-embedding shape independently of the - * rest of the spec. + * The tenant is provisioned by baking the rendered `pooler.exs` script into the container's own + * startup `Cmd`, not via a post-start `docker exec`. The script travels via + * {@link StartContainerSpec.secretFiles} (`docker cp`'d in) rather than being embedded literally + * in `Cmd`, because `Cmd` becomes real process argv and would leak the DB password it carries + * (CWE-214/522); {@link buildSupavisorStartCmd} only ever references the resulting file's fixed + * path, never the secret content. */ import { serviceContainerName } from "../../../command-internal/docker-ids.ts"; @@ -60,35 +32,17 @@ const SUPAVISOR_SESSION_PORT = "5432"; /** The Supavisor transaction-mode port. */ const SUPAVISOR_TRANSACTION_PORT = "6543"; -/** - * The fixed in-container path the rendered `pooler.exs` tenant script is - * `docker cp`'d to (see {@link buildSupavisorContainerSpec}'s - * `secretFiles`). - */ +/** The fixed in-container path the rendered `pooler.exs` tenant script is `docker cp`'d to. */ const SUPAVISOR_POOLER_TENANT_CONTAINER_PATH = "/app/pooler_tenant.exs"; /** - * An unescaped single-quote wrap around the rendered `pooler.exs` script - * embedded directly into the container's `Cmd` would only be safe with an - * architecture that calls the Docker Engine API directly, so that `Cmd` - * string never becomes a subprocess's own argv — see this module's header - * comment for why that isn't this port's architecture. - * - * This `Cmd` instead reads the script from - * {@link SUPAVISOR_POOLER_TENANT_CONTAINER_PATH} at container - * startup: `eval "$(cat <path>)"`'s double-quoted command substitution - * passes the file's content to `eval` as a single argument, the same way an - * inline single-quote wrap would pass a literal as a single argument. Two - * quoting nuances, both immaterial for every value these fields can take - * today: `$()` strips the script's own trailing newline (irrelevant to - * `Code.eval_string`), and the surrounding double quotes re-expand a - * `$`/backtick sequence in the file's content that a single-quote wrap - * never would (every interpolated `pooler.exs` field is a fixed/internal - * value today — `db.password` in particular has no config.toml field at - * all, always literally `"postgres"`, see `postgres.service.ts`'s - * `POSTGRES_PASSWORD` — none of which contain `$`, a backtick, or a - * single quote). A future caller that ever makes one of these fields - * genuinely attacker-controlled must revisit this quoting. + * Reads the tenant script from {@link SUPAVISOR_POOLER_TENANT_CONTAINER_PATH} at container + * startup (`eval "$(cat <path>)"`) instead of embedding it as a quoted literal in `Cmd` — see this + * module's header for why. The double-quoted command substitution passes the file's content to + * `eval` as a single argument, the same as a single-quote literal would, except it also + * re-expands any `$`/backtick in the content; every interpolated `pooler.exs` field is fixed or + * internal today, so none can contain those characters. Revisit the quoting if a field ever + * becomes attacker-controlled. */ export function buildSupavisorStartCmd(): ReadonlyArray<string> { return [ @@ -99,10 +53,7 @@ export function buildSupavisorStartCmd(): ReadonlyArray<string> { } export interface SupavisorContainerSpecInput { - /** - * The already-resolved `config.db.pooler.image`. Not part of the decoded - * `@supabase/config` schema; resolution is the caller's responsibility. - */ + /** The already-resolved `config.db.pooler.image`; resolution is the caller's responsibility. */ readonly image: string; /** The project id, used to derive this container's own name via {@link serviceContainerName}. */ readonly projectId: string; @@ -116,13 +67,13 @@ export interface SupavisorContainerSpecInput { readonly defaultPoolSize: number; /** `config.db.pooler.max_client_conn` — also the tenant's `DefaultMaxClients`/`default_max_clients`. */ readonly maxClientConn: number; - /** `config.auth.jwt_secret` — used for BOTH `API_JWT_SECRET` and `METRICS_JWT_SECRET`. */ + /** `config.auth.jwt_secret`, used for both `API_JWT_SECRET` and `METRICS_JWT_SECRET`. */ readonly jwtSecret: string; /** The `db` container's own hostname. Also the tenant's `DbHost`/`db_host`. */ readonly dbHost: string; /** Hardcoded `5432`. Also the tenant's `DbPort`/`db_port`. */ readonly dbPort: number; - /** Hardcoded `"postgres"` — used only for `DATABASE_URL` (Supavisor's own metadata store), NOT the tenant script. */ + /** Hardcoded `"postgres"`, used only for `DATABASE_URL` (Supavisor's own metadata store) — not the tenant script. */ readonly dbUser: string; /** Used for both `DATABASE_URL` and the tenant's `DbPassword`/`db_password`. */ readonly dbPassword: string; diff --git a/apps/cli/src/commands/start/services/vector.service.ts b/apps/cli/src/commands/start/services/vector.service.ts index b3bbeabc2a..1ce25e7a51 100644 --- a/apps/cli/src/commands/start/services/vector.service.ts +++ b/apps/cli/src/commands/start/services/vector.service.ts @@ -1,33 +1,12 @@ /** - * Vector container spec builder, gated on - * `config.analytics.enabled && !isContainerExcluded(config.analytics.vector_image, - * excluded)` — see `service-catalog.ts`'s `vector` entry - * (`excludeKey: "vector"`, depends on `logflare` being healthy). Gating and - * image resolution/pre-pull are the caller's job (a future `start.handler.ts`); - * this module only assembles the container spec once the caller has already - * decided to start it, matching `docker-create-args.ts`'s "image already - * resolved/pulled" contract. + * Vector container spec builder, gated on `config.analytics.enabled` by the caller. * - * Vector is the one `start` service that inspects the HOST's own Docker - * daemon endpoint to decide how to mount/reach that daemon's socket from - * INSIDE the Vector container for its `docker_logs` source. That decision is - * split into two independently exported, independently testable pieces, - * matching every other `start` service's "builder stays pure" shape: - * - * - {@link resolveDockerDaemonHost} — the one IMPURE piece: discovers - * the current `scheme://host` string a caller should feed into the pure - * branch below. See its doc comment for exactly what it reads/shells out to - * and why. - * - {@link resolveVectorDockerSocketPlan} — the PURE scheme-branching - * logic: given an already-resolved daemon host string, decides the - * `DOCKER_HOST` env override, bind mount, and `--security-opt` this - * container needs. This is the highest-parity-risk logic in this module — - * Docker Desktop, Colima, OrbStack, and rootless Podman each take a - * different branch. - * - * {@link buildVectorContainerSpec} itself stays a pure function of an - * already-resolved {@link VectorDockerSocketPlan}, exactly like every - * other `start`-service builder in this directory. + * Vector inspects the host's own Docker daemon endpoint to decide how to mount or reach it from + * inside its container for the `docker_logs` source. That resolution splits into two pieces: + * {@link resolveDockerDaemonHost} discovers the daemon host string, and + * {@link resolveVectorDockerSocketPlan} branches on its scheme to decide the `DOCKER_HOST` + * override, bind mount, and `--security-opt` — Docker Desktop, Colima, OrbStack, and rootless + * Podman each take a different branch. */ import { Effect, Stream } from "effect"; @@ -59,13 +38,9 @@ export interface ParsedDockerHostUrl { } /** - * Splits a Docker host string of the form `scheme://addr` and, for a `tcp` - * host, strips any path/query so `.host` is exactly the `host:port` pair — - * docker host strings never carry a path in practice. Throws on a string - * with no `://` separator or an empty address — the daemon host resolved by - * {@link resolveDockerDaemonHost} is always a well-formed `scheme://` - * string in practice, so this should never actually throw outside a test - * feeding it garbage on purpose. + * Splits a Docker host string of the form `scheme://addr`; for a `tcp` host, strips any + * path/query so `.host` is exactly the `host:port` pair. Throws on a string with no `://` + * separator or an empty address. */ export function parseDockerHostUrl(host: string): ParsedDockerHostUrl { const separatorIndex = host.indexOf("://"); @@ -85,10 +60,8 @@ export function parseDockerHostUrl(host: string): ParsedDockerHostUrl { } /** - * Extracts the port from a Docker `tcp://` host string, covering exactly the - * shapes it can take (`host:port` or `[ipv6]:port`) — only the port is ever - * needed here, so this returns just that (`undefined` when the string has no - * trailing `:<digits>`). + * Extracts the port from a Docker `tcp://` host string (`host:port` or `[ipv6]:port`); returns + * `undefined` when there's no trailing `:<digits>`. */ export function splitHostPortPort(hostPort: string): string | undefined { const lastColon = hostPort.lastIndexOf(":"); @@ -98,12 +71,9 @@ export function splitHostPortPort(hostPort: string): string | undefined { } /** - * Recognizes the KNOWN rootful-socket paths Docker Desktop for Mac - * (`.docker/run` and the older `.docker/desktop`) and Colima expose, where - * binding the STANDARD `/var/run/docker.sock` path (handled specially by - * those runtimes, "under the hood") is required instead of binding the - * detected path directly. Anything else (Podman, OrbStack, a bare Linux - * socket) is assumed bindable-as-is. + * Recognizes the rootful-socket paths Docker Desktop for Mac (`.docker/run`, the older + * `.docker/desktop`) and Colima expose, which must bind `/var/run/docker.sock` instead of the + * detected path directly. Anything else (Podman, OrbStack, a bare Linux socket) is bindable as-is. */ export function shouldMountRootDockerSocket(host: string): boolean { return ( @@ -114,10 +84,7 @@ export function shouldMountRootDockerSocket(host: string): boolean { ); } -/** - * The platform-default Docker host: `platform` defaults to - * `process.platform`. - */ +/** The platform-default Docker host; `platform` defaults to `process.platform`. */ export function platformDefaultDockerHost(platform: NodeJS.Platform = process.platform): string { return platform === "win32" ? "npipe:////./pipe/docker_engine" : "unix:///var/run/docker.sock"; } @@ -130,39 +97,27 @@ export interface VectorDockerSocketPlan { /** Security options — only the `unix`/non-root sub-branch sets `label:disable`. */ readonly securityOpt: ReadonlyArray<string>; /** - * True for the `npipe` scheme: the Vector container still gets created and - * started on this branch, but — uniquely among every `start` service — it - * is NOT added to the health-wait list a later `WaitForHealthyService` - * call waits on. Orchestration-only signal for a future - * `start.handler.ts`; this module's builder does not act on it. + * True for the `npipe` scheme: this container is still created and started, but the caller + * must exclude it from the health-wait list. This module's builder does not act on the flag + * itself. */ readonly isNpipe: boolean; } /** - * The pure decision of what `DOCKER_HOST` env override, bind mount, and - * `--security-opt` Vector's container needs to reach the REAL Docker daemon - * from inside its own container, given an already-resolved daemon host - * string (see {@link resolveDockerDaemonHost} for how a caller obtains - * one): + * The pure decision of what `DOCKER_HOST` env override, bind mount, and `--security-opt` Vector's + * container needs to reach the real Docker daemon from inside itself, given an already-resolved + * daemon host string (see {@link resolveDockerDaemonHost}): * - * - `tcp` — proxy through `host.docker.internal` on the SAME port the daemon - * host itself specified (falling back to the default DinD port `2375` when - * the host string has no parseable port). - * - `npipe` (Windows) — same `host.docker.internal:2375` proxy target; a - * stderr warning belongs here too, which is this module's caller's - * responsibility (output side effects don't belong in a pure spec - * builder). - * - `unix` — no env override; instead mounts the daemon socket read-only. - * Docker Desktop/Colima's KNOWN rootful-socket paths - * ({@link shouldMountRootDockerSocket}) get the STANDARD - * `/var/run/docker.sock` bound to itself (that path is special-cased by - * those runtimes "under the hood") — the ACTUAL detected path is never - * used as the bind source in this sub-branch. Anything else (Podman, - * OrbStack) binds the actual detected socket path onto the standard path - * instead, plus `--security-opt label:disable` (needed for Podman/ - * OrbStack's differently-labeled socket to be readable from inside a - * container with a different SELinux/AppArmor label). + * - `tcp` — proxies through `host.docker.internal` on the daemon host's own port, falling back to + * the default DinD port `2375` when unparseable. + * - `npipe` (Windows) — same `host.docker.internal:2375` proxy target; a stderr warning belongs + * to the caller, since output side effects don't belong in a pure spec builder. + * - `unix` — no env override; mounts the daemon socket read-only instead. Docker Desktop/Colima's + * known rootful-socket paths ({@link shouldMountRootDockerSocket}) bind the standard + * `/var/run/docker.sock` to itself; anything else (Podman, OrbStack) binds the detected socket + * path onto the standard path plus `--security-opt label:disable`, needed for a differently + * labeled socket to be readable inside the container. */ export function resolveVectorDockerSocketPlan( daemonHost: string, @@ -208,16 +163,11 @@ function collectText(stream: Stream.Stream<Uint8Array, unknown>) { } /** - * Best-effort resolution of the CURRENT docker CLI context's daemon - * endpoint, WITHOUT reimplementing Docker's context store file format — - * `docker context inspect` already performs that exact resolution (context - * name from `$DOCKER_CONTEXT`/`~/.docker/config.json`'s `currentContext`, - * then that context's stored endpoint) using the same `docker` binary this - * CLI already shells out to elsewhere. Deliberately does NOT fall back to - * `podman` (unlike `spawnContainerCli`): Podman's own `context inspect` - * output has no equivalent `Endpoints.docker.Host` shape, and a Podman-only - * host is expected to set `DOCKER_HOST` directly (checked first by - * {@link resolveDockerDaemonHost}, before this ever runs). + * Best-effort resolution of the current docker CLI context's daemon endpoint via + * `docker context inspect`, rather than reimplementing Docker's context store file format. Does + * not fall back to `podman` (unlike `spawnContainerCli`): its `context inspect` output has no + * equivalent `Endpoints.docker.Host` shape, and a Podman-only host is expected to set + * `DOCKER_HOST` directly, checked first by {@link resolveDockerDaemonHost}. */ function inspectDockerContextHost(spawner: Spawner): Effect.Effect<string, string> { return Effect.scoped( @@ -248,16 +198,11 @@ function inspectDockerContextHost(spawner: Spawner): Effect.Effect<string, strin } /** - * Discovers the daemon host string {@link resolveVectorDockerSocketPlan} - * branches on: an explicit `DOCKER_HOST` env var always wins (checked first, - * no shell-out needed); otherwise the CURRENT docker context's own endpoint - * (Docker Desktop, Colima, and OrbStack all select a non-default context - * rather than setting `DOCKER_HOST`) via {@link inspectDockerContextHost}; - * finally this platform's bare default socket/pipe path when neither source - * resolves (no context support, `docker` missing — - * `ensureImagesCached`/`spawnContainerCli` will already have surfaced a - * clearer "docker not found" error earlier in a real `start` run if that's - * the actual cause). + * Discovers the daemon host string {@link resolveVectorDockerSocketPlan} branches on: an explicit + * `DOCKER_HOST` env var wins first; otherwise the current docker context's own endpoint via + * {@link inspectDockerContextHost} (Docker Desktop, Colima, and OrbStack all select a non-default + * context rather than setting `DOCKER_HOST`); finally this platform's bare default socket/pipe + * path when neither source resolves. */ export function resolveDockerDaemonHost( spawner: Spawner, @@ -281,13 +226,11 @@ const VECTOR_HEALTHCHECK = { } as const; /** - * Builds the Vector entrypoint script: writes the rendered `vector.yaml` via - * a `cat <<'EOF'` heredoc, waits on Logflare `/health` (sinks would otherwise - * start too early), then `exec`s Vector so it is PID 1. A TERM trap covers - * the wait so `docker stop` does not burn 10s if Logflare is still down; - * `-T 2` bounds each probe so a hung health endpoint cannot defer the trap. - * Slim Vector ships BusyBox wget, so the wait uses `-q --spider` instead of - * GNU `--no-verbose --tries`. + * Writes the rendered `vector.yaml` via a `cat <<'EOF'` heredoc, waits on Logflare's `/health` + * (sinks would otherwise start too early), then `exec`s Vector so it stays PID 1. A TERM trap + * covers the wait so `docker stop` does not burn 10s if Logflare is still down; `-T 2` bounds each + * probe so a hung health endpoint can't defer the trap. Slim Vector ships BusyBox wget, so the + * wait uses `-q --spider` instead of GNU's `--no-verbose --tries`. */ export function buildVectorEntrypointScript( vectorYaml: string, @@ -309,7 +252,7 @@ export function buildVectorEntrypointScript( export interface VectorContainerSpecInput { /** `config.analytics.vector_image`, already resolved/pulled by the caller. */ readonly image: string; - /** `serviceContainerName("vector", projectId)` — also the `vectorConfig.VectorId` template field. */ + /** `serviceContainerName("vector", projectId)`, also used as the `vector.yaml` template's `vectorId` field. */ readonly containerName: string; /** The shared Docker network every `start` container joins. */ readonly networkId: string; @@ -323,7 +266,7 @@ export interface VectorContainerSpecInput { readonly gotrueId: string; /** PostgREST's own container id — a `vector.yaml` template field. */ readonly restId: string; - /** Realtime's own container id — UNLIKE Kong's `kong.yml`, Vector's `vector.yaml` really does use Realtime's container id here, not the tenant id. */ + /** Realtime's own container id — unlike Kong's `kong.yml`, `vector.yaml` uses the container id here, not the tenant id. */ readonly realtimeId: string; /** Storage's own container id — a `vector.yaml` template field. */ readonly storageId: string; @@ -335,11 +278,7 @@ export interface VectorContainerSpecInput { readonly dockerSocketPlan: VectorDockerSocketPlan; } -/** - * Assembles Vector's {@link StartContainerSpec}. Pure — no Effect or - * ambient I/O — matching every other `start`-service builder in this - * directory. - */ +/** Builds Vector's {@link StartContainerSpec}. */ export function buildVectorContainerSpec(input: VectorContainerSpecInput): StartContainerSpec { const slim = usesSlimImageRuntime(input.image); const vectorYaml = renderStartVectorYaml({ diff --git a/apps/cli/src/commands/start/start.command.ts b/apps/cli/src/commands/start/start.command.ts index 54065b1cb2..a82506b225 100644 --- a/apps/cli/src/commands/start/start.command.ts +++ b/apps/cli/src/commands/start/start.command.ts @@ -15,11 +15,10 @@ import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { START_EXCLUDABLE_KEYS } from "./start.exclude.ts"; import { start } from "./start.handler.ts"; -// `--exclude`/`-x` is a pflag-style string-slice flag, which CSV-splits each -// occurrence (`--exclude gotrue,realtime` -> two values) and accumulates -// across repeats — matching `status`'s own `--exclude`/`--override-name` handling. -// Malformed CSV fails at parse time with pflag's exact diagnostic (CLI-2005); the -// shorthand makes pflag frame it as `"-x, --exclude"` (see `stringSliceFlag`). +/** + * A pflag-style string-slice flag: CSV-splits each occurrence (`--exclude gotrue,realtime` -> two + * values) and accumulates across repeats, matching `status`'s own `--exclude` handling. + */ export const startExcludeFlag = stringSliceFlag( "exclude", `Names of containers to not start. [${START_EXCLUDABLE_KEYS.join(",")}]`, @@ -41,21 +40,10 @@ const config = { export type StartFlags = CliCommand.Command.Config.Infer<typeof config>; -// `start` makes no Management API calls and talks directly to Docker, so it -// deliberately avoids `managementApiRuntimeLayer` — -// it provides only the services the handler + instrumentation consume, mirroring -// `stop`/`status`'s runtime shape. `ChildProcessSpawner`/`ProcessControl`/`RuntimeInfo` -// are not listed here: they come from `BunServices`/`processControlLayer`/ -// `runtimeInfoLayer` in the root runtime (`shared/cli/run.ts`), the same way -// `stop`/`status` rely on the former. `HttpClient.HttpClient` is NOT provided by the -// root runtime — `BunServices.layer` never supplies it, and `httpTransportClientLayer` is a -// different service tag entirely — so it's composed here via `httpClientLayer`, -// the same `FetchHttpClient`-backed layer `db reset`/`seed buckets` use, needed for the -// health-check probes (`waitForHealthyServices`) and `seedBucketsRun`. -// `dockerRunLayer`/`dbConnectionLayer` ARE listed here — the fresh-volume -// `SetupLocalDatabase` equivalent (`start.handler.ts`'s `startSetupLocalDatabase` -// call) needs both: the PG15+ one-shot migrate jobs run through `DockerRun`, and -// the schema/globals/API-privileges SQL runs over a direct `DbConnection` session. +// `start` talks directly to Docker with no Management API calls, so it composes its own runtime +// instead of `managementApiRuntimeLayer`. `httpClientLayer` is included explicitly because the +// root runtime doesn't supply `HttpClient.HttpClient`; `dockerRunLayer`/`dbConnectionLayer` back +// the fresh-volume database setup (one-shot migrate jobs plus direct-connection schema SQL). const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); const httpClient = httpClientLayer.pipe(Layer.provide(debugLoggerLayer)); diff --git a/apps/cli/src/commands/start/start.command.unit.test.ts b/apps/cli/src/commands/start/start.command.unit.test.ts index 98450205bf..a76f7975f7 100644 --- a/apps/cli/src/commands/start/start.command.unit.test.ts +++ b/apps/cli/src/commands/start/start.command.unit.test.ts @@ -34,9 +34,6 @@ describe("start --exclude flag (pflag StringSlice parity)", () => { }); test("keeps only the first CSV record of a multiline value (pflag reads ONE record)", async () => { - // Verified against pflag's actual CSV behavior (CLI-2005): `start -x $'a\nb"c'` - // raises no parse error and excludes only `a` — pflag calls - // `csv.Reader.Read()` once, so the malformed second line is silently dropped. const [, exclude] = await Effect.runPromise( startExcludeFlag .parse({ flags: { exclude: ['a\nb"c'] }, arguments: [] }) @@ -56,9 +53,6 @@ describe("start --exclude flag (pflag StringSlice parity)", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - // The flag has both a shorthand and long spelling, so pflag frames the - // diagnostic with BOTH spellings — `-x, --exclude` — regardless of which - // one was typed. Verified against pflag's actual output (CLI-2005). expect(normalizeCause(exit.cause).message).toBe( 'invalid argument "a\\"b" for "-x, --exclude" flag: parse error on line 1, column 2: bare " in non-quoted-field', ); @@ -66,8 +60,6 @@ describe("start --exclude flag (pflag StringSlice parity)", () => { }); test("rejects a blank-only value with pflag's EOF diagnostic", async () => { - // Verified against pflag's actual output (CLI-2005): `start -x $'\n'` → - // `invalid argument "\n" for "-x, --exclude" flag: EOF`. const exit = await Effect.runPromise( startExcludeFlag .parse({ flags: { exclude: ["\n"] }, arguments: [] }) diff --git a/apps/cli/src/commands/start/start.e2e.test.ts b/apps/cli/src/commands/start/start.e2e.test.ts index 238c3df666..29de1ce538 100644 --- a/apps/cli/src/commands/start/start.e2e.test.ts +++ b/apps/cli/src/commands/start/start.e2e.test.ts @@ -18,19 +18,8 @@ describe("supabase start", () => { rmSync(projectDir, { recursive: true, force: true }); }); - // Golden-path smoke test for the real subprocess boundary: exclude-flag - // validation runs unconditionally as the handler's very first step, before - // config load or any Docker access, so an invalid `--exclude` value must - // print the `WARNING:` text regardless of what happens afterwards. No - // `supabase/config.toml` is seeded — an absent config is not itself a - // failure, the command proceeds with defaults — so the command runs past - // config loading and reaches a real Docker call, which this test - // forces to fail fast and deterministically via an unreachable `DOCKER_HOST` rather than - // relying on whether a real Docker daemon happens to be reachable in the sandbox: with a - // real daemon this pins the test to a fast, predictable failure instead of a slow real - // image pull; without one it fails just as fast because `docker`/`podman` can't be - // spawned at all. Either way the command must still exit non-zero cleanly after printing - // the warning — this is the only invariant asserted, not which downstream error fires. + // An unreachable `DOCKER_HOST` forces a fast, deterministic failure regardless of whether a + // real Docker daemon is reachable in the sandbox. test( "prints the invalid --exclude warning then fails cleanly on the Docker call", { timeout: E2E_TIMEOUT_MS }, diff --git a/apps/cli/src/commands/start/start.exclude.ts b/apps/cli/src/commands/start/start.exclude.ts index 587abe91d7..001445d501 100644 --- a/apps/cli/src/commands/start/start.exclude.ts +++ b/apps/cli/src/commands/start/start.exclude.ts @@ -2,12 +2,9 @@ import { aqua, yellow } from "../../command-internal/colors.ts"; import { SERVICE_CATALOG } from "../../command-internal/service-catalog.ts"; /** - * The `--exclude` values' canonical order: Gotrue, Realtime, Storage, - * ImgProxy, Kong, Inbucket, Postgrest, Pgmeta, Studio, EdgeRuntime, Logflare, - * Vector, Supavisor. This is NOT `SERVICE_CATALOG`'s stored order, - * which is the actual container *start* sequence (Postgres first, Kong before - * Gotrue, etc). Expressed here as `service` keys so the actual `excludeKey` - * strings stay single-sourced from the catalog. + * The `--exclude` values' canonical order — distinct from `SERVICE_CATALOG`'s stored order, which + * is the actual container start sequence. Expressed as `service` keys so the `excludeKey` strings + * stay single-sourced from the catalog. */ const EXCLUDABLE_SERVICE_KEYS_IN_GO_ORDER: ReadonlyArray<string> = [ "gotrue", @@ -30,10 +27,8 @@ const EXCLUDE_KEY_BY_SERVICE = new Map( ); /** - * The 13 valid `--exclude` values, in the canonical order above. Postgres has - * no `excludeKey` (it is never excludable), so it is deliberately absent — - * `db`/`postgres` are rejected as invalid `--exclude` values the same as any - * other unrecognized string. + * The valid `--exclude` values, in the canonical order above. Postgres has no `excludeKey` (it's + * never excludable), so `db`/`postgres` are rejected the same as any other unrecognized string. */ export const START_EXCLUDABLE_KEYS: ReadonlyArray<string> = EXCLUDABLE_SERVICE_KEYS_IN_GO_ORDER.map( (service) => EXCLUDE_KEY_BY_SERVICE.get(service) ?? "", @@ -44,18 +39,15 @@ export interface StartExcludePartition { readonly valid: ReadonlyArray<string>; /** Raw `--exclude` values that matched nothing, in input order. */ readonly invalid: ReadonlyArray<string>; - /** - * The stderr `WARNING:` text, present only when `invalid` is non-empty. - */ + /** The stderr `WARNING:` text, present only when `invalid` is non-empty. */ readonly warning?: string; } /** - * Partitions raw `--exclude` values into valid and invalid. An unrecognized - * `--exclude` value never fails the command — it prints a `WARNING:` to - * stderr and the command proceeds regardless. The valid-options list in the - * warning is alphabetically sorted, unlike `START_EXCLUDABLE_KEYS`'s - * canonical-order sequence used for the flag's help text. + * Partitions raw `--exclude` values into valid and invalid. An unrecognized value never fails the + * command — it prints a `WARNING:` to stderr and the command proceeds regardless. The warning's + * valid-options list is alphabetically sorted, unlike `START_EXCLUDABLE_KEYS`'s canonical order + * used for the flag's help text. */ export function partitionStartExcludeFlags( rawValues: ReadonlyArray<string>, diff --git a/apps/cli/src/commands/start/start.gates.ts b/apps/cli/src/commands/start/start.gates.ts index 1905129338..eda523cb2e 100644 --- a/apps/cli/src/commands/start/start.gates.ts +++ b/apps/cli/src/commands/start/start.gates.ts @@ -10,32 +10,15 @@ import { envOverrideBool } from "../../command-internal/local-config-values.ts"; import { START_SERVICES } from "./start.services.ts"; /** - * Every per-service "should this container actually start" boolean, minus - * Postgres (always-on, unconditional — handled directly by the caller, - * before any other service). Each boolean is `<section>.enabled` (resolved - * through {@link envOverrideBool}'s `SUPABASE_<SECTION>_ENABLED` - * override — the same mechanism `status-values.ts`'s - * `resolveStatusLocalState` already uses for its own overlapping subset - * of these fields) AND-ed with "not excluded" (the service's `--exclude` key - * absent from `excludedKeys`, per `partitionStartExcludeFlags`'s - * `valid` set). + * Every per-service start gate except Postgres (always-on, handled by the caller) and Edge + * Runtime (bypasses the generic bring-up path; `start.handler.ts` reads its `enabled` flag + * directly instead of going through {@link resolveStartImagePlan}). Each boolean is + * `<section>.enabled` AND-ed with "not excluded". * - * `storage-api`'s exclude key backs BOTH `storage` and (compounded further) - * `imgproxy` — `imgproxy` is additionally gated on `storage` already being - * enabled (ImgProxy mounts Storage's own volumes) and on - * `storage.image_transformation.enabled` — the SAME boolean feeds both the - * actual container gate here and `storage.service.ts`'s - * `StorageEnvInput.imageTransformationEnabled` - * (`ENABLE_IMAGE_TRANSFORMATION`), so callers must reuse `gates.imgproxy`, - * not recompute a second, possibly-diverging boolean. - * - * `edgeRuntime` is deliberately excluded from `GATE_KEY_BY_SERVICE`/ - * `DOCKERFILE_ALIAS_BY_SERVICE`/{@link resolveStartImagePlan} below — - * Edge Runtime doesn't go through the generic `StartContainerSpec` - * bring-up path (`services/edge-runtime.service.ts`'s header explains why it - * doesn't map cleanly), so `start.handler.ts` reads this boolean directly and - * calls `startStackEdgeRuntimeContainer` itself, in its real - * container-start position (between ImgProxy and pg-meta). + * `imgproxy` is also gated on `storage` being enabled and on + * `storage.image_transformation.enabled` — the same boolean feeds + * `StorageEnvInput.imageTransformationEnabled`, so callers must reuse `gates.imgproxy` rather than + * recompute a second, possibly-diverging value. */ export interface StartGates { readonly kong: boolean; @@ -68,10 +51,8 @@ function asRecord(value: unknown): Record<string, unknown> | undefined { } /** - * Evaluates all 12 excludable "should this container actually start" gates in - * one pass (Postgres and Edge Runtime are handled separately by the caller — - * see this module's header). Pure — no Effect, no I/O — so every gate - * combination is unit-testable without a Docker mock. + * Evaluates every excludable start gate in one pass. Postgres and Edge Runtime are handled + * separately by the caller (see this module's header). */ export function resolveStartGates(inputs: StartGateInputs): StartGates { const { config, projectEnvValues, excludedKeys, document } = inputs; @@ -113,16 +94,9 @@ export function resolveStartGates(inputs: StartGateInputs): StartGates { "storage.enabled", projectEnvValues, ); - // With no `[storage.image_transformation]` table in config.toml, the field - // never becomes an overridable key at all, so - // `SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED` alone can never flip it - // on — the section must be present first. `@supabase/config`'s decoded - // `config.storage.image_transformation` can't be used as a presence proxy - // either — it always decodes to a defaulted `{enabled: false}`, never - // `undefined` — so presence must come from the raw document, same - // `asRecord(document?.[...])` gate `resolveAuthEmailSmtp`/ - // `resolveGotruePasskeyWebauthn`/`resolveAuthSms` already use for the - // identical optional-section shape. + // The section must be present in the raw document before the env override can flip it on: + // `@supabase/config` always decodes `storage.image_transformation` to a defaulted + // `{enabled: false}`, never `undefined`, so presence can't be read off the typed config. const imageTransformationSectionPresent = asRecord(asRecord(document?.["storage"])?.["image_transformation"]) !== undefined; const configuredImageTransformationEnabled = @@ -205,10 +179,9 @@ const DOCKERFILE_ALIAS_BY_SERVICE: Readonly<Record<string, string>> = { }; /** - * `START_SERVICES`' `service` key -> `services.shared.ts`'s - * `LocalServiceVersionName`, for the subset of start's services that have a - * `supabase/.temp/*-version` linked-project pin. Kong and ImgProxy have no - * such pin and are deliberately absent here. + * `START_SERVICES`' `service` key -> `LocalServiceVersionName`, for services that have a + * `supabase/.temp/*-version` linked-project pin. Kong and ImgProxy have no such pin, so they're + * absent here. */ const START_SERVICE_TO_LOCAL_VERSION_NAME: Readonly<Record<string, LocalServiceVersionName>> = { gotrue: "auth", @@ -229,18 +202,13 @@ export interface StartImagePlanEntry { } /** - * The ordered list of non-Postgres, non-EdgeRuntime services that will - * actually start this run, each paired with its default image reference. - * Iterates `START_SERVICES` (already in the real container-start - * order) so the returned order IS the order the caller should both pre-pull - * images in and create+start containers in. + * The ordered list of non-Postgres, non-EdgeRuntime services that will actually start this run, + * each paired with its default image reference, in the real container-start order (the order the + * caller should both pre-pull images in and create+start containers in). * - * Deliberately gates `imgproxy`'s image on the SAME compound - * `gates.imgproxy` boolean the actual container-start gate uses, rather than - * pre-pulling it whenever Storage alone is enabled: pre-pulling an image for - * a container that will never be created has no user-visible benefit, and - * this port has no compose-based best-effort pre-pull pass to mirror in the - * first place (see `lib/image-prepull.ts`'s header). + * Gates `imgproxy`'s image on the same `gates.imgproxy` boolean the container-start gate uses, + * rather than pre-pulling it whenever Storage alone is enabled — pre-pulling an image for a + * container that will never be created has no user-visible benefit. */ export function resolveStartImagePlan( gates: StartGates, diff --git a/apps/cli/src/commands/start/start.handler.ts b/apps/cli/src/commands/start/start.handler.ts index 007745c0be..09c51f41ad 100644 --- a/apps/cli/src/commands/start/start.handler.ts +++ b/apps/cli/src/commands/start/start.handler.ts @@ -183,13 +183,7 @@ import { buildPgMetaContainerSpec } from "./services/pg-meta.service.ts"; import { buildStudioContainerSpec } from "./services/studio.service.ts"; import { buildSupavisorContainerSpec } from "./services/supavisor.service.ts"; -/** - * The analytics API key's only possible value — never configurable. - * Duplicated locally rather than hoisted: `logflare.service.ts`/ - * `studio.service.ts` each already hardcode this same literal independently, - * matching that existing precedent instead of introducing a new shared - * constant for it. - */ +/** The analytics API key's only possible value; never configurable. */ const ANALYTICS_API_KEY = "api-key"; function asRecord(value: unknown): Record<string, unknown> | undefined { @@ -199,12 +193,9 @@ function asRecord(value: unknown): Record<string, unknown> | undefined { } /** - * Wraps a synchronous `envOverride*`/`envOverride*` config-override read that throws on a - * malformed value into a typed `StartInvalidConfigError` failure — - * config validation hard-fails on a bad decode before any Docker work runs — - * instead of leaking an untyped Effect defect that bypasses - * `withJsonErrorHandling`'s `Effect.catch` (which, unlike this pipeline's `Effect.onError` - * rollback, only intercepts typed failures, never defects). + * Wraps a synchronous `envOverride*` config read that throws on a malformed value into a typed + * `StartInvalidConfigError`, so a bad override fails the command through the normal error path + * instead of surfacing as an untyped Effect defect. */ function wrapConfigOverride<T>( dottedFieldPath: string, @@ -220,23 +211,14 @@ function wrapConfigOverride<T>( } /** - * Every value {@link buildGotrueContainerSpec} needs from `config`/ - * `values`, minus `dbHost`/`dbPassword` (which that builder derives itself - * from `projectId`/`dbUrl`). See this module's header for the `@supabase/ - * config` schema gaps (`captcha`/`passkey`/`webauthn`/`email.smtp` presence, - * `external` provider filtering) `gotrue.service.ts`'s own doc comment - * documents — reused here. + * Every value {@link buildGotrueContainerSpec} needs from `config`/`values`, minus + * `dbHost`/`dbPassword` (derived by that builder itself). * - * A configured `auth.signing_keys_path` is honored for anon/service_role JWT - * SIGNING (`values.jwtSecret`/`resolveLocalConfigValues`'s own - * `loadFirstSigningKey`), for the stack-wide JWKS document - * (`resolveLocalJwks`), AND here as GoTrue's own `GOTRUE_JWT_KEYS` — - * all three resolve the SAME file via - * {@link resolveConfiguredSigningKeys}, so GoTrue always signs with - * (one of) the key(s) the published JWKS advertises. `undefined` (the - * default ES256 key, matching `gotrue.service.ts`'s hardcoded - * `GOTRUE_DEFAULT_SIGNING_KEY`) only when no `signing_keys_path` is - * configured or auth is disabled. + * A configured `auth.signing_keys_path` is honored for anon/service_role JWT signing, for the + * stack-wide JWKS document, and here as `GOTRUE_JWT_KEYS` — all three resolve the same file via + * {@link resolveConfiguredSigningKeys}, so GoTrue always signs with a key the published JWKS + * advertises. `undefined` (the default ES256 key) only when no `signing_keys_path` is configured + * or auth is disabled. */ function resolveGotrueEnvInput(params: { @@ -258,13 +240,10 @@ function resolveGotrueEnvInput(params: { "local_smtp.enabled", projectEnvValues, ); - // `[auth.email.smtp]`'s presence-based `enabled` default — reading the - // schema-decoded `config.auth.email.smtp` here would always see `enabled: - // false` when the key is merely absent from the TOML table (`@supabase/ - // config`'s decode-time default), silently falling back to Mailpit even - // when a real SMTP server is configured. `resolveAuthEmailSmtp` - // resolves this correctly off the raw document, same as the passkey/ - // webauthn/external-provider reads below. + // Reading the schema-decoded `config.auth.email.smtp` here would always see `enabled: false` + // when the key is merely absent from the TOML table, silently falling back to Mailpit even when + // a real SMTP server is configured. `resolveAuthEmailSmtp` resolves this correctly off the raw + // document. const resolvedSmtp = resolveAuthEmailSmtp(asRecord(document?.["auth"]), projectEnvValues); const smtp = resolvedSmtp?.enabled === true @@ -277,10 +256,8 @@ function resolveGotrueEnvInput(params: { senderName: resolvedSmtp.senderName, } : undefined; - // Same generic-Viper-override gap as `inbucketEnabled` above, for - // `local_smtp.admin_email`/`sender_name` — value-typed fields, so no - // raw-document presence gate needed, matching `local_smtp.port`'s - // existing treatment. + // Same override gap as `inbucketEnabled` above; these are value-typed fields, so no + // raw-document presence gate is needed. const mailpitAdminEmail = envOverride( "SUPABASE_LOCAL_SMTP_ADMIN_EMAIL", config.local_smtp.admin_email, @@ -347,15 +324,10 @@ function resolveGotrueEnvInput(params: { } /** - * Read-and-discard existence/readability check for one already-resolved - * `content_path` — same pattern as `local-config-values.ts`'s - * `readAuthEmailTemplateContent` and `push.auth-email-content.ts`'s - * `readTemplateContent`, reusing their established error message shape. - * Closes the gap where a resolved-but-never-read path (e.g. a `content_path` - * naming a missing file, only reachable when `auth.enabled = false`) would - * otherwise reach Docker unverified — the root-privileged daemon silently - * creates a directory at a bind-mounted host path that doesn't exist, so an - * unprivileged read here must succeed first. + * Existence/readability check for one already-resolved `content_path`. Without it, a + * resolved-but-never-read path (e.g. a missing file, only reachable when `auth.enabled = false`) + * would reach Docker unverified — the root-privileged daemon silently creates a directory at a + * bind-mounted host path that doesn't exist, instead of failing. */ function readKongEmailTemplateContent( section: "template" | "notification", @@ -370,22 +342,14 @@ function readKongEmailTemplateContent( } /** - * Kong's email template mounts: every configured template, then every - * ENABLED notification, suffixed `_notification`. Resolves, containment- - * checks, and read-verifies each `content_path` HERE — once, before any - * Docker work — via `resolveEmailTemplateContentPath` (the same check - * config validation and `config push` apply) followed by - * `readKongEmailTemplateContent`. The resulting `resolvedPath` is what the - * caller threads straight into `buildKongEmailTemplateBind`; nothing - * re-derives it later, right before the `docker create` call for Kong - * (potentially minutes later, after image pulls/Postgres bring-up/ - * migrations) — closing the TOCTOU window between an earlier - * validation-only pass and Kong's own independent re-resolution. + * Kong's email template mounts: every configured template, then every enabled notification, + * suffixed `_notification`. Resolves, containment-checks, and read-verifies each `content_path` + * once, here, before any Docker work; the caller threads the resulting `resolvedPath` straight + * into the Kong container spec instead of re-deriving it later, closing the TOCTOU window before + * Kong's own `docker create` call. * - * Skips (never throws for) an entry whose resolver returns `undefined` — per - * its own contract that only happens for an empty/absent `content_path`, - * which should be unreachable here since Kong's set is built from configured - * entries, but this omits the mount defensively rather than crashing. + * Skips (never throws for) an entry whose resolver returns `undefined`, defensively, even though + * that should be unreachable since Kong's set is built from configured entries. */ function resolveKongEmailTemplateMounts( email: ResolvedAuthEmail, @@ -421,11 +385,9 @@ function resolveKongEmailTemplateMounts( } /** - * What `--ignore-health-check` prints when it downgrades a health-check timeout - * to a warning. That decision belongs to this caller, not `../../shared/db-bootstrap/health-check.ts` - * (which only implements the polling contract), and it writes straight to - * stderr — bypassing the `Output.fail` renderer that would otherwise append the - * error's `suggestion` for it. + * What `--ignore-health-check` prints when it downgrades a health-check timeout to a warning. + * Writes straight to stderr, bypassing the `Output.fail` renderer, so it appends the error's + * `suggestion` itself. */ function healthWarningText(error: HealthCheckTimeoutError): string { return error.suggestion === undefined ? error.message : `${error.message}\n${error.suggestion}`; @@ -451,12 +413,9 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { Effect.mapError((error) => new StartWorkdirError({ message: error.message })), ); - // 1. `--exclude` validation runs as the VERY FIRST step — before config - // loads or checking whether the stack is already running, so this - // warning fires unconditionally on every invocation with an invalid - // `--exclude` value, including the already-running short-circuit below. - // `excludedKeys` (the VALID subset) is what actually gates container - // bring-up later. + // 1. `--exclude` validation runs before config loads or the already-running check, so this + // warning fires on every invocation with an invalid `--exclude` value. `excludedKeys` (the + // valid subset) is what actually gates container bring-up later. const partition = partitionStartExcludeFlags(flags.exclude); if (partition.warning !== undefined && output.format === "text") { yield* output.raw(partition.warning, "stderr"); @@ -484,11 +443,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { }), }); const { config, projectId, projectEnvValues } = context; - // `SUPABASE_EXPERIMENTAL`/`--experimental`, read deep inside - // `startDatabase`'s fresh-volume setup pipeline — resolved here - // (project `.env` aware, like `db reset`'s identical gate) so it can be - // threaded straight through to `startDatabase`'s own - // `setup.experimental` below. + // Resolved here (project `.env` aware) so it can be threaded through to `startDatabase`'s + // own `setup.experimental` below. const experimental = yield* resolveExperimentalWithProjectEnv(projectEnvValues); // Single source resolved once, fed to both Kong's template mounts and GoTrue's env builder — // see {@link resolveAuthEmail}'s doc comment. @@ -504,17 +460,10 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { message: cause instanceof Error ? cause.message : String(cause), }), }); - // Kong mounts every configured template (regardless of `auth.enabled` — - // Kong is the stack's mandatory gateway) and every ENABLED notification's - // `content_path`, unconditionally. Resolving, containment-checking, AND - // read-verifying every path happens exactly ONCE, here, before any Docker - // work — not only inside `resolveLocalConfigValues`'s own - // `auth.enabled`-gated `readAuthEmailTemplateContent` call. The resulting - // `resolvedPath`s are threaded straight into the Kong container-spec - // input below instead of being discarded and re-derived later inside - // `buildKongEmailTemplateBind`, which closes the TOCTOU window - // between this pass and Kong's `docker create` call (potentially minutes - // later, after image pulls/Postgres bring-up/migrations). + // Kong mounts every configured template and every enabled notification's `content_path`, + // regardless of `auth.enabled` — see {@link resolveKongEmailTemplateMounts} for why this + // resolves and verifies each path once, here, rather than re-deriving it before Kong's + // `docker create` call. const kongEmailTemplateMounts = yield* Effect.try({ try: () => resolveKongEmailTemplateMounts(resolvedEmail, cliSettings.workdir), catch: (cause) => @@ -522,15 +471,9 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { message: cause instanceof Error ? cause.message : String(cause), }), }); - // Every `time.Duration`-shaped config field — including these 5 — must - // fail fast, before `start` touches Docker at all: these fields are only - // parsed inside GoTrue's own env builder (`gotrue.service.ts`), which - // never runs at all when auth is disabled or `gotrue` is excluded — so a - // malformed value would otherwise be silently accepted instead of - // failing the command. Validate eagerly here, discarding the parsed - // nanosecond counts, since `resolveGotrueEnvInput` (below) re-resolves - // and re-parses these same fields for the actual GoTrue container - // build. + // Duration fields (Go duration syntax) are otherwise only parsed inside GoTrue's own env + // builder, which never runs when auth is disabled or `gotrue` is excluded — so a malformed + // value must be validated eagerly here or it would be silently accepted. const gotrueSessionsForValidation = resolveGotrueSessions( config.auth.sessions, projectEnvValues, @@ -538,11 +481,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { yield* wrapConfigOverride("auth.email.max_frequency", () => parseGoDuration(resolvedEmail.max_frequency), ); - // Wrapped like `resolvedEmail` above: `resolveLocalConfigValues`'s own SMS validation - // only runs `if (authEnabled)` (`local-config-values.ts`), so this direct call is the - // ONLY place a malformed `auth.sms.*` override is ever caught when auth is disabled — an - // unwrapped throw here would surface as an Effect defect instead of the normal - // `StartInvalidConfigError` config-load failure. + // `resolveLocalConfigValues`'s own SMS validation only runs when auth is enabled, so this is + // the only place a malformed `auth.sms.*` override is caught when auth is disabled. const smsForValidation = yield* Effect.try({ try: () => resolveAuthSms( @@ -558,11 +498,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { yield* wrapConfigOverride("auth.sms.max_frequency", () => parseGoDuration(smsForValidation.max_frequency), ); - // SMS validation downgrades `EnableSignup` to `false` and prints a - // warning when no provider is enabled — `resolveAuthSms` already - // applies the downgrade itself, so this only needs to detect whether - // that branch fired (the user configured `enable_signup = true` with - // every provider disabled) to print the matching warning. + // `resolveAuthSms` already downgrades `enable_signup` when no provider is enabled; this only + // detects whether that branch fired, to print the matching warning. if ( !smsForValidation.twilio.enabled && !smsForValidation.twilio_verify.enabled && @@ -591,16 +528,9 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { yield* wrapConfigOverride("auth.mfa.phone.max_frequency", () => parseGoDuration(resolveAuthMfa(config.auth.mfa, projectEnvValues).phone.max_frequency), ); - // Same gap for the remaining GoTrue overrides: `auth.rate_limit.*` (plain `uint`s) and - // `auth.web3.*.enabled`/`auth.oauth_server.{enabled,allow_dynamic_registration}` (plain - // `bool`s) must all validate unconditionally, regardless of - // `auth.enabled`/`--exclude gotrue`. - // `resolveGotrueRateLimit`/`resolveGotrueWeb3`/`resolveGotrueOAuthServer` already - // throw internally on a bad override, so — unlike the duration fields above — calling each - // whole (pure) function once here is simpler than re-deriving every field individually; - // `resolveGotrueEnvInput` below re-resolves them a second time for the real container build, - // which is safe since they're pure. `auth.oauth_server.authorization_url_path` is a plain - // string and can't throw, so it needs no eager check. + // These GoTrue overrides must validate unconditionally too, regardless of + // `auth.enabled`/`--exclude gotrue`. The resolvers already throw internally on a bad + // override, so calling each once here is simpler than re-deriving every field individually. yield* wrapConfigOverride("auth.rate_limit", () => resolveGotrueRateLimit(config.auth.rate_limit, projectEnvValues), ); @@ -610,14 +540,9 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { yield* wrapConfigOverride("auth.oauth_server", () => resolveGotrueOAuthServer(config.auth.oauth_server, projectEnvValues), ); - // Same gap for `auth.passkey.enabled`/`auth.webauthn.*` and per-provider `auth.external. - // <name>.{enabled,skip_nonce_check,email_optional}` — these raw - // (unmodeled by `@supabase/config`) booleans must validate - // unconditionally too, regardless of `auth.enabled`/`--exclude gotrue`. - // Both resolvers already throw internally on a bad raw bool (`rawUnmodeledBool`) - // and are otherwise only reached from `resolveGotrueEnvInput`'s `case "gotrue":` branch below — - // itself gated on auth being enabled and gotrue not excluded — so calling each here, once, - // eagerly and discarding the result, closes the same "validates but doesn't reach it" gap. + // Same gap for the raw (unmodeled by `@supabase/config`) `auth.passkey`/`auth.webauthn`/ + // `auth.external.<name>` booleans, which are otherwise only reached once auth is enabled and + // gotrue isn't excluded. yield* wrapConfigOverride("auth.passkey", () => resolveGotruePasskeyWebauthn(context.loaded?.document, projectEnvValues), ); @@ -628,26 +553,14 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { projectEnvValues, ), ); - // Same gap for `auth.third_party.<provider>.{enabled,...}` — this must - // validate unconditionally regardless of `auth.enabled`, even though - // validation itself is otherwise only meaningful when auth is enabled. - // `resolveThirdPartyProviders` is otherwise never called by this - // handler at all (GoTrue's own container build never wires third-party - // JWT settings) — so a malformed override (e.g. - // `SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED=bogus`) would otherwise - // never fail this command at all (review: PRRT_kwDOErm0O86WXFqj). + // Same gap for `auth.third_party.<provider>.*` — `resolveThirdPartyProviders` is otherwise + // never called by this handler at all, so a malformed override would never fail the command. yield* wrapConfigOverride("auth.third_party", () => resolveThirdPartyProviders(config.auth.third_party, projectEnvValues), ); - // `[functions.<slug>.env]` has no supported meaning for `start` — this - // must reject any unknown key unconditionally, well before any Docker - // work, matching the established config-validation contract. - // `@supabase/config`'s own schema DOES model `[functions.<slug>.env]` - // (`packages/config/src/functions.ts`) — a legitimate schema feature the - // shared package keeps for other consumers — so this is a CLI-side - // rejection, not a schema change. Confirmed against the - // established parity contract: a config with `[functions.foo.env]` - // fails with `'functions[foo]' has invalid keys: env`. + // `[functions.<slug>.env]` has no supported meaning for `start`, so any key here must be + // rejected before any Docker work. `@supabase/config`'s schema still models this table for + // other consumers, so the rejection is CLI-side, not a schema change. for (const [slug, func] of Object.entries(config.functions)) { if (Object.keys(func.env).length > 0) { yield* Effect.fail( @@ -657,38 +570,22 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ); } } - // `checkDbToml` resolves `[db.vault]`/`[db.seed]`/`db.migrations.enabled`/the effective - // `api.auto_expose_new_tables` tri-state — this must run unconditionally, - // before any Docker work. The port only ran this inside - // `startSetupLocalDatabase`, which is itself gated on the DB container's - // healthcheck passing AND a fresh volume (the `NoBackupVolume` gate) — so a malformed - // `SUPABASE_DB_SEED_ENABLED`/an undecryptable `[db.vault]` secret went completely unvalidated - // whenever `start` reused an existing volume. The resolved Webhooks flag is also retained so - // existing volumes can converge `pg_net`; `startSetupLocalDatabase`'s own internal call - // (an already-accepted duplicate config-load pass, matching `db start`'s own independent - // resolution — see `../../shared/db-bootstrap/db-setup.ts`'s header) still resolves fresh-setup - // values for its own use when it runs. + // Must run unconditionally, before any Docker work: without this, a malformed + // `SUPABASE_DB_SEED_ENABLED` or an undecryptable `[db.vault]` secret would go unvalidated + // whenever `start` reuses an existing volume instead of provisioning a fresh one. + // `startSetupLocalDatabase` still resolves its own fresh-setup values independently when it runs. const dbTomlValues = yield* checkDbToml(fs, path, cliSettings.workdir); const dbContainerId = localDbContainerId(projectId); const filterValue = cliProjectFilterValue(projectId); - // Shared status-values helper — reused by BOTH the already-running branch - // (full status pipeline, health-checked + "stopped" diffed) and the - // success path at the end (a direct pretty-print/`toValues` call, no - // re-health-check — see each call site's own comment for why they - // differ). + // Shared by the already-running branch (full status pipeline, re-health-checked) and the + // success path (a direct pretty-print, no re-health-check). // - // `precomputedLocal` is only ever passed by the success-path call: the - // already-running branch delegates to the shared status pipeline, which - // loads config (and therefore re-derives keys) a SECOND time in that - // same process, so recomputing here matches that. The success path, by - // contrast, never reloads config again after bring-up — it prints - // straight from the already-populated config — so it must reuse the - // SAME `values` that were already used to build every container spec, - // instead of re-deriving (and, for asymmetric JWTs, re-signing with a - // new `exp`) a second time. See {@link resolveStatusLocalState}'s - // `precomputedLocal` param doc for why a second derivation is unsafe. + // `precomputedLocal` is passed only by the success path, which must reuse the same `values` + // already used to build every container spec instead of re-deriving them a second time — for + // asymmetric JWTs, a second derivation would re-sign with a new `exp`. See + // {@link resolveStatusLocalState}'s `precomputedLocal` param for why that's unsafe. const buildStatusValues = Effect.fnUntraced(function* ( excluded: ReadonlyArray<string>, precomputedLocal?: LocalConfigValues, @@ -715,9 +612,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { const inBitbucketPipeline = isBitbucketPipeline(); - // 3. Missing proceeds to startup; other inspect failures propagate. - // Verified stopped stacks are recovered unless Bitbucket's lack of named volumes - // makes removing the Postgres container destructive. + // 3. A missing container proceeds to startup; other inspect failures propagate. Stopped + // stacks are recovered unless Bitbucket's lack of named volumes makes removal destructive. const inspectDbState = inspectContainerState(spawner, dbContainerId).pipe( Effect.catch((error) => isContainerNotFoundMessage(error.message) ? Effect.succeed(undefined) : Effect.fail(error), @@ -732,19 +628,14 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { const shouldRecoverStoppedStack = isRecoverableStoppedState(dbState) && !inBitbucketPipeline; const reportAlreadyRunningStatus = Effect.fnUntraced(function* () { - // Gated here on text mode for internal consistency - // with every other supplementary stderr line this handler prints (see - // the exclude warning above and the success-path messages below) — this - // port's `--output-format json|stream-json` callers get a clean - // structured payload with no extra text noise. + // Gated on text mode for consistency with every other supplementary stderr line this + // handler prints — json/stream-json callers get a clean structured payload with no noise. if (output.format === "text") { yield* output.raw(startAlreadyRunningMessage(), "stderr"); } - // The full status pipeline for this branch: health-check + - // "stopped services" diffing, distinct from the success path's direct - // pretty-print call below (see that branch's own comment for why the - // two differ). + // The full status pipeline for this branch: health-check plus "stopped services" diffing, + // distinct from the success path's direct pretty-print call below. if (!flags.ignoreHealthCheck) { const state = yield* inspectContainerState(spawner, dbContainerId).pipe( Effect.mapError((cause) => new StatusDbInspectError({ message: cause.message })), @@ -780,9 +671,7 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { const excluded = [...stopped, ...flags.exclude]; if (output.format === "text") { - // The pretty-branch banner -- distinct from (and printed in - // ADDITION to) `startAlreadyRunningMessage()` above; both - // lines really do stack in this branch. + // Distinct from, and stacks with, `startAlreadyRunningMessage()` above. yield* output.raw(`${aqua("supabase")} local development setup is running.\n\n`, "stderr"); const { values: statusValues, names } = yield* buildStatusValues(excluded); yield* output.raw(renderStatusPretty(statusValues, names)); @@ -796,17 +685,10 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { return yield* reportAlreadyRunningStatus(); } - // 4. A best-effort update-suggestion check: a Management API call gated - // on the project being linked AND the user being logged in, purely to - // print an "update available" hint, every error silently swallowed. - // Deliberately NOT implemented — this command has zero Management API - // dependency by design. - - // 5. Gate evaluation — see `start.gates.ts` for the full boolean table. - // `envOverrideBool` throws synchronously on an unparsable value — - // wrapped so that throw becomes the typed `StartInvalidConfigError` - // every other malformed-config path in this handler uses, not an - // untyped Effect defect. + // 4. No update-suggestion check: `start` has no Management API dependency by design. + + // 5. Gate evaluation — see `start.gates.ts`. Wrapped because `envOverrideBool` throws + // synchronously on an unparsable value, and this handler surfaces that as a typed error. const gates = yield* Effect.try({ try: () => resolveStartGates({ @@ -821,8 +703,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { }), }); - // 6. JWKS resolution — runs UNCONDITIONALLY, before any image pull, - // regardless of whether auth/realtime/postgrest/storage end up enabled. + // 6. JWKS resolution runs unconditionally, before any image pull, regardless of which + // services end up enabled. const jwks = yield* Effect.tryPromise({ try: () => resolveLocalJwks(config, cliSettings.workdir, values.jwtSecret, projectEnvValues), catch: (cause) => @@ -831,22 +713,12 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { }), }); - // Same treatment as `majorVersion` below, for the sibling - // `edge_runtime.deno_version` -> image switch, applied before validation - // at the end of config loading. Start-only (Edge Runtime has no - // `db start` equivalent), so it stays outside the shared bootstrap-config - // derivation below. + // The `edge_runtime.deno_version` -> image switch is start-only (no `db start` equivalent), + // so it's resolved here rather than inside the shared bootstrap-config derivation below. const denoVersion = envOverrideDenoVersion(config.edge_runtime.deno_version, projectEnvValues); - // Every field the fresh-DB bootstrap needs, already resolved — major - // version, orioledb/S3 overrides, the fresh-DB setup jobs' own - // `enabled`/`ip_version`/`max_header_length`/`file_size_limit` - // overrides, the Postgres image + linked-service version pins, - // `db.health_timeout`, and the Storage migration pin. Shared with - // `db start`'s own native container bootstrap (`startDatabase`, - // `command-internal/db-bootstrap/start-database.ts`) — see - // `bootstrap-config.ts`'s own header for exactly why this is a single - // TS home instead of two independently-drifting copies. + // Every field the fresh-DB bootstrap needs, shared with `db start`'s own native bootstrap — + // see `bootstrap-config.ts`'s header for why this is one shared derivation. const { majorVersion, orioledbVersion, @@ -872,19 +744,15 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { (message) => new StartInvalidConfigError({ message }), ); - // 7. Resolve every image that will actually be pulled BEFORE any - // container is created. + // 7. Resolve every image that will actually be pulled before any container is created. const imagePlan = resolveStartImagePlan(gates, serviceVersionOverrides); - // Edge Runtime doesn't go through `resolveStartImagePlan` (see - // `start.gates.ts`'s header) — its default image is resolved - // independently, pre-pulled whenever it's enabled and not excluded. + // Edge Runtime doesn't go through `resolveStartImagePlan` (see `start.gates.ts`'s header), + // so its default image is resolved independently, pre-pulled when enabled and not excluded. const edgeRuntimeDefaultImage = gates.edgeRuntime ? yield* resolveEdgeRuntimeImage(fs, path, cliSettings.workdir, denoVersion) : undefined; - // Pre-pull only ever touches non-excluded services, and the - // one-shot setup-job images are resolved lazily, only when the - // fresh-DB setup job actually runs (see the conditional resolve further - // down, gated the same way that job itself is gated). + // Pre-pull only touches non-excluded services; the one-shot setup-job images are resolved + // lazily, only when the fresh-DB setup job actually runs. const resolvedImages = yield* ensureImagesCached( spawner, [ @@ -896,20 +764,13 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ); const resolveImage = (image: string) => resolvedImages.get(image) ?? image; - // Hoisted out of the Edge Runtime bring-up below: Studio's own bind - // mounts are resolved UNCONDITIONALLY of `config.edge_runtime.enabled`, - // so these manifest values must be available to `buildSpecForService`'s - // "studio" case regardless of whether Edge Runtime itself is enabled. + // Studio's bind mounts are resolved unconditionally of `config.edge_runtime.enabled`, since + // `buildSpecForService`'s "studio" case needs them regardless of whether Edge Runtime itself + // is enabled. // - // `config.functions.<slug>.env.<VAR>` is schema-marked deferred - // (`env(...)`, `packages/config/src/lib/env.ts`) and only gets its - // literal interpolated by `resolveCliConfigSubtree` — without this step, a - // configured `[functions.<slug>.env]` entry reaches Edge Runtime as the - // literal string `"env(API_KEY)"` instead of the real secret. - // `functions serve`'s own call site already resolves this subtree first - // (`shared/functions/serve.ts:615-622`) before the same - // `toPlainFunctionRecord` call — same reasoning as `resolvedEdgeRuntime` - // below, just for the sibling `functions` subtree. + // `config.functions.<slug>.env.<VAR>` is schema-marked deferred and only gets its literal + // interpolated by `resolveCliConfigSubtree` — without this, a configured `env` entry reaches + // Edge Runtime as the literal string `"env(API_KEY)"` instead of the real secret. const resolvedFunctions = yield* resolveCliConfigSubtree( config.functions, { values: projectEnvValues ?? {} }, @@ -920,11 +781,9 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { const configFunctions = yield* inferFunctionsManifest({ cwd: cliSettings.workdir, config: { ...config, functions: configDeclaredFunctions }, - // `search: false`: `cliSettings.workdir` is already the fully-resolved chdir target (same - // reasoning as `local-project-context.ts`'s `loadCliProjectEnvironment` call) — letting - // `findCliProjectPaths` climb ancestors again here would let an unrelated ancestor project's - // `supabase/functions` win when `--workdir`/`SUPABASE_WORKDIR` points at a subdirectory with - // no `supabase/config.toml` of its own. + // `search: false`: `cliSettings.workdir` is already the fully-resolved chdir target. + // Climbing ancestors again here could let an unrelated ancestor project's + // `supabase/functions` win when `--workdir` points at a subdirectory with no config of its own. search: false, }); const rawConfigFunctions = rawFunctionConfigRecord(context.loaded?.document); @@ -942,27 +801,17 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ) : new Set<string>(); - // Every container's network mode (and the network it creates) resolves - // to `--network-id` when set, ahead of the generated - // `supabase_network_<project>` fallback — and `--network-id` falls back - // to the `SUPABASE_NETWORK_ID` shell/project-dotenv env var ONLY when - // the flag was never passed, the same override mechanism as - // `SUPABASE_YES`/`SUPABASE_EXPERIMENTAL` (review: PRRT_kwDOErm0O86VlqIL). - // See {@link resolveDockerNetworkMode}'s doc comment for the full 3-way - // flag/env precedence (shared with `db start` and the `functions` - // Docker paths). + // `--network-id` wins over the generated `supabase_network_<project>` fallback, falling back + // to `SUPABASE_NETWORK_ID` only when the flag was never passed. See + // {@link resolveDockerNetworkMode} for the full flag/env precedence. const networkIdFlag = yield* NetworkIdFlag; const networkId = resolveDockerNetworkMode({ explicit: Option.getOrUndefined(networkIdFlag), envOverride: viperEnvStringWithProjectFallback("SUPABASE_NETWORK_ID", projectEnvValues), projectId, }); - // Every container unconditionally gets the Linux-only - // `host.docker.internal:host-gateway` extra host (empty on - // darwin/windows, where Docker Desktop already resolves that hostname) - // — same expression already used for the one-shot migrate jobs - // (`db-setup.ts`) and Edge Runtime bring-up - // (`edge-runtime-script.layer.ts`). + // Linux-only `host.docker.internal:host-gateway` extra host; empty on darwin/windows, where + // Docker Desktop already resolves that hostname. const extraHosts = runtimeInfo.platform === "linux" ? ["host.docker.internal:host-gateway"] : []; const startOpts: ContainerOpts = { @@ -987,22 +836,15 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { const vectorContainerName = serviceContainerName("vector", projectId); const mailpitContainerName = serviceContainerName("inbucket", projectId); - // The TLS cert/key disk read is gated on the post-override - // `api.enabled` itself, not just `api.tls.enabled`: when API is - // disabled, `CertContent`/`KeyContent` stay at their embedded defaults. - // Not the SAME `apiEnabled` `resolveStartGates` (`start.gates.ts:87-92`) - // computes for its own `gates.postgrest` — that one is additionally - // ANDed with `--exclude postgrest`, which has no equivalent in config - // validation — so this is resolved separately here. + // The TLS cert/key disk read is gated on `api.enabled` itself, not just `api.tls.enabled`. + // Resolved separately from `gates.postgrest`'s own `apiEnabled`, which is additionally + // combined with `--exclude postgrest` — a distinction config validation has no equivalent for. const apiEnabled = yield* wrapConfigOverride("api.enabled", () => envOverrideBool("SUPABASE_API_ENABLED", config.api.enabled, "api.enabled", projectEnvValues), ); - // Hoisted out of the "kong" case below (it used to be computed only - // there): the post-bring-up health-probe CA-trust lookup near the end of - // this function needs the SAME env-overridden value, not the raw - // `config.api.tls.enabled` — there must be one source of truth here, - // since the health probe's trust pool and its target URL both read - // that same already-overridden value. + // The post-bring-up health-probe CA-trust lookup needs this same env-overridden value, not + // the raw `config.api.tls.enabled` — both the trust pool and its target URL must read one + // source of truth. const apiTlsEnabled = yield* wrapConfigOverride("api.tls.enabled", () => envOverrideBool( "SUPABASE_API_TLS_ENABLED", @@ -1011,12 +853,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { projectEnvValues, ), ); - // Same override gap as `apiTlsEnabled` above, for the two custom - // cert/key path fields — `SUPABASE_API_TLS_CERT_PATH`/ - // `SUPABASE_API_TLS_KEY_PATH` must apply before reading `CertPath`/ - // `KeyPath` from disk into `CertContent`/`KeyContent`. Mirrors the - // identical resolution `local-config-values.ts` already does for - // `status`/`stop`. + // Same override gap as `apiTlsEnabled` above: the env overrides must apply before reading + // the cert/key paths from disk. const apiTlsCertPath = envOverride( "SUPABASE_API_TLS_CERT_PATH", config.api.tls.cert_path, @@ -1061,9 +899,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ); } - // Same gap for `storage.vector.enabled` — both the long-running Storage - // container AND `seedBucketsRun`'s `effectiveLocalStorageConfig` - // splice further down must see the same already-overridden value. + // Same gap for `storage.vector.enabled` — both the Storage container and `seedBucketsRun`'s + // config splice further down must see the same already-overridden value. const storageVectorEnabled = yield* wrapConfigOverride("storage.vector.enabled", () => envOverrideBool( "SUPABASE_STORAGE_VECTOR_ENABLED", @@ -1072,13 +909,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { projectEnvValues, ), ); - // Same gap for `storage.s3_protocol.enabled` — a plain bool that must - // validate unconditionally before any Docker work, the exact same - // mechanism as `storage.vector.enabled` above. The Storage spec builder - // below only parsed this lazily, so it was silently accepted when - // Storage is excluded/disabled — same class of gap already fixed for - // `storage.file_size_limit`, the GoTrue duration fields, and - // `db.health_timeout`. + // Same gap for `storage.s3_protocol.enabled`: the Storage spec builder only parsed this + // lazily, so a malformed override was silently accepted whenever Storage is excluded/disabled. const storageS3ProtocolEnabled = yield* wrapConfigOverride("storage.s3_protocol.enabled", () => envOverrideBool( "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", @@ -1087,14 +919,9 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { projectEnvValues, ), ); - // Same gap for `storage.analytics.enabled` — the `Enabled` bool sibling - // of the `max_namespaces`/`max_tables`/`max_catalogs` uint fields - // validated below, all of which must validate unconditionally, before - // any Docker work. `start` itself never reads this field locally (only - // `seed buckets --linked` does, unreachable from `start`'s own inline - // seeding since every call here passes `projectRef: ""`) — validate - // purely for fail-fast parity and discard the result, same as the uint - // siblings below. + // Same gap for `storage.analytics.enabled`. `start` never reads this field itself (only + // `seed buckets --linked` does), so it's validated here purely for fail-fast parity and the + // result discarded. yield* wrapConfigOverride("storage.analytics.enabled", () => envOverrideBool( "SUPABASE_STORAGE_ANALYTICS_ENABLED", @@ -1103,13 +930,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { projectEnvValues, ), ); - // `storage.analytics.{max_namespaces,max_tables,max_catalogs}` and - // `storage.vector.{max_buckets,max_indexes}` are plain `uint`s that must - // validate unconditionally, before any Docker work, the same as every - // other field above. Unlike their `enabled` siblings above, `start`'s - // own logic never reads these fields (only `config push`/`pull` do), so - // there is no downstream re-resolution to reuse — validate purely for - // fail-fast parity and discard the result. + // These plain `uint` fields must validate unconditionally too. `start` never reads them + // itself (only `config push`/`pull` do), so they're validated purely for fail-fast parity. yield* wrapConfigOverride("storage.analytics.max_namespaces", () => envOverrideUint( "SUPABASE_STORAGE_ANALYTICS_MAX_NAMESPACES", @@ -1151,11 +973,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ), ); - // Same gap for `api.schemas`/`api.extra_search_path`/`api.max_rows` — - // both PostgREST's own container AND Studio's copy of the same - // PGRST_DB_* env must see the same already-overridden values. The two - // array fields use the same comma-split-override pattern as - // `auth.additional_redirect_urls`/`auth.webauthn.rp_origins` above. + // Same gap for `api.schemas`/`api.extra_search_path`/`api.max_rows` — both PostgREST's own + // container and Studio's copy of the same `PGRST_DB_*` env must see the overridden values. const apiSchemasOverride = envOverride("SUPABASE_API_SCHEMAS", undefined, projectEnvValues); const apiSchemas = apiSchemasOverride !== undefined ? apiSchemasOverride.split(",") : config.api.schemas; @@ -1172,11 +991,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { envOverrideApiMaxRows(config.api.max_rows, projectEnvValues), ); - // Same gap for Mailpit's three ports — `SUPABASE_LOCAL_SMTP_PORT`/ - // `_SMTP_PORT`/`_POP3_PORT` must apply before building Mailpit's port - // bindings. `smtp_port`/`pop3_port` have no TOML default, matching - // `mailpit.service.ts`'s own `!== 0` publish guard, so `?? 0` here - // preserves that "unconfigured" signal. + // Same gap for Mailpit's three ports. `smtp_port`/`pop3_port` have no TOML default, so `?? 0` + // here preserves the "unconfigured" signal `mailpit.service.ts`'s `!== 0` publish guard checks. const mailpitPort = yield* wrapConfigOverride("local_smtp.port", () => envOverridePort( "SUPABASE_LOCAL_SMTP_PORT", @@ -1212,12 +1028,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { projectEnvValues, ), ); - // Same gap for Logflare's deprecated Vector port — - // `analytics.vector_port` (a `uint16`, "Deprecated together with - // syslog") must validate unconditionally too; nothing downstream in - // `start` reads the resolved value, but a malformed override must still - // fail before any Docker work. Result discarded — no native code path - // consumes it. + // Same gap for Logflare's deprecated `analytics.vector_port`: nothing downstream reads the + // resolved value, but a malformed override must still fail before any Docker work. yield* wrapConfigOverride("analytics.vector_port", () => envOverridePort( "SUPABASE_ANALYTICS_VECTOR_PORT", @@ -1227,16 +1039,9 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ), ); - // Same gap for Supavisor's pooler fields — `SUPABASE_DB_POOLER_*` must - // apply before building the pooler's port/mode fields; `pool_mode` - // specifically decides the published host port (5432 session vs 6543 - // transaction). All four throw synchronously on a malformed override — - // wrapped via `wrapConfigOverride` so a bad value fails as a typed - // `StartInvalidConfigError` instead of an untyped Effect defect - // bypassing `withJsonErrorHandling`'s `Effect.catch` - // (see `wrapConfigOverride`'s doc comment) — same bug class already fixed - // for `dbHealthTimeoutSeconds`/`db.settings`/the Edge Runtime - // `policy`/`inspector_port` overrides elsewhere in this function. + // Same gap for Supavisor's pooler fields — `pool_mode` specifically decides the published + // host port (5432 session vs 6543 transaction). Wrapped via `wrapConfigOverride` so a bad + // value fails as a typed error instead of an untyped Effect defect. const poolerPort = yield* wrapConfigOverride("db.pooler.port", () => envOverridePort( "SUPABASE_DB_POOLER_PORT", @@ -1255,14 +1060,10 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { envOverrideMaxClientConn(config.db.pooler.max_client_conn, projectEnvValues), ); - // Same bug class as `dbHealthTimeoutSeconds` (now resolved by the shared - // `resolveDbBootstrapConfig` call above): `edge_runtime.policy` - // (an enum) and `edge_runtime.inspector_port` (a plain uint) decode during - // the same unconditional config-load pass, before - // any Docker work — regardless of `--exclude edge-runtime`. The Edge Runtime - // branch below re-resolves both against `resolvedEdgeRuntime`'s env-interpolated subtree - // for the real container build; this eager call only needs the raw `config.edge_runtime` - // value to prove it parses. + // `edge_runtime.policy`/`edge_runtime.inspector_port` must validate unconditionally too, + // regardless of `--exclude edge-runtime`. The Edge Runtime branch below re-resolves both + // against the env-interpolated subtree for the real container build; this eager call only + // needs the raw config value to prove it parses. const edgeRuntimePolicy = yield* wrapConfigOverride("edge_runtime.policy", () => envOverrideEdgeRuntimePolicy(config.edge_runtime.policy, projectEnvValues), ); @@ -1276,11 +1077,9 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ); /** - * Every case returns `{ spec, excludeFromHealthWatch? }`: `spec` is the - * {@link StartContainerSpec} to bring up; `excludeFromHealthWatch` - * (only ever set by "vector") is returned explicitly instead of a - * captured mutable variable — Vector's `npipe`-scheme exception is the - * only case that ever sets it. + * Every case returns `{ spec, excludeFromHealthWatch? }`. `excludeFromHealthWatch` is + * returned explicitly rather than a captured mutable variable — only the "vector" case + * (its `npipe`-scheme exception) ever sets it. */ const buildSpecForService = Effect.fnUntraced(function* (service: string, image: string) { switch (service) { @@ -1532,47 +1331,30 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { } }); - // `isFreshVolume`: set once, inside the pre-create volume-existence - // check, then read much later on a rollback. A plain outer `let` is - // this port's equivalent of a mutable global shared across that whole - // window — unlike `excludeFromHealthWatch` (returned explicitly from - // `buildSpecForService` because a clean return-value alternative - // existed there), `isFreshVolume` must survive PAST `bringUp`'s own - // failure path (`Effect.onError` below, which runs on ANY failure — - // including a defect or interrupt — and has no access to a value - // `bringUp` only returns on success) — there is no non-mutable way to - // thread a value computed mid-effect into a sibling failure handler. + // Set once, inside the pre-create volume-existence check, then read on a rollback. A plain + // outer `let` is necessary here: `isFreshVolume` must still be readable from `bringUp`'s own + // failure path (`Effect.onError` below, which runs on any failure including a defect or + // interrupt), and there's no non-mutable way to thread a value computed mid-effect into a + // sibling failure handler. let isFreshVolume = false; - // 8. Bring-up: network -> Postgres (+ its own health wait, GATED on - // `--ignore-health-check` exactly like every other service's wait below - // — this must propagate a Postgres health-wait failure immediately, - // before "Starting containers..." even prints — there is no - // Postgres-specific carve-out) -> the fresh-volume-gated - // `SetupLocalDatabase` equivalent (BEFORE any other service starts) -> - // the 12 remaining enabled+non-excluded services plus Edge Runtime, in - // the real start order (`start.gates.ts`'s `imagePlan` + `edgeRuntime`). - // Any OTHER failure in this whole phase (including a NON-ignored - // Postgres timeout) rolls back and fails the command outright. An - // IGNORED Postgres timeout instead short-circuits to `{ kind: - // "postgresUnhealthyIgnored" }` below — skipping every later step in - // this phase, and the bulk health check/bucket seeding/ - // `cli_stack_started` capture after it — while still letting the - // command reach the final "Started..." tail, printing the warning and - // falling through to that SAME unconditional tail rather than returning - // early from the whole command. + // 8. Bring-up order: network -> Postgres (+ health wait, gated on `--ignore-health-check` + // like every other service below) -> the fresh-volume-gated `SetupLocalDatabase` equivalent, + // before any other service starts -> the remaining enabled, non-excluded services plus Edge + // Runtime, in the real start order. + // + // Any failure other than an ignored Postgres timeout rolls back and fails the command. An + // ignored Postgres timeout instead short-circuits to `{ kind: "postgresUnhealthyIgnored" }` + // below, skipping the rest of this phase and the bulk health check/bucket seeding, but still + // falling through to the same unconditional "Started..." tail rather than returning early. const bringUp = Effect.gen(function* () { - // `--debug` — threaded into `setup.debug` below so a failed fresh-volume - // Realtime/Storage/Auth migrate job (see `db-setup.ts`'s `runStartMigrateJob` - // doc comment) tees its own stderr. + // Threaded into `setup.debug` below so a failed fresh-volume migrate job tees its own + // stderr (see `db-setup.ts`'s `runStartMigrateJob`). const bringUpDebug = yield* DebugFlag; - // Runs the DB bootstrap sequence (network -> volume probe -> container - // create+start -> health wait -> fresh-volume setup -> `_current_branch`) — shared - // with `db start`'s own native container bootstrap, see `startDatabase`'s own - // header (`command-internal/db-bootstrap/start-database.ts`) for the full call order and - // for why this function has zero knowledge of `--ignore-health-check`: that decision - // belongs entirely to THIS caller, immediately below. + // Runs the DB bootstrap sequence, shared with `db start`'s own native bootstrap — see + // `startDatabase`'s header for the full call order. `startDatabase` has no knowledge of + // `--ignore-health-check`; that decision belongs to this caller, immediately below. const dbBootstrapResult = yield* startDatabase(spawner, { fs, path, @@ -1584,20 +1366,17 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { dbPort: values.dbPort, containerOpts: startOpts, postgresSpec: { - // `port` overridden by SUPABASE_DB_PORT — the published port binds - // straight from the already-overridden `config.db.port`. - // `settings` overridden by any `SUPABASE_DB_SETTINGS_*` field — - // serialized from the same already-overridden `config.db.settings`. + // `port`/`settings` bind straight from the already env-overridden + // `config.db.port`/`config.db.settings`. db: { ...config.db, port: values.dbPort, major_version: majorVersion, settings: resolveDbSettingsEnvOverrides(config.db.settings, projectEnvValues), }, - // `orioledb_version` overridden by SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION, - // matching the value already used to select `postgresImage` above — - // `postgresExtraEnv` reads this same field, and its four sibling - // S3 fields, for its S3/`POSTGRES_INITDB_ARGS` branch. + // Matches the already env-overridden value used to select `postgresImage` above; + // `postgresExtraEnv` reads this and its sibling S3 fields for its + // `POSTGRES_INITDB_ARGS` branch. experimental: { ...config.experimental, orioledb_version: orioledbVersion, @@ -1607,10 +1386,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { s3_secret_key: s3SecretKey, }, jwtSecret: values.jwtSecret, - // Overridden by SUPABASE_AUTH_JWT_EXPIRY — Postgres's JWT_EXP - // (seeding app.settings.jwt_exp) and GoTrue's GOTRUE_JWT_EXP both - // read the same already-overridden value; using the raw config - // value here would let Postgres and GoTrue disagree. + // Postgres's `JWT_EXP` and GoTrue's `GOTRUE_JWT_EXP` both read this same + // already-overridden value; the raw config value would let them disagree. jwtExpiry: values.authJwtExpiry, projectId, networkId, @@ -1619,21 +1396,18 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { // `fromBackup` stays unset: `supabase start` always calls the DB // bootstrap with an empty `fromBackup` — only `db start` ever sets it. }, - // Already resolved as part of THIS run's own batched pre-pull (`resolvedImages`, - // above) — `supabase start` has no per-container lazy resolve of its own, unlike - // `db start` (see `startDatabase`'s header for why this is caller-supplied). + // Already resolved as part of this run's batched pre-pull above — `start` has no + // per-container lazy resolve of its own, unlike `db start`. resolvePostgresImage: Effect.succeed(resolveImage(postgresImage)), dbHealthTimeoutSeconds, webhooksEnabled: dbTomlValues.webhooksEnabled, setup: { majorVersion, experimental, - // The fresh-volume setup's per-job gates read the EFFECTIVE, - // env-overridden `{Realtime,Storage,Auth}.enabled` values, NOT - // additionally filtered by `--exclude` the way `gates.*` is — the - // one-shot migration jobs run regardless of `--exclude`, since - // they're part of the DB bootstrap, which finishes before this - // handler's own excluded-services filtering even begins. + // Reads the effective, env-overridden `{Realtime,Storage,Auth}.enabled` values, not + // additionally filtered by `--exclude` the way `gates.*` is — these one-shot migration + // jobs run regardless of `--exclude`, since the DB bootstrap finishes before this + // handler's own excluded-services filtering begins. config: { ...config, realtime: { @@ -1654,9 +1428,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { }, dbUrl: values.dbUrl, jwtSecret: values.jwtSecret, - // Already resolved, unconditionally, near the top of THIS handler's own prelude - // (feeding the long-running Realtime/GoTrue/PostgREST containers too) — reused - // here rather than re-resolved, see `startDatabase`'s header for why. + // Already resolved unconditionally near the top of this handler (feeding the + // long-running Realtime/GoTrue/PostgREST containers too), so it's reused, not re-resolved. jwks: Effect.succeed(jwks), apiUrl: values.apiUrl, authExternalUrl: resolveAuthExternalUrl(context.loaded?.document, projectEnvValues), @@ -1679,15 +1452,8 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { if (Result.isFailure(dbBootstrapResult)) { const error = dbBootstrapResult.failure; if (flags.ignoreHealthCheck && isUnhealthyStartError(error)) { - // `ignoreHealthCheck && isUnhealthyStartError(error)` applies - // uniformly to whatever the DB bootstrap returns AS A WHOLE — - // including Postgres's own health-wait error, which propagates - // immediately, before any of the steps below (fresh-volume setup, - // `initCurrentBranch`, every other service, the bulk health check) - // ever run. Downgrade to a warning and short-circuit the rest of - // this phase, falling through to the SAME unconditional tail - // every other path reaches, not an early return from the whole - // command. + // Downgrades to a warning and short-circuits the rest of this phase, falling through + // to the same unconditional tail every other path reaches. yield* output.raw(`${healthWarningText(error)}\n`, "stderr"); return { kind: "postgresUnhealthyIgnored" as const }; } @@ -1698,11 +1464,9 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { yield* output.raw(START_STARTING_CONTAINERS_MESSAGE, "stderr"); } - // An insertion-ordered container NAME -> resolved image map. Watches - // container names, not the ids `docker create` returns, so an - // unhealthy container reports as `supabase_auth_demo` rather than 64 - // hex characters. Keying the images by that same name keeps the watch - // list and its images from drifting apart. + // An insertion-ordered container name -> resolved image map. Keyed by name (not the id + // `docker create` returns) so an unhealthy container reports as `supabase_auth_demo` + // rather than 64 hex characters, and the watch list stays in sync with its images. const started = new Map<string, string>(); let postgrestGateway: HealthCheckPostgrestGateway | undefined; let edgeRuntimeGateway: HealthCheckPostgrestGateway | undefined; @@ -1711,38 +1475,27 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { for (const entry of START_SERVICES) { if (entry.service === "postgres") continue; - // Edge Runtime doesn't go through `imagePlan`/`buildSpecForService` — - // see `start.gates.ts`'s header — so it's special-cased here, in its - // real relative position (between ImgProxy and pg-meta). + // Edge Runtime doesn't go through `imagePlan`/`buildSpecForService` (see + // `start.gates.ts`'s header), so it's special-cased here, between ImgProxy and pg-meta. if (entry.service === "edgeRuntime") { if (!gates.edgeRuntime || edgeRuntimeDefaultImage === undefined) continue; - // `config.edge_runtime.secrets` is still schema-decoded plain - // strings here — `toPlainEdgeRuntimeConfig` only emits entries - // whose values are `Redacted` (`shared/functions/serve.ts`), and a - // value only becomes `Redacted` after `resolveCliConfigSubtree`'s - // env-interpolation + secret-path-redaction pass. Without this step - // every configured `[edge_runtime.secrets]` entry is silently - // dropped — `functions serve`'s own call site already resolves the - // subtree first (`shared/functions/serve.ts:603-610`) before - // calling the same helper. + // `config.edge_runtime.secrets` is still schema-decoded plain strings here — + // `toPlainEdgeRuntimeConfig` only emits entries whose values are `Redacted`, which a + // value only becomes after `resolveCliConfigSubtree`'s env-interpolation and + // secret-path-redaction pass. Without this step every configured secret is silently + // dropped. const resolvedEdgeRuntime = yield* resolveCliConfigSubtree( config.edge_runtime, { values: projectEnvValues ?? {} }, "edge_runtime", { goViperCompat: true }, ); - // Every `config.Secret`-typed field (including - // `edge_runtime.secrets`) must be decrypted unconditionally, so - // the real Edge Runtime container always receives plaintext. - // `toPlainEdgeRuntimeConfig` only does `env()` interpolation and - // `Redacted`-unwrapping — it never decrypts a dotenvx `encrypted:` - // value — so without this step the literal ciphertext would reach - // the container's env file. `checkDbToml` (called - // unconditionally, before any Docker work) already validates - // every `edge_runtime.secrets.*` entry is decryptable via - // `assertDecryptableSecrets`, but only for that validation's - // own side effect — the decrypted plaintext is discarded there, - // not threaded back into this value. + // Every `config.Secret`-typed field must be decrypted unconditionally so the Edge + // Runtime container receives plaintext. `toPlainEdgeRuntimeConfig` only interpolates + // `env()` and unwraps `Redacted` — it never decrypts a dotenvx `encrypted:` value, so + // without this step the literal ciphertext would reach the container's env file. + // `checkDbToml` already validates every secret is decryptable, but discards the + // decrypted plaintext there. const rawEdgeRuntimeSecrets = toPlainEdgeRuntimeConfig(resolvedEdgeRuntime).secrets; const dotenvPrivateKeys = collectDotenvPrivateKeys({ ...projectEnvValues, @@ -1792,16 +1545,11 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { platform: runtimeInfo.platform, }; const runtime: StartedRuntime = yield* startStackEdgeRuntimeContainer(edgeRuntimeInput); - // Deliberately NOT calling `runtime.cleanup` here — see - // `edge-runtime.service.ts`'s header for why. Unlike every other - // service built here (`createContainer`'s `restartPolicy: - // "unless-stopped"`), Edge Runtime's own bring-up sets no Docker - // restart policy at all — but its bind-mounted host temp files - // (env-file/multiline-env-script staging) must still exist for - // as long as the container can be reattached to; `start - // EdgeRuntimeContainer` already runs `cleanup` on a failed or - // interrupted bring-up internally, so only the success path must - // leave it alone. + // Not calling `runtime.cleanup` here: unlike every other service (`createContainer`'s + // `restartPolicy: "unless-stopped"`), Edge Runtime sets no Docker restart policy, so + // its bind-mounted host temp files must still exist for as long as the container can + // be reattached to. `startStackEdgeRuntimeContainer` already runs cleanup internally on + // a failed or interrupted bring-up, so only the success path must leave it alone. started.set(runtime.containerId, edgeRuntimeInput.image); edgeRuntimeGateway = { containerId: runtime.containerId, @@ -1814,21 +1562,12 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { const image = imagePlanByService.get(entry.service); if (image === undefined) continue; - // Several service builders do synchronous, throwing work over - // config.toml string fields `@supabase/config`'s schema does not - // itself validate as durations/sizes (GoTrue's - // `auth.email/sms.max_frequency`/sessions/mfa duration parsing and - // Storage's file-size-limit parsing are now caught earlier, eagerly, - // before any Docker work — see the `resolvedEmail`/ - // `storageFileSizeLimit` validation above — but this `catchDefect` - // stays as defense-in-depth for any other field of this shape). A - // malformed value would otherwise surface as an uncaught Effect - // defect — `Effect.onError` below still rolls back on a defect - // either way, but the user would see an opaque defect instead of a - // typed config error. `catchDefect` converts any such throw into - // the same typed config error every other malformed-config path in - // this handler already produces, matching the fail-fast-at-decode - // behavior every other field validates with. + // Defense-in-depth: some service builders do synchronous, throwing work over config + // fields `@supabase/config`'s schema doesn't itself validate (most such fields are now + // caught earlier, eagerly, above). A malformed value would otherwise surface as an + // uncaught Effect defect — `Effect.onError` below still rolls back either way, but + // `catchDefect` converts it into the same typed config error every other malformed-config + // path produces, instead of an opaque defect. const resolvedServiceImage = resolveImage(image); const { spec, excludeFromHealthWatch } = yield* buildSpecForService( entry.service, @@ -1866,19 +1605,13 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { storageContainerId, }; }).pipe( - // The rollback's `NoBackupVolume` value — `true` only when this run's - // Postgres volume was freshly created (see `isFreshVolume` above): a - // rollback prunes volumes on a brand-new, empty first-ever `start`, - // but never touches a pre-existing user's data on a failed restart. + // The rollback's `NoBackupVolume` value is `true` only when this run's Postgres volume was + // freshly created — a rollback prunes volumes on a first-ever `start`, but never touches a + // pre-existing user's data on a failed restart. // - // `Effect.onError`, not `Effect.tapError`: this must roll back on ANY - // failure from bring-up, including a `context.Canceled`-equivalent - // interrupt from a SIGINT during bring-up. `tapError` is built on - // `Cause.findError`, which only matches `Fail` reasons — a pure fiber - // interrupt never reaches it. `onError` fires on any failure outcome - // (including interruption) and its cleanup effect runs - // uninterruptibly, matching the unconditional rollback check every - // other failure path in this handler needs. + // `Effect.onError`, not `Effect.tapError`: this must roll back on any failure from bring-up, + // including a SIGINT interrupt. `tapError` is built on `Cause.findError`, which only + // matches `Fail` reasons — a pure fiber interrupt never reaches it. Effect.onError(() => rollbackStart(spawner, filterValue, isFreshVolume, cliSettings.workdir, debug), ), @@ -1920,75 +1653,32 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { const bringUpResult = yield* bringUp; - // Only reached when Postgres itself became healthy (or the volume - // already existed) — the `postgresUnhealthyIgnored` short-circuit above - // skips this entire block, falling through to the tail below either way - // but never re-entering these later steps once the DB bootstrap has - // already returned. + // Only reached when Postgres itself became healthy — the `postgresUnhealthyIgnored` + // short-circuit above skips this entire block and falls through to the tail below instead. if (bringUpResult.kind === "started") { const { started, postgrestGateway, edgeRuntimeGateway, storageContainerId } = bringUpResult; - // Wraps steps 9-11 below (bulk health wait, the ignore-health-check - // storage-only recheck-and-seed, the success-path bucket seeding, and - // the `cli_stack_started` capture) in the same `Effect.onError` rollback - // `bringUp`'s own pipe uses above. This whole tail must be checked for - // a single failure outcome exactly once — a SIGINT/SIGTERM landing - // anywhere in this tail, not just during bring-up, must roll back - // too. Per-step manual `rollbackStart` calls would miss a pure - // fiber interrupt between steps (see the `bringUp` pipe's doc comment - // for why `onError`, not `tapError`, is required), so every fail path - // below just fails and lets this single outer `onError` roll back. + // Wraps steps 9-11 below in the same `Effect.onError` rollback `bringUp`'s own pipe uses + // above, so a SIGINT/SIGTERM landing anywhere in this tail also rolls back. Per-step manual + // `rollbackStart` calls would miss a pure fiber interrupt between steps (see the `bringUp` + // pipe's comment for why `onError`, not `tapError`, is required). yield* Effect.gen(function* () { // 9. Bulk health check over every non-Postgres started container, at // the generic 30s service timeout. if (output.format === "text") { yield* output.raw(START_WAITING_FOR_HEALTH_CHECKS_MESSAGE, "stderr"); } - // The PostgREST/Edge Runtime readiness probes go through Kong over HTTP(S) — - // when `api.tls.enabled`, Kong's local cert is self-signed, so the root - // runtime's `HttpClient.HttpClient` (built from `FetchHttpClient.layer` over - // plain `fetch`) would fail TLS verification on every probe and the health - // check would exhaust its full timeout even though the services are - // actually healthy. Resolve the same local Kong CA `seedBucketsRun`'s - // own gateway calls already trust (`projectRef: ""` never touches the - // network — see `resolveStorageCredentials`'s local branch) and - // override just the underlying `FetchHttpClient.Fetch` primitive — NOT the - // whole `HttpClient.HttpClient` layer — so this only takes effect for a - // `FetchHttpClient`-backed client (production) and is a no-op against a - // hand-rolled `HttpClient.make(...)` mock (this file's own integration - // tests), which never reads `FetchHttpClient.Fetch` at all. + // PostgREST/Edge Runtime readiness probes go through Kong over HTTP(S); when + // `api.tls.enabled`, Kong's cert is self-signed, so the default `HttpClient` would fail + // TLS verification and the health check would time out even though the services are + // healthy. Overrides only the underlying `FetchHttpClient.Fetch` primitive, so it's a + // no-op against a mock `HttpClient` (this file's own tests). // - // Folds the hoisted, env-overridden `apiEnabled`/`apiPort`/`apiTlsEnabled`/ - // `apiTlsCertPath`/`apiTlsKeyPath`/`values.apiUrl` into `config` (not the - // raw values) so a `SUPABASE_API_ENABLED`/`SUPABASE_API_PORT`/ - // `SUPABASE_API_TLS_{ENABLED,CERT_PATH,KEY_PATH}`/`SUPABASE_API_EXTERNAL_URL` - // override that actually brought Kong up on a different port/TLS/cert/ - // external URL also reaches every local Storage-gateway caller below. - // `resolveStorageCredentials` now folds the same `SUPABASE_API_*` - // overrides itself (`resolveLocalApiConfig`, - // `storage-credentials.ts` — #6452) and re-resolves this - // pre-folded config to identical values, so the api fold here is what - // guarantees the exact resolved-URL/TLS/cert locals Kong's own spec used - // (`apiEnabled`/`apiTlsCertPath`/`apiTlsKeyPath`) are the ones handed - // on. Also folds in the - // already-resolved `values.jwtSecret`/`values.serviceRoleKey` (decrypted, - // env/dotenv-overridden, signing-keys-aware — the same values the real - // GoTrue/Storage containers were started with) instead of the raw - // `config.auth.*`. `resolveStorageCredentials`'s local branch now - // applies the same env/dotenv override + decrypt composition itself, - // but its own derivation is symmetric-only (`generateGoJwt`), so this - // fold remains load-bearing for the `signing_keys_path` case where - // `values.serviceRoleKey` is asymmetric-signed. - // Also folds in `storageFileSizeLimit`/ - // `storageVectorEnabled` so `seedBucketsRun` (which reads - // `config.storage.file_size_limit`/`config.storage.vector.enabled` to fill - // bucket defaults and gate vector-upsert seeding, `seed-buckets.ts`) - // sees the same values the real Storage container was started with, not the - // raw un-overridden config. - // Reused for both this health-check CA lookup and the two - // `seedBucketsRun` calls below (`resolvedConfig`), so bucket - // seeding never independently reloads config.toml and silently drops - // these same overrides. + // `effectiveLocalStorageConfig` folds the hoisted, env-overridden api/auth/storage values + // into `config` rather than the raw config, so every consumer below sees the exact + // resolved port/TLS/cert/secrets the real containers were started with. Reused for both + // this CA lookup and the two `seedBucketsRun` calls below, so bucket seeding never + // independently reloads config and drops these overrides. const effectiveLocalStorageConfig = { ...config, api: { @@ -2042,23 +1732,15 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { if (Result.isFailure(healthResult)) { const error = healthResult.failure; if (flags.ignoreHealthCheck && isUnhealthyStartError(error)) { - // `ignoreHealthCheck`/`isUnhealthyStartError` only gates THIS - // wait, not Postgres's own earlier one. There's additionally a - // narrower, storage-only recheck-and-seed here: when it's a fresh - // volume and Storage was among the - // started containers, wait for Storage alone to become healthy, and - // if it does, seed buckets. A seed FAILURE there REPLACES this - // original health error and hard-fails (with rollback) — - // since a plain seed error never satisfies - // `isUnhealthyStartError` and so never gets this branch's own - // downgrade-to-warning treatment. A seed SUCCESS (or a storage - // recheck that never turns healthy) changes nothing: fall through to - // the same downgrade-to-warning as every other ignored-unhealthy - // failure. + // Gates only this wait, not Postgres's own earlier one. Additionally, when it's a + // fresh volume and Storage was among the started containers, wait for Storage alone + // and seed buckets if it becomes healthy. A seed failure there replaces this health + // error and hard-fails (a plain seed error never satisfies `isUnhealthyStartError`, + // so it skips this branch's own downgrade-to-warning). A seed success, or a storage + // recheck that never turns healthy, falls through to the same downgrade-to-warning. if (isFreshVolume && storageContainerId !== undefined) { - // `images` is intentionally the whole run's registry, not scoped to - // this one-container watch list — the hint can only ever key off - // containers that actually appear in this call's own failures. + // `images` is the whole run's registry, not scoped to this one-container watch + // list — the hint can only key off containers that appear in this call's own failures. const storageHealthResult = yield* withLocalKongCa( waitForHealthyServices(spawner, [storageContainerId], { images: started, @@ -2092,24 +1774,14 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { } } - // 10. Storage-bucket seeding, gated on `isFreshVolume && - // storageContainerId !== undefined` — only when the Postgres data - // volume was freshly created this run AND Storage actually started. - // Reached only on a genuine health-check SUCCESS - // (`Result.isSuccess`): unreachable on the `--ignore-health-check` - // downgrade-to-warning fallthrough — that fallthrough still fails - // with the original unhealthy error before ever reaching it - // (mutually exclusive with the narrower storage-only recheck-and-seed - // path implemented above, inside the `Result.isFailure(healthResult)` - // branch: that branch only runs when this one's - // `Result.isSuccess(healthResult)` guard is false). + // 10. Storage-bucket seeding, gated on a freshly created volume and Storage having + // started. Reached only on a genuine health-check success — mutually exclusive with the + // narrower storage-only recheck-and-seed path above, which only runs when this guard is + // false. // - // A seeding failure propagates as a normal command failure and - // still rolls back via the same outer `Effect.onError` as - // everything else in this tail: a plain seed error (unlike the - // health-check timeout above) never satisfies - // `isUnhealthyStartError`, so it always takes that branch - // regardless of `--ignore-health-check`. + // A seeding failure rolls back via the same outer `Effect.onError`: a plain seed error + // never satisfies `isUnhealthyStartError`, so it always fails regardless of + // `--ignore-health-check`. if (Result.isSuccess(healthResult) && isFreshVolume && storageContainerId !== undefined) { yield* seedBucketsRun({ projectRef: "", @@ -2124,12 +1796,9 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { }); } - // 11. Success ONLY: fire `cli_stack_started` exactly once, no - // properties/groups — this capture sits AFTER the entire - // bulk-health-check block (including the ignore-health-check - // downgrade path above), so a genuine bulk-health-check failure - // never reaches it even when `--ignore-health-check` downgrades it - // to a warning. + // 11. Fires `cli_stack_started` exactly once, only on success. Sits after the entire + // bulk-health-check block, so a genuine failure never reaches it even when + // `--ignore-health-check` downgrades it to a warning. if (Result.isSuccess(healthResult)) { yield* analytics.capture(EventStackStarted, {}); } @@ -2140,28 +1809,19 @@ export const start = Effect.fn("start")(function* (flags: StartFlags) { ); } - // The final status render trusts "config-enabled + not --exclude'd" as - // a proxy for "actually running" — true whenever this line is reached - // normally (it would have already failed and rolled back before - // getting here if any enabled, non-excluded container failed to - // start). The one exception: an IGNORED Postgres health-check timeout - // (`bringUpResult.kind === "postgresUnhealthyIgnored"` above) reaches - // this same tail even though every OTHER enabled, non-excluded - // container was never even created — the status render shows them - // anyway, purely from config, with no Docker query to contradict it. - // Edge Runtime now genuinely starts under that same "config-enabled" - // gate (no more force-exclusion from status rendering), so the raw - // `--exclude` values are enough on their own. + // The final status render trusts "config-enabled and not --exclude'd" as a proxy for + // "actually running", since a real failure would have already rolled back before reaching + // here. The exception: an ignored Postgres health-check timeout reaches this same tail even + // though every other container was never created — the status render shows them anyway, + // purely from config, with no Docker query to contradict it. const statusExcluded = flags.exclude; if (output.format === "text") { yield* output.raw(startCompletedMessage(), "stderr"); - // Called DIRECTLY, unlike the already-running branch's `status.Run`: no - // re-health-check, no "stopped services" diffing, just the raw - // `--exclude` values against the config/values already resolved (and - // just health-checked) above. `values` is passed through as - // `precomputedLocal` so this reuses the exact keys already baked into - // the containers `bringUp` just created, instead of re-deriving them. + // Unlike the already-running branch, no re-health-check or "stopped services" diffing — + // just the raw `--exclude` values against the config already resolved above. `values` is + // passed as `precomputedLocal` so this reuses the exact keys already baked into the + // containers `bringUp` just created. const { values: statusValues, names } = yield* buildStatusValues(statusExcluded, values); yield* output.raw(renderStatusPretty(statusValues, names)); yield* output.raw(startSecurityNotice(), "stderr"); From 4bfa127722439a82a8a8df33e424f915bd353b03 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:05:07 +0100 Subject: [PATCH 36/56] chore(repo): trim comments, shard 31 (1439 -> 677 comment lines) --- .../projects/api-keys/api-keys.handler.ts | 9 +- .../projects/create/create.command.ts | 25 +- .../projects/create/create.handler.ts | 7 +- .../create/create.integration.test.ts | 26 +- .../projects/delete/delete.handler.ts | 40 +- .../delete/delete.integration.test.ts | 5 - .../commands/projects/list/list.handler.ts | 32 +- .../projects/list/list.integration.test.ts | 27 +- .../src/commands/projects/projects.errors.ts | 25 +- .../src/commands/projects/projects.format.ts | 24 +- .../src/commands/projects/projects.prompt.ts | 11 +- apps/cli/src/commands/pull/pull.aggregate.ts | 156 +++---- apps/cli/src/commands/pull/pull.command.ts | 19 +- apps/cli/src/commands/pull/pull.e2e.test.ts | 18 +- apps/cli/src/commands/pull/pull.errors.ts | 40 +- apps/cli/src/commands/pull/pull.format.ts | 75 ++-- apps/cli/src/commands/pull/pull.handler.ts | 201 +++------ .../commands/pull/pull.integration.test.ts | 405 ++++-------------- apps/cli/src/commands/pull/pull.layers.ts | 63 +-- apps/cli/src/commands/pull/pull.live.test.ts | 10 +- apps/cli/src/commands/pull/pull.steps.ts | 62 +-- apps/cli/src/commands/pull/pull.types.ts | 6 +- .../src/commands/secrets/list/list.handler.ts | 6 +- .../secrets/list/list.integration.test.ts | 6 +- .../src/commands/secrets/secrets.errors.ts | 8 - .../src/commands/secrets/secrets.format.ts | 15 +- .../src/commands/secrets/set/set.handler.ts | 224 +++------- .../secrets/set/set.integration.test.ts | 138 ++---- .../commands/secrets/unset/unset.handler.ts | 12 +- .../secrets/unset/unset.integration.test.ts | 14 +- 30 files changed, 456 insertions(+), 1253 deletions(-) diff --git a/apps/cli/src/commands/projects/api-keys/api-keys.handler.ts b/apps/cli/src/commands/projects/api-keys/api-keys.handler.ts index b100496808..2bf078e0c7 100644 --- a/apps/cli/src/commands/projects/api-keys/api-keys.handler.ts +++ b/apps/cli/src/commands/projects/api-keys/api-keys.handler.ts @@ -29,10 +29,9 @@ import type { ProjectsApiKeysFlags } from "./api-keys.command.ts"; type ApiKeys = typeof V1GetProjectApiKeysOutput.Type; /** - * Struct spec for the raw API key response. Only `-o yaml` hits the raw - * struct — `-o toml`/`-o env` encode the `SUPABASE_<NAME>_KEY` env map - * instead — and yaml.v3 renders the `nullable.Nullable[T]` fields as - * `map[bool]T`. + * Struct spec for the raw API key response, used only by `-o yaml`; `-o toml`/`-o env` encode + * the `SUPABASE_<NAME>_KEY` env map instead. Nullable fields render as a single-key map to keep + * the yaml output format stable. */ const GO_API_KEYS_LIST = goSlice( goStruct([ @@ -71,8 +70,6 @@ export const projectsApiKeys = Effect.fn("projects.api-keys")(function* ( const goFmt = Option.getOrUndefined(goOutputFlag); - // Go encodes the `SUPABASE_<NAME>_KEY` env map for both toml and env - // (`api_keys.go:34-36`). if (goFmt === "toml") { yield* output.raw(encodeToml(apiKeysToEnv(keys)) + "\n"); return; diff --git a/apps/cli/src/commands/projects/create/create.command.ts b/apps/cli/src/commands/projects/create/create.command.ts index f063b7b661..511f591635 100644 --- a/apps/cli/src/commands/projects/create/create.command.ts +++ b/apps/cli/src/commands/projects/create/create.command.ts @@ -27,9 +27,8 @@ const AWS_REGIONS = [ "us-west-2", ] as const; -// Derived from the generated Management API schema so these choices can't -// drift from `POST /v1/projects`'s `release_channel`/`postgres_engine` fields -// on regen. +// Read from the generated schema so these choices track the `release_channel`/`postgres_engine` +// fields on regen. const RELEASE_CHANNELS = V1CreateAProjectInput.fields.release_channel.schema.literals; const POSTGRES_ENGINES = V1CreateAProjectInput.fields.postgres_engine.schema.literals; @@ -75,19 +74,12 @@ const config = { Flag.withDescription("Select a desired instance size for your project."), Flag.optional, ), - // TS-only flag with no historical equivalent, even though the API field - // exists — disclosed in SIDE_EFFECTS.md, matching how `--reveal` is - // disclosed on `projects api-keys`. highAvailability: Flag.boolean("high-availability").pipe( Flag.withDescription("Enable high availability for the project."), Flag.optional, ), - // TS-only, no Go CLI equivalent: these target the `release_channel` / - // `postgres_engine` fields on `POST /v1/projects`, which the upstream OpenAPI - // spec intentionally hides (typed as `{"deprecated": true, "type": "null"}`) - // even though the Management API accepts them — restored via - // `packages/api/scripts/openapi-overrides.json`. Hidden + gated behind - // `--experimental` until PROD-548 exposes them officially (CLI-2180). + // Hidden and `--experimental`-gated: the upstream OpenAPI spec marks these fields deprecated + // even though the API accepts them, restored via `packages/api/scripts/openapi-overrides.json`. releaseChannel: Flag.choice("release-channel", RELEASE_CHANNELS).pipe( Flag.withDescription("Select a release channel for the project."), Flag.optional, @@ -124,12 +116,9 @@ export const projectsCreateCommand = Command.make("create", config).pipe( ]), Command.withHandler((flags) => projectsCreate(flags).pipe( - // `high-availability` is intentionally not in `safeFlags`: Go marks only - // `org-id` telemetry-safe (`markFlagTelemetrySafe`), and it's a boolean flag - // anyway — boolean values are always logged verbatim by the instrumentation - // regardless of `safeFlags`. See the same pattern on `projects api-keys`'s - // `--reveal`. `config` is passed so `region`/`size`/`release-channel`/ - // `postgres-engine` (all `Flag.choice`) are auto-detected as telemetry-safe. + // `high-availability` is omitted from `safeFlags` since boolean flags are always logged + // verbatim regardless of that list. `config` auto-detects `region`/`size`/`release-channel`/ + // `postgres-engine` as safe since they're `Flag.choice`. withCommandTelemetry({ flags, safeFlags: ["org-id"], config }), withJsonErrorHandling, ), diff --git a/apps/cli/src/commands/projects/create/create.handler.ts b/apps/cli/src/commands/projects/create/create.handler.ts index daf5e46245..ba98e22dfd 100644 --- a/apps/cli/src/commands/projects/create/create.handler.ts +++ b/apps/cli/src/commands/projects/create/create.handler.ts @@ -22,9 +22,8 @@ export const projectsCreate = Effect.fn("projects.create")(function* (flags: Pro let createdRef: string | undefined; yield* Effect.gen(function* () { - // Go gates interactivity on `term.IsTerminal(stdin) && interactive` - // (`projects.go:63`); `--interactive` defaults to true. We additionally - // require a text-mode `Output` so json/stream-json never prompt. + // Interactive mode requires a TTY stdin, `--interactive` (default true), and text-mode + // output, so json/stream-json never prompt. const interactive = Option.getOrElse(flags.interactive, () => true); const effectiveInteractive = interactive && tty.stdinIsTty && output.interactive; @@ -37,8 +36,6 @@ export const projectsCreate = Effect.fn("projects.create")(function* (flags: Pro const releaseChannel = Option.getOrUndefined(flags.releaseChannel); const postgresEngine = Option.getOrUndefined(flags.postgresEngine); - // Non-interactive: `--org-id`, `--db-password`, `--region` are required, - // plus exactly 1 positional arg for the project name. if (!effectiveInteractive) { const missing: Array<string> = []; if (name.length === 0) missing.push("project name"); diff --git a/apps/cli/src/commands/projects/create/create.integration.test.ts b/apps/cli/src/commands/projects/create/create.integration.test.ts index 169366846c..4503b14110 100644 --- a/apps/cli/src/commands/projects/create/create.integration.test.ts +++ b/apps/cli/src/commands/projects/create/create.integration.test.ts @@ -102,8 +102,6 @@ function postBody(api: { requests: ReadonlyArray<{ method: string; body?: unknow return api.requests.find((r) => r.method === "POST")?.body as Record<string, unknown> | undefined; } -// Mirrors init.integration.test.ts's `findFailure`: pulls the first typed -// error out of an `Exit`'s `Cause` without an `as` cast on the union member. function findFailure(exit: Exit.Exit<unknown, unknown>): Record<string, unknown> { expect(Exit.isFailure(exit)).toBe(true); if (!Exit.isFailure(exit)) { @@ -351,8 +349,6 @@ describe("projects create integration", () => { it.live("treats --interactive=false as non-interactive even on a TTY", () => { const { layer, api } = setup({ stdinIsTty: true }); return Effect.gen(function* () { - // On a TTY but with --interactive=false and a required flag missing, Go's - // PreRunE marks the flags required and never prompts. const exit = yield* Effect.exit( projectsCreate({ ...BASE_FLAGS, @@ -364,7 +360,6 @@ describe("projects create integration", () => { if (Exit.isFailure(exit)) { expect(JSON.stringify(exit.cause)).toContain("ProjectsCreateMissingArgError"); } - // No prompts and no org fetch happened. expect(api.requests.some((r) => r.method === "GET")).toBe(false); }).pipe(Effect.provide(layer)); }); @@ -378,13 +373,11 @@ describe("projects create integration", () => { }); return Effect.gen(function* () { yield* projectsCreate({ ...BASE_FLAGS }); - // org list was fetched for the interactive prompt expect(api.requests.some((r) => r.method === "GET")).toBe(true); const body = postBody(api); expect(body?.name).toBe("my-proj"); expect(body?.organization_slug).toBe("org-abc"); expect(body?.region).toBe("us-west-2"); - // blank password prompt generates a 16-char password expect(String(body?.db_pass)).toHaveLength(16); expect(out.stderrText).toContain("Selected org-id:"); expect(out.stderrText).toContain("Selected region:"); @@ -487,7 +480,6 @@ describe("projects create integration", () => { dbPassword: Option.some("s3cret-pass"), region: Option.some("us-east-1"), }); - // PascalCase field names at the top level — no table header. expect(out.stdoutText).toContain('Name = "alpha"'); }).pipe(Effect.provide(layer)); }); @@ -543,9 +535,8 @@ describe("projects create integration", () => { region: Option.some("us-east-1"), size: Option.some("micro"), }); - // Struct fields serialize in alphabetical order; the cli-e2e replay - // server byte-compares the request body. JSON.parse → stringify - // round-trips key order, so this asserts the on-the-wire order. + // JSON.stringify preserves key order, so this pins the exact wire order rather than + // just deep-equality. const body = api.requests.find((r) => r.method === "POST")?.body; expect(JSON.stringify(body)).toBe( '{"db_pass":"s3cret-pass","desired_instance_size":"micro","name":"alpha","organization_slug":"acme","region":"us-east-1"}', @@ -554,8 +545,8 @@ describe("projects create integration", () => { }); it.live("tolerates a 201 response with a placeholder/short ref (lenient parse)", () => { - // The typed client rejects refs shorter than 20 chars; `executeRaw` must - // render the placeholder verbatim (cli-e2e fixtures embed `__PROJECT_REF__`). + // The typed client validates refs to be 20+ chars; `executeRaw` bypasses that to render + // placeholders verbatim. const { layer, out } = setup({ byMethod: { POST: { status: 201, body: { ...CREATED, id: "__PROJECT_REF__", ref: "__PROJECT_REF__" } }, @@ -607,10 +598,6 @@ describe("projects create integration", () => { }).pipe(Effect.provide(layer)); }); - // The established --size enum is an 18-value list that does not include - // "nano" (or "pico") and rejects any other value at flag-parse time. TS - // previously listed "nano" as a valid choice, silently succeeding where - // it should error. it.live("rejects --size nano at flag-parse time, matching Go's 18-value enum", () => { const root = Command.make("supabase").pipe( Command.withSubcommands([projectsCreateCommand]), @@ -640,9 +627,8 @@ describe("projects create integration", () => { }); }); -// Distinguishes "the --size flag itself was rejected at parse time" from any -// other failure (e.g. a missing runtime service in this minimal test setup), -// so the regression test above can't pass for the wrong reason. +// Confirms the failure came from the `--size` flag itself, not an unrelated error (e.g. a missing +// runtime service in this minimal setup), so the regression test can't pass for the wrong reason. function rejectsInvalidSizeChoice(error: unknown): boolean { if (typeof error !== "object" || error === null || !("errors" in error)) return false; const { errors } = error; diff --git a/apps/cli/src/commands/projects/delete/delete.handler.ts b/apps/cli/src/commands/projects/delete/delete.handler.ts index e9c2910886..25151d212b 100644 --- a/apps/cli/src/commands/projects/delete/delete.handler.ts +++ b/apps/cli/src/commands/projects/delete/delete.handler.ts @@ -37,21 +37,19 @@ export const projectsDelete = Effect.fn("projects.delete")(function* (flags: Pro const cliSettings = yield* CommandSettings; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - // `--yes` OR `SUPABASE_YES` — the env var must auto-confirm too, not just - // the flag. + // `resolveYes` also honors `SUPABASE_YES`, not just the `--yes` flag. const yes = yield* resolveYes; const tty = yield* Tty; const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; - // Captured for the cache write in `Effect.ensuring` below — whatever - // `flags.ProjectRef` resolved to, which delete sets from the arg/prompt - // before deleting. + // Captured so the `Effect.ensuring` cache write below can use whichever ref resolved from + // the arg or prompt. let resolvedRef: string | undefined; yield* Effect.gen(function* () { - // Ref resolution: explicit arg, else prompt on a TTY, else fail. Delete - // never reads the linked ref file as a source. + // Unlike other commands, delete does not fall back to the linked ref file — only the arg + // or an interactive prompt resolve the ref. let ref: string; if (Option.isSome(flags.ref) && flags.ref.value.length > 0) { ref = flags.ref.value; @@ -64,19 +62,14 @@ export const projectsDelete = Effect.fn("projects.delete")(function* (flags: Pro } resolvedRef = ref; - // Validate the ref, then confirm. if (!PROJECT_REF_PATTERN.test(ref)) { return yield* new InvalidProjectRefError({ ref, message: INVALID_PROJECT_REF_MESSAGE }); } - // `aqua` mirrors lipgloss's profile detection (plain when stderr - // is not a TTY). const title = `Do you want to delete project ${aqua(ref)}? This action is irreversible.`; - // Established prompt behavior: `--yes`/`SUPABASE_YES` auto-confirms with - // the `<title> [y/N] y` stderr echo; a non-TTY stdin still prints the - // label and scans one piped line (100ms), so - // `echo y | supabase projects delete <ref>` confirms; empty/unparseable - // input falls back to the No default. + // `promptYesNo` echoes `<title> [y/N] <answer>` to stderr even on a non-TTY: it scans one + // piped line (100ms timeout) so `echo y | supabase projects delete <ref>` confirms; + // unparseable input falls back to No. const confirmed = yield* promptYesNo(output, yes, title, false); if (!confirmed) { return yield* new ProjectsDeleteCancelledError({ message: CONTEXT_CANCELED_MESSAGE }); @@ -110,21 +103,14 @@ export const projectsDelete = Effect.fn("projects.delete")(function* (flags: Pro ); yield* deleting?.clear() ?? Effect.void; - // The per-ref keyring credential delete is skipped entirely: the access - // token is only ever *stored* under the profile name, never a ref, so - // that delete would always target a non-existent entry — a functional - // no-op. The only thing it could emit is a keyring-backend *availability* - // error ("Keyring is not supported on WSL", e.g. on a headless CI runner - // with no D-Bus session), which the TS `@napi-rs/keyring` kernel keyutils - // backend never hits anyway. + // No per-ref keyring credential delete: the access token is stored under the profile + // name, not the ref, so there is nothing to remove here. - // Best-effort unlink: when the linked ref file matches the deleted ref, - // remove the `supabase/.temp` directory. + // Best-effort: removes `supabase/.temp` only when the linked ref matches the deleted + // project. const tempDir = path.join(cliSettings.workdir, "supabase", ".temp"); const refPath = path.join(tempDir, "project-ref"); - // The link file written by `supabase link` holds exactly the ref. - // Compare against the trimmed content so a corrupt/multi-ref file can't - // trigger an unintended `.temp` removal. + // Compare trimmed content so a corrupt/multi-ref file can't trigger an unintended removal. const matches = yield* fs .readFileString(refPath) .pipe(Effect.map((content) => content.trim() === ref)) diff --git a/apps/cli/src/commands/projects/delete/delete.integration.test.ts b/apps/cli/src/commands/projects/delete/delete.integration.test.ts index 3d7531ae48..2d7096271a 100644 --- a/apps/cli/src/commands/projects/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/projects/delete/delete.integration.test.ts @@ -159,7 +159,6 @@ describe("projects delete integration", () => { if (Exit.isFailure(exit)) { expect(JSON.stringify(exit.cause)).toContain("ProjectsDeleteRefRequiredError"); } - // No ref resolved → no linked-project cache write. expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); @@ -172,8 +171,6 @@ describe("projects delete integration", () => { if (Exit.isFailure(exit)) { expect(JSON.stringify(exit.cause)).toContain("ProjectsDeleteCancelledError"); } - // Established non-TTY behavior: still prints the label and echoes the - // (empty) scanned line before the No default cancels. expect(out.stderrText).toContain("Do you want to delete project "); expect(out.stderrText).toContain("? This action is irreversible. [y/N] \n"); expect(hasMethod(api, "DELETE")).toBe(false); @@ -186,7 +183,6 @@ describe("projects delete integration", () => { const { layer, out, api } = setup({ yes: false }); return Effect.gen(function* () { yield* projectsDelete({ ref: Option.some(VALID_REF) }); - // Established `--yes` branch bytes. expect(out.stderrText).toContain("Do you want to delete project "); expect(out.stderrText).toContain("? This action is irreversible. [y/N] y\n"); expect(hasMethod(api, "DELETE")).toBe(true); @@ -205,7 +201,6 @@ describe("projects delete integration", () => { const { layer, out, api } = setup({ stdinIsTty: false, stdinInput: "y\n" }); return Effect.gen(function* () { yield* projectsDelete({ ref: Option.some(VALID_REF) }); - // The piped answer is echoed to stderr, matching the non-TTY prompt. expect(out.stderrText).toContain("[y/N] y\n"); expect(hasMethod(api, "DELETE")).toBe(true); }).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/commands/projects/list/list.handler.ts b/apps/cli/src/commands/projects/list/list.handler.ts index 3260a97322..fd1611ed9d 100644 --- a/apps/cli/src/commands/projects/list/list.handler.ts +++ b/apps/cli/src/commands/projects/list/list.handler.ts @@ -70,18 +70,16 @@ export const projectsList = Effect.fn("projects.list")(function* (_flags: Projec const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - // The linked ref is loaded purely as a marker — a not-linked error is - // ignored, no prompt fires. `resolveOptional` never fails or prompts. + // Loaded purely as a marker for the "linked" column; `resolveOptional` never fails or prompts. const linkedRef = yield* resolver.resolveOptional(Option.none()); yield* Effect.gen(function* () { const fetching = output.format === "text" ? yield* output.task("Fetching projects...") : undefined; - // `executeRaw` returns the undecoded response: the generated - // `V1ProjectWithDatabaseResponse.ref` schema enforces `isMinLength(20)` + - // `^[a-z]+$`, which the cli-e2e replay fixtures (literal `__PROJECT_REF__`) - // cannot satisfy. Auth / URL / headers are still handled by the API client. + // `executeRaw` skips response decoding: the generated `ref` schema requires 20+ lowercase + // letters, which placeholder refs in test fixtures don't satisfy. Auth, URL, and headers + // still go through the API client. const response = yield* api.executeRaw(operationDefinitions.v1ListAllProjects, {}).pipe( Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.mapError( @@ -122,22 +120,14 @@ export const projectsList = Effect.fn("projects.list")(function* (_flags: Projec } yield* fetching?.clear() ?? Effect.void; - // Established behavior: prints the not-linked message to stderr when no - // ref resolves, then renders the table anyway. "supabase link" is - // colored via `Aqua` — plain on a non-TTY — and uses no backticks, - // unlike the resolver's hard-fail message. + // Prints the not-linked message to stderr but still renders the table below. if (Option.isNone(linkedRef)) { yield* output.raw("Cannot find project ref. Have you run supabase link?\n", "stderr"); } - // CLI-2167 follow-up: after `link <branch>`, `linkedRef` is the BRANCH's - // own ref, which never matches a row here (this endpoint only returns - // real projects), so the "you are here" marker silently vanished. An - // exact match always wins outright; only when it misses do we fall back - // to the PARENT chain (env → `linked-project.json` → `project-ref` file) - // and mark that ref's row instead. `linkedRef` itself (used below for the - // stderr message and the linked-project-cache write) is untouched — only - // the marker comparison changes. TS-only QoL, no Go counterpart. + // `markerRef` decides which row shows as linked, since a linked branch's own ref never + // matches a project row here. An exact match on `linkedRef` wins outright; only when it + // misses do we fall back to the parent chain (env → linked-project.json → project-ref file). let markerRef = linkedRef; if (Option.isSome(linkedRef)) { const hasExactMatch = parsed.some( @@ -170,8 +160,8 @@ export const projectsList = Effect.fn("projects.list")(function* (_flags: Projec return; } if (goFmt === "toml") { - // The list is built with `append`, so an empty list stays a nil slice - // and BurntSushi emits nothing for the wrapper. + // Passing `undefined` (not an empty array) omits the wrapper entirely when there are no + // projects. yield* output.raw( encodeGoToml( { projects: projects.length > 0 ? projects : undefined }, @@ -181,8 +171,6 @@ export const projectsList = Effect.fn("projects.list")(function* (_flags: Projec return; } - // goFmt is undefined or "pretty" — defer to TS --output-format for - // JSON/stream-json, otherwise render the Glamour-styled table. if (output.format === "json" || output.format === "stream-json") { yield* output.success("", { projects }); return; diff --git a/apps/cli/src/commands/projects/list/list.integration.test.ts b/apps/cli/src/commands/projects/list/list.integration.test.ts index e9edd6ff77..e0f76fcb22 100644 --- a/apps/cli/src/commands/projects/list/list.integration.test.ts +++ b/apps/cli/src/commands/projects/list/list.integration.test.ts @@ -44,8 +44,7 @@ const OTHER_PROJECT: Projects[number] = { region: "eu-west-1", }; -// A project whose `id` is the parent-fallback ref used below (CLI-2167 -// follow-up) — distinct from `SAMPLE_PROJECT`/`OTHER_PROJECT`. +// Distinct fixture for the parent-fallback marker tests below. const PARENT_PROJECT: Projects[number] = { ...SAMPLE_PROJECT, id: "parentprojectrefxxxx", @@ -56,8 +55,7 @@ const PARENT_PROJECT: Projects[number] = { const tempRoot = useTempWorkdir("supabase-projects-list-int-"); -// Distinct 20-lowercase-letter refs for the parent-fallback marker tests -// below (CLI-2167 follow-up). +// Distinct 20-lowercase-letter refs used by the parent-fallback marker tests below. const BRANCH_OWN_REF = "branchownrefyyyyyyyy"; const OTHER_CACHE_REF = "othercacherefzzzzzzz"; @@ -95,9 +93,8 @@ interface SetupOpts { readonly network?: "fail"; // When `false`, the linked project ref is unset so no bullet renders. readonly linked?: boolean; - // Explicit override — takes precedence over `linked` when provided, for - // tests that need to seed `SUPABASE_PROJECT_ID` to something other than - // the `linked: true` default (CLI-2167 follow-up parent-fallback tests). + // Explicit override — takes precedence over `linked` when provided, for tests that seed a + // project ref other than the `linked: true` default. readonly projectId?: Option.Option<string>; } @@ -236,8 +233,7 @@ describe("projects list integration", () => { projectId: Option.none(), response: [SAMPLE_PROJECT, PARENT_PROJECT], }); - // Directly linked to SAMPLE_PROJECT (a real row) — the cache pointing - // elsewhere must be irrelevant since the exact match short-circuits. + // Cache points elsewhere; the exact match on SAMPLE_PROJECT must still win outright. writeProjectRefFile(workdir, SAMPLE_PROJECT.id); writeLinkedProjectCacheFile(workdir, OTHER_CACHE_REF); return Effect.gen(function* () { @@ -251,10 +247,9 @@ describe("projects list integration", () => { "no marker when the linked ref matches no row and the parent chain yields nothing usable", () => { const { layer, out } = setup({ - // Present but not ref-shaped: `resolveOptional` returns it unvalidated - // (so `linkedRef` is Some, matching no row), while the parent chain's - // only candidate is this same invalid value — kind "invalid", not - // "resolved" — so the fallback also yields nothing. + // `resolveOptional` returns this unvalidated ref as Some (matching no row); the + // parent chain's only candidate is the same invalid value, so the fallback also + // yields nothing. projectId: Option.some("not-a-valid-ref"), response: [SAMPLE_PROJECT, PARENT_PROJECT], }); @@ -308,8 +303,6 @@ describe("projects list integration", () => { return Effect.gen(function* () { yield* projectsList({}); expect(out.stdoutText).toContain("[[projects]]"); - // PascalCase field names, embedded fields first, `Linked` last, and - // the Database sub-table after the primitives. expect(out.stdoutText).toContain(' Name = "alpha"'); expect(out.stdoutText).toContain(" Linked = true"); expect(out.stdoutText).toContain(" [projects.Database]"); @@ -365,8 +358,8 @@ describe("projects list integration", () => { }); it.live("tolerates placeholder/short refs in the response (lenient parse)", () => { - // The typed client rejects refs shorter than 20 chars; the raw-HTTP path - // must render them verbatim (cli-e2e fixtures embed `__PROJECT_REF__`). + // The typed client rejects refs under 20 chars; the raw-HTTP path renders them verbatim + // so placeholder fixtures still work. const placeholder = { ...SAMPLE_PROJECT, id: "__PROJECT_REF__", ref: "__PROJECT_REF__" }; const { layer, out } = setup({ response: [placeholder as unknown as Projects[number]] }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/projects/projects.errors.ts b/apps/cli/src/commands/projects/projects.errors.ts index e2b39c40c4..9f103a70ee 100644 --- a/apps/cli/src/commands/projects/projects.errors.ts +++ b/apps/cli/src/commands/projects/projects.errors.ts @@ -6,11 +6,6 @@ import { statusCodeActionability, } from "../../shared/telemetry/error-actionability.ts"; -// --------------------------------------------------------------------------- -// HTTP-bound errors — one (Network + UnexpectedStatus) pair per error site. -// Templates match the established `errors.Errorf(...)` phrasing byte-for-byte. -// --------------------------------------------------------------------------- - export class ProjectsListNetworkError extends Data.TaggedError("ProjectsListNetworkError")<{ readonly message: string; }> { @@ -60,7 +55,7 @@ export class ProjectsCreateUnexpectedStatusError extends Data.TaggedError( } } -// Interactive org list fetched by `create` when `--org-id` is omitted. +/** Interactive org list fetched by `create` when `--org-id` is omitted. */ export class ProjectsOrgsListNetworkError extends Data.TaggedError("ProjectsOrgsListNetworkError")<{ readonly message: string; readonly decode?: boolean; @@ -107,7 +102,7 @@ export class ProjectsDeleteUnexpectedStatusError extends Data.TaggedError( } } -// "Project does not exist:<ref>" (404 branch of the delete flow). +/** 404 branch of the delete flow. */ export class ProjectsDeleteNotFoundError extends Data.TaggedError("ProjectsDeleteNotFoundError")<{ readonly message: string; }> { @@ -139,11 +134,7 @@ export class ProjectsApiKeysUnexpectedStatusError extends Data.TaggedError( } } -// --------------------------------------------------------------------------- -// Pure-path errors (validation, prompt-time semantics, user cancellation). -// --------------------------------------------------------------------------- - -// `list` rejects `--output env` (`utils.ErrEnvNotSupported`). +/** `list` rejects `--output env`. */ export class ProjectsEnvNotSupportedError extends Data.TaggedError("ProjectsEnvNotSupportedError")<{ readonly message: string; }> { @@ -152,8 +143,7 @@ export class ProjectsEnvNotSupportedError extends Data.TaggedError("ProjectsEnvN } } -// Non-interactive `create` missing required params — `--org-id`, -// `--db-password`, `--region` are required, plus exactly 1 positional arg. +/** Non-interactive `create` missing a required param: `--org-id`, `--db-password`, `--region`, or the name argument. */ export class ProjectsCreateMissingArgError extends Data.TaggedError( "ProjectsCreateMissingArgError", )<{ @@ -164,7 +154,7 @@ export class ProjectsCreateMissingArgError extends Data.TaggedError( } } -// Interactive `create` name prompt returned blank. +/** Interactive `create` name prompt returned blank. */ export class ProjectsCreateNameEmptyError extends Data.TaggedError("ProjectsCreateNameEmptyError")<{ readonly message: string; }> { @@ -173,8 +163,7 @@ export class ProjectsCreateNameEmptyError extends Data.TaggedError("ProjectsCrea } } -// `delete` non-interactive with no positional ref — exactly 1 positional -// arg is required on a non-TTY. +/** `delete` non-interactive with no positional ref given on a non-TTY. */ export class ProjectsDeleteRefRequiredError extends Data.TaggedError( "ProjectsDeleteRefRequiredError", )<{ @@ -185,7 +174,7 @@ export class ProjectsDeleteRefRequiredError extends Data.TaggedError( } } -// User declined the delete confirmation prompt (`errors.New(context.Canceled)`). +/** User declined the delete confirmation prompt. */ export class ProjectsDeleteCancelledError extends Data.TaggedError("ProjectsDeleteCancelledError")<{ readonly message: string; }> { diff --git a/apps/cli/src/commands/projects/projects.format.ts b/apps/cli/src/commands/projects/projects.format.ts index db0f479f43..c676027cd5 100644 --- a/apps/cli/src/commands/projects/projects.format.ts +++ b/apps/cli/src/commands/projects/projects.format.ts @@ -4,21 +4,12 @@ import { renderGlamourTable } from "../../output/glamour-table.ts"; import { apiKeyValue } from "../../command-internal/api-keys.format.ts"; import { formatTimestamp } from "../../command-internal/timestamp.format.ts"; -// --------------------------------------------------------------------------- -// Pure formatters — no Effect / no service dependencies, kept unit-testable. -// Match the established byte output for `projects list`, `projects create`, -// `projects api-keys`. -// --------------------------------------------------------------------------- - type ApiKey = typeof ApiKeyResponse.Type; /** - * Lenient project record. `projects list` / `create` parse the `/v1/projects` - * response via the raw HTTP client because the typed client's `ref: - * isMinLength(20)` + `^[a-z]+$` schema rejects the cli-e2e `__PROJECT_REF__` - * placeholder fixtures (the same reason `suggestUpgrade` and the - * linked-project cache bypass the typed client). Projects therefore flow - * through as plain JSON objects. + * Lenient project record. `projects list`/`create` parse the `/v1/projects` response via the raw + * HTTP client because the typed schema's `ref` pattern (20+ lowercase letters) rejects placeholder + * refs in test fixtures, so projects flow through as plain JSON objects. */ export type LinkedProject = Readonly<Record<string, unknown>> & { readonly linked: boolean }; @@ -70,13 +61,8 @@ export function dashboardUrlForProfile(profile: string): string { return DASHBOARD_URLS[profile] ?? DASHBOARD_URLS.supabase!; } -// --------------------------------------------------------------------------- -// Tables. `renderGlamourTable` lays out cells directly, so literal `|` in a -// project name flows through unescaped and matches the established glamour -// byte output (the markdown `\|` escape is decoded back to `|` by glamour -// upstream). -// --------------------------------------------------------------------------- - +// `renderGlamourTable` lays out cells directly, so a literal `|` in a project name passes +// through unescaped. const LIST_HEADERS = [ "LINKED", "ORG ID", diff --git a/apps/cli/src/commands/projects/projects.prompt.ts b/apps/cli/src/commands/projects/projects.prompt.ts index ba581adfb0..d792d12a77 100644 --- a/apps/cli/src/commands/projects/projects.prompt.ts +++ b/apps/cli/src/commands/projects/projects.prompt.ts @@ -19,8 +19,7 @@ const mapOrgsListError = mapHttpError({ statusMessage: (status, body) => `Unexpected error retrieving organizations: ${body} (${status})`, }); -// Region codes offered in the interactive prompt, in the established order, -// which also matches the `--region` enum. +// Order matches the `--region` enum choices. const REGION_CODES = [ "ap-east-1", "ap-northeast-1", @@ -82,8 +81,6 @@ export const promptOrgId = Effect.fnUntraced(function* () { */ export const promptProjectRegion = Effect.fnUntraced(function* () { const output = yield* Output; - // Established prompt layout: the region code renders as the primary label - // and the friendly name as the description. const options = REGION_CODES.map((code) => ({ value: code, label: code, @@ -93,15 +90,13 @@ export const promptProjectRegion = Effect.fnUntraced(function* () { "Which region do you want to host the project in?", options, ); - // Narrow the `string` choice back to a region literal so it satisfies the - // typed create-project input. The chosen value always comes from the options, - // so the fallback is never reached in practice. + // Narrows the `string` selection back to a region literal for the typed input; the fallback + // is unreachable since the choice always comes from `REGION_CODES`. const matched = REGION_CODES.find((code) => code === chosen); return matched ?? "us-east-1"; }); const PASSWORD_LENGTH = 16; -// Established charset: lower + upper + digits (62 chars), no separators. const PASSWORD_CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; /** diff --git a/apps/cli/src/commands/pull/pull.aggregate.ts b/apps/cli/src/commands/pull/pull.aggregate.ts index ee445434ea..4dfa8f11ba 100644 --- a/apps/cli/src/commands/pull/pull.aggregate.ts +++ b/apps/cli/src/commands/pull/pull.aggregate.ts @@ -10,19 +10,13 @@ import type { } from "./pull.types.ts"; /** - * Pure result-shaping helpers for `supabase pull` — no Effect, no services. - * Each `pull<Step>StepResult` maps one sub-step's own already-frozen - * outcome type into the shared `PullStepResult` shape `pull.handler.ts` - * aggregates across all four steps (`pull.types.ts`). Status derivation rules - * are documented per function below. + * Pure result-shaping helpers for `supabase pull`. Each `pull<Step>StepResult` maps one + * sub-step's outcome into the shared `PullStepResult` shape that `pull.handler.ts` aggregates + * across all four steps. */ -/** - * Strips a leading `workdir` prefix from an absolute path — the same - * prefix-strip `workdir-project.ts`'s `relativeConfigPath` - * implements, duplicated here (rather than imported) so this module stays - * free of that file's own Effect-importing neighbors. - */ +// Duplicated from `workdir-project.ts`'s `relativeConfigPath` so this module stays +// Effect-import-free. function pullRelativeToWorkdir(workdir: string, path: string): string { return path.startsWith(workdir) ? path.slice(workdir.length).replace(/^[/\\]/, "") : path; } @@ -39,13 +33,9 @@ export interface PullConfigStepOutcome { } /** - * `config` step: `unchanged` when the plan had no work at all; `planned` when - * there was work but it was never applied (`--dry-run`, OR the aggregated - * confirmation was declined — the two read identically from this step's own - * perspective, since neither one ever reaches the write); `changed` once the - * plan actually ran (confirmed, not a dry run). `detail` is always the config - * step's own JSON payload verbatim, since `config pull` computes a real diff - * report even for a dry-run or declined outcome. + * `config` step: `unchanged` with no work at all, `planned` when work exists but was skipped + * (dry run or a declined confirmation), and `changed` once the plan applied. `detail` is always + * the payload passed in, since `config pull` computes a diff report even when nothing applied. */ export function pullConfigStepResult( outcome: PullConfigStepOutcome, @@ -64,19 +54,17 @@ export function pullConfigStepResult( export type PullMigrationHistoryStepOutcome = | { /** - * Never attempted this run — `supabase/migrations` already had files and - * `--with-migration-history` was not passed (`"not_needed"`), or the - * aggregated confirmation was declined while this step would otherwise - * have run (`"declined"`). + * Never attempted: `"not_needed"` when migrations already existed and + * `--with-migration-history` wasn't passed, or `"declined"` when the aggregated + * confirmation was declined before this step could run. */ readonly kind: "skipped"; readonly reason: "not_needed" | "declined"; } | { /** - * Would have run (bootstrap or `--with-migration-history`), but - * `--dry-run` suppressed it — migration fetch has no real preview - * machinery. + * Would have run (bootstrap or `--with-migration-history`), but `--dry-run` suppressed + * it — migration fetch has no real preview machinery. */ readonly kind: "planned"; } @@ -86,12 +74,8 @@ export type PullMigrationHistoryStepOutcome = readonly workdir: string; }; -/** - * `migration_history` step: `skipped` when never attempted (either it wasn't - * needed, or the aggregated confirmation was declined before it could run); - * `planned` for a dry run that would have fetched; otherwise `changed`/ - * `unchanged` based on whether the fetch actually wrote any files. - */ +/** Maps `PullMigrationHistoryStepOutcome` to a `PullStepResult`; `changed`/`unchanged` depends on + * whether the fetch wrote files. */ export function pullMigrationHistoryStepResult( outcome: PullMigrationHistoryStepOutcome, ): PullStepResult { @@ -122,11 +106,9 @@ export type PullDbStepOutcome = | { readonly kind: "applied"; readonly outcome: DbPullOutcome; readonly workdir: string }; /** - * `db` step: `planned` for a dry run (`db pull` has no real preview - * machinery, same rationale as migration history above); `unchanged` for `db - * pull`'s own "already in sync" finding (`DbPullInSyncError`, caught by - * the handler and reported here — a finding, not a failure, at the `pull` - * level per ADR 0024); `changed` once a schema was actually written. + * `db` step: `planned` for a dry run; `unchanged` for `db pull`'s "already in sync" finding + * (a finding, not a failure, at the `pull` level — see ADR 0024); `changed` once a schema was + * written. */ export function pullDbStepResult(outcome: PullDbStepOutcome): PullStepResult { if (outcome.kind === "planned") { @@ -161,11 +143,9 @@ export type PullFunctionsStepOutcome = | { readonly kind: "downloaded"; readonly result: DownloadFunctionsResult }; /** - * `functions` step: `planned` for a dry run (no preview machinery); - * `unchanged` when the project has no functions at all; `changed` once at - * least one slug downloaded. `written` lists each downloaded slug's function - * directory — `DownloadFunctionsResult` doesn't enumerate individual files, - * so the directory is the most useful representative path per slug. + * `functions` step: `planned` for a dry run, `unchanged` with no functions, `changed` once at + * least one slug downloaded. `written` lists each slug's directory since + * `DownloadFunctionsResult` doesn't enumerate individual files. */ export function pullFunctionsStepResult(outcome: PullFunctionsStepOutcome): PullStepResult { if (outcome.kind === "planned") { @@ -206,11 +186,9 @@ function hasStringTag(value: unknown): value is { readonly _tag: string } { } /** - * Duck-types a caught failure value carrying `writtenSoFar` — a write-loop error (e.g. - * `MigrationFetchWriteError`) that had already written some files before a LATER item in - * the same loop failed (a tampered/malformed remote row, a mid-loop write failure, ...). - * Absent for every other failure, including a write-loop error whose very first item - * failed (nothing written yet). + * True when a write-loop failure (e.g. `MigrationFetchWriteError`) already wrote some files + * before a later item failed. Absent for every other failure, including one whose first item + * failed with nothing written yet. */ function hasWrittenSoFar( value: unknown, @@ -224,11 +202,8 @@ function hasWrittenSoFar( } /** - * Duck-types a caught failure value (a plain `Error`, a tagged domain error, - * or anything else `pull.handler.ts` extracts from a step's `Exit`) into a - * message string — kept structural, rather than importing Effect's `Cause` - * type, so this file stays free of Effect imports. The handler owns deciding - * exactly what value reaches here. + * Extracts a message string from an arbitrary caught failure value, kept structural rather than + * importing Effect's `Cause` type so this file stays Effect-import-free. */ function pullFailureMessage(cause: unknown): string { if (hasStringMessage(cause) && cause.message.length > 0) { @@ -244,42 +219,33 @@ function pullFailureSuggestion(cause: unknown): string | undefined { return hasStringSuggestion(cause) && cause.suggestion.length > 0 ? cause.suggestion : undefined; } -/** The squashed cause's own `_tag`, when it has one — a machine consumer's only way to classify - * a non-first (never re-failed) step failure without parsing `message`. */ +/** The cause's own `_tag`, when present — lets a machine consumer classify a step failure + * without parsing `message`. */ function pullFailureCode(cause: unknown): string | undefined { return hasStringTag(cause) && cause._tag.length > 0 ? cause._tag : undefined; } /** - * POSIX single-quote escaping for a value inserted into a suggested shell command — - * mirrors `commands/db/shared/pgdelta-next-diagnostics.ts`'s `shellQuote`. Kept as its - * own tiny copy rather than a shared import: this file is deliberately Effect-import-free - * (see `pull.handler.ts`'s own note on why `--remote-label` needs quoting — `config - * pull`'s own `--remote-label` accepts labels requiring TOML quoting, including - * whitespace, so an unquoted value here could render an invalid or dangerous - * copy-pasteable command). + * POSIX single-quote escaping for a value inserted into a suggested shell command. + * `--remote-label` can contain whitespace, so leaving it unquoted could render an invalid or + * dangerous copy-pasteable command. */ function pullShellQuote(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } /** - * Strips control characters (CR/LF/tab) from a value before it is inlined into a - * suggested shell command — the same CWE-117 concern `pull.format.ts`'s - * `pullSanitizeRowText` guards against for rendered summary text. Kept as its own copy - * here (rather than importing that sibling helper) since `pull.format.ts` already - * imports FROM this module (`pullCounts`) and this file is deliberately - * Effect-import-free. + * Strips control characters (CR/LF/tab) from a value before it's inlined into a suggested shell + * command, guarding the same CWE-117 concern as `pull.format.ts`'s `pullSanitizeRowText`. Kept + * as its own copy since `pull.format.ts` imports from this module, so importing back would cycle. */ function pullSanitizeCommandToken(value: string): string { return value.replace(/[\r\n\t]+/g, " "); } /** - * The ` --remote-label <value>` suffix for a suggested command — sanitized and - * shell-quoted, empty when no label was passed. Shared by `pullRetryHint`'s config-step - * command and `pullWithMigrationHistoryCommand` below, so every suggested command that - * carries a user-supplied `--remote-label` renders it identically. + * The ` --remote-label <value>` suffix for a suggested command — sanitized, shell-quoted, and + * empty when no label was passed. Shared so every suggested command renders it identically. */ function pullRemoteLabelFlag(remoteLabel: string | undefined): string { return remoteLabel === undefined @@ -288,16 +254,9 @@ function pullRemoteLabelFlag(remoteLabel: string | undefined): string { } /** - * The exact standalone command to retry ONE failed step on its own — - * `pull.handler.ts` appends this line to a failed step's own - * `failure.suggestion` (Phase 3), on top of whatever the step's own - * error/suggestion already says, so a user watching `pull` fail doesn't have - * to guess which of its four sub-commands to rerun, or redo every step that - * already succeeded by rerunning the whole orchestrator. Takes the - * already-RESOLVED `ref` — never a branch name someone typed for - * `--project-ref`, since not every sub-command necessarily resolves branch - * names the same way `pull` does, while a resolved ref is always a valid - * `--project-ref` value everywhere. + * The standalone command to retry one failed step on its own, appended to that step's own + * `failure.suggestion`. Always uses the already-resolved `ref`, never a branch name, since not + * every sub-command resolves branch names the same way `pull` does. */ export function pullRetryHint( step: PullStepId, @@ -308,15 +267,9 @@ export function pullRetryHint( const commandByStep: Record<PullStepId, string> = { config: `supabase config pull --project-ref ${ref}${remoteLabelFlag}`, migration_history: `supabase migration fetch --project-ref ${ref}`, - // `pull.steps.ts`'s own `pullDbStep` passes `forceMigrationMode: true` to - // `runDbPull`, overriding any ambient `--experimental`/`SUPABASE_EXPERIMENTAL` - // gate so the orchestrated invocation never takes the deprecated declarative - // export path. An explicit `--experimental=false` reproduces that same override - // on the standalone retry — `resolveExperimentalWithProjectEnv` - // (`command-internal/global-flags.ts`) gives an explicit flag value precedence - // over both the flag's own parsed value and the `SUPABASE_EXPERIMENTAL`/project - // `.env` fallback — so this suggested command performs the SAME operation the - // failed step did, not a different one. + // `pullDbStep` runs with `forceMigrationMode: true`, overriding any ambient `--experimental` + // gate; `--experimental=false` reproduces that override so this suggested command performs + // the same operation the failed step did. db: `supabase db pull --project-ref ${ref} --experimental=false`, functions: `supabase functions download --project-ref ${ref}`, }; @@ -324,14 +277,9 @@ export function pullRetryHint( } /** - * The exact `supabase pull --with-migration-history ...` command the db step's own - * migration-conflict remedy (`pull.handler.ts`'s `pullDbStepFailureResult`) suggests - * rerunning — named with the SAME resolved `ref`/`remoteLabel` `pull` itself targeted, - * so blindly rerunning the bare `--with-migration-history` command (with no target) can - * never silently retarget a different project than the one this run actually resolved - * (a branch, an explicit different ref, ...). Shares `pullRemoteLabelFlag` with - * `pullRetryHint` above so the two suggested commands render `--remote-label` - * identically. + * The `supabase pull --with-migration-history ...` command suggested by the db step's + * migration-conflict remedy, using the same resolved `ref`/`remoteLabel` this run targeted so a + * bare rerun can't silently retarget a different project. */ export function pullWithMigrationHistoryCommand( ref: string, @@ -341,17 +289,9 @@ export function pullWithMigrationHistoryCommand( } /** - * Builds a `status: "failed"` result for `step` from an arbitrary caught value. - * `written` is populated from the cause's own `writtenSoFar` when it carries one (a - * write-loop error whose earlier items had already written before a later one failed) — - * relativized against `workdir` exactly like every other step's own `written` array, - * when the caller has one. Three of the four steps' failure causes can carry one today: - * `migration_history` (`MigrationFetchWriteError`), `db` (`DbPullWriteError`, populated - * when the migration file write succeeds but the subsequent remote-history update - * fails), and `functions` (the per-slug download loop's `attachDownloadWrittenSoFar`, - * `shared/functions/download.ts`, once at least one earlier slug has already - * downloaded) — `pull.handler.ts` passes `workdir` at every one of those three call - * sites. Falls back to `[]` when the cause carries no such information. + * Builds a `status: "failed"` result for `step` from an arbitrary caught value. `written` comes + * from the cause's own `writtenSoFar` when present (relativized against `workdir`), covering a + * write-loop error whose earlier items already wrote before a later one failed. */ export function pullFailedStepResult( step: PullStepId, diff --git a/apps/cli/src/commands/pull/pull.command.ts b/apps/cli/src/commands/pull/pull.command.ts index df06adb4b5..829bcad068 100644 --- a/apps/cli/src/commands/pull/pull.command.ts +++ b/apps/cli/src/commands/pull/pull.command.ts @@ -10,9 +10,7 @@ import { pull } from "./pull.handler.ts"; import { pullRuntimeLayer } from "./pull.layers.ts"; const config = { - // `config pull`'s settled vocabulary (CLI-2167): one flag that accepts - // either a project ref or a branch of the linked project — no separate - // `--target`. + // Accepts either a project ref or a branch name of the linked project — no separate `--target`. projectRef: Flag.string("project-ref").pipe( Flag.withDescription( "Project ref of the Supabase project, or the name (or UUID) of one of its branches. Values that are exactly 20 lowercase letters are always treated as project refs.", @@ -49,22 +47,17 @@ export type PullFlags = CliCommand.Command.Config.Infer<typeof config>; // uses below, instead of re-implementing the `safeFlags`/telemetry wrapper inline. export const pullHandler = (flags: PullFlags) => pull(flags).pipe( - // `--project-ref` accepts branch names here (CLI-2167 vocabulary), so its - // value is only safe to log verbatim when it is actually ref-shaped — a - // user-created branch name must never reach PostHog. Same guard as - // `link`/`config diff`/`config pull`. `--remote-label` is a free-form, - // user-chosen string and is NEVER safe to log verbatim (mirrors - // `config pull`). + // `--project-ref` accepts branch names, so its value is only safe to log verbatim when it's + // actually ref-shaped — a user-created branch name must never reach PostHog. + // `--remote-label` is a free-form, user-chosen string and is never safe to log verbatim. withCommandTelemetry({ flags, safeFlags: Option.isSome(flags.projectRef) && PROJECT_REF_PATTERN.test(flags.projectRef.value) ? ["project-ref"] : [], - // Net-new TS command, no Go parity contract (CLI-2156): the handler - // itself rejects every `-o/--output` value with a message pointing at - // `--output-format`, so the full global choice set — single-sourced - // from the flag's own definition — is declared "allowed" here. + // The handler rejects every `-o`/`--output` value with a message pointing at + // `--output-format`, so the full global choice set is declared "allowed" here. outputFormats: GLOBAL_OUTPUT_FORMATS, }), withJsonErrorHandling, diff --git a/apps/cli/src/commands/pull/pull.e2e.test.ts b/apps/cli/src/commands/pull/pull.e2e.test.ts index ddb77bf917..608e2021eb 100644 --- a/apps/cli/src/commands/pull/pull.e2e.test.ts +++ b/apps/cli/src/commands/pull/pull.e2e.test.ts @@ -5,18 +5,14 @@ import { describe, expect, test } from "vitest"; import { runSupabase, stripAnsi } from "../../../tests/helpers/cli.ts"; -// A fake-but-well-formed token (bypasses the auth-layer's own eager -// `SUPABASE_ACCESS_TOKEN` check, same precedent as `config pull`'s/`config -// push`'s e2e tests) so the run reaches this command's own handler instead of -// failing generically on "Access token not provided" first. +// A well-formed token bypasses the auth layer's eager `SUPABASE_ACCESS_TOKEN` check, so the run +// reaches this command's own handler instead of failing on "Access token not provided" first. const TEST_TOKEN = "sbp_" + "a".repeat(40); describe("pull CLI surface", () => { test("plain `supabase pull` parses its flags and fails on target resolution, not argument parsing", async () => { - // A loadable `supabase/config.toml` with no `project_id` env/ref-file - // state is required to get PAST config loading (`openConfigPullSource` - // runs before target resolution) and into `resolveConfigTarget` - // itself, which is what this test actually exercises. + // A loadable config.toml with no project_id/ref-file state is required to get past config + // loading and into `resolveConfigTarget`, which this test exercises. const cwd = await mkdtemp(join(tmpdir(), "supabase-pull-e2e-")); try { await mkdir(join(cwd, "supabase"), { recursive: true }); @@ -29,9 +25,6 @@ describe("pull CLI surface", () => { const cleanStderr = stripAnsi(stderr); expect(cleanStderr).not.toContain("required flag"); expect(cleanStderr).not.toContain("Unrecognized flag"); - // Positive anchor: an unlinked hermetic workdir surfaces this exact - // target-resolution error — a real domain failure reached only once - // flag parsing and config loading have both already succeeded. expect(cleanStderr).toContain("Cannot find project ref. Have you run supabase link?"); expect(exitCode).toBe(1); } finally { @@ -47,9 +40,6 @@ describe("pull CLI surface", () => { env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN }, }); const cleanStderr = stripAnsi(stderr); - // Confirms the real argument parser accepts `-o json` (a valid choice - // in the shared global `--output` enum) and hands it to pull's own - // handler-level rejection, rather than failing at the parser boundary. expect(cleanStderr).not.toContain("Unrecognized flag"); expect(cleanStderr).not.toContain("invalid choice"); expect(cleanStderr).toContain( diff --git a/apps/cli/src/commands/pull/pull.errors.ts b/apps/cli/src/commands/pull/pull.errors.ts index aecd2ae24c..15fc2a6e61 100644 --- a/apps/cli/src/commands/pull/pull.errors.ts +++ b/apps/cli/src/commands/pull/pull.errors.ts @@ -20,13 +20,9 @@ interface PullStatusErrorArgs { } /** - * `--project-ref` target-resolution errors, minted the same way - * `config diff`/`config pull` mint theirs (`mintConfigTargetErrors`). - * The tags are template-interpolated inside that shared helper, so - * `error-actionability-coverage.unit.test.ts`'s static scan cannot see them - * there — each minted class is re-exported here under its own top-level - * export statement, under a `Pull`-prefixed name, so the scan and its - * runtime classification check both register it. + * `--project-ref` target-resolution errors, minted via `mintConfigTargetErrors`. Its tags are + * template-interpolated, so `error-actionability-coverage.unit.test.ts`'s static scan can't see + * them there — each class is re-exported below under its own top-level export so the scan finds it. */ const pullTargetErrors = mintConfigTargetErrors("Pull"); @@ -52,11 +48,8 @@ export type PullParentRefInvalidError = InstanceType<typeof PullParentRefInvalid export const PullBranchNotReadyError = pullTargetErrors.BranchNotReadyError; export type PullBranchNotReadyError = InstanceType<typeof PullBranchNotReadyError>; -/** - * A transport/decode failure resolving a branch-shaped `--project-ref` - * (`GET`-by-UUID or `FIND`-by-name), mirroring `config diff`/`config pull`'s - * own `*BranchResolveNetworkError`. - */ +/** A transport/decode failure resolving a branch-shaped `--project-ref` (`GET`-by-UUID or + * `FIND`-by-name). */ export class PullBranchResolveNetworkError extends Data.TaggedError( "PullBranchResolveNetworkError", )<PullNetworkErrorArgs> { @@ -67,10 +60,7 @@ export class PullBranchResolveNetworkError extends Data.TaggedError( } } -/** - * An unexpected HTTP status resolving a branch-shaped `--project-ref`, - * mirroring `config diff`/`config pull`'s own `*BranchResolveStatusError`. - */ +/** An unexpected HTTP status resolving a branch-shaped `--project-ref`. */ export class PullBranchResolveStatusError extends Data.TaggedError( "PullBranchResolveStatusError", )<PullStatusErrorArgs> { @@ -79,11 +69,8 @@ export class PullBranchResolveStatusError extends Data.TaggedError( } } -/** - * The Go-compat global `-o/--output` flag was passed. `pull` is a net-new TS - * command with no Go parity contract, so machine output goes through - * `--output-format` only (mirrors `config diff`/`config pull`, CLI-2156). - */ +/** The global `-o`/`--output` flag was passed; `pull` only supports `--output-format` for + * machine output. */ export class PullOutputFlagUnsupportedError extends Data.TaggedError( "PullOutputFlagUnsupportedError", )<{ readonly message: string }> { @@ -106,14 +93,9 @@ export class PullWorkdirError extends Data.TaggedError("PullWorkdirError")<{ } /** - * At least one of the config file, `supabase/migrations`, or - * `supabase/functions` has uncommitted (or untracked) changes and there is no - * human on hand to read the warning and answer the prompt honestly — mirrors - * `config pull`'s own `ConfigPullUncommittedChangesError` dirty-guard - * over the config file, generalized here to the other two directories `pull` - * also writes into, since the orchestrator owns this check once instead of - * delegating to `config pull`'s own guard. Only `--force` overrides this - * guard; `--yes` never does, on any TTY. + * At least one of the config file, `supabase/migrations`, or `supabase/functions` has + * uncommitted or untracked changes with no interactive prompt to confirm past it. Only + * `--force` overrides this guard; `--yes` never does. */ export class PullUncommittedChangesError extends Data.TaggedError("PullUncommittedChangesError")<{ readonly message: string; diff --git a/apps/cli/src/commands/pull/pull.format.ts b/apps/cli/src/commands/pull/pull.format.ts index 94245ebfa9..734a67ac78 100644 --- a/apps/cli/src/commands/pull/pull.format.ts +++ b/apps/cli/src/commands/pull/pull.format.ts @@ -2,9 +2,8 @@ import { pullCounts } from "./pull.aggregate.ts"; import { PULL_STEP_ORDER, type PullAggregate, type PullStepResult } from "./pull.types.ts"; /** - * Pure text/JSON formatters for `supabase pull` — no Effect, no services. - * `pull.handler.ts` owns sequencing only; every rendering/status-summary - * decision lives here and in `pull.aggregate.ts`. + * Pure text/JSON formatters for `supabase pull`; rendering and status-summary decisions live + * here and in `pull.aggregate.ts`. */ /** Version of the machine payload's own shape — bump when it changes incompatibly. */ @@ -40,14 +39,11 @@ export function pullPayload(aggregate: PullAggregate): Record<string, unknown> { }, dry_run: aggregate.dryRun, confirmed: aggregate.confirmed, - // Present (possibly empty) on every disposition, not just a dirty-tree abort — a - // script driving `--dry-run --output-format json` has no other way to know an - // equivalent real run would abort (or downgrade its prompt default) on this path. + // Present (possibly empty) on every disposition: a `--dry-run --output-format json` script + // has no other way to know a real run would hit this dirty-tree guard. dirty_paths: aggregate.dirtyPaths, - // A `changed` status always implies a write, but a FAILED step can also carry a - // non-empty `written` array (the partial-write case `writtenSoFar` reports — - // `pull.aggregate.ts`'s `pullFailedStepResult`), so `wrote` is derived from every - // step's actual recorded writes, not just the `changed` statuses. + // A failed step can still carry a non-empty `written` array (a partial write before it + // failed), so `wrote` is derived from every step's recorded writes, not just `changed` ones. wrote: aggregate.results.some( (result) => result.status === "changed" || result.written.length > 0, ), @@ -58,10 +54,9 @@ export function pullPayload(aggregate: PullAggregate): Record<string, unknown> { } /** - * One-line `message` passed alongside the payload to `output.success` — an - * agent echoing just `.message` should never mistake a dry-run/declined/ - * partial-failure result for a completed pull, so every branch names its own - * disposition up front, then always reports the full per-status breakdown. + * One-line `message` for `output.success`. Every branch names its own disposition up front + * (dry-run, declined, partial-failure, or complete) so an agent reading just `.message` can't + * mistake one for a completed pull. */ export function pullSummaryMessage(aggregate: PullAggregate): string { const counts = pullCounts(aggregate.results); @@ -82,10 +77,9 @@ export function pullSummaryMessage(aggregate: PullAggregate): string { const PULL_STATUS_COLUMN_WIDTH = "unchanged".length; /** - * Strips control characters (CR/LF/tab) from a failure message before it is inlined into an - * aligned summary row (CWE-117) — a failure message can carry remote-controlled content (a - * function slug, an API response body) that would otherwise forge fake additional summary rows - * in text-mode output. + * Strips control characters (CR/LF/tab) from a failure message before it's inlined into an + * aligned summary row (CWE-117): a message can carry remote-controlled content (a function + * slug, an API response body) that would otherwise forge fake summary rows in text-mode output. */ function pullSanitizeRowText(message: string): string { return message.replace(/[\r\n\t]+/g, " "); @@ -110,14 +104,9 @@ function pullStepDetailText(result: PullStepResult): string { } /** - * Splits a failed step's `failure.suggestion` (which can carry embedded - * `\n`-separated lines — e.g. a list of `supabase migration repair` commands - * plus this step's own retry hint) into individual, sanitized, non-empty - * lines — a watching-a-real-terminal user needs this exactly as much as a - * script reading the JSON payload's `failure.suggestion` field does. Each - * line is sanitized the same way the row's own detail text is (CWE-117): a - * suggestion can carry remote-controlled content, so no line may itself - * embed a further CR/LF/tab that could forge additional fake summary rows. + * Splits a failed step's `failure.suggestion` into individual, sanitized, non-empty lines (it + * can carry embedded `\n`-separated commands). Sanitized the same way as row text (CWE-117), + * since a suggestion can also carry remote-controlled content. */ function pullSuggestionLines(suggestion: string): ReadonlyArray<string> { return suggestion @@ -127,10 +116,9 @@ function pullSuggestionLines(suggestion: string): ReadonlyArray<string> { } /** - * The full text-mode summary block: a header line naming the target project, - * then one aligned row per step in `PULL_STEP_ORDER` order. A `failed` - * step's row inlines its failure message even though only the first original - * failure re-fails the process (`pull.handler.ts`'s job). + * The full text-mode summary block: a header line naming the target project, then one aligned + * row per step in `PULL_STEP_ORDER` order. A `failed` step's row inlines its own failure message + * even when it isn't the step whose failure exits the process. */ export function renderPullSummary(aggregate: PullAggregate): string { const resultByStep = new Map(aggregate.results.map((result) => [result.step, result] as const)); @@ -193,10 +181,8 @@ function joinPathList(paths: ReadonlyArray<string>): string { } /** - * The dirty-tree warning sentence naming every path in `dirtyPaths` — shared between the - * confirmation body's trailing warning (below) and `PullUncommittedChangesError`'s own - * message (`pull.handler.ts`), so the two surfaces can never drift on wording. Singular/plural - * verb agreement follows `dirtyPaths.length`. + * The dirty-tree warning sentence naming every path in `dirtyPaths` — shared with + * `PullUncommittedChangesError`'s own message so the two surfaces can never drift on wording. */ export function pullDirtyWarningMessage(dirtyPaths: ReadonlyArray<string>): string { const verb = dirtyPaths.length === 1 ? "has" : "have"; @@ -204,20 +190,13 @@ export function pullDirtyWarningMessage(dirtyPaths: ReadonlyArray<string>): stri } /** - * The single aggregated confirmation prompt's disclosure body — printed - * before the actual yes/no question, mirroring `renderConfigPullText`'s - * role in `config pull`. `db pull` and `functions download` have no preview - * machinery of their own, so they get one qualitative line each instead of a - * real diff; migration history only gets a line when it will actually run - * this invocation (see the confirmed bootstrap-auto-run decision, ADR 0024). - * The body's lines are ordered to match `PULL_STEP_ORDER` (config → - * migration_history → db → functions). + * The single aggregated confirmation prompt's disclosure body, printed before the yes/no + * question. `db pull` and `functions download` get one qualitative line each (no preview + * machinery); migration history only gets a line when it will actually run (see ADR 0024). */ export function pullConfirmMessage(input: PullConfirmMessageInput): string { - // `ref`/`branch` are sanitized before interpolation (CWE-117): a branch name (or, for - // completeness, a ref) containing CR/LF could otherwise forge fake lines in this - // header — the same concern `pullSanitizeRowText` already guards against for - // rendered failure/suggestion text elsewhere in this file. + // Sanitized before interpolation (CWE-117): a branch name containing CR/LF could otherwise + // forge fake lines in this header. const ref = pullSanitizeRowText(input.ref); const branch = input.branch === undefined ? undefined : pullSanitizeRowText(input.branch); const lines: Array<string> = [ @@ -226,13 +205,11 @@ export function pullConfirmMessage(input: PullConfirmMessageInput): string { : `Pulling from project ${ref} (branch "${branch}")`, "", ]; - // config if (input.configDiffText !== undefined && input.configDiffText.length > 0) { lines.push(input.configDiffText.trimEnd(), ""); } else { lines.push("No config differences found.", ""); } - // migration_history if (input.willFetchMigrationHistory) { const reason = input.migrationHistoryReason === "flag" @@ -245,11 +222,9 @@ export function pullConfirmMessage(input: PullConfirmMessageInput): string { ); } } - // db lines.push( "Pull the remote database schema into supabase/migrations (also updates the remote migration history table; requires Docker).", ); - // functions lines.push("Download every Edge Function's source into supabase/functions."); if (input.dirtyPaths.length > 0) { lines.push("", pullDirtyWarningMessage(input.dirtyPaths)); diff --git a/apps/cli/src/commands/pull/pull.handler.ts b/apps/cli/src/commands/pull/pull.handler.ts index d32b8da47e..27ae257773 100644 --- a/apps/cli/src/commands/pull/pull.handler.ts +++ b/apps/cli/src/commands/pull/pull.handler.ts @@ -65,8 +65,7 @@ import { } from "./pull.errors.ts"; import type { PullFlags } from "./pull.command.ts"; -/** Error construction for `resolveConfigTarget` (`../../command-internal/project-target.ts`), - * keeping `pull`'s own minted tagged error classes (`pull.errors.ts`). */ +/** Wires `pull`'s own tagged error classes into `resolveConfigTarget`. */ const pullTargetErrors = configTargetErrorsFor({ notLinked: PullBranchNotLinkedError, parentRefInvalid: PullParentRefInvalidError, @@ -81,31 +80,24 @@ const mapBranchResolveError = mapHttpError({ statusMessage: unexpectedStatusMessage, }); -/** The union of every typed error the four Phase-3 step runners - * (`pull.steps.ts`) can fail with — keeps `firstFailureCause` (Phase 3, below) - * typed instead of erasing the error channel to `unknown`. */ +/** The union of every typed error the four step runners (`pull.steps.ts`) can fail with — keeps + * `firstFailureCause` typed instead of erasing the error channel to `unknown`. */ type PullStepFailureCause = | Effect.Error<ReturnType<typeof pullConfigStep>> | Effect.Error<ReturnType<typeof pullMigrationHistoryStep>> | Effect.Error<ReturnType<typeof pullDbStep>> | Effect.Error<ReturnType<typeof pullFunctionsStep>>; -/** The config step's own machine payload, verbatim — `runPlan`'s fields plus - * the run's actual `{dryRun, declined}` disposition (unknown until the - * aggregated confirmation resolves). One call site so every one of the four - * places that builds a config `PullStepResult` (dry run, declined, - * and the real Phase 3 apply) shapes the payload identically. */ +/** The config step's own machine payload, verbatim — `runPlan`'s fields plus the run's actual + * `{dryRun, declined}` disposition. One call site so every place that builds a config + * `PullStepResult` shapes the payload identically. */ const configPullPayloadFor = (runPlan: ConfigPullRunPlan, outcome: ConfigPullOutcome) => configPullPayload(runPlan.changeSet, runPlan.scope, runPlan.plan, runPlan.context, outcome); /** - * Emits the aggregate exactly like every non-failure disposition (dry run, - * declined, or a fully-succeeded run) — text mode renders the aligned summary - * block to stdout, machine mode reports one `output.success` envelope. The - * mixed-failure disposition (Phase 4) does NOT use this: it needs the - * text-mode render to still happen while machine mode instead feeds the - * payload into `MachineErrorContext` ahead of a re-failed cause, so it is - * inlined at that one call site instead of being folded in here. + * Emits the aggregate for every non-failure disposition (dry run, declined, or fully + * succeeded). The mixed-failure disposition is inlined separately instead, since machine mode + * there feeds the payload into `MachineErrorContext` ahead of a re-failed cause. */ const pullEmit = (output: typeof Output.Service, aggregate: PullAggregate) => output.format === "text" @@ -113,13 +105,9 @@ const pullEmit = (output: typeof Output.Service, aggregate: PullAggregate) => : output.success(pullSummaryMessage(aggregate), pullPayload(aggregate)); /** - * Runs `effect`, capturing a typed failure into `{kind: "failed", cause}` - * instead of letting it propagate — but a defect or interruption in its - * `Cause` propagates immediately via `Effect.failCause`, preserving the - * original `Cause` (never swallowed as a mere per-step failure). Backs Phase - * 3's partial-failure isolation: each of the four steps runs even when an - * earlier one failed, and the ORIGINAL `Cause` of the first typed failure is - * preserved (not rebuilt) for re-failing once every step has reported. + * Runs `effect`, capturing a typed failure into `{kind: "failed", cause}` instead of letting it + * propagate; a defect or interruption still propagates immediately via `Effect.failCause`. Lets + * every step run even when an earlier one failed, preserving the first failure to re-fail with. */ function pullCaptureStep<A, E, R>( effect: Effect.Effect<A, E, R>, @@ -142,12 +130,9 @@ function pullCaptureStep<A, E, R>( } /** - * Appends `extra` to `result`'s own `failure.suggestion` on its own line — - * never replacing whatever is already there. A no-op for a non-failed - * result. Shared by `pullDbStepFailureResult`'s `--with-migration-history` - * remedy and `pullWithRetryHint`'s generic per-step retry command below, - * so both compose onto the same field in a fixed order without clobbering - * each other. + * Appends `extra` to `result`'s own `failure.suggestion` on its own line, never replacing + * what's already there. A no-op for a non-failed result, so multiple suggestion sources can + * compose without clobbering each other. */ function pullAppendSuggestion(result: PullStepResult, extra: string): PullStepResult { if (result.failure === undefined) { @@ -164,28 +149,11 @@ function pullAppendSuggestion(result: PullStepResult, extra: string): PullStepRe } /** - * The db step's own failure result, with one addition: when the underlying - * cause is `DbPullMigrationConflictError` (the remote migration history - * doesn't match local files), its built-in suggestion — a list of `supabase - * migration repair` commands — never mentions the more direct fix `pull` - * itself offers. `db pull` doesn't know it's being called from `pull`, so this - * lives here rather than on the shared error class (still used by other - * callers). Scoped to the `suggestion` field only; the underlying error class - * and its own message/suggestion text are untouched. - * - * The suggested command names the exact target `pull` itself resolved - * (`ref`, and `remoteLabel` when one was passed) via `pullWithMigrationHistoryCommand` - * (`pull.aggregate.ts`) — omitting it would let a bare `supabase pull - * --with-migration-history` rerun silently target the checkout's linked - * project instead of whatever this run actually resolved (a branch, an - * explicit different ref, ...), a data-safety concern given the command - * writes to the remote migration history table. - * - * `workdir` relativizes any `writtenSoFar` migration path the underlying - * `DbPullWriteError` may carry (populated when the migration file write - * succeeded but the subsequent remote-history update failed) — threaded - * through to `pullFailedStepResult` exactly like the `migration_history` - * step's own `workdir` argument below. + * The db step's failure result, with one addition: when the cause is + * `DbPullMigrationConflictError`, appends a suggestion to rerun with `--with-migration-history`, + * naming the exact `ref`/`remoteLabel` this run resolved. A bare rerun could otherwise silently + * retarget the checkout's linked project instead, which matters since the command writes to the + * remote migration history table. */ function pullDbStepFailureResult( cause: unknown, @@ -204,11 +172,9 @@ function pullDbStepFailureResult( } /** - * Appends the exact standalone command to retry just THIS failed step - * (`pullRetryHint`, `pull.aggregate.ts`) on top of whatever the step's - * own failure already says — including, for the db step, the - * `--with-migration-history` remedy `pullDbStepFailureResult` may have - * already appended above. A no-op for a non-failed result. + * Appends the standalone command to retry just this failed step on top of whatever the step's + * own failure already says — including, for the db step, the `--with-migration-history` remedy + * `pullDbStepFailureResult` may have already appended. A no-op for a non-failed result. */ function pullWithRetryHint( result: PullStepResult, @@ -219,12 +185,10 @@ function pullWithRetryHint( } /** - * `supabase pull` — orchestrates `config pull`, an optional `migration - * fetch`, `db pull`, and `functions download` behind one target resolution, - * one confirmation, and one aggregated result (ADR 0024). See that ADR and - * the command's `SIDE_EFFECTS.md` for the full side-effect inventory — - * notably, the `db` step writes `supabase_migrations.schema_migrations` on - * the REMOTE database, not just local files, and requires Docker. + * `supabase pull` orchestrates `config pull`, an optional `migration fetch`, `db pull`, and + * `functions download` behind one target resolution, one confirmation, and one aggregated + * result (see ADR 0024). The `db` step also writes to the remote database's migration history + * table and requires Docker. */ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { const output = yield* Output; @@ -245,24 +209,21 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { let resolvedRef: string | undefined; yield* Effect.gen(function* () { - // Phase 0.1: reject the Go-compat `-o/--output` flag outright — `pull` is - // a net-new TS command with no Go parity contract (CLI-2156). + // Rejects `-o`/`--output` outright; `pull` only supports `--output-format`. if (Option.isSome(goOutputFlag)) { return yield* new PullOutputFlagUnsupportedError({ message: unsupportedOutputFlagMessage("pull"), }); } - // Phase 0.2: the resolved `--workdir`/`SUPABASE_WORKDIR` must exist and - // be a directory before anything else — beats every step's own target + // Must exist and be a directory before anything else — beats every step's own target // resolution and network calls. yield* validateWorkdirIsDirectory(cliSettings.workdir, fs).pipe( Effect.mapError((error) => new PullWorkdirError({ message: error.message })), ); - // Phase 0.3-0.4: open the base config source, then resolve the pull - // target exactly once — every sub-step below targets `ref` directly - // instead of re-resolving (ADR 0024's "resolve once" decision). + // Resolves the pull target exactly once; every sub-step below targets `ref` directly + // instead of re-resolving (ADR 0024). const source = yield* openConfigPullSource(); const { ref, branch } = yield* resolveConfigTarget( requested, @@ -271,62 +232,45 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { ); resolvedRef = ref; - // Phase 1: preview. `planConfigPullRun` does everything up through - // the schema-validation gate — no git check, no prompt, no write. + // `planConfigPullRun` does everything up through the schema-validation gate: no git check, + // no prompt, no write. const runPlan = yield* planConfigPullRun({ target: { ref, branch }, remoteLabel, source, }); - // Every retry hint below (Phase 3's `pullWithRetryHint`/`pullDbStepFailureResult`) - // names the PLANNED remote destination's label, never the raw `remoteLabel` flag - // value above — for a branch-derived implicit target, `runPlan.context.destination` - // names the `[remotes.<branch>]` block the plan actually targets even though - // `--remote-label` was never passed. Feeding the raw flag value back into a retry - // hint would target the wrong (or no) block on a bare rerun when the config step's - // own TOCTOU guard (or any other config-step failure) trips before that block is - // ever created. `undefined` for a root destination, matching the raw flag's own - // `undefined` for that case. + // Retry hints below use the planned remote destination's label, not the raw `remoteLabel` + // flag: for a branch-derived implicit target, that's the `[remotes.<branch>]` block the + // plan actually targets, which may differ from an unset `--remote-label`. const plannedRemoteLabel = runPlan.context.destination.kind === "remote" ? runPlan.context.destination.label : undefined; - // The config dirty check is scoped to whether the CONFIG step itself has - // work to write — mirrors `config pull`'s own guard (CLI-2064 bug A): a - // converged config (nothing to pull into the file) never spawns `git - // status` at all, so an uncommitted-but-otherwise-clean config file never - // aborts a pull that was never going to touch it. + // Only checks git status when the config step actually has work to write, so an + // uncommitted-but-otherwise-clean config file never aborts a pull that wasn't going to + // touch it. const configDirty = flags.force || !runPlan.hasWork ? false : Option.getOrElse(yield* pathHasUncommittedChanges(runPlan.configFilePath), () => false); - // Unlike the config step, the db step always attempts to run (no flag - // skips it) and has no preview machinery of its own — there is no way to - // know ahead of time whether it will find schema drift and actually write - // into `supabase/migrations` this invocation (ADR 0024's "resolve once, - // no preview for db/functions" scope). Gating this check on - // `shouldFetchMigrationHistory` alone would silently miss real - // uncommitted work whenever the db step is the one about to write, so the - // migrations directory is checked unconditionally instead — skipped only - // by `--force` — regardless of whether the migration-history step itself - // also runs this invocation. + // The db step always runs and has no preview machinery (ADR 0024), so migrations are + // checked unconditionally for uncommitted changes — not just when migration-history also + // runs — skipped only by `--force`. const migrationsDir = path.join(cliSettings.workdir, "supabase", "migrations"); const migrationsDirty = flags.force ? false : Option.getOrElse(yield* pathHasUncommittedChanges(migrationsDir), () => false); - // The functions step always runs unconditionally too, with no "does it - // have work" signal at all without calling the API first — same - // unconditional treatment as migrations, minus the drift uncertainty. + // Functions always runs too, with no way to know if there's work without calling the API + // first — checked unconditionally like migrations. const functionsDir = path.join(cliSettings.workdir, "supabase", "functions"); const functionsDirty = flags.force ? false : Option.getOrElse(yield* pathHasUncommittedChanges(functionsDir), () => false); - // Tracked separately (not collapsed into one boolean) so the confirmation - // body and the abort error can each name exactly which path(s) are dirty. - // Ordered config → migrations → functions, matching `PULL_STEP_ORDER`. + // Tracked separately (not collapsed into one boolean) so the confirmation body and the + // abort error can each name exactly which path(s) are dirty. const dirtyPaths: ReadonlyArray<string> = [ ...(configDirty ? [runPlan.context.configPath] : []), ...(migrationsDirty ? ["supabase/migrations"] : []), @@ -334,17 +278,11 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { ]; const dirty = dirtyPaths.length > 0; - // Auto-run migration history whenever `supabase/migrations` is missing or - // empty, even without `--with-migration-history` — the confirmed - // bootstrap-auto-run decision (ADR 0024): there is nothing to overwrite, - // and it is what makes a fresh checkout work by default. Mirrors - // `migration fetch`'s OWN "existing files" check exactly (a raw, - // unfiltered directory listing — NOT the filtered `loadLocalVersions` - // count `db diff`/`pull` reconciliation uses elsewhere) so the two can - // never disagree about whether the directory is "empty": a directory - // holding only a `README.md`/`.gitkeep`/deprecated `_init.sql` reads as - // non-empty here, exactly as it does to `migration fetch`'s own - // overwrite-confirmation guard. + // Auto-runs whenever `supabase/migrations` is missing or empty, even without + // `--with-migration-history` (ADR 0024's bootstrap decision): a fresh checkout has nothing + // to overwrite. Uses the same raw, unfiltered directory listing `migration fetch`'s own + // overwrite-confirmation guard uses, so a directory holding only a README/.gitkeep reads as + // non-empty in both places. const shouldFetchMigrationHistory = flags.withMigrationHistory ? true : (yield* fs.readDirectory(migrationsDir).pipe( @@ -364,16 +302,13 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { ? "flag" : "bootstrap"; - // Phase 2: one confirmation, asymmetric preview (a real diff for config, - // a qualitative description for db/functions/migration-history). + // One confirmation with an asymmetric preview: a real diff for config, a qualitative + // description for db/functions/migration-history. // - // Rendered whenever the changeset has ANY change at all — not just when - // `runPlan.hasWork` (which can be `false` even with real changes present, when every - // one of them was skipped as an env-reference, an unpushable family, etc.). Gating on - // `hasWork` alone would silently fall back to "No config differences found." even - // though the diff found real (if unwritable) drift — mirrors standalone `config - // pull`'s own `!runPlan.hasWork` branch (`command-internal/config-pull-run.ts`'s - // `runConfigPull`), which renders `renderConfigPullText` regardless of `hasWork`. + // Rendered whenever the changeset has any change at all, not just when `runPlan.hasWork` + // (which can be `false` even with real changes, when every one was skipped as an + // env-reference, an unpushable family, etc.) — otherwise a real (if unwritable) diff would + // silently read as "No config differences found." const configDiffText = runPlan.changeSet.changes.length > 0 ? renderConfigPullText( @@ -393,10 +328,8 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { dirtyPaths, }); - // Printed once, ahead of BOTH the dry-run early return and the real - // confirmation prompt below, so a dry run sees the same disclosure body - // (config diff/no-diff, migration-history/db/functions lines, dirty - // warning) a real run would — not just the 4-row status summary. + // Printed ahead of both the dry-run early return and the real confirmation prompt, so a + // dry run sees the same disclosure body a real run would, not just the status summary. if (output.format === "text") { yield* output.raw(confirmBody); } @@ -472,8 +405,8 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { return; } - // Phase 3: execute. Each step is failure-isolated — one failing does not - // stop the rest from running and being reported. + // Each step is failure-isolated: one failing doesn't stop the rest from running and being + // reported. const stepContext: PullStepContext = { ref, assumeYes: true }; let firstFailureCause: Cause.Cause<PullStepFailureCause> | undefined; const results: Array<PullStepResult> = []; @@ -482,10 +415,8 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { if (configCapture.kind === "ok") { results.push( pullConfigStepResult( - // `pullConfigStep` (`pull.steps.ts`) returns the plan's own - // ABSOLUTE `configFilePath` (`loaded.path`) — overridden here to the - // workdir-relative `context.configPath` `PullConfigStepOutcome` - // documents, matching the dry-run/declined branches above. + // `pullConfigStep` returns the plan's absolute `configFilePath`; overridden here to + // the workdir-relative path, matching the dry-run/declined branches above. { ...configCapture.value, configFilePath: runPlan.context.configPath }, configPullPayloadFor(runPlan, { dryRun: false, declined: false }), ), @@ -563,7 +494,6 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { firstFailureCause ??= functionsCapture.cause; } - // Phase 4: aggregate, emit, exit. const aggregate = pullAggregate({ ref, branch, @@ -584,9 +514,8 @@ export const pull = Effect.fn("pull")(function* (flags: PullFlags) { } return yield* Effect.failCause(firstFailureCause); }).pipe( - // CLI Invariant #1: telemetry flushes on EVERY invocation, - // including target-resolution failures; the linked-project cache write - // only fires once a ref has actually resolved. + // Telemetry flushes on every invocation, including target-resolution failures; the + // linked-project cache write only fires once a ref has resolved. Effect.ensuring( Effect.suspend(() => resolvedRef === undefined ? Effect.void : linkedProjectCache.cache(resolvedRef), diff --git a/apps/cli/src/commands/pull/pull.integration.test.ts b/apps/cli/src/commands/pull/pull.integration.test.ts index 99556007da..82fc43729f 100644 --- a/apps/cli/src/commands/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/pull/pull.integration.test.ts @@ -50,25 +50,11 @@ import type { PullFlags } from "./pull.command.ts"; import { pullHandler } from "./pull.command.ts"; /** - * Scenario-oriented integration coverage for `supabase pull` (CLI-1272, - * ADR 0024). Drives the real exported `pullHandler` — the SAME - * `safeFlags`/`withCommandTelemetry`/`withJsonErrorHandling` - * wiring `pull.command.ts`'s `Command.withHandler` composes (mirrors - * `functions download`'s own exported-handler precedent) — against a fully - * mocked db-pull/ - * migration-fetch/functions-download substrate: `PgDeltaEngine` is - * mocked directly (so every db step forces the pg-delta diff engine via - * `[experimental.pgdelta] enabled = true`, skipping both the migra/edge-runtime - * path and the pg_dump initial-pull seed entirely — pg-delta initial pulls - * diff against an empty shadow and never dump), `DbConnection`/ - * `DbConfigResolver` are mocked with a shared fake Postgres session - * (shared by the migration-history and db steps, since both read the - * SAME `supabase_migrations.schema_migrations` table), and a composite - * `ChildProcessSpawner` combines the real shadow-container container-lifecycle - * fake with a "docker is not running" answer for `docker info` (so the - * functions step's Docker-unbundle path never activates — it falls back to - * the plain multipart HTTP download) and a controllable "git status" answer - * for the config-file dirty guard. + * Scenario-oriented integration coverage for `supabase pull` (ADR 0024), driving the real + * exported `pullHandler` against a fully mocked db-pull/migration-fetch/functions-download + * substrate. `PgDeltaEngine` is mocked directly to force the pg-delta diff engine; `DbConnection` + * shares one fake Postgres session across the migration-history and db steps, since both read + * `supabase_migrations.schema_migrations`. */ const tempRoot = useTempWorkdir("supabase-pull-int-"); @@ -124,8 +110,7 @@ function seedLocalMigration( return path; } -/** Writes `supabase/.env`, backing `env(VAR)` resolution — copied from - * `config/pull/pull.integration.test.ts`'s own helper. */ +/** Writes `supabase/.env`, backing `env(VAR)` resolution. */ function writeProjectEnv(dotenv: string): void { const dir = join(tempRoot.current, "supabase"); mkdirSync(dir, { recursive: true }); @@ -150,20 +135,14 @@ function pullFlags(overrides: Partial<PullFlags> = {}): PullFlags { }; } -/** Drives the exact wiring `pull.command.ts`'s `Command.withHandler` uses — - * imported rather than reimplemented, so this suite can never drift from - * what actually ships (mirrors `functions download`'s own exported-handler - * precedent). */ +/** Drives the exact wiring `pull.command.ts`'s `Command.withHandler` uses, imported rather than + * reimplemented, so this suite can't drift from what actually ships. */ function runPull(flags: PullFlags) { return pullHandler(flags); } -// --------------------------------------------------------------------------- -// Composite ChildProcessSpawner: the real shadow-container lifecycle fake, -// plus a controllable "docker info" (Docker running?) answer and a -// controllable "git status" (dirty config file?) answer. -// --------------------------------------------------------------------------- - +// Combines the shadow-container fake with a controllable "docker info" (Docker running?) +// answer and a controllable "git status" (dirty path?) answer. function composeSpawner( opts: { /** Reports `supabase/config.toml` dirty. */ @@ -203,10 +182,9 @@ function composeSpawner( }), ); } - // `pull.handler.ts` spawns up to three of these (config, migrations, - // functions), each with its own basename as the trailing pathspec - // — mirrors the real dirty guard, so each location can be - // independently marked dirty/clean in a single test. + // Each of the three dirty checks (config, migrations, functions) uses its own + // basename as the trailing pathspec, so each can be independently marked + // dirty/clean in one test. const pathspec = command.args[command.args.length - 1]; const dirty = pathspec === "migrations" @@ -232,9 +210,8 @@ function composeSpawner( ); } if (command.command === "docker" && command.args[0] === "info") { - // Functions step's `isDockerRunning()` check — answering "not - // running" keeps the functions step on the plain multipart HTTP - // download path instead of the Docker-unbundle path. + // Functions step's `isDockerRunning()` check; "not running" keeps it on the plain + // multipart HTTP download path instead of Docker-unbundle. return Effect.succeed( ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(9500), @@ -259,13 +236,8 @@ function composeSpawner( return { layer, shadowSpawned: shadow.spawned, gitCalls }; } -// --------------------------------------------------------------------------- -// A shared fake Postgres session backing BOTH the migration-history step -// (`readMigrationTable`) and the db step (`listRemoteMigrations`/ -// `updateMigrationHistory`) — both read the SAME -// `supabase_migrations.schema_migrations` table in production. -// --------------------------------------------------------------------------- - +// Shared fake Postgres session for both the migration-history and db steps, since both read +// `supabase_migrations.schema_migrations`. interface RemoteMigrationRow { readonly version: string; readonly name: string; @@ -276,9 +248,9 @@ function makeMigrationSession( remoteMigrations: ReadonlyArray<RemoteMigrationRow>, callOrder: Array<string>, tag: "target" | "shadow", - /** Fails the remote-history UPSERT (the db step's own "Update remote migration history - * table?" write) — proves `steps.db.written` still reports the migration file already - * on disk, via `DbPullWriteError.writtenSoFar` (CLI-1272 review, Fix A). */ + /** Fails the remote-history UPSERT (the db step's "Update remote migration history table?" + * write), to prove `steps.db.written` still reports the migration file already on disk via + * `DbPullWriteError.writtenSoFar`. */ historyUpdateFails = false, ): { readonly session: DbSession; @@ -373,12 +345,8 @@ function makeDbConfigLayers( }; } -// --------------------------------------------------------------------------- -// A fully-mocked `PgDeltaEngine` — every db step forces this engine via -// `[experimental.pgdelta] enabled = true` in `writeConfig`, so neither the -// migra/edge-runtime path nor the pg_dump initial-pull seed is ever reached. -// --------------------------------------------------------------------------- - +// Every db step forces the pg-delta engine via `[experimental.pgdelta] enabled = true` in +// `writeConfig`, so neither migra/edge-runtime nor the pg_dump initial-pull seed is reached. interface DiffOutcome { readonly changes: boolean; readonly files?: ReadonlyArray<{ readonly name: string; readonly sql: string }>; @@ -425,11 +393,8 @@ function makePgDeltaEngine(diffOutcome: () => DiffOutcome): { return { layer, diffCount: () => diffCount }; } -// --------------------------------------------------------------------------- -// Management API mock: config GET, branch-by-name resolution, and the -// functions list/body endpoints, all through one URL-routed handler. -// --------------------------------------------------------------------------- - +// Config GET, branch-by-name resolution, and the functions list/body endpoints, all routed +// through one URL-matched handler. function multipartFixture(content: string): { readonly boundary: string; readonly body: string } { const boundary = "pull-test"; return { @@ -456,9 +421,8 @@ interface ApiOpts { readonly functionsListStatus?: number; /** A transport (not status-code) failure resolving a branch-name `--project-ref`. */ readonly branchNetworkFails?: boolean; - /** Fails ONLY this slug's own `/body` download with a 500 — every other slug still - * downloads normally (Fix A, CLI-1272 review: proves a partial functions-step - * download reports the earlier, already-downloaded slug(s) as written). */ + /** Fails only this slug's `/body` download with a 500; every other slug still downloads + * normally, proving a partial functions-step download reports earlier slugs as written. */ readonly functionBodyFailsForSlug?: string; } @@ -517,14 +481,9 @@ function makeApiMock(opts: ApiOpts) { }); } -// --------------------------------------------------------------------------- -// A REAL captured `Stdio` layer — only the json/stream-json envelope tests -// need it (the `MachineErrorContext` merge lives inside the real -// `jsonOutputLayer`/`streamJsonOutputLayer` `fail`/`success` implementations, -// which `mockOutput` never replicates), mirroring -// `status.integration.test.ts`'s `mockCapturingStdio`. -// --------------------------------------------------------------------------- - +// A real captured `Stdio` layer — only json/stream-json envelope tests need it, since +// `MachineErrorContext` merging lives inside the real output layers' `fail`/`success` +// implementations, which `mockOutput` never replicates. function mockCapturingStdio(args: ReadonlyArray<string>) { const stdout: Array<string> = []; const stderr: Array<string> = []; @@ -545,12 +504,9 @@ function mockCapturingStdio(args: ReadonlyArray<string>) { } /** - * Wraps `base` so every `promptConfirm` call first runs `onConfirm` (a - * synchronous side effect) before delegating to the real mock — mirrors - * `config/pull/pull.integration.test.ts`'s own `withConfirmSideEffect`, used - * here to simulate a concurrent edit landing on `supabase/config.toml` - * WHILE the orchestrator's own confirmation prompt is "on screen" (the - * config step's TOCTOU guard, `applyConfigPullRun`). + * Wraps `base` so every `promptConfirm` call first runs `onConfirm` before delegating to the + * real mock — simulates a concurrent edit landing on `supabase/config.toml` while the + * orchestrator's confirmation prompt is on screen (the config step's TOCTOU guard). */ function withConfirmSideEffect( base: Layer.Layer<Output>, @@ -569,10 +525,6 @@ function withConfirmSideEffect( ).pipe(Layer.provide(base)); } -// --------------------------------------------------------------------------- -// setup() -// --------------------------------------------------------------------------- - interface SetupOpts { readonly format?: "text" | "json" | "stream-json"; readonly goOutput?: Option.Option<"env" | "pretty" | "json" | "toml" | "yaml">; @@ -586,13 +538,12 @@ interface SetupOpts { readonly api?: ApiOpts; readonly remoteMigrations?: ReadonlyArray<RemoteMigrationRow>; readonly diffOutcome?: () => DiffOutcome; - /** Fails the db step's own remote-history UPSERT after the migration file has - * already been written to disk (Fix A, CLI-1272 review). */ + /** Fails the db step's own remote-history UPSERT after the migration file has already been + * written to disk. */ readonly dbHistoryUpdateFails?: boolean; - /** Runs as a side effect of the ORCHESTRATOR's own "Proceed with pull?" - * confirmation, before it resolves — simulates a concurrent edit landing - * on `supabase/config.toml` while that prompt is on screen (text-mode, - * interactive TTY only). */ + /** Runs as a side effect of the orchestrator's "Proceed with pull?" confirmation, before it + * resolves — simulates a concurrent edit to `supabase/config.toml` while that prompt is on + * screen (text-mode, interactive TTY only). */ readonly confirmSideEffect?: () => void; /** Overrides `cliSettings.workdir` — defaults to the temp project root. */ readonly workdir?: string; @@ -683,9 +634,8 @@ function setup(opts: SetupOpts = {}) { Layer.succeed(DnsResolverFlag, "native"), Layer.succeed(NetworkIdFlag, Option.none()), Layer.succeed(CliArgs, { args: [] }), - // Listed after `buildTestRuntime` so it overrides the real spawner - // `BunServices.layer` provides (last-wins, same precedent as - // `config/pull/pull.integration.test.ts`'s `mockGitStatusSpawner`). + // Listed after `buildTestRuntime` so it overrides the real spawner `BunServices.layer` + // provides (last-wins). spawner.layer, ); @@ -706,10 +656,6 @@ function setup(opts: SetupOpts = {}) { } describe("pull integration", () => { - // ------------------------------------------------------------------------- - // 1. Fresh-checkout bootstrap. - // ------------------------------------------------------------------------- - it.live( "bootstraps a fresh checkout: migration history auto-runs, config/functions/db all report changed", () => { @@ -730,8 +676,6 @@ describe("pull integration", () => { expect(readFileSync(configPath(), "utf8")).toContain("max_rows = 1000"); expect(existsSync(join(migrationsDir(), "20260101000000_init.sql"))).toBe(true); - // The db step wrote at least one MORE file beyond the one - // migration-history just fetched. expect(readdirSync(migrationsDir()).length).toBeGreaterThan(1); expect( existsSync(join(tempRoot.current, "supabase", "functions", "hello", "index.ts")), @@ -783,13 +727,6 @@ describe("pull integration", () => { expect((steps["functions"] as Record<string, unknown>)["status"]).toBe("changed"); expect(payload["wrote"]).toBe(true); - // The db step's remote write actually happened — not just a local - // file write. `historyUpserts` is the mocked Postgres session's own - // record of every parameterized write against - // `supabase_migrations.schema_migrations` on the (mocked) REMOTE - // database, and the payload's own `remote_history_updated` field - // (`pull.aggregate.ts`'s `pullDbStepResult`) reports the same - // fact back to a machine consumer. expect(dbConfig.historyUpserts.length).toBeGreaterThan(0); const dbDetail = (steps["db"] as Record<string, unknown>)["detail"] as Record< string, @@ -797,26 +734,18 @@ describe("pull integration", () => { >; expect(dbDetail["remote_history_updated"]).toBe(true); - // The linked-project cache was written with the resolved ref once - // target resolution succeeded. expect(linkedProjectCache.cached).toBe(true); expect(linkedProjectCache.cachedRef).toBe(VALID_REF); }).pipe(Effect.provide(layer)); }, ); - // ------------------------------------------------------------------------- - // 1b. Config diff body renders even when every change is skipped. - // ------------------------------------------------------------------------- - it.live( "renders the config diff body (not 'No config differences found.') when the only diff is skipped as an env() reference", () => { - // Every OTHER managed field sits at its schema default (`v2ProjectConfigResponse()`'s - // own design — see `tests/helpers/config-fixtures.ts`), so `auth.site_url` is the - // ONLY change in the whole changeset — and, being declared as `env(SITE_URL)`, it is - // skipped rather than written, leaving `runPlan.hasWork` false even though a real - // (unwritable) diff exists. + // Every other managed field sits at its schema default, so `auth.site_url` is the only + // change in the changeset — and, being declared as `env(SITE_URL)`, it's skipped rather + // than written, leaving `runPlan.hasWork` false even though a real (unwritable) diff exists. writeConfig('[auth]\nsite_url = "env(SITE_URL)"\n'); writeProjectEnv("SITE_URL=https://local.example.com\n"); const { layer, out } = setup({ yes: true }); @@ -825,16 +754,11 @@ describe("pull integration", () => { expect(out!.stdoutText).not.toContain("No config differences found."); expect(out!.stdoutText).toContain("auth.site_url [update, skip: env() reference]"); - // The config step itself still reports unchanged — nothing was actually written. expect(stepLine(out!.stdoutText, "config")).toContain("unchanged"); }).pipe(Effect.provide(layer)); }, ); - // ------------------------------------------------------------------------- - // 2. Target resolved once. - // ------------------------------------------------------------------------- - it.live( "a branch-name --project-ref resolves exactly once: one branch lookup, one config GET, no picker prompt", () => { @@ -857,10 +781,6 @@ describe("pull integration", () => { }, ); - // ------------------------------------------------------------------------- - // 3. Exactly one confirmation. - // ------------------------------------------------------------------------- - it.live( "on an interactive TTY with real diffs, the confirmation prompt appears exactly once", () => { @@ -885,10 +805,8 @@ describe("pull integration", () => { "the single confirmation suppresses BOTH sub-step prompts, and both underlying writes still complete: db updates remote history, migration fetch overwrites the pre-existing file", () => { writeConfig("[api]\nmax_rows = 500\n"); - // Pre-existing local file sharing the remote row's exact version+name — - // `migration fetch`'s own overwrite prompt is only reachable when - // `supabase/migrations` is non-empty, and this file is what gets - // clobbered. + // Shares the remote row's exact version+name: `migration fetch`'s overwrite prompt only + // fires when `supabase/migrations` is non-empty, and this file is what gets clobbered. const preExisting = seedLocalMigration( "20260101000000", "init", @@ -904,46 +822,31 @@ describe("pull integration", () => { statements: ["create table remote_only ();"], }, ], - // Real schema drift — `db pull`'s own remote-history-update prompt is - // only reachable when the diff actually writes a migration file. + // Real schema drift: `db pull`'s remote-history-update prompt only fires when the + // diff actually writes a migration file. diffOutcome: () => ({ changes: true, files: [{ name: "pull", sql: "alter table remote_only add column bar text;" }], }), }); return Effect.gen(function* () { - // `--with-migration-history` over an already-populated directory — - // the OTHER condition (besides real db drift) that makes both - // sub-prompts reachable if `assumeYes` didn't suppress them. + // `--with-migration-history` over an already-populated directory is the other + // condition (besides real db drift) that would make both sub-prompts reachable if + // `assumeYes` didn't suppress them. yield* runPull(pullFlags({ withMigrationHistory: true })); - // Exactly one prompt reached the terminal, even though BOTH `db - // pull`'s own "Update remote migration history table?" prompt and - // `migration fetch`'s own "Do you want to overwrite existing files…?" - // prompt would fire on their own if either step ran standalone - // against this same drift/pre-existing state. expect(out!.promptConfirmCalls).toHaveLength(1); expect(out!.promptConfirmCalls[0]?.message).toBe("Proceed with pull?"); - // `migration fetch`'s own overwrite prompt was suppressed, but the - // overwrite itself still ran to completion: the pre-existing file's - // content is now the REMOTE row's statements, not the original local - // ones. const rewritten = readFileSync(preExisting, "utf8"); expect(rewritten).toContain("create table remote_only"); expect(rewritten).not.toContain("create table local_only"); - // `db pull`'s own remote-history-update prompt was suppressed, but - // the remote write itself still ran to completion. expect(dbConfig.historyUpserts.length).toBeGreaterThan(0); }).pipe(Effect.provide(layer)); }, ); - // ------------------------------------------------------------------------- - // 4. --dry-run. - // ------------------------------------------------------------------------- - it.live("--dry-run writes nothing and reports every step planned/skipped", () => { const before = 'project_id = "test"\n\n[experimental.pgdelta]\nenabled = true\n[api]\nmax_rows = 500\n'; @@ -999,10 +902,6 @@ describe("pull integration", () => { }, ); - // ------------------------------------------------------------------------- - // 5. Declined confirmation. - // ------------------------------------------------------------------------- - it.live( "declining the confirmation writes nothing; migration history reports declined, db/functions report planned", () => { @@ -1025,10 +924,6 @@ describe("pull integration", () => { }, ); - // ------------------------------------------------------------------------- - // 7. Partial failure — still exactly one JSON envelope. - // ------------------------------------------------------------------------- - it.live( "a functions-step failure still reports every other step, exits non-zero, with exactly one JSON envelope", () => { @@ -1061,10 +956,6 @@ describe("pull integration", () => { expect(String(failure["message"])).toContain("500"); expect(processControl.exitCode).toBe(1); - // A partial step failure (ref resolved, but a later step failed) still - // performs BOTH CLI Invariant #1 finalizers — the ref - // resolved before the functions step ever ran, so the cache write - // still fires alongside the telemetry flush. expect(linkedProjectCache.cached).toBe(true); expect(linkedProjectCache.cachedRef).toBe(VALID_REF); expect(telemetry.flushed).toBe(true); @@ -1072,11 +963,6 @@ describe("pull integration", () => { }, ); - // ------------------------------------------------------------------------- - // 8. Steady state: db in-sync, config unchanged, migration history not - // needed, functions unchanged. - // ------------------------------------------------------------------------- - it.live( "a steady-state pull (no config drift, no schema drift, no functions) exits 0 with every step non-failed", () => { @@ -1100,10 +986,6 @@ describe("pull integration", () => { }, ); - // ------------------------------------------------------------------------- - // 8b. --remote-label. - // ------------------------------------------------------------------------- - it.live( "--remote-label creates the named [remotes.*] block even on an otherwise zero-drift config", () => { @@ -1139,10 +1021,6 @@ describe("pull integration", () => { }, ); - // ------------------------------------------------------------------------- - // 9. Dirty supabase/config.toml. - // ------------------------------------------------------------------------- - describe("dirty supabase/config.toml", () => { it.live("interactive TTY: the prompt defaults to decline", () => { writeConfig("[api]\nmax_rows = 500\n"); @@ -1232,14 +1110,8 @@ describe("pull integration", () => { }); }); - // ------------------------------------------------------------------------- - // 9b. Dirty supabase/migrations. Checked unconditionally (skipped only by - // --force) because the db step always attempts to run and has no preview - // machinery to know ahead of time whether it will find drift and write - // there — so this fires regardless of whether migration-history itself - // will run this invocation. - // ------------------------------------------------------------------------- - + // Checked unconditionally (skipped only by --force): the db step always runs with no preview + // machinery, so this fires regardless of whether migration-history itself runs. describe("dirty supabase/migrations", () => { it.live( "already-populated, no --with-migration-history pending: still aborts without --force (the db step might still write there)", @@ -1307,12 +1179,8 @@ describe("pull integration", () => { ); }); - // ------------------------------------------------------------------------- - // 9c. Dirty supabase/functions. Checked unconditionally (skipped only by - // --force), same as migrations, since the functions step always runs with - // no "does it have work" signal available without calling the API first. - // ------------------------------------------------------------------------- - + // Checked unconditionally (skipped only by --force), same as migrations, since the functions + // step always runs with no "does it have work" signal without calling the API first. describe("dirty supabase/functions", () => { it.live("aborts without --force, even with zero config/db drift", () => { writeConfig(); @@ -1355,11 +1223,6 @@ describe("pull integration", () => { ); }); - // ------------------------------------------------------------------------- - // 9d. Multiple dirty locations at once — the confirmation body/abort error - // names every one of them, not just the first found. - // ------------------------------------------------------------------------- - describe("multiple dirty locations at once", () => { it.live( "config AND functions dirty together: the confirmation body names both, config before functions", @@ -1411,10 +1274,6 @@ describe("pull integration", () => { ); }); - // ------------------------------------------------------------------------- - // 10. Config-write-visible-to-db-step ordering. - // ------------------------------------------------------------------------- - it.live( "the db step's shadow setup observes the config step's own db.major_version write, not the stale pre-pull value", () => { @@ -1449,11 +1308,6 @@ describe("pull integration", () => { }, ); - // ------------------------------------------------------------------------- - // 10b. forceMigrationMode: the db step stays in migration mode even when - // the ambient --experimental gate is on (CLI-1272 gap-close). - // ------------------------------------------------------------------------- - it.live( "the db step stays in migration mode with the ambient --experimental gate on, never taking the declarative export path", () => { @@ -1463,13 +1317,10 @@ describe("pull integration", () => { yes: true, experimental: true, remoteMigrations: [{ version: "20260101000000", name: "init", statements: ["select 1;"] }], - // A real diff, so the db step actually writes — if `pullDbStep` didn't - // pass `forceMigrationMode: true` through to `runDbPull`, this ambient - // `--experimental` gate would route it to the declarative export path - // instead, which the mocked `PgDeltaEngine.exportDeclarativeSchema` - // (`makePgDeltaEngine`, above) dies on — "pull never declares - // --declarative" — turning a silent mode switch into a hard failure - // here rather than a quiet behavior change. + // A real diff, so the db step actually writes. If `pullDbStep` didn't forward + // `forceMigrationMode: true`, the ambient `--experimental` gate would route this to + // the declarative path instead, which the mocked `PgDeltaEngine.exportDeclarativeSchema` + // dies on. diffOutcome: () => ({ changes: true, files: [{ name: "pull", sql: "alter table foo add column bar text;" }], @@ -1486,10 +1337,6 @@ describe("pull integration", () => { }, ); - // ------------------------------------------------------------------------- - // 11. Migration-history-before-db ordering. - // ------------------------------------------------------------------------- - it.live( "the migration-history step's remote read happens before the db step's own remote read", () => { @@ -1512,10 +1359,6 @@ describe("pull integration", () => { }, ); - // ------------------------------------------------------------------------- - // 12. -o/--output rejected. - // ------------------------------------------------------------------------- - it.live( "supabase pull -o json fails with a message pointing at --output-format, not a machine payload", () => { @@ -1532,11 +1375,6 @@ describe("pull integration", () => { }, ); - // ------------------------------------------------------------------------- - // Phase 0 preconditions: an invalid workdir, and a network (not status-code) - // failure resolving a branch-name --project-ref. - // ------------------------------------------------------------------------- - it.live( "a --workdir naming a directory that does not exist fails before any target resolution", () => { @@ -1549,10 +1387,6 @@ describe("pull integration", () => { expect(rendered).toContain("PullWorkdirError"); expect(api.requests).toHaveLength(0); - // CLI Invariant #1: telemetry flushes on EVERY invocation, - // even a target-resolution failure — but the linked-project cache - // write only fires once a ref has actually resolved, which never - // happened here (the workdir check runs before target resolution). expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cached).toBe(false); }).pipe(Effect.provide(layer)); @@ -1576,9 +1410,9 @@ describe("pull integration", () => { "a supabase/migrations read failure that is NOT 'missing' (a plain file colliding with the directory) fails with a read error, instead of silently treating it as empty", () => { writeConfig(); - // `fs.readDirectory` against a plain FILE fails with ENOTDIR, not - // "NotFound" — the bootstrap-detection read (Phase 0) must propagate - // this instead of collapsing it to "no migrations yet". + // `fs.readDirectory` against a plain file fails with ENOTDIR, not "NotFound" — the + // bootstrap-detection read must propagate this instead of treating it as "no migrations + // yet". writeFileSync(migrationsDir(), "not a directory"); const { layer } = setup({ yes: true }); return Effect.gen(function* () { @@ -1589,10 +1423,6 @@ describe("pull integration", () => { }, ); - // ------------------------------------------------------------------------- - // 13. Auto-run vs explicit --with-migration-history. - // ------------------------------------------------------------------------- - describe("migration-history auto-run vs --with-migration-history", () => { it.live( "supabase/migrations already has files and the flag is not set: skipped as not_needed", @@ -1617,11 +1447,8 @@ describe("pull integration", () => { "supabase/migrations already has files but --with-migration-history is set: the step actually runs", () => { writeConfig(); - // Named "init" to match the remote row's own name below — the fetch - // rewrites this SAME file rather than leaving a stale - // differently-named duplicate under the same version behind (which - // would desync the remote/local version reconciliation the db step - // runs next). + // Named "init" to match the remote row below, so the fetch rewrites this file instead + // of leaving a stale differently-named duplicate under the same version. seedLocalMigration("20260101000000", "init"); const { layer, out } = setup({ yes: true, @@ -1659,11 +1486,6 @@ describe("pull integration", () => { ); }); - // ------------------------------------------------------------------------- - // Per-step Phase-3 failure isolation: each of the four steps' own failure - // branch, one at a time, proving the OTHER three still ran and reported. - // ------------------------------------------------------------------------- - describe("per-step failure isolation", () => { it.live( "a config-step failure (a concurrent edit during the confirmation prompt) still runs migration_history/db/functions, in text mode", @@ -1673,9 +1495,8 @@ describe("pull integration", () => { const { layer, out } = setup({ stdinIsTty: true, confirm: [true], - // Keeps `[experimental.pgdelta] enabled = true` intact — the point - // is the config step's own TOCTOU guard, not accidentally routing - // the db step onto the (unmocked) migra/edge-runtime path. + // Keeps `[experimental.pgdelta] enabled = true` intact, so this exercises the config + // step's TOCTOU guard rather than routing the db step onto the unmocked migra path. confirmSideEffect: () => writeFileSync( configPath(), @@ -1697,9 +1518,6 @@ describe("pull integration", () => { expect(stepLine(out!.stdoutText, "db")).toContain("unchanged"); expect(stepLine(out!.stdoutText, "functions")).toContain("changed"); - // The retry hint names the exact standalone command to rerun just - // the failed config step, using the resolved ref — surfaced in the - // text-mode summary, not just the JSON payload. expect(out!.stdoutText).toContain( `To retry just this step, run: supabase config pull --project-ref ${VALID_REF}`, ); @@ -1748,10 +1566,9 @@ describe("pull integration", () => { const { layer, out } = setup({ stdinIsTty: true, confirm: [true], - // Concurrent edit during the confirmation prompt trips the config - // step's own TOCTOU guard BEFORE the planned `[remotes.staging]` - // block (a branch-derived implicit target — `--remote-label` was - // never passed) is ever written. + // Concurrent edit during the confirmation prompt trips the config step's TOCTOU + // guard before the planned `[remotes.staging]` block (implicit target; + // --remote-label was never passed) is ever written. confirmSideEffect: () => writeFileSync( configPath(), @@ -1771,10 +1588,6 @@ describe("pull integration", () => { expect(stepLine(out!.stdoutText, "config")).toContain("failed"); expect(readFileSync(configPath(), "utf8")).not.toContain("[remotes.staging]"); - // The plan targeted `[remotes.staging]` — the retry hint must name that - // SAME planned destination label, never the raw (absent) --remote-label - // flag value, or a standalone `config pull` rerun would see a ref-shaped - // target with no matching block and write into the config root instead. expect(out!.stdoutText).toContain( `To retry just this step, run: supabase config pull --project-ref ${BRANCH_REF} --remote-label 'staging'`, ); @@ -1789,11 +1602,9 @@ describe("pull integration", () => { const { layer, out } = setup({ yes: true, api: { functionSlugs: [] }, - // A path-traversal `name` trips `runMigrationFetch`'s own - // injection guard (CWE-22) — a real, reachable write failure, not a - // synthetic one. Since the fetch never gets to write this row, the - // db step's own remote/local reconciliation (same table) also - // conflicts — a real cascading-failure case, not a synthetic one. + // A path-traversal `name` trips `runMigrationFetch`'s injection guard (CWE-22): a + // real, reachable write failure that also cascades into a db-step reconciliation + // conflict. remoteMigrations: [ { version: "20260101000000", name: "../evil", statements: ["select 1;"] }, ], @@ -1802,8 +1613,6 @@ describe("pull integration", () => { return Effect.gen(function* () { const exit = yield* Effect.exit(runPull(pullFlags())); expect(Exit.isFailure(exit)).toBe(true); - // The FIRST failure (migration_history) is what re-fails the - // process, even though db independently fails too. expect(JSON.stringify(exit)).toContain("MigrationFetchWriteError"); expect(JSON.stringify(exit)).not.toContain("DbPullMigrationConflictError"); @@ -1812,9 +1621,6 @@ describe("pull integration", () => { expect(stepLine(out!.stdoutText, "db")).toContain("failed"); expect(stepLine(out!.stdoutText, "functions")).toContain("unchanged"); - // Both the migration_history step's own retry hint AND the db - // step's own (its failure is a migration conflict, cascading from - // the same hostile row) show up in the text-mode summary. expect(out!.stdoutText).toContain( `To retry just this step, run: supabase migration fetch --project-ref ${VALID_REF}`, ); @@ -1833,10 +1639,9 @@ describe("pull integration", () => { format: "json", yes: true, api: { functionSlugs: [] }, - // The first row is well-formed and writes successfully; the SECOND - // row's path-traversal `name` trips the write loop's own injection - // guard (CWE-22) only once the first row is already on disk — a - // real, reachable partial-write case, not a synthetic one. + // The first row writes successfully; the second row's path-traversal `name` trips + // the write loop's injection guard (CWE-22) only once the first row is already on + // disk. remoteMigrations: [ { version: "20260101000000", name: "good", statements: ["select 1;"] }, { version: "20260102000000", name: "../evil", statements: ["select 1;"] }, @@ -1846,11 +1651,8 @@ describe("pull integration", () => { return Effect.gen(function* () { yield* runPull(pullFlags()); - // The first row's file was actually written to disk... expect(existsSync(join(migrationsDir(), "20260101000000_good.sql"))).toBe(true); - // ...and the failed step's own JSON payload reports it as written, instead of - // always claiming `written: []` on a failed step. const envelope = JSON.parse(capturingStdio!.stdout[0]!) as Record<string, unknown>; const steps = envelope["steps"] as Record<string, unknown>; const migrationHistory = steps["migration_history"] as Record<string, unknown>; @@ -1859,10 +1661,6 @@ describe("pull integration", () => { "supabase/migrations/20260101000000_good.sql", ]); - // Fix D (CLI-1272 review): the top-level `wrote` flag is derived from every - // step's actual recorded writes, not just a "changed" status — no OTHER step - // changed anything in this scenario, so before this fix `wrote` was `false` - // even though a file was genuinely written to disk. expect(envelope["wrote"]).toBe(true); }).pipe(Effect.provide(layer)); }, @@ -1902,10 +1700,9 @@ describe("pull integration", () => { "a db-step migration-conflict failure carries BOTH the --with-migration-history hint AND the generic retry hint, in that order", () => { writeConfig(); - // A local file that does not match the single remote history row — - // `reconcileMigrations` treats every non-matching version on - // either side as a conflict — trips `db pull`'s own - // `DbPullMigrationConflictError`, not a synthetic one. + // A local file that doesn't match the remote history row trips `db pull`'s own + // `DbPullMigrationConflictError`, since `reconcileMigrations` treats any non-matching + // version as a conflict. seedLocalMigration("20260101000000"); const { layer, capturingStdio } = setup({ format: "json", @@ -1929,11 +1726,6 @@ describe("pull integration", () => { expect(String(failure["code"])).toBe("DbPullMigrationConflictError"); const suggestion = String(failure["suggestion"]); - // The suggested `--with-migration-history` remedy names the exact - // RESOLVED target `pull` itself used — omitting `--project-ref` would let a - // blind rerun silently retarget the checkout's linked project instead - // (a data-safety concern: this remedy writes the remote migration history - // table). const migrationHistoryHintIndex = suggestion.indexOf( `Alternatively, rerun \`supabase pull --with-migration-history --project-ref ${VALID_REF}\``, ); @@ -2029,13 +1821,10 @@ describe("pull integration", () => { return Effect.gen(function* () { yield* runPull(pullFlags()); - // The first slug actually downloaded to disk... expect( existsSync(join(tempRoot.current, "supabase", "functions", "hello", "index.ts")), ).toBe(true); - // ...and the failed step's own JSON payload reports its directory as - // written, instead of always claiming `written: []` on a failed step. const envelope = JSON.parse(capturingStdio!.stdout[0]!) as Record<string, unknown>; const steps = envelope["steps"] as Record<string, unknown>; const functions = steps["functions"] as Record<string, unknown>; @@ -2098,13 +1887,8 @@ describe("pull integration", () => { ); }); - // ------------------------------------------------------------------------- - // dry-run / declined, when migration history is genuinely NOT needed - // (`supabase/migrations` already has files) — the "not_needed" reason - // branch of each early-return, distinct from the bootstrap ("declined"/ - // "planned" via an empty directory) cases exercised above. - // ------------------------------------------------------------------------- - + // Exercises the "not_needed" reason branch (migrations already populated), distinct from the + // bootstrap ("declined"/"planned" via an empty directory) cases above. it.live( "--dry-run over an already-populated supabase/migrations reports migration_history as not_needed", () => { @@ -2134,15 +1918,9 @@ describe("pull integration", () => { ); }); -// --------------------------------------------------------------------------- -// `--project-ref` telemetry redaction: the raw `--project-ref` VALUE recorded -// in `cli_command_executed`'s `flags` map depends on the literal argv the -// wrapper's `changedFlagNames` scan reads from `Stdio.args` — a separate -// source from the `PullFlags` object the handler itself runs on — so -// each case below overrides `Stdio.args` on top of `setup()`'s own default -// (mirrors `config diff`'s own telemetry-wiring precedent). -// --------------------------------------------------------------------------- - +// The raw `--project-ref` value in `cli_command_executed`'s `flags` map is read from the +// literal argv (`Stdio.args`), a separate source from the `PullFlags` object the handler runs +// on, so each case below overrides `Stdio.args` on top of `setup()`'s default. describe("pull telemetry wiring", () => { const withProjectRefArgs = (base: ReturnType<typeof setup>["layer"], projectRef: string) => Layer.mergeAll( @@ -2161,8 +1939,8 @@ describe("pull telemetry wiring", () => { }); it.live("redacts a branch-name-shaped --project-ref in cli_command_executed", () => { - // `--project-ref` accepts branch names too (CLI-2167 vocabulary) — a - // user-created branch name must never reach PostHog verbatim. + // `--project-ref` accepts branch names too; a user-created branch name must never reach + // PostHog verbatim. writeConfig(); const { layer, analytics } = setup({ yes: true }); return Effect.gen(function* () { @@ -2173,15 +1951,9 @@ describe("pull telemetry wiring", () => { }); }); -// --------------------------------------------------------------------------- -// `--output-format stream-json`: `streamJsonOutputLayer` (`output.layer.ts`) -// nests the success payload under `data` (`{type: "result", data: {...}, -// timestamp}`), but its `fail` implementation spreads `MachineErrorContext`'s -// payload at the TOP level, alongside `type`/`error`/`timestamp` — an -// asymmetry within the SAME output layer, not something to assume matches -// `success`'s own shape. -// --------------------------------------------------------------------------- - +// `streamJsonOutputLayer` nests the success payload under `data`, but its `fail` +// implementation spreads `MachineErrorContext`'s payload at the top level alongside +// `type`/`error`/`timestamp` — an asymmetry within the same layer. describe("pull stream-json output", () => { it.live( "a successful run emits exactly one NDJSON result event with the payload nested under data", @@ -2234,13 +2006,8 @@ describe("pull stream-json output", () => { const event = JSON.parse(capturingStdio!.stdout[0]!) as Record<string, unknown>; expect(event["type"]).toBe("error"); expect(event["data"]).toBeUndefined(); - // `error` is the FIRST failing step's own (re-failed) cause, not the - // aggregate's summary message — same underlying cause the payload's - // own `steps.functions.failure.message` reports below. const error = event["error"] as Record<string, unknown>; expect(String(error["message"])).toContain("500"); - // The aggregate payload (`pullPayload`) is spread directly onto - // the event, sibling to `type`/`error`/`timestamp` — not nested. const steps = event["steps"] as Record<string, unknown>; expect((steps["functions"] as Record<string, unknown>)["status"]).toBe("failed"); const failure = (steps["functions"] as Record<string, unknown>)["failure"] as Record< diff --git a/apps/cli/src/commands/pull/pull.layers.ts b/apps/cli/src/commands/pull/pull.layers.ts index ebe005379e..bc4378fe90 100644 --- a/apps/cli/src/commands/pull/pull.layers.ts +++ b/apps/cli/src/commands/pull/pull.layers.ts @@ -25,49 +25,11 @@ import { } from "../../command-internal/pgdelta-engine-runtime.layer.ts"; /** - * Runtime layer for `supabase pull`. Composes only the db-specific pieces of - * `db pull`'s own runtime (`pgDeltaDbConfigRuntimeLayer` / - * `pgDeltaCommandRuntimeLayer` / `migraRuntimeLayer` — - * `DbConfigResolver`/`DbConnection`, the pg-delta/migra diff - * engines, shadow DB / Docker spawning — plus the bare identity-stitch and - * stdin layers) with the eager Management API stack `config pull`/`functions - * download` use (`managementApiRuntimeLayer`), plus `MachineErrorContext` - * for the single-envelope machine-mode failure path (`status`'s own - * precedent). `migrationDbRuntimeLayer` (`migration fetch`'s own - * runtime) is documented as a strict subset of this db-schema-pull stack — no - * separate migration layer is merged in. - * - * Deliberately NOT merged in: `db/pull/pull.layers.ts`'s - * `dbSchemaPullRuntimeLayer` wrapper itself, specifically its - * `linkedDbResolverRuntimeLayer(command)` piece - * (`command-internal/management-api-runtime.layer.ts:174-204`). That - * piece independently binds the same four services - * `managementApiRuntimeLayer` already provides — - * `ProjectRefResolver`, `LinkedProjectCache`, `CommandSettings`, - * `CommandRuntime` — backed by the LAZY `CommandPlatformApiFactory` stack - * instead of the eager one `managementApiRuntimeLayer` uses. - * - * An earlier version of this file merged both wrappers and relied on - * `Layer.mergeAll` argument order to make the eager binding win, citing - * `Context.mergeAll`'s "last wins" duplicate-key rule - * (`.repos/effect/packages/effect/src/Context.ts:1112-1113`). That reasoning - * doesn't apply here: both wrappers' resolver/cache layers are ultimately the - * SAME module-level singletons (`projectRefLayer`, - * `linkedProjectCacheLayer` — `config/project-ref.layer.ts:29`), - * and `Layer.effect` builds via `fromBuildMemo`, which memoizes a layer by ITS - * OWN object identity in a shared `MemoMap` - * (`.repos/effect/packages/effect/src/Layer.ts:371-379`). `Layer.mergeAll` - * builds every top-level layer CONCURRENTLY - * (`Effect.forEach(layers, ..., { concurrency: layers.length })` — - * `.repos/effect/packages/effect/src/Layer.ts:1143-1147`), so the singleton is - * built exactly once and whichever of the two `.pipe(Layer.provide(...))` - * wrappers happens to reach its `.build(...)` call first decides which - * factory backs it — a build-order race, not a property of merge position or - * `Context.mergeAll`. Merging in only the db-specific pieces below — - * bypassing `linkedDbResolverRuntimeLayer` entirely — removes the - * second binding, so `managementApiRuntimeLayer`'s eager-backed - * resolver/cache is the SOLE provider of all four services and there is no - * race left to reason about. + * Runtime layer for `supabase pull`: composes `db pull`'s db-specific pieces with the eager + * Management API stack and `MachineErrorContext` for single-envelope machine failures. Excludes + * `dbSchemaPullRuntimeLayer`'s `linkedDbResolverRuntimeLayer` piece, which independently binds + * the same services via a different (lazy) factory — merging both would race over which + * factory wins, since concurrent layer builds settle a shared singleton in build order. */ export const pullRuntimeLayer = Layer.mergeAll( pgDeltaDbConfigRuntimeLayer, @@ -80,17 +42,10 @@ export const pullRuntimeLayer = Layer.mergeAll( ); /** - * Compile-time guarantee that `pullRuntimeLayer` exposes the services - * `pull.handler.ts`/`pull.steps.ts` need — mirrors - * `management-api-runtime.layer.ts`'s own `_serviceCoverageCheck` - * (`managementApiRuntimeLayer`, this file's neighbor). `Layer`'s - * provided-services parameter is declared contravariant - * (`interface Layer<in ROut, out E, out RIn>`), so this assignment only - * type-checks when every service listed here is one `pullRuntimeLayer` - * genuinely provides; a service dropped from the merge above without being - * dropped here fails to compile. Root-level services (`Output`, `CliArgs`, - * `Tty`, …) are deliberately excluded, same as the management-API precedent - * — they come from `runCli`, not this command-level layer. + * Compile-time check that `pullRuntimeLayer` provides every service `pull.handler.ts`/ + * `pull.steps.ts` need; a service dropped from the merge above without being dropped here fails + * to compile. Root-level services (`Output`, `CliArgs`, `Tty`, …) are excluded since they come + * from `runCli`, not this command-level layer. */ type PullServices = | CommandPlatformApi diff --git a/apps/cli/src/commands/pull/pull.live.test.ts b/apps/cli/src/commands/pull/pull.live.test.ts index 4e5296215d..1daccebb27 100644 --- a/apps/cli/src/commands/pull/pull.live.test.ts +++ b/apps/cli/src/commands/pull/pull.live.test.ts @@ -2,13 +2,9 @@ import { expect } from "vitest"; import { requireLiveSuccess, test } from "../../../tests/helpers/live.ts"; -// Golden path only: the one thing mocks cannot prove is the real four-step -// orchestration (`config pull`, migration-history fetch, `db pull`'s -// shadow-db diff, `functions download`) actually reaching a live Management -// API and its provisioned project's data plane in one pass, against a fresh -// `supabase init` checkout (the `workspace` fixture behind `cli`). Branch -// coverage for dry-run/declined/partial-failure dispositions lives in -// pull.aggregate.unit.test.ts and any handler-level integration test. +// Golden path only: the real four-step orchestration reaching a live Management API and its +// project's data plane in one pass, against a fresh `supabase init` checkout. Branch coverage for +// other dispositions lives in pull.aggregate.unit.test.ts and handler-level integration tests. test("pulls config, migration history, db schema, and functions from a fresh project", async ({ cli, project, diff --git a/apps/cli/src/commands/pull/pull.steps.ts b/apps/cli/src/commands/pull/pull.steps.ts index d13979e668..08e93e9eeb 100644 --- a/apps/cli/src/commands/pull/pull.steps.ts +++ b/apps/cli/src/commands/pull/pull.steps.ts @@ -29,26 +29,16 @@ import type { import type { PullStepContext } from "./pull.types.ts"; /** - * `supabase pull` step runners — one per `PULL_STEP_ORDER` entry - * (`pull.types.ts`). Each is a thin adapter over its sub-step's own run-core: - * it calls the real implementation and shapes the result into the - * `PullXStepOutcome` union `pull.aggregate.ts`'s mappers expect. - * - * No confirmation, `Exit`-based failure capture, or aggregation lives here — - * that is `pull.handler.ts`'s job (Phase 3). These runners are only ever - * called once the aggregated confirmation has already been accepted (or - * `--yes` bypassed it), so every one of them actually performs its side - * effect — there is no internal "dry run"/"declined" branch here; those - * outcomes are built directly by `pull.handler.ts` from `pull.aggregate.ts`'s - * mappers without ever reaching this module. + * `supabase pull` step runners — one per `PULL_STEP_ORDER` entry, each a thin adapter over its + * sub-step's own run-core. No confirmation, failure capture, or aggregation lives here: these + * only run once the aggregated confirmation is accepted, so every one performs its side effect + * (there's no internal dry-run/declined branch). */ /** - * `config` step: applies the already-planned config write (skipped when the - * plan had no work at all) and reports the fixed `{dryRun: false, confirmed: - * true}` shape `pullConfigStepResult` expects for an executed run — the - * `dryRun`/declined variants of this outcome are built directly by - * `pull.handler.ts` (Phase 1/2), never through this function. + * `config` step: applies the already-planned config write (skipped when the plan had no work) + * and reports the fixed `{dryRun: false, confirmed: true}` shape `pullConfigStepResult` expects + * for an executed run. */ export const pullConfigStep = Effect.fnUntraced(function* (input: { readonly runPlan: ConfigPullRunPlan; @@ -67,14 +57,9 @@ export const pullConfigStep = Effect.fnUntraced(function* (input: { /** * `migration_history` step: fetches the remote migration history table into - * `supabase/migrations` via `migration fetch`'s run-core, targeting the - * already-resolved `context.ref` directly (`{setFlags: [], connType: - * "linked"}` — resolve-once, per ADR 0024) and suppressing its own internal - * overwrite prompt with `assumeYes` (the orchestrator's own confirmation - * already covers this). Only called when the orchestrator decided this step - * should actually run this invocation (`--with-migration-history`, or an - * empty/missing `supabase/migrations`) — the "not needed" skip is built - * directly by `pull.handler.ts`, never through this function. + * `supabase/migrations`, targeting the already-resolved `context.ref` directly (ADR 0024) and + * suppressing its own overwrite prompt with `assumeYes` since the orchestrator's confirmation + * already covers it. */ export const pullMigrationHistoryStep = Effect.fnUntraced(function* (context: PullStepContext) { const cliSettings = yield* CommandSettings; @@ -97,18 +82,12 @@ export const pullMigrationHistoryStep = Effect.fnUntraced(function* (context: Pu }); /** - * `db` step: pulls the linked project's schema in migration mode (no - * `--declarative`/diff-engine override), targeting `context.ref` directly and - * suppressing `db pull`'s own remote-history-update prompt with `assumeYes`. - * `forceMigrationMode: true` closes the gap the constructed flags alone - * cannot: without it, an ambient `--experimental`/`SUPABASE_EXPERIMENTAL` gate - * would silently switch `runDbPull` to the declarative export path — writing - * `supabase/schemas/**` and potentially `config.toml`'s `schema_paths` — none - * of which `pull`'s own dirty-check/confirmation guards against (see - * `DbPullInvoke.forceMigrationMode`'s doc comment). `DbPullInSyncError` — the - * remote already matches local migrations — is caught here and reported as - * `in_sync` (a finding, not a failure, at the `pull` level per ADR 0024) - * rather than propagating to `pull.handler.ts`'s failure-capture path. + * `db` step: pulls the linked project's schema in migration mode, targeting `context.ref` + * directly and suppressing `db pull`'s remote-history-update prompt with `assumeYes`. + * `forceMigrationMode: true` prevents an ambient `--experimental` gate from silently switching to + * the declarative export path, which `pull`'s dirty-check and confirmation don't guard against. + * `DbPullInSyncError` is caught and reported as `in_sync`, a finding rather than a failure at the + * `pull` level (ADR 0024). */ export const pullDbStep = Effect.fnUntraced(function* (context: PullStepContext) { const cliSettings = yield* CommandSettings; @@ -141,12 +120,9 @@ export const pullDbStep = Effect.fnUntraced(function* (context: PullStepContext) }); /** - * `functions` step: downloads every Edge Function's source from the linked - * project via the shared `downloadFunctions` run-core, matching the - * standalone `functions download` command's own `--use-api`/`--use-docker` - * defaults. `legacyBundle` is always `false`, so `proxyDownload` is - * unreachable and `resolveProjectRef` never re-resolves — the target ref was - * already resolved once, up front, by the orchestrator. + * `functions` step: downloads every Edge Function's source, matching the standalone `functions + * download` command's `--use-api`/`--use-docker` defaults. `legacyBundle: false` keeps + * `proxyDownload` unreachable, so `resolveProjectRef` just returns the already-resolved ref. */ export const pullFunctionsStep = Effect.fnUntraced(function* (context: PullStepContext) { const api = yield* CommandPlatformApi; diff --git a/apps/cli/src/commands/pull/pull.types.ts b/apps/cli/src/commands/pull/pull.types.ts index 1296998fa7..ece1f2da2c 100644 --- a/apps/cli/src/commands/pull/pull.types.ts +++ b/apps/cli/src/commands/pull/pull.types.ts @@ -7,8 +7,8 @@ export type PullStepStatus = "changed" | "unchanged" | "skipped" | "planned" | " export interface PullStepFailure { readonly message: string; readonly suggestion?: string; - /** The squashed cause's own `_tag`, when it has one — lets a machine consumer of the JSON - * payload classify a non-first (never re-failed) step failure without parsing `message`. */ + /** The cause's own `_tag`, when present — lets a machine consumer classify a step failure + * without parsing `message`. */ readonly code?: string; } @@ -17,7 +17,7 @@ export interface PullStepResult { readonly status: PullStepStatus; /** Workdir-relative paths written by this step. Empty when nothing was written. */ readonly written: ReadonlyArray<string>; - /** Per-step machine detail, shape varies by step (see plan's payload section). */ + /** Per-step machine detail; shape varies by step. See SIDE_EFFECTS.md's JSON payload shape. */ readonly detail: Record<string, unknown>; /** Present for `status: "skipped"` (e.g. "not_needed", "declined", "--with-migration-history not set"). */ readonly reason?: string; diff --git a/apps/cli/src/commands/secrets/list/list.handler.ts b/apps/cli/src/commands/secrets/list/list.handler.ts index 8dcd08f002..225202e8c3 100644 --- a/apps/cli/src/commands/secrets/list/list.handler.ts +++ b/apps/cli/src/commands/secrets/list/list.handler.ts @@ -39,7 +39,7 @@ function sortSecrets(secrets: Secrets): Secrets { return [...secrets].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); } -/** Type shape for the secrets response, used to drive `-o yaml|toml` key casing (see `apps/cli-go/pkg/api/types.gen.go`). */ +/** Struct shape for the secrets response; drives `-o yaml|toml` key casing. */ const GO_SECRET_RESPONSE = goStruct([ ["name", goString], ["updated_at", goPtr(goString)], @@ -60,8 +60,6 @@ export const secretsList = Effect.fn("secrets.list")(function* (flags: SecretsLi const ref = yield* resolver.resolve(flags.projectRef); - // Write the linked-project cache and persist the telemetry state file - // whether the main API call succeeds or fails. yield* Effect.gen(function* () { const fetching = output.format === "text" ? yield* output.task("Fetching secrets...") : undefined; @@ -92,8 +90,6 @@ export const secretsList = Effect.fn("secrets.list")(function* (flags: SecretsLi return; } - // goFmt is undefined or "pretty" — defer to TS --output-format for JSON/stream-json, - // otherwise render the Glamour-styled table (Go --output pretty parity). if (output.format === "json" || output.format === "stream-json") { yield* output.success("", { secrets: sorted }); return; diff --git a/apps/cli/src/commands/secrets/list/list.integration.test.ts b/apps/cli/src/commands/secrets/list/list.integration.test.ts index 10b67ac642..2a2253d3c7 100644 --- a/apps/cli/src/commands/secrets/list/list.integration.test.ts +++ b/apps/cli/src/commands/secrets/list/list.integration.test.ts @@ -88,8 +88,7 @@ describe("secrets list integration", () => { }); return Effect.gen(function* () { yield* secretsList({ projectRef: Option.none() }); - // Reference pipeline: markdown `\|` → glamour decodes to literal `|`. - // Our renderer skips the markdown step and emits the literal pipe directly. + // The renderer skips markdown escaping entirely, so the pipe passes through literally. expect(out.stdoutText).toContain("with|pipe"); }).pipe(Effect.provide(layer)); }); @@ -122,8 +121,6 @@ describe("secrets list integration", () => { const { layer, out } = setup({ goOutput: "json", response: SAMPLE_SECRETS }); return Effect.gen(function* () { yield* secretsList({ projectRef: Option.none() }); - // Sorted (BAR before FOO) and alphabetical-key JSON, matching the - // SecretResponse {Name, UpdatedAt, Value} field order. expect(out.stdoutText).toBe( `[ { @@ -155,7 +152,6 @@ describe("secrets list integration", () => { return Effect.gen(function* () { yield* secretsList({ projectRef: Option.none() }); expect(out.stdoutText).toContain("[[secrets]]"); - // PascalCase field names with BurntSushi's 2-space indent (CLI-1975). expect(out.stdoutText).toContain(' Name = "BAR"'); expect(out.stdoutText).toContain(' Value = "digest-bar"'); }).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/commands/secrets/secrets.errors.ts b/apps/cli/src/commands/secrets/secrets.errors.ts index 866d5dce5a..ea1e120e2b 100644 --- a/apps/cli/src/commands/secrets/secrets.errors.ts +++ b/apps/cli/src/commands/secrets/secrets.errors.ts @@ -7,10 +7,6 @@ import { statusCodeActionability, } from "../../shared/telemetry/error-actionability.ts"; -// --------------------------------------------------------------------------- -// HTTP-bound errors (network + unexpected-status pairs) -// --------------------------------------------------------------------------- - export class SecretsListNetworkError extends Data.TaggedError("SecretsListNetworkError")<{ readonly message: string; readonly decode?: boolean; @@ -80,10 +76,6 @@ export class SecretsUnsetUnexpectedStatusError extends Data.TaggedError( } } -// --------------------------------------------------------------------------- -// Pure-path errors (validation, file I/O, user cancellation) -// --------------------------------------------------------------------------- - export class SecretsEnvFileOpenError extends Data.TaggedError("SecretsEnvFileOpenError")<{ readonly message: string; readonly reason: "not_found" | "permission" | "other"; diff --git a/apps/cli/src/commands/secrets/secrets.format.ts b/apps/cli/src/commands/secrets/secrets.format.ts index c7db91384c..ae9783a1fc 100644 --- a/apps/cli/src/commands/secrets/secrets.format.ts +++ b/apps/cli/src/commands/secrets/secrets.format.ts @@ -3,19 +3,8 @@ import { renderGlamourTable } from "../../output/glamour-table.ts"; const SECRETS_HEADERS = ["NAME", "DIGEST"] as const; /** - * Reproduces the byte output of `secrets list` pretty mode: - * - * 1. Build a markdown table source where each cell content is wrapped in - * backticks and any `|` in the name is markdown-escaped with `\|`. - * 2. Pipe that through `glamour.RenderTable` (`AsciiStyle`). - * - * Glamour's renderer strips the backticks and converts the markdown-escaped - * `\|` back to a literal `|` in cell content before laying out columns. The - * net result is the cell content rendered as-is. `renderGlamourTable` skips - * the markdown intermediate step entirely and lays out columns directly, so - * we pass the raw name and digest with no escaping or wrapping. Verified - * byte-identical on `secrets list` with empty, single, and pipe-containing - * names. + * Reproduces `secrets list`'s established pretty-mode byte output. `renderGlamourTable` lays + * out columns directly, so a literal `|` in a secret name passes through unescaped. */ export function renderSecretsListTable( secrets: ReadonlyArray<{ readonly name: string; readonly value: string }>, diff --git a/apps/cli/src/commands/secrets/set/set.handler.ts b/apps/cli/src/commands/secrets/set/set.handler.ts index 323fbdc7b4..c7b56a8cc3 100644 --- a/apps/cli/src/commands/secrets/set/set.handler.ts +++ b/apps/cli/src/commands/secrets/set/set.handler.ts @@ -37,55 +37,18 @@ const mapSetError = mapHttpError({ const decodeCliConfig = Schema.decodeUnknownSync(CliConfigSchema); -// Excludes arrays, matching `packages/config/src/io.ts`'s `isObject` (the -// identical "is this a table" check used when merging `[remotes.*]`). A TOML -// array for a map-typed field (e.g. `[edge_runtime] secrets = ["actual-secret"]`) -// is not a recoverable table: `Object.entries` on an array yields index keys -// ("0", "1", ...), which would otherwise fabricate spurious secret names. The -// mapstructure decoder `pkg/config/config.go`'s `UnmarshalExact` uses never -// does this either — it never sets `WeaklyTypedInput`, so a slice source for -// a map-typed field hits `UnconvertibleTypeError` in `decodeMap` rather than -// the index-as-key `decodeMapFromSlice` path, and the whole field is left -// empty. +// Excludes arrays: `Object.entries` on an array yields index keys ("0", "1", ...), which would +// otherwise fabricate spurious secret names from a misconfigured `secrets = [...]` field. function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); } /** - * Best-effort recovery for a schema-decode failure (as opposed to a raw - * TOML/JSON parse failure) on `supabase/config.toml`. The `viper`+ - * `mapstructure` decode (`pkg/config/config.go:749`) mutates the - * target struct field-by-field: a type error anywhere — an unrelated - * top-level table (`analytics.port`), a sibling field inside the same - * `edge_runtime` table (`edge_runtime.inspector_port`), *or* a single bad - * entry inside the `edge_runtime.secrets` map itself (`BAD = 123`) — does not - * stop the rest of `edge_runtime.secrets` from landing in `utils.Config`. - * `UnmarshalExact` still populates every field (and every map entry) it *can* - * decode before aggregating errors: `mapstructure`'s map decoder - * (`decodeMapFromMap`) iterates each key independently, appends a per-entry - * error and `continue`s rather than aborting, then still calls `val.Set` with - * whatever entries succeeded. Confirmed empirically against this repo's - * actual `pkg/config`: a TOML with both a malformed `edge_runtime.inspector_port` - * and a valid `[edge_runtime.secrets]` block still yields a populated - * `EdgeRuntime.Secrets` (`InspectorPort` is left at its zero value), and a - * `[edge_runtime.secrets]` block with one bad entry alongside a good one - * still yields the good entry. - * `Schema.decodeUnknownSync` has no such tolerance; a single bad field - * anywhere discards the whole decode — re-decoding the *entire* `edge_runtime` - * subtree would still fail in the sibling-field case (`inspector_port` comes - * along for the ride), and re-decoding the whole `secrets` map atomically - * would still fail when just one entry in that map is bad. To keep - * `secrets set` at parity without loosening `packages/config`'s decode - * semantics for every caller: re-slice `edge_runtime.secrets` out of the - * pre-decode document (`cause.document` — only set when the document itself - * parsed fine and the *schema* decode is what failed, see - * `CliConfigParseError`), decode each entry independently and keep only - * the ones that succeed (mirroring `decodeMapFromMap`'s per-key tolerance), - * then decode the filtered map against the full schema, where every other - * field (including the rest of `edge_runtime`) defaults cleanly. A true parse - * failure (`cause.document` undefined) has no recoverable structure in either - * implementation — `viper.MergeConfig` also fails the whole load before - * `mapstructure` ever runs in that case. + * Best-effort recovery of `[edge_runtime.secrets]` from a document that failed schema decode + * (not a raw parse failure). Matches the CLI's established per-field decode tolerance: an + * unrelated bad field, or a single bad entry inside `secrets` itself, must not discard every + * otherwise-valid secret. `Schema.decodeUnknownSync` has no such tolerance, so this re-slices + * `edge_runtime.secrets` out of the pre-decode document and decodes each entry independently. */ function recoverEdgeRuntimeConfig(cause: CliConfigParseError): CliConfig | null { if (cause.document === undefined) { @@ -93,13 +56,9 @@ function recoverEdgeRuntimeConfig(cause: CliConfigParseError): CliConfig | null } const edgeRuntime = cause.document.edge_runtime; const secretsField = isRecord(edgeRuntime) ? edgeRuntime.secrets : undefined; - // `redactEdgeRuntimeSecrets` (`packages/config/src/io.ts`) wraps a malformed, - // non-object `secrets` field (e.g. a TOML array) in a single `Redacted` - // rather than leaving it a plain record, so an uncaught error can't leak it - // either. Unwrap before the `isRecord` check below — otherwise the - // `Redacted` wrapper object itself (an object, just not a secrets map) gets - // misread as a one-entry map and fabricates a bogus secret from its - // internal fields. + // A malformed, non-object `secrets` field arrives wrapped in `Redacted`; unwrap before the + // `isRecord` check below, or the wrapper object itself gets misread as a one-entry map and + // fabricates a bogus secret from its internal fields. const secrets = Redacted.isRedacted(secretsField) ? Redacted.value(secretsField) : secretsField; const decodableSecrets = isRecord(secrets) ? filterDecodableSecrets(secrets) : undefined; try { @@ -112,19 +71,10 @@ function recoverEdgeRuntimeConfig(cause: CliConfigParseError): CliConfig | null } /** - * Mirrors mapstructure's per-entry map decode tolerance - * (`decodeMapFromMap`, invoked via `v.UnmarshalExact` in - * `pkg/config/config.go:749`): a decode error on one secret - * value doesn't discard the whole `[edge_runtime.secrets]` map — only that - * entry is dropped, and every other entry is still recovered. - * - * Each value arrives wrapped in `Redacted` (whatever its underlying type) — - * `CliConfigParseError.document` wraps every `edge_runtime.secrets` entry - * so an uncaught parse error can't leak a resolved secret, or a malformed - * non-string entry, into a log or trace (see the field doc on `.document`). - * Unwrap before re-decoding: `secret()`'s schema is a plain `Schema.String`, - * not `Redacted`, and a non-string entry (e.g. an array) still fails that - * decode and is dropped below, same as the reference decoder would. + * Decodes each `edge_runtime.secrets` entry independently and keeps only the ones that + * succeed, so one bad value doesn't discard the whole map. Each value arrives wrapped in + * `Redacted` (protecting it from leaking into a log if decode fails); unwrap before re-decoding + * since `secret()`'s schema is a plain `Schema.String`, not `Redacted`. */ function filterDecodableSecrets(secrets: Record<string, unknown>): Record<string, unknown> { const kept: Record<string, unknown> = {}; @@ -134,7 +84,7 @@ function filterDecodableSecrets(secrets: Record<string, unknown>): Record<string decodeCliConfig({ edge_runtime: { secrets: { [name]: plainValue } } }); kept[name] = plainValue; } catch { - // Drop this entry only, matching mapstructure's per-key error handling. + // Drop this entry only. } } return kept; @@ -154,35 +104,18 @@ export const secretsSet = Effect.fn("secrets.set")(function* (flags: SecretsSetF const ref = yield* resolver.resolve(flags.projectRef); yield* Effect.gen(function* () { - // Source 1: `[edge_runtime.secrets]` from `supabase/config.toml`. - // - // Only resolved secret values are sent — entries whose `env(VAR)` references - // are unresolved are skipped. This filters on whether the SHA256 hash is - // set: the hash is empty exactly when `DecryptSecretHookFunc` - // (`pkg/config/secret.go:98`) sees a still-literal `env(VAR)` and returns - // without hashing. In the TS path, `resolveCliConfigSubtree` - // wraps every resolved secret leaf in `Redacted<string>`; unresolved env() - // literals stay as plain strings, so `Redacted.isRedacted(...)` is the - // equivalent guard. + // Source 1: `[edge_runtime.secrets]` from `supabase/config.toml`. Only resolved values are + // sent: `resolveCliConfigSubtree` wraps every resolved secret leaf in `Redacted<string>`, + // while unresolved `env(VAR)` references stay plain strings, so `Redacted.isRedacted` + // distinguishes them. const merged = new Map<string, string>(); - // A malformed config.toml (or a malformed `.env`/`.env.local` sibling — - // see the `CliProjectEnvParseError` catch below) is swallowed here (logged - // to the debug logger) and this proceeds with an empty - // `EdgeRuntime.Secrets` — env-file and positional-arg secrets still - // work. `secrets set` has no `--linked`/`--local`/`--db-url` flag, so - // (unlike most commands) the config isn't loaded any earlier either; - // this is the only load, and it must not be fatal. + // A malformed config.toml (or sibling .env/.env.local) is swallowed here (logged, not + // fatal) and proceeds with empty config-sourced secrets — env-file and positional-arg + // secrets still work. // - // Pass `ref` so a matching `[remotes.*]` block is merged over the base - // config before decode, mirroring `flags.LoadConfig` - // (`internal/utils/flags/config_path.go:11-12`: `utils.Config.ProjectId = - // ProjectRef` before `Load()`) merging the override in `loadFromFile` - // (`pkg/config/config.go:604-609`) ahead of the tolerant decode below. - // Without this, a schema-decode error on `--project-ref <remote-ref>` - // would recover the *base* `[edge_runtime.secrets]` instead of the - // explicitly selected remote's override. - // `goViperCompat: true` opts into `applyRemoteOverride`'s duplicate- - // `project_id`/format checks (`packages/config/src/io.ts`) — required for + // Passing `ref` merges a matching `[remotes.*]` block over the base config before decode, + // so a schema-decode error on a remote target recovers that remote's override, not the base + // document. `goViperCompat: true` enables the duplicate-project_id/format checks needed for // the `DuplicateRemoteProjectIdError` catch below to ever fire. const loadedConfig = yield* loadCliConfig(runtimeInfo.cwd, { projectRef: ref, @@ -192,12 +125,9 @@ export const secretsSet = Effect.fn("secrets.set")(function* (flags: SecretsSetF if (loaded === null) { return Effect.succeed(null); } - // Go prints this from inside config load, before any command output - // (`pkg/config/config.go:605`) — unconditionally on a matching - // `[remotes.*]` block, ahead of the (possibly failing) decode. Other - // handlers surface it the same way (e.g. `config push`); this - // path must not silently drop it just because it maps straight down - // to `.config` below. + // Printed unconditionally as soon as a matching `[remotes.*]` block is found, ahead of + // the (possibly failing) decode — other handlers surface this the same way, so this + // path must not silently drop it. return ( loaded.appliedRemote !== undefined ? output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr") @@ -205,36 +135,17 @@ export const secretsSet = Effect.fn("secrets.set")(function* (flags: SecretsSetF ).pipe(Effect.as(loaded.config)); }), Effect.catchTag("CliConfigParseError", (cause) => { - // `smol-toml`'s `TomlError` embeds a source codeblock after a - // blank-line separator — literal file content, which for this file's - // `[edge_runtime.secrets]` section can include real secret values. - // Truncating before the separator handles that case (`cause.document - // === undefined`, a raw parse failure with no decoded document to - // recover from — see the field doc on `CliConfigParseError`). - // - // A schema-decode error (`cause.document !== undefined`) has no such - // separator: Effect's `ParseError` puts the rejected value inline on - // one line (e.g. `Expected string, actual ["actual-secret"]`), which - // the truncation above wouldn't catch. The pinned mapstructure - // decode-error types (`UnconvertibleTypeError.Error()`, - // `DecodeError.Error()`, `github.com/go-viper/mapstructure/v2 - // v2.5.0`) never include the rejected value, only type names — so a - // fixed, content-free message here matches that behaviour rather - // than just being defensive. + // `smol-toml` embeds a source codeblock (which can include real secret values) after a + // blank-line separator on a raw parse failure; truncate before it. A schema-decode + // error puts the rejected value inline instead, with no such separator, so use a fixed, + // content-free message there. const shortMessage = cause.document === undefined ? String(cause.cause).split("\n\n")[0] : "schema validation failed"; - // The override notice is printed unconditionally as soon as a - // `[remotes.*]` block's `project_id` matches, *before* `mapstructure` - // decode ever runs (`pkg/config/config.go:604-609`) — so the notice is - // still owed here even though decode subsequently failed and this - // whole load is non-fatal. `cause.appliedRemote` carries that match - // through the failed decode (see the field doc on - // `CliConfigParseError.appliedRemote`); the success path above - // handles the non-error case. Emitted ahead of the debug log below to - // match that order: the print happens inside `loadFromFile`, - // the debug log only after config loading swallows the error. + // Printed here too since a matching `[remotes.*]` block is found before decode runs, + // even though decode then failed. Emitted ahead of the debug log below to preserve + // that order. return ( cause.appliedRemote !== undefined ? output.raw(`Loading config override: [remotes.${cause.appliedRemote}]\n`, "stderr") @@ -246,35 +157,20 @@ export const secretsSet = Effect.fn("secrets.set")(function* (flags: SecretsSetF Effect.as(recoverEdgeRuntimeConfig(cause)), ); }), - // `loadCliConfig` resolves `env(VAR)` references against - // `.env`/`.env.local` (`loadCliProjectEnvironment` inside - // `loadCliConfigFile`) *before* schema decode, so a malformed dotenv - // line fails with this distinct tag rather than `CliConfigParseError`. - // `Load()` (`pkg/config/config.go:788-791`) calls `loadNestedEnv` - // first too and returns immediately on error, before `loadFromFile` (the - // TOML parse) ever runs — so `EdgeRuntime.Secrets` never gets populated - // in this failure path, unlike the schema-decode-only case above. Recover - // to `null`, not `recoverEdgeRuntimeConfig`: there is no parsed document - // to recover a subtree from. + // A malformed dotenv line fails with this distinct tag (env resolution runs before + // schema decode), so there's no parsed document to recover a subtree from — recover to + // `null`, not `recoverEdgeRuntimeConfig`. Effect.catchTag("CliProjectEnvParseError", (cause) => debugLogger.debug(`failed to parse ${cause.path}:${cause.line}`).pipe(Effect.as(null)), ), - // Two `[remotes.*]` blocks declare the same `project_id` as `ref` — - // `flags.LoadConfig` swallows *any* `Load()` error non-fatally, - // including this one, which `loadFromFile` raises before - // `mapstructure` ever runs (`pkg/config/config.go:601`). - // `cause.message` already matches that string verbatim (see - // `DuplicateRemoteProjectIdError`'s field doc). + // Two `[remotes.*]` blocks declaring the same `project_id` as `ref`; swallowed + // non-fatally like every other load error here. Effect.catchTag("DuplicateRemoteProjectIdError", (cause) => debugLogger.debug(cause.message).pipe(Effect.as(null)), ), - // A `[remotes.*]` block's `project_id` fails the ref-pattern check — - // raised from `Config.Validate` (`pkg/config/config.go:996-1001`), which - // runs inside the same `Config.Load()` call as the duplicate check above - // (`config.go:882`). `flags.LoadConfig` swallows this the same - // non-fatal way, so a malformed remote block must not abort an - // otherwise-valid `secrets set`. `cause.message` already matches that - // string verbatim (see `InvalidRemoteProjectIdError`'s field doc). + // A `[remotes.*]` block's `project_id` fails the ref-pattern check; swallowed the same + // non-fatal way, so a malformed remote block must not abort an otherwise-valid + // `secrets set`. Effect.catchTag("InvalidRemoteProjectIdError", (cause) => debugLogger.debug(cause.message).pipe(Effect.as(null)), ), @@ -292,16 +188,10 @@ export const secretsSet = Effect.fn("secrets.set")(function* (flags: SecretsSetF { goViperCompat: true }, ); for (const [name, value] of Object.entries(resolved.secrets ?? {})) { - // `DecryptSecretHookFunc` (`pkg/config/secret.go:98`) never - // hashes an empty value, and only config entries with a non-empty - // SHA256 are included — so an empty `[edge_runtime.secrets]` entry - // is silently skipped rather than sent as an empty-string - // overwrite of a remote secret. `Redacted.isRedacted` already - // excludes the other SHA256-empty case (a still-literal - // `env(VAR)` reference); check for a non-empty value too so both - // zero-hash cases match. This applies to config-sourced secrets - // only — an explicit `--env-file`/positional `NAME=` below is - // sent as-is unconditionally, regardless of source. + // An empty `[edge_runtime.secrets]` value is skipped rather than sent as an + // empty-string overwrite of a remote secret. This applies to config-sourced secrets + // only — an explicit `--env-file`/positional `NAME=` below is sent as-is regardless + // of value. if (Redacted.isRedacted(value) && Redacted.value(value).length > 0) { merged.set(name, Redacted.value(value)); } @@ -356,10 +246,8 @@ export const secretsSet = Effect.fn("secrets.set")(function* (flags: SecretsSetF merged.set(pair.slice(0, eqIdx), pair.slice(eqIdx + 1)); } - // Filter SUPABASE_-prefixed entries with stderr warning (Go `set.go:67-71`). - // The API rejects these names server-side anyway (`@supabase/api`'s schema - // also rejects them via regex), so the filter MUST happen client-side - // before any request is built — otherwise we'd surface a SchemaError instead. + // The API also rejects `SUPABASE_`-prefixed names server-side, but filtering client-side + // first avoids surfacing a raw SchemaError instead of this warning. const body: Array<{ name: string; value: string }> = []; for (const [name, value] of merged) { if (name.startsWith("SUPABASE_")) { @@ -378,22 +266,16 @@ export const secretsSet = Effect.fn("secrets.set")(function* (flags: SecretsSetF } // The Management API caps a single bulk-create request at 100 secrets - // (`V1BulkCreateSecretsInput`'s `isMaxLength(100)` check in `@supabase/api`). - // Go issues one unbatched request (`internal/secrets/set/set.go`), so against - // the capped API a >100-entry env file would be rejected wholesale; split into - // batches of at most 100 so large env files still upload. + // (`V1BulkCreateSecretsInput`'s `isMaxLength(100)` check); split into batches so large env + // files still upload instead of being rejected wholesale. const SECRETS_PER_REQUEST = 100; const batches: Array<typeof body> = []; for (let i = 0; i < body.length; i += SECRETS_PER_REQUEST) { batches.push(body.slice(i, i + SECRETS_PER_REQUEST)); } - // Validate every batch (per-entry name/value constraints and the 100-item - // cap) before sending any request. Without this, a schema-invalid entry in a - // later batch would only surface after earlier batches had already been - // uploaded, leaving the project partially updated. Decoding fails with the - // same `SchemaError` `bulkCreateSecrets` raises. This validation is wholly - // user-derived, so keep it distinct from response-schema decode failures. + // Validated up front so a schema-invalid entry in a later batch can't leave the project + // partially updated after earlier batches already uploaded. yield* Effect.forEach( batches, (batch) => Schema.decodeUnknownEffect(V1BulkCreateSecretsInput)({ ref, body: batch }), diff --git a/apps/cli/src/commands/secrets/set/set.integration.test.ts b/apps/cli/src/commands/secrets/set/set.integration.test.ts index 9d4225728f..dd6205fdba 100644 --- a/apps/cli/src/commands/secrets/set/set.integration.test.ts +++ b/apps/cli/src/commands/secrets/set/set.integration.test.ts @@ -97,8 +97,6 @@ function writeSupabaseDotEnv(content: string) { } function parsePostBody(body: unknown): Array<{ name: string; value: string }> { - // `mockCommandPlatformApi` JSON-decodes the request body when it parses; this - // helper just narrows the type for the test assertions. return body as Array<{ name: string; value: string }>; } @@ -175,9 +173,9 @@ describe("secrets set integration", () => { "rejects the whole upload when a later batch has an invalid entry (no partial update)", () => { const { layer, api } = setup(); - // Index 120 lands in the SECOND batch (batch 0 covers indices 0-99): a - // value exceeding the 24576-byte cap there must fail up-front validation - // before batch 0 (which is otherwise entirely valid) is ever sent. + // Index 120 lands in the second batch (batch 0 covers indices 0-99): a value exceeding + // the 24576-byte cap there must fail up-front validation before batch 0 (otherwise + // entirely valid) is ever sent. const secrets = Array.from({ length: 150 }, (_, i) => i === 120 ? `KEY${i}=${"x".repeat(24577)}` : `KEY${i}=value${i}`, ); @@ -319,11 +317,8 @@ LITERAL = "plain-value" it.live( "skips an empty [edge_runtime.secrets] value instead of overwriting a remote secret (Go set.go:48-52 parity)", () => { - // `DecryptSecretHookFunc` (`pkg/config/secret.go:98`) leaves `SHA256` - // empty for an empty value, and only entries with a non-empty SHA256 - // are included — so a literal `EMPTY = ""` in config.toml is never - // sent, which prevents it from silently overwriting a same-named - // remote secret with an empty string. + // An empty `EMPTY = ""` value in config.toml is never sent, which prevents it from + // silently overwriting a same-named remote secret with an empty string. writeConfig( `[edge_runtime.secrets] EMPTY = "" @@ -497,13 +492,10 @@ FOO = "literal-foo" it.live( "recovers [edge_runtime.secrets] when an unrelated field fails schema decode (CLI-1867 Go parity)", () => { - // Valid TOML syntax throughout, but `analytics.port` has the wrong type - // for its schema field. The viper+mapstructure decode - // (`pkg/config/config.go:749`) mutates the target struct field-by-field, - // so an unrelated type error doesn't stop `EdgeRuntime.Secrets` from - // landing on `utils.Config` — `secrets set` still reads it. Effect - // Schema's `decodeUnknownSync` is atomic and would otherwise discard the - // whole document, silently dropping `FROM_CONFIG` too. + // Valid TOML throughout, but `analytics.port` has the wrong type. The CLI's established + // per-field decode tolerance means an unrelated type error doesn't stop + // `edge_runtime.secrets` from being read; Effect Schema's `decodeUnknownSync` is atomic + // and would otherwise discard the whole document, silently dropping `FROM_CONFIG` too. writeConfig( `[edge_runtime.secrets] FROM_CONFIG = "config-value" @@ -531,15 +523,10 @@ port = "not-a-number" it.live( "recovers [edge_runtime.secrets] when a sibling field in the same edge_runtime table fails schema decode (CLI-1867 Go parity)", () => { - // Valid TOML syntax throughout, but `edge_runtime.inspector_port` has - // the wrong type for its schema field — a SIBLING of `secrets` inside - // the same `edge_runtime` table, not an unrelated top-level table. Go's - // viper+mapstructure decode (`pkg/config/config.go:749`) mutates the - // target struct field-by-field even within the same table, so - // `EdgeRuntime.Secrets` still lands on `utils.Config` while - // `InspectorPort` is left at its zero value — verified empirically - // against `pkg/config` directly. The recovery must therefore re-decode - // `secrets` on its own rather than the whole `edge_runtime` subtree. + // Valid TOML throughout, but `edge_runtime.inspector_port` (a sibling of `secrets` in + // the same table, not an unrelated top-level table) has the wrong type. The recovery + // must re-decode `secrets` on its own rather than the whole `edge_runtime` subtree, or + // the sibling error would take `FROM_CONFIG` down with it. writeConfig( `[edge_runtime] inspector_port = "not-a-number" @@ -567,14 +554,10 @@ FROM_CONFIG = "config-value" it.live( "tolerates a malformed supabase/.env, logs it to the debug logger, and still sets CLI-arg secrets (CLI-1867 Go parity)", () => { - // `loadCliConfig` resolves `env(VAR)` references against - // `supabase/.env`/`.env.local` *before* schema decode, so a malformed - // dotenv line fails with `CliProjectEnvParseError` rather than - // `CliConfigParseError`. `Load()` (`pkg/config/config.go:788-791`) - // calls `loadNestedEnv` first too and swallows any error the same - // non-fatal way — so this must not abort the command either. `.env` - // is only read once a `supabase/config.toml`/`.json` is found - // (`findCliProjectPaths`), so a config.toml must exist here too. + // `loadCliConfig` resolves `env(VAR)` references against `.env`/`.env.local` before + // schema decode, so a malformed dotenv line fails with `CliProjectEnvParseError` rather + // than `CliConfigParseError`, and this must not abort the command either. `.env` is only + // read once a config.toml/.json is found, so one must exist here too. writeConfig( `[edge_runtime.secrets] FROM_CONFIG = "config-value" @@ -599,14 +582,10 @@ FROM_CONFIG = "config-value" it.live( "recovers valid [edge_runtime.secrets] entries when a sibling entry in the same map fails schema decode (CLI-1867 Go parity)", () => { - // `GOOD` is a valid secret value; `BAD` is not (a non-string TOML value - // for a field whose schema expects a string-like secret). Go's - // mapstructure decodes `map[string]Secret` entry-by-entry - // (`decodeMapFromMap`), appending a per-entry error and continuing - // rather than discarding the whole map, so `GOOD` still lands on - // `utils.Config.EdgeRuntime.Secrets` even with `BAD` present. Effect - // Schema's `decodeUnknownSync` is atomic per record and would otherwise - // discard `GOOD` too when re-decoding the whole `secrets` map at once. + // `GOOD` is a valid secret value; `BAD` is a non-string TOML value for a field whose + // schema expects a string-like secret. The recovery decodes each `edge_runtime.secrets` + // entry independently, so `GOOD` still lands even with `BAD` present — Effect Schema's + // `decodeUnknownSync` is atomic per record and would otherwise discard `GOOD` too. writeConfig( `[edge_runtime.secrets] GOOD = "config-value" @@ -631,11 +610,9 @@ BAD = 123 it.live( "skips an empty recovered [edge_runtime.secrets] entry alongside an unrelated schema error (Go set.go:48-52 parity)", () => { - // Same empty-value skip as the happy path, but exercised through - // `recoverEdgeRuntimeConfig`/`filterDecodableSecrets`: `EMPTY` decodes - // fine on its own (it's a valid, if empty, string), so it must be - // dropped downstream in the same merge loop the happy path uses, not - // resurrected as a false "recoverable" entry. + // Same empty-value skip as the happy path, exercised through the recovery path instead: + // `EMPTY` decodes fine on its own, so it must be dropped downstream in the same merge + // loop the happy path uses, not resurrected as a false "recoverable" entry. writeConfig( `[edge_runtime.secrets] EMPTY = "" @@ -664,13 +641,9 @@ port = "not-a-number" it.live( "does not fabricate a secret named 0 when [edge_runtime.secrets] is an array (CLI-1867 Go parity)", () => { - // `edge_runtime.secrets` as an array (instead of a table) is not - // recoverable structure: the mapstructure decoder never sets - // `WeaklyTypedInput`, so a slice source for a map-typed field hits - // `UnconvertibleTypeError` in `decodeMap` rather than the index-as-key - // `decodeMapFromSlice` path, and the whole field is left empty. Before - // the `isRecord` fix, `Object.entries(["actual-secret"])` would turn - // this into a spurious `{ "0": "actual-secret" }` entry. + // `edge_runtime.secrets` as an array (instead of a table) is not recoverable: the whole + // field is left empty rather than being misread as `{ "0": "actual-secret" }` via + // `Object.entries`. writeConfig( `[analytics] port = "not-a-number" @@ -697,14 +670,9 @@ secrets = ["actual-secret"] it.live( "recovers the selected remote's [edge_runtime.secrets] override, not the base, on schema-decode error (CLI-1867 Go parity)", () => { - // `analytics.port` is an unrelated schema-decode error that triggers the - // recovery path. `remotes.staging.project_id` matches the ref the - // resolver defaults to (`mockCommandSettings`'s `VALID_REF`), so - // Go seeds `Config.ProjectId` before `Load()` - // (`internal/utils/flags/config_path.go:11-12`) and merges the remote - // override in `loadFromFile` (`pkg/config/config.go:604-609`) before the - // tolerant decode this PR models — the recovered secret must reflect the - // remote's override value, not the base document's. + // `analytics.port` triggers the recovery path. `remotes.staging.project_id` matches the + // resolved ref, so the remote override is merged before the tolerant decode — the + // recovered secret must reflect the remote's override value, not the base document's. writeConfig( `[edge_runtime.secrets] FROM_CONFIG = "base-value" @@ -731,10 +699,8 @@ FROM_CONFIG = "remote-value" ]); expect(debugLogger.messages).toHaveLength(1); expect(debugLogger.messages[0]).toContain("failed to parse supabase/config.toml"); - // Go prints the override notice unconditionally as soon as the - // `project_id` match is found, *before* `mapstructure` decode ever - // runs (`pkg/config/config.go:604-609`) — so it's still owed here - // even though the decode that follows fails and recovers. + // The override notice is owed here too, even though the decode that follows fails + // and recovers. expect(out.stderrText).toContain("Loading config override: [remotes.staging]\n"); }).pipe(Effect.provide(layer)); }, @@ -743,11 +709,9 @@ FROM_CONFIG = "remote-value" it.live( "prints the remote override notice to stderr when [remotes.*] matches the resolved ref (Go parity: pkg/config/config.go:605)", () => { - // No decode error here — the plain success path. `loadFromFile` - // prints `Loading config override: [remotes.<name>]` to stderr - // unconditionally whenever a `[remotes.*]` block's `project_id` matches - // `Config.ProjectId`, before `mapstructure` ever runs. `mockCommandSettings` - // defaults the resolved ref to `VALID_REF`. + // No decode error here — the plain success path. The override notice still prints + // unconditionally whenever a `[remotes.*]` block's `project_id` matches the resolved + // ref. `mockCommandSettings` defaults that ref to `VALID_REF`. writeConfig( `[edge_runtime.secrets] FROM_CONFIG = "base-value" @@ -800,11 +764,9 @@ FROM_CONFIG = "config-value" it.live( "tolerates two [remotes.*] blocks sharing the target project_id, logs it, and still sets CLI-arg secrets (CLI-1867 Go parity)", () => { - // `flags.LoadConfig` swallows *any* `Load()` error non-fatally, - // including the duplicate-`project_id` error `loadFromFile` raises - // before `mapstructure` ever runs (`pkg/config/config.go:601`). There - // is no parsed document to recover a subtree from, so config-sourced - // secrets are dropped entirely — only CLI-arg secrets survive. + // Swallowed non-fatally like every other load error here. There's no parsed document to + // recover a subtree from, so config-sourced secrets are dropped entirely — only + // CLI-arg secrets survive. writeConfig( `[edge_runtime.secrets] FROM_CONFIG = "config-value" @@ -833,14 +795,9 @@ project_id = "dupe-project-id" it.live( "tolerates a [remotes.*] block with a malformed project_id and still sets CLI-arg secrets (Go parity)", () => { - // `flags.LoadConfig` swallows *any* `Load()` error non-fatally, - // including the invalid-format error `Config.Validate` raises for - // every `[remotes.*].project_id` that doesn't match the ref pattern - // (`pkg/config/config.go:996-1001`), which runs inside the same - // `Config.Load()` call (`config.go:882`) as the duplicate check - // above. There is no parsed document to recover a subtree from, so - // config-sourced secrets are dropped entirely — only CLI-arg secrets - // survive. + // Swallowed non-fatally like every other load error here. There's no parsed document to + // recover a subtree from, so config-sourced secrets are dropped entirely — only + // CLI-arg secrets survive. writeConfig( `[edge_runtime.secrets] FROM_CONFIG = "config-value" @@ -893,13 +850,10 @@ project_id = "not-a-valid-ref" it.live( "does not echo a literal secret value from config.toml into the debug log on a schema-decode error", () => { - // Unlike the syntax-error case above, a schema-decode failure has no - // blank-line-separated source codeblock to truncate: Effect's decode - // error puts the rejected value inline on one line (e.g. `Expected - // string, actual ["sk_live_TOTALLY_REAL_SECRET_VALUE"]`). The bad entry - // sits inside `[edge_runtime.secrets]` itself, so this also exercises - // the per-entry recovery path — `PLANTED_SECRET` is dropped, but the - // CLI-arg secret still goes through. + // Unlike the syntax-error case above, a schema-decode failure has no separator to + // truncate: the rejected value appears inline on one line. The bad entry sits inside + // `[edge_runtime.secrets]` itself, so this also exercises the per-entry recovery path — + // `PLANTED_SECRET` is dropped, but the CLI-arg secret still goes through. writeConfig( `[edge_runtime.secrets] PLANTED_SECRET = ["sk_live_TOTALLY_REAL_SECRET_VALUE"] @@ -1004,7 +958,7 @@ PLANTED_SECRET = ["sk_live_TOTALLY_REAL_SECRET_VALUE"] envFile: Option.none(), secrets: ["FOO=bar"], }); - // Go ignores `--output` for `set` (set.go:42) — text-mode message lands regardless. + // `--output` doesn't affect `set`; the text-mode message lands regardless. expect(out.stdoutText).toBe("Finished supabase secrets set.\n"); }).pipe(Effect.provide(layer)); }, diff --git a/apps/cli/src/commands/secrets/unset/unset.handler.ts b/apps/cli/src/commands/secrets/unset/unset.handler.ts index e431cfcf8a..8ecd6b0c6e 100644 --- a/apps/cli/src/commands/secrets/unset/unset.handler.ts +++ b/apps/cli/src/commands/secrets/unset/unset.handler.ts @@ -43,7 +43,7 @@ export const secretsUnset = Effect.fn("secrets.unset")(function* (flags: Secrets const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - // `--yes` OR `SUPABASE_YES` (mirrors viper's AutomaticEnv, root.go:318-320). + // `resolveYes` also honors `SUPABASE_YES`, not just the `--yes` flag. const yes = yield* resolveYes; const ref = yield* resolver.resolve(flags.projectRef); @@ -52,8 +52,6 @@ export const secretsUnset = Effect.fn("secrets.unset")(function* (flags: Secrets let names: ReadonlyArray<string> = flags.names; if (names.length === 0) { - // Fetches the full list and filters out SUPABASE_-prefixed entries. - // Reuse the LIST error pair here. const all: Secrets = yield* api.v1 .listAllSecrets({ ref }) .pipe(Effect.catch(mapListErrorForUnset)); @@ -67,11 +65,9 @@ export const secretsUnset = Effect.fn("secrets.unset")(function* (flags: Secrets const label = `Do you want to unset these function secrets?\n • ${names.join("\n • ")}\n\n`; - // `PromptYesNo(msg, true)` (`console.go:64-82`): `--yes`/`SUPABASE_YES` - // auto-confirms with the `<label> [Y/n] y` stderr echo; a non-TTY stdin - // still prints the label and scans one piped line (100ms), so `echo n | - // supabase secrets unset` declines instead of hardcoding the Yes default - // (CLI-1974). + // `promptYesNo` echoes `<label> [Y/n] <answer>` to stderr even on a non-TTY: it scans one + // piped line (100ms timeout) so `echo n | supabase secrets unset` declines instead of + // hardcoding the Yes default. const confirmed = yield* promptYesNo(output, yes, label, true); if (!confirmed) { diff --git a/apps/cli/src/commands/secrets/unset/unset.integration.test.ts b/apps/cli/src/commands/secrets/unset/unset.integration.test.ts index cb23abbd7b..e0a2090e1e 100644 --- a/apps/cli/src/commands/secrets/unset/unset.integration.test.ts +++ b/apps/cli/src/commands/secrets/unset/unset.integration.test.ts @@ -152,9 +152,8 @@ describe("secrets unset integration", () => { const { layer, out, api } = setup({ yes: false, stdinIsTty: false }); return Effect.gen(function* () { yield* secretsUnset({ projectRef: Option.none(), names: ["FOO"] }); - // `PromptText` prints the label to stderr, the 100ms non-TTY read scans - // nothing, and the empty input is echoed back before the true default wins - // (`console.go:64-102`). + // The 100ms non-TTY read scans nothing here, and the empty input is echoed back before + // the true default wins. expect(out.stderrText).toContain( "Do you want to unset these function secrets?\n • FOO\n\n [Y/n] \n", ); @@ -170,7 +169,6 @@ describe("secrets unset integration", () => { if (Exit.isFailure(exit)) { expect(JSON.stringify(exit.cause)).toContain("SecretsUnsetCancelledError"); } - // The piped answer is echoed to stderr, matching non-TTY `PromptText`. expect(out.stderrText).toContain("[Y/n] n\n"); expect(api.requests.filter((r) => r.method === "DELETE")).toHaveLength(0); }).pipe(Effect.provide(layer)); @@ -191,7 +189,6 @@ describe("secrets unset integration", () => { const { layer, out, api } = setup(); return Effect.gen(function* () { yield* secretsUnset({ projectRef: Option.none(), names: ["FOO"] }); - // Same bytes as the `viper.GetBool("YES")` branch (`console.go:70-72`). expect(out.stderrText).toContain( "Do you want to unset these function secrets?\n • FOO\n\n [Y/n] y\n", ); @@ -289,11 +286,8 @@ describe("secrets unset integration", () => { }); it.live("--output-format=json without --yes takes the Yes default silently", () => { - // TS-only machine mode has no Go equivalent; `promptYesNo` documents - // that json/stream-json never prompts and takes the call site's default — - // for unset that is Yes, mirroring the non-TTY default-through behavior - // for scripts. Deliberate (CLI-1974 review); pass --yes explicitly in - // automation for clarity. + // json/stream-json never prompts and takes the call site's default, which for unset is + // Yes — pass --yes explicitly in automation for clarity. const { layer, out, api } = setup({ yes: false, format: "json" }); return Effect.gen(function* () { yield* secretsUnset({ projectRef: Option.none(), names: ["FOO"] }); From 233e57503306e7ee10c2e03879162883c827a891 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:06:16 +0100 Subject: [PATCH 37/56] chore(repo): trim comments, shard 44 (1193 -> 373 comment lines) --- .../src/project-config/project-config.ts | 1380 ++++------------- 1 file changed, 280 insertions(+), 1100 deletions(-) diff --git a/packages/config/src/project-config/project-config.ts b/packages/config/src/project-config/project-config.ts index 10f4d94069..7783aa2323 100644 --- a/packages/config/src/project-config/project-config.ts +++ b/packages/config/src/project-config/project-config.ts @@ -18,21 +18,10 @@ import { expectString } from "./registry-row.ts"; import { projectConfigMappingRows } from "./registry.ts"; /** - * A deeply-readonly JSON value — the shape of everything under - * `_apiResponse`, which holds (a clone of) a parsed Management API JSON - * payload and is recursively frozen at attach time. Typed recursively - * readonly so no narrowing path reaches a mutable view: with plain `unknown` - * values, `Array.isArray(...)` would narrow to a mutable array whose - * `.push` compiles and then throws against the frozen runtime value. (A - * programmatic `attachApiResponse` caller can technically hand over - * non-JSON structured-cloneable values — Dates, Maps; those step outside - * this type by their own choice, exactly like any other consumer-side - * assertion.) One narrowing caveat no user-space type can close: the lib's - * own `Array.isArray` guard is typed `arg is any[]`, so narrowing through it - * yields a MUTABLE array view (microsoft/TypeScript#17002) whose `.push` - * compiles and then throws against the frozen value — narrow with a - * readonly-preserving guard (`(v): v is ReadonlyArray<ReadonlyJsonValue> => - * Array.isArray(v)`) instead. + * A deeply-readonly JSON value, the shape of everything under `_apiResponse` (frozen at attach + * time). `Array.isArray` narrows to a mutable `any[]` (microsoft/TypeScript#17002), so narrow + * arrays with a readonly-preserving guard instead, e.g. + * `(v): v is ReadonlyArray<ReadonlyJsonValue> => Array.isArray(v)`. */ export type ReadonlyJsonValue = | string @@ -43,179 +32,39 @@ export type ReadonlyJsonValue = | { readonly [key: string]: ReadonlyJsonValue }; /** - * The hosted-project subset of {@link CliConfig}: the sections a Management - * API project-config response can speak for (`api`, `auth`, `db`, - * `realtime`, `storage`, `workers`, `experimental`) — never the local-only - * sections (`studio`, service ports, `edge_runtime`, `analytics`, - * `[remotes.*]`, …) that only make sense for a checkout on disk - * (`docs/cli-config-loading.md`'s vocabulary). + * The hosted-project subset of {@link CliConfig}: the sections a Management API project-config + * response can speak for (`api`, `auth`, `db`, `realtime`, `storage`, `workers`, `experimental`), + * never the local-only sections that only make sense for a checkout on disk. * - * Deliberately sparse (`DeepPartial`), not a fully-materialized `CliConfig` - * with schema defaults filled in: an API response never mentions a section - * or field it doesn't manage, and a `ProjectConfig` that flooded in schema - * defaults for everything it didn't report would fabricate drift against a - * local document that genuinely differs only where the API actually speaks - * (CLI-2230's design rule). Sparseness is also what makes a `ProjectConfig` - * usable as an operand of `subtractCliConfig`/`omitDefaultValues` - * (`../sparse.ts`): those helpers take an {@link EffectiveConfig}, and a - * `ProjectConfig` (minus `_apiResponse`, which those walks never see — see - * below) is structurally assignable to it, since `EffectiveConfig` is - * `DeepPartial<Omit<CliConfig, "remotes">>` and every key `ProjectConfig` - * can carry is one of `CliConfig`'s non-`remotes` keys. + * Sparse (`DeepPartial`) rather than schema-defaulted, since an API response never mentions a + * field it doesn't manage and defaulting the rest would fabricate drift. * - * `_apiResponse` follows ADR 0019: present only on a value built by - * {@link fromApiProjectConfig} (never on one built by - * {@link fromConfigDocument}), holding a deep-cloned, deep-frozen copy of the - * raw, pre-mapping `data.attributes` object (frozen/cloned rather than - * aliasing the caller's object: neither this package nor a caller can - * accidentally mutate it after the fact). It is attached as a non-enumerable - * property at runtime (rule 1), so it is invisible to every *serializer* — - * `JSON.stringify`, object spread, `Object.assign`, `structuredClone` — and - * to the structural walks in `../sparse.ts`, and is therefore never - * persisted to a config file. Invisible to serializers is not invisible to - * every possible inspection, though: a debug inspector that deliberately - * shows non-enumerable own properties (e.g. Bun's `console.log`) still - * prints it. Never log an API-sourced `ProjectConfig` directly — the raw - * attributes can include an HMAC digest of a secret value. A caller that - * loses `_apiResponse` across a spread/`structuredClone`/state-store - * round-trip can re-attach it via {@link attachApiResponse}. + * `_apiResponse`, attached only by {@link fromApiProjectConfig}, is a non-enumerable, deep-frozen + * copy of the raw API payload — invisible to serialization and to `../sparse.ts`'s structural + * walks. It can include an HMAC digest of a secret value, so never log a `ProjectConfig` directly; + * reattach a dropped `_apiResponse` with {@link attachApiResponse}. * - * The seven hosted-section keys above are a vocabulary-level ceiling, not a - * per-field guarantee: they name every section a project-config response - * *could* speak for, not how much of each section a given operand actually - * does. `fromConfigDocument`'s operand (a `CliConfig`/`EffectiveConfig`) can - * genuinely carry any field in any of the seven. `fromApiProjectConfig`'s - * operand speaks for far fewer — `realtime` maps zero rows today (every field - * is local dev-server tuning with no hosted counterpart, `./registry.ts`'s - * comment on `realtime`), and `workers`/`experimental` have no v2 - * project-config API counterpart at all, so an API-sourced `ProjectConfig` - * never carries those two keys regardless of what the remote project has - * configured. A comparison consumer (CLI-2156) must restrict its comparison - * to the fields both operands actually speak for, never treat one operand's - * whole-section presence/absence as drift against the other's — that - * granularity gap is not only whole-section: several record-entry and - * optional-substruct fields the registry maps *unconditionally* (every - * mailer template/notification row, `email.smtp.enabled`, every - * `db.settings.*` row, `sessions.timebox`/`inactivity_timeout`, - * `captcha.enabled`, …) appear on an API-sourced `ProjectConfig` even when a - * local document never declared that sub-section at all, since the mapping - * has no "the local document is silent here" signal to withhold on. Use - * {@link comparableProjectConfigPaths}/{@link isComparableProjectConfigPath} - * to restrict a comparison to exactly the fields `fromApiProjectConfig` can - * actually speak for, rather than hand-maintaining an equivalent field list. - * The gap runs the other direction too, but far more narrowly than it once - * did: `auth.oauth_server.enabled`, and `storage.analytics.enabled`/ - * `storage.vector.enabled`, are ordinary comparable paths on BOTH arms today - * (CLI-2314 retired the whole-container omission this paragraph used to - * describe) — only their few gated sibling fields - * (`allow_dynamic_registration`/`authorization_url_path` for - * `auth.oauth_server`; `max_namespaces`/`max_tables`/`max_catalogs` for - * `storage.analytics`; `max_buckets`/`max_indexes` for `storage.vector`) are - * pruned from `fromConfigDocument`'s output while the container's `enabled` - * reads `false` ({@link DISABLED_SENTINEL_PRUNES}), matching - * `fromApiProjectConfig`'s own treatment of that same retained-but-inert - * platform state — not a case where `fromConfigDocument` goes silent on a - * comparable path `fromApiProjectConfig` speaks for. - * - * Per ADR 0021, a `ProjectConfig` value is NOT a verbatim projection of - * whichever operand produced it — both {@link fromConfigDocument} and - * {@link fromApiProjectConfig} canonicalize toward the state a `config push` - * would actually converge on (SMS-provider push precedence, disabled-sentinel - * pruning of gated siblings, duration/byte-size re-quantization, and more — - * see that ADR for the full enumeration). A `ProjectConfig` built from a - * document is therefore not a faithful rendering of what the user wrote in - * their config file; see {@link fromConfigDocument}'s own docstring. - * - * Relatedly (CLI-2316), a document-sourced `ProjectConfig` never carries any - * of the paths in {@link DOCUMENT_ONLY_LOCAL_PATHS} — ports, TLS/URL - * overrides, `db.pooler.{enabled,port}`, the whole `db.migrations`/`db.seed` - * subtrees, every config-side `realtime.*` field, and most of - * `experimental.*` — even though each lives inside one of the seven hosted - * sections above: none has any hosted counterpart on either arm — confirmed - * directly against the `v2GetProjectConfig` OpenAPI-generated schema, not - * assumed. `db.major_version` and `db.pooler`'s other 3 fields - * (`pool_mode`/`default_pool_size`/`max_client_conn`) are deliberately NOT in - * that list, despite genuinely having no `config push` write path either: - * both `config diff` and `config pull` — `ProjectConfig`'s actual current - * consumers — need them to stay normally comparable so `config pull` can - * still sync the platform's real (read-only-via-push) Postgres version and - * pooler settings into the file; only `config push` itself (which doesn't - * consult `ProjectConfig` at all today — it still runs on the legacy v1 - * `config-sync` mappers) would ever need to know these are unpushable, and - * that distinction belongs to CLI-2313/CLI-2314's push rework, not to this - * list. + * Not every field in the seven hosted sections is comparable on both arms; use + * {@link comparableProjectConfigPaths}/{@link isComparableProjectConfigPath} to restrict a + * comparison to fields both sides speak for. Per ADR 0021, values are canonicalized toward the + * state `config push` would converge on rather than mirroring their source verbatim. */ export type ProjectConfig = DeepPartial<Pick<CliConfig, HostedSectionKey>> & { - // Readonly, recursively: the runtime value is deep-frozen - // (attachFrozenApiResponse), so any compile-permitted mutation — a - // top-level assignment or a `.push` on a narrowed nested array — would - // throw a TypeError in this ESM package. + // Deep-frozen at runtime; a compile-permitted mutation throws a TypeError. readonly _apiResponse?: { readonly [key: string]: ReadonlyJsonValue }; }; /** - * Deep-copies `value` (a hosted-section subtree rooted at `path`) at the - * OBJECT level for the DOCUMENT arm specifically — named for that arm (PR - * #6451 review round; the prior name, `copyHostedValueWithoutSecrets`, only - * described its original single responsibility and this function has never - * had a second caller: `fromApiProjectConfig` never shares it, since the API - * arm's own secret handling is the `isSecret`-row branch in - * `applyMappingRows` below, an entirely different mechanism over a different - * input shape) — dropping every leaf whose full path matches an `x-secret` - * schema annotation (CLI-2230's secret-omission finding) OR is a member of - * {@link DOCUMENT_ONLY_LOCAL_PATHS} (CLI-2316 — a field that lives inside a - * hosted section but has no live hosted counterpart on either arm): - * `fromConfigDocument`'s - * input is a *decoded* `CliConfig`/`EffectiveConfig`, where `secret()`-annotated fields - * (`../lib/env.ts`) hold plaintext or an unresolved `env(VAR)` literal, never - * a `Redacted` wrapper (decode never redacts — only - * `resolveCliConfigValue`/`resolveCliConfigSubtree`, `../project.ts`, do, - * and only post-decode). Sharing the input subtree by reference, as this - * function's predecessor did, would carry that plaintext straight onto the - * returned `ProjectConfig` — and since `fromApiProjectConfig` never reports - * an `x-secret` field's value (ADR 0019 rule 5, the API only ever returns an - * HMAC digest), a document-sourced `ProjectConfig` that kept its secrets - * would register as drift against the API-sourced side for every secret - * field, which is worse than useless for a diff consumer. Arrays are copied - * recursively, element by element — no schema validation runs on this - * function's input, so a caller can still hand it an array of objects at any - * path (`experimental.inspect.rules` was the one schema field shaped that - * way, until CLI-2316 excluded the whole `experimental.inspect` subtree as - * CLI-only — this branch stays defensive against object-shaped array - * elements arriving through any future field or a loosely-typed caller), and - * a merely-sliced container would alias them back to the (possibly frozen) - * input, breaking the fresh-copy contract. No `x-secret` leaf in - * `CliConfigSchema` sits inside an array, so - * the secret-path walk carries through elements as a no-op; empty-record - * *elements* are preserved (the empty-container prune applies only to record - * children — arrays compare wholesale in `../sparse.ts`, so their contents - * must survive verbatim). - * - * A child that stripping leaves as (or that already was) an empty plain - * object is pruned from `result` entirely, rather than kept as `{}` litter: - * a genuinely-empty container carries no comparable information either way - * (nothing for a diff consumer to compare against), and left in place it is - * exactly the kind of key `subtractCliConfig` keeps verbatim forever, since - * a baseline that never declared that key at all treats `{}` the same as - * any other "present" value (CLI-2230's secret-strip empty-container - * finding). Pruning recurses back up through {@link fromConfigDocument}'s - * own per-section loop too, so a hosted section that turns out to contain - * nothing but secrets disappears from the projection outright instead of - * surviving as an empty section. Arrays are exempt — `[]` is a meaningful, - * explicit value (e.g. "no redirect URLs"), never litter from secret - * stripping. + * Deep-copies `value` (a hosted-section subtree at `path`), dropping every leaf matching an + * `x-secret` schema annotation or a member of {@link DOCUMENT_ONLY_LOCAL_PATHS}. Copies rather than + * aliases, so a caller's secret plaintext never rides along on the returned `ProjectConfig`. A + * container stripping leaves empty is pruned entirely rather than kept as `{}`; arrays are exempt, + * since `[]` is itself a meaningful value. */ function copyHostedValueForDocument(value: unknown, path: ReadonlyArray<string>): unknown { if (Array.isArray(value)) { - // Elements are copied recursively too — this function's input is never - // schema-validated, so an object-shaped array element is still reachable - // (see this function's own docstring), and a merely-sliced container - // would alias them back to the (possibly frozen) input, breaking the - // fresh-copy contract. The path passes through unchanged: no x-secret - // pattern descends through an array in the hosted schema today, and - // empty-record *elements* are preserved (the empty-container prune below - // applies only to record children — arrays compare wholesale, so their - // contents must survive verbatim). + // Copy elements recursively rather than aliasing them, since this function's input is never + // schema-validated and may still hold objects. return value.map((element) => copyHostedValueForDocument(element, path)); } if (isObject(value)) { @@ -226,12 +75,8 @@ function copyHostedValueForDocument(value: unknown, path: ReadonlyArray<string>) continue; } const copied = copyHostedValueForDocument(child, childPath); - // Prune only containers this copy itself EMPTIED (a secret-stripped - // subtree, possibly cascading upward) — never one that was empty in the - // input. An originally-empty object can be data: a record entry's value - // is an empty struct by schema design (`storage.analytics.buckets`, - // `storage.vector.buckets`), so `{ buckets: { reports: {} } }` must - // keep its entry — the KEY is the information. + // Prune only a container this copy emptied itself, never one that was already empty — an + // originally-empty object can be meaningful data (e.g. a record entry's value). if ( isObject(copied) && Object.keys(copied).length === 0 && @@ -248,131 +93,18 @@ function copyHostedValueForDocument(value: unknown, path: ReadonlyArray<string>) } /** - * `fromConfigDocument`-ONLY exclusions (CLI-2316), applied by - * {@link isDocumentOnlyLocalPath} inside {@link copyHostedValueForDocument}'s - * object recursion, alongside {@link isSecretPath}. Every one of these fields - * lives inside a {@link HOSTED_SECTION_KEYS} section, so the whole-section - * copy would otherwise include it, but none has ANY real hosted counterpart — - * confirmed against the actual `v2GetProjectConfig` OpenAPI-generated schema - * (`packages/api/src/generated/contracts.ts`), not just this package's own - * `./api-attributes.ts` mirror of it, and cross-checked against every - * `apps/cli/src/commands/config/push/config-sync/*.sync.ts` mapper - * plus `seed buckets`, `db inspect`, `db schema declarative generate`, and - * `start`'s local bootstrap. - * - * This is a narrower, corrected list (PR #6451 review round): the original - * version of this list additionally excluded `db.major_version` and the - * WHOLE `db.pooler` subtree, reasoning that `config push` never writes - * either. That reasoning doesn't hold for what this list actually gates: - * `fromConfigDocument`/`ProjectConfig` is consumed by `config diff` and - * `config pull` (`v2GetProjectConfig`) TODAY — `config push` still runs - * entirely on the legacy v1 `config-sync/*.sync.ts` mappers below, with zero - * `ProjectConfig` involvement, so "push doesn't read it" says nothing about - * whether this list should exclude it. `database.major_version` and - * `pooler.{pool_mode,default_pool_size,max_client_conn}` are real, - * `v2GetProjectConfig`-reported hosted facts (confirmed directly against the - * OpenAPI-generated schema, not assumed): excluding them made them - * PERMANENTLY `unmanaged` in `config diff` (`isDeclaredAtPath && - * localValue === undefined`, `./config-diff.ts`) for every stock project, - * since the `supabase init` template declares all four - * (`apps/cli/src/shared/init/project-init.templates.ts`) — unlike, say, - * `auth.oauth_server`, which starts UNDECLARED and only becomes `unmanaged` - * on ITS first pull. `unmanaged` paths never reach `config diff`'s - * `changes` array at all (`hasAncestorPathKey`, `./config-diff.ts`), and - * `config pull`'s planner only ever writes from `changes` - * (`planConfigPull`, `pull.plan.ts`) — so excluding these four - * permanently blocked `config pull` from ever syncing the platform's real - * Postgres version or pooler settings into the file, for every project, - * forever. `pooler.enabled`/`pooler.port` stay excluded: `v2GetProjectConfig` - * reports neither (verified directly against the generated schema — the - * `pooler` struct has no `enabled` or `port` field at all), so unlike their - * 3 siblings they have no hosted fact to ever compare or pull: + * Paths inside a hosted section with no real hosted counterpart on either arm, so + * {@link copyHostedValueForDocument} excludes them from a document-sourced `ProjectConfig`: local + * bind ports/TLS overrides, `db.pooler`'s `enabled`/`port`, the `db.migrations`/`db.seed` subtrees, + * every config-side `realtime.*` field, and local-only `experimental.*` engine/backend selection. * - * - `api.port`, `api.tls`, `api.external_url` — local Kong bind port/TLS - * termination/URL override. `apiToUpdateBody` (`api.sync.ts`) reads only - * `db_schema`/`db_extra_search_path`/`max_rows`; none of these three. (All - * three ARE still read by the auth mapper — `auth.sync.ts`'s - * `jwtIssuer`/`authExternalUrl` derivation reads `api.external_url`/ - * `api.tls` to compute a COMPARISON-ONLY effective URL/issuer for - * `auth.jwt_issuer`; that derived value is compared against the remote but - * never itself sent in `authToUpdateBody` — a comparison-input role, not a - * push target, so it doesn't change these three fields' own exclusion.) - * - `db.port`, `db.shadow_port`, `db.health_timeout` — local Postgres/ - * shadow-DB bind ports and local health-check wait. None is referenced - * anywhere in `db.sync.ts`, and `v2GetProjectConfig` reports none of them; - * a hosted project has no "port" (it's reached over a fixed HTTPS URL) and - * no CLI-configurable startup health check. - * - `db.pooler.enabled`, `db.pooler.port` — ONLY these 2 of the section's 5 - * fields (see above for why `pool_mode`/`default_pool_size`/ - * `max_client_conn` are NOT here): `v2GetProjectConfig`'s `pooler` struct - * has no `enabled`/`port` field, and legacy `config-sync/` has no - * `pooler`/`pgbouncer`/`supavisor` reference anywhere — both fields are - * read exclusively by `start`'s local Supavisor bootstrap, with no hosted - * fact behind either on any arm. - * - `db.migrations`, `db.seed` — whole subtrees (their own children, - * `enabled`/`schema_paths`/`sql_paths`, never need listing separately: the - * container itself is skipped before this function ever recurses into - * them). Both describe how the LOCAL CLI behaves during `db push`/`db - * reset`, not anything about the hosted project — no matching - * `v2GetProjectConfig` attribute exists for either. - * - `realtime.enabled`, `realtime.ip_version`, `realtime.max_header_length` - * — every config-side `realtime` field, i.e. the whole section (see - * `./registry.ts`'s own comment on why the 12 real hosted `realtime.*` API - * attributes have no config-side counterpart in EITHER direction — this - * entry closes the document-arm half of that same gap). A `ProjectConfig` - * built from a document therefore never carries a POPULATED `realtime` key - * (pruned by the empty-section rule below whenever the document declares - * any of these 3 — a document that instead declares `realtime` itself - * empty, e.g. `{realtime: {}}`, still projects `{realtime: {}}` verbatim, - * same as any other section: pruning only fires on a container this - * function's OWN exclusion emptied, never one that started empty), matching - * `fromApiProjectConfig` already never carrying a populated one either. - * - `experimental.stack` — selects the local CLI stack backend. - * - `experimental.orioledb_version`, `experimental.s3_host`, - * `experimental.s3_region` — local OrioleDB-with-S3 storage engine config - * (`experimental.s3_access_key`/`s3_secret_key` need no entry: both are - * already `x-secret`-stripped). `experimental.pgdelta`, - * `experimental.inspect` — whole subtrees, local `db diff`/`db pull` - * engine choice and `db inspect` query config respectively. Only - * `experimental.webhooks.enabled` in this section is genuinely pushed - * (`experimental.sync.ts` POSTs to enable database webhooks) and is - * deliberately NOT in this list. + * `db.major_version` and `db.pooler`'s other three fields (`pool_mode`, `default_pool_size`, + * `max_client_conn`) are real hosted facts and deliberately excluded from this list, so `config + * diff`/`config pull` keep them comparable and can sync them from the platform. `auth.enabled`/ + * `storage.enabled` and `db.network_restrictions.enabled` are also deliberately excluded: each is a + * genuine management opt-out a document can still declare, not a value to hide. * - * Deliberately NOT listed, despite looking like the same "local toggle" - * shape as the entries above — each was checked against `v2GetProjectConfig` - * and/or how `config push` actually treats it, not excluded on the strength - * of its description alone: `auth.enabled`/`storage.enabled` (kept: a - * document that declares this flag still genuinely declares it, and this - * list only excludes fields with no live hosted counterpart on either arm - * from the projection entirely — CLI-2314 retired the PR #6339 rule that - * additionally treated this flag's OWN presence as an "is this section - * managed by push" signal and pruned every other declared field in the - * section on it, once that rule was found to silently hide genuine hosted - * customization behind an unrelated local Docker toggle; see - * {@link DISABLED_SENTINEL_PRUNES}'s docstring), `db.network_restrictions.enabled` - * (kept: the same deliberate management-opt-out shape, CLI-2314's own - * ruling), `api.auto_expose_new_tables` and - * `auth.third_party`/`auth.jwt_issuer`/`auth.signing_keys_path` (real hosted - * concepts push either already sends or simply hasn't been wired to send yet - * — a push-capability gap, not a CLI-only field), and `storage.buckets` - * (real hosted state via `seed buckets --linked`, which writes buckets onto - * the remote project through the Storage API — a different write path than - * `config push`, but still hosted; a bucket entry's OWN `objects_path` child - * is itself local-only — the local upload-source directory, never sent - * anywhere — but `storage.buckets` has no registry row at all today, so - * nothing currently reads `ProjectConfig` at that path either way). - * - * `studio.*`/`inbucket.*` need no entry here despite being named in the - * report this list is derived from: neither `studio` nor `local_smtp` (the - * config-side key `[inbucket]` normalizes to, `../io.ts`) is a member of - * `HOSTED_SECTION_KEYS` at all, so both are already excluded by - * construction — confirmed by this file's own "keeps exactly the hosted - * sections" test. - * - * Exact-match only, no wildcard segments: unlike `../lib/secret-paths.ts`'s - * patterns (which need a `"*"` segment for a dynamic `Schema.Record` key, - * e.g. `db.vault.*`), every path below names a static struct field, so a - * plain length-and-segment comparison is enough. + * Exact-match only; every path below names a static struct field. */ export const DOCUMENT_ONLY_LOCAL_PATHS: ReadonlyArray<ReadonlyArray<string>> = [ ["api", "port"], @@ -405,15 +137,9 @@ function isDocumentOnlyLocalPath(path: ReadonlyArray<string>): boolean { } /** - * Applies every registry row's `normalizeDocument` (`./registry-row.ts`) to - * `output` in place, at `row.configPath`, after the secret-omitting copy - * above has already run — CLI-2230's duration/byte-size finding. A row - * without `normalizeDocument` is untouched; a row whose `configPath` is - * absent from `output` is skipped (nothing to normalize); otherwise the - * leaf is replaced with the row's canonicalized value — or REMOVED when the - * canonicalizer returns `undefined` (unmanaged absence, e.g. an empty - * `test_otp` map the push wrapper would omit), pruning any containers the - * removal empties, consistent with the copy's own self-emptied-section rule. + * Applies every registry row's `normalizeDocument` (`./registry-row.ts`) to `output` in place, at + * `row.configPath`. A row whose canonicalizer returns `undefined` is removed from `output`, + * pruning any container the removal empties. */ function applyDocumentNormalizations(output: Record<string, unknown>): void { for (const row of projectConfigMappingRows) { @@ -433,12 +159,7 @@ function applyDocumentNormalizations(output: Record<string, unknown>): void { } } -/** - * Deletes the leaf at `path` from `output`, then walks back up deleting each - * container the removal left empty — a normalization that withdraws the only - * field of a section must not leave a bare `{}` behind, matching the - * secret-omitting copy's treatment of sections it empties itself. - */ +/** Deletes the leaf at `path` from `output`, then deletes each ancestor container the removal left empty. */ function removePathAndEmptiedAncestors( output: Record<string, unknown>, path: ReadonlyArray<string>, @@ -467,72 +188,31 @@ function removePathAndEmptiedAncestors( } /** - * The two operand policies {@link fromConfigDocument} implements for how an - * ABSENT field — present in neither the raw file nor an in-memory operand — - * should be read. Named here for the first time: until now, every caller - * picked one implicitly by which overload it happened to call, never by a - * name either could reference. - * - * - `"absent-is-default"`: the operand is a bare {@link EffectiveConfig} (no - * `document`). Every value on it is schema-materialized, so an - * absent/undeclared field's value IS the schema default, and that default - * is asserted as the caller's actual intent. Used today only by - * `../config-diff.ts`'s `defaultProjection()` baseline helper (deliberately - * — see that function's own note). - * - `"absent-is-hands-off"`: the operand is a {@link CliConfigWithRawPresence} - * pair (`document` supplied). {@link applyRawPresenceMask} runs and, for - * its own fixed list of paths (`db.ssl_enforcement`, - * `storage.image_transformation`, `storage.s3_protocol`, `auth.captcha`, - * the six `auth.hook.*` names, `auth.email.smtp`, `auth.external.*`), an - * absent raw field is read as "hands off, I'm not speaking for this" and - * removed from the projection entirely rather than standing in for the - * schema default. This is what `config diff`/`config pull`/`config push` - * all actually use in production today — `diff.handler.ts`, - * `pull.handler.ts`, and `push.handler.ts` all call - * `fromConfigDocument`/`diffProjectConfig` with a `{config, document}` - * pair. + * The two policies {@link fromConfigDocument} implements for reading a field absent from both the + * raw file and the in-memory operand: * - * The two policies agree everywhere except one cell. Danger matrix (rows: - * whether the field is locally declared; columns: whether the hosted value - * matches the schema default or has been customized on the platform): + * - `"absent-is-default"`: the operand is a bare {@link EffectiveConfig}. Every value on it is + * schema-materialized, so an absent field's value is asserted to be the schema default. + * - `"absent-is-hands-off"`: the operand is a {@link CliConfigWithRawPresence} pair. For a fixed + * list of paths, {@link applyRawPresenceMask} removes an absent raw field from the projection + * instead of standing in the schema default. `config diff`/`config pull`/`config push` all use + * this policy in production. * - * | | hosted ≈ default | hosted **customized** | - * |---|---|---| - * | field declared | safe either policy | safe either policy — intended update | - * | **field absent** | safe either policy (the generic `declared`-based diff classification and default-baseline suppression already prevent noise here — see `config-diff.ts`) | **only hazardous cell**, and only under `absent-is-default`: a consumer gets back `remote_only` with `local = <schema default>`; treating `remote_only` as "push this" would silently revert a real hosted customization to default. `absent-is-hands-off` closes this for its fixed field list; the generic `declared` mechanism in `diffProjectConfig` closes it for every other comparable path (an undeclared field differing from remote classifies `remote_only`, never `update`, so `config push`'s `update`/`local_only`-only routing never touches it). | - * - * Mitigations that hold TODAY, not just aspirationally: `config push`'s - * routing (`apps/cli/src/commands/config/push/push.plan.ts`) never - * writes a `remote_only` change; `config pull`'s whole purpose is to close - * this gap by declaring every drifted path into the file; `config diff` is - * the seatbelt that shows the user `remote_only` entries before anything - * happens; every `ConfigChange` (`../config-diff.ts`) already carries a - * `declared: boolean` a consumer can filter on directly. + * The two policies agree everywhere except one cell: an absent field whose hosted value has been + * customized. Under `"absent-is-default"`, that reads back as `remote_only` with `local = <schema + * default>` — treating `remote_only` as "push this" would silently revert the customization. + * `"absent-is-hands-off"` closes this for its fixed path list; `diffProjectConfig`'s generic + * `declared` classification closes it for every other comparable path. */ export type ConfigAbsencePolicy = "absent-is-default" | "absent-is-hands-off"; /** - * A `{ config, document }` pair {@link fromConfigDocument} accepts as an - * alternative to a bare {@link EffectiveConfig}: supplying it is what SELECTS - * the {@link ConfigAbsencePolicy} `"absent-is-hands-off"` policy over the - * bare-operand `"absent-is-default"` default (human review round on PR - * #6339, thread 1) — `document` is the raw, pre-decode document object - * (`LoadedCliConfig.document`, `../config-document.ts` — post-`env()`, - * remotes-merged, retained precisely so a caller can inspect key presence a - * decoded value loses to schema defaults), unlocking {@link - * applyRawPresenceMask} for its own fixed list of paths, since decode has - * already erased the distinction between "the file declared this with a - * default value" and "the file never mentioned this at all". `LoadedCliConfig` - * is structurally assignable to this interface WITHOUT a cast — its `config: - * CliConfig` fits `EffectiveConfig` (a `CliConfig` is one), its `document?: - * Record<string, unknown>` matches exactly. Declared independently rather - * than importing `LoadedCliConfig` by name: not for pure-runtime-graph - * reasons (`config-document.ts` is already reachable from this package's pure - * entrypoint, and this very file already imports `isObject` from it), but so - * `fromConfigDocument`'s public contract doesn't couple its parameter shape - * to the loader's own type name — this type is local-checkout-side on its own - * terms (ADR 0020's `Cli*` convention), independent of which loader happens - * to produce a matching shape. + * A `{ config, document }` pair {@link fromConfigDocument} accepts as an alternative to a bare + * {@link EffectiveConfig}. Supplying `document` — the raw, pre-decode document object + * (`LoadedCliConfig.document`, `../config-document.ts`) — selects the `"absent-is-hands-off"` + * {@link ConfigAbsencePolicy}, since decode alone can't distinguish "declared with a default + * value" from "never mentioned". `LoadedCliConfig` satisfies this interface structurally, without + * a cast. */ export interface CliConfigWithRawPresence { readonly config: EffectiveConfig; @@ -540,15 +220,8 @@ export interface CliConfigWithRawPresence { } /** - * Reads one property off the `{ config, document }` pair shape through the - * same guarded boundary as the dispatcher's source reads - * ({@link readSourceProperty}) and the envelope reads - * ({@link readEnvelopeProperty}): plain data never carries getters, so an - * accessor that throws here — e.g. `toProjectConfig({ cliConfig: { get - * config() { throw ... } } })` — is programmatic caller input and must - * surface as the documented failure type, not a raw `Error` escaping past - * the telemetry classification (a bug an earlier round of this file left - * open: the pair shape's own property reads were unguarded). + * Reads one property off the `{ config, document }` pair shape, translating a throwing accessor + * into {@link ProjectConfigParseError} rather than letting a raw `Error` escape. */ function readConfigDocumentSourceProperty(input: Record<string, unknown>, key: string): unknown { try { @@ -563,25 +236,11 @@ function readConfigDocumentSourceProperty(input: Record<string, unknown>, key: s } /** - * Unwraps the two shapes {@link fromConfigDocument} accepts: a bare - * `EffectiveConfig` operand, or a {@link CliConfigWithRawPresence} pair. - * Presence of an own `config` key decides which shape was intended — no key - * on `CliConfigSchema` (`../base.ts`) is literally named `config`, so a real - * decoded document can never collide with the pair shape today. Same - * one-own-key shape-sniffing pattern as {@link unwrapApiResponse}'s envelope - * detection below, including that function's own documented trade: a - * hypothetical future top-level section literally named `config` would be - * misread as the pair shape instead of a plain operand — closing that - * off would need an explicit discriminator key, which would break every - * existing bare-`EffectiveConfig` call site for a collision this - * vanishingly unlikely. + * Unwraps the two shapes {@link fromConfigDocument} accepts: a bare `EffectiveConfig` operand, or + * a {@link CliConfigWithRawPresence} pair, decided by presence of an own `config` key. * - * `document` is genuinely OPTIONAL — absent, or present with an explicit - * `undefined` value, both mean "no masking" and are equally legal. A - * PRESENT `document` that isn't a plain object (`null`, a string, an array, - * …) is different: unlike absence, it's a caller handing over a value this - * function cannot use, so it throws rather than silently degrading to - * unmasked output with no signal that masking was skipped. + * `document` absent, or present as explicit `undefined`, both mean "no masking". A present + * `document` that isn't a plain object throws rather than silently degrading to unmasked output. */ function unwrapConfigDocumentSource(input: Record<string, unknown>): { readonly config: unknown; @@ -607,96 +266,33 @@ function unwrapConfigDocumentSource(input: Record<string, unknown>): { } /** - * Projects a {@link CliConfig} document (or any {@link EffectiveConfig} - * operand — a full `CliConfig` is one) down to its hosted-section subset. - * Copies each hosted section deeply and only when own-present on `config`, - * omitting every `x-secret` leaf and every {@link DOCUMENT_ONLY_LOCAL_PATHS} - * entry — a field with no live hosted counterpart on either arm, e.g. - * `db.port`, `db.pooler.{enabled,port}`, `db.migrations`, `db.seed`, the - * whole `realtime` section (both via {@link copyHostedValueForDocument}; - * CLI-2316) — and canonicalizing every field a registry row's - * `normalizeDocument` covers ({@link applyDocumentNormalizations}) — parity - * with - * {@link fromApiProjectConfig}'s own secret omission and canonical - * duration/byte-size spellings, so the same logical hosted config compares - * equal regardless of which side produced it, and so this function never - * leaks a document's plaintext secrets onto a value that will sit next to - * an API-sourced `ProjectConfig` in a diff. The returned value is always a - * fresh copy — safe to call even when `config` is frozen (e.g. - * {@link getDefaultCliConfig}'s memo). Never attaches `_apiResponse`; that - * only happens in {@link fromApiProjectConfig}. Throws - * {@link ProjectConfigParseError} if a value at a normalized path is - * malformed in a way `normalizeDocument` cannot tolerate — in practice this - * should not happen, since every `normalizeDocument` implementation returns - * its input verbatim rather than throwing. + * Projects a {@link CliConfig} document (or any {@link EffectiveConfig} operand) down to its + * hosted-section subset: copies each present hosted section, omitting every `x-secret` leaf and + * every {@link DOCUMENT_ONLY_LOCAL_PATHS} entry, and canonicalizing fields a registry row's + * `normalizeDocument` covers. The result is always a fresh copy and never carries `_apiResponse`. * - * NOT a verbatim projection of `config` (ADR 0021). This and - * {@link fromApiProjectConfig} both build CONVERGENCE PROJECTIONS — the - * normalized shape multiple actors on either side of the local/hosted - * boundary (the CLI's `config diff`/`config pull`, and Studio, which calls - * this package directly) build so the two sides compare like for like, not a - * prediction of any one actor's write path. Beyond secret omission and - * per-field canonicalization, this function also applies - * {@link applySmsProviderPrecedence} (a document enabling several SMS - * providers converges on only one staying `enabled`) and - * {@link applyDisabledSentinels} (a disabled section/entry drops the sibling - * fields the platform itself retains as inert while it is off, matching what - * {@link fromApiProjectConfig} reports for that same hosted state) — do not - * render this value to a user as "your local config". + * Not a verbatim projection of `config` (ADR 0021): this and {@link fromApiProjectConfig} both + * build convergence projections so a local and hosted config compare like for like, applying + * {@link applySmsProviderPrecedence} and {@link applyDisabledSentinels} in addition to secret + * omission and canonicalization. Do not render this value to a user as "your local config". * - * How an ABSENT field is read depends on which {@link ConfigAbsencePolicy} - * this call selects — see that type's own docstring for the full policy - * definitions and danger matrix, summarized only briefly here. The - * convergence projection is exact for a genuinely sparse `config` — one that - * only carries the keys the caller means to speak for. It holds only "exact - * modulo schema defaults" for a fully-materialized decoded document passed - * BARE (the common `"absent-is-default"` case, since a full `CliConfig` is a - * valid operand): decode cannot recover whether the raw file actually wrote a - * key or merely inherited its schema default, a distinction - * {@link applyRawPresenceMask} needs and only has with a raw `document`. + * How an absent field reads depends on the selected {@link ConfigAbsencePolicy} — pass a + * {@link CliConfigWithRawPresence} pair instead of a bare `config` whenever a `document` is + * available (e.g. from `loadCliConfig`) to get the safer `"absent-is-hands-off"` policy; see that + * type's docstring for the danger this avoids. * - * **This limit has a first-class remedy**: pass a {@link - * CliConfigWithRawPresence} pair instead of a bare `config` — this selects - * `"absent-is-hands-off"` and is the RECOMMENDED form whenever a `document` - * is available (i.e. whenever the config came from `loadCliConfig` rather - * than being constructed in-memory, e.g. `getDefaultCliConfig()`'s memo). - * With `document` present, this function additionally applies - * {@link applyRawPresenceMask} for its own fixed list of paths — see that - * function's docstring for exactly which. Without `document`, this - * function's behavior is unchanged (`"absent-is-default"`), and a caller - * diffing its output against a remote `ProjectConfig` should still first - * strip schema defaults with `omitDefaultValues` and intersect to the fields - * both operands actually speak for — see ADR 0021's "Limits" section for the - * verified boundary, and {@link ConfigAbsencePolicy}'s docstring for the - * residual hazard that remains even with a `document` supplied, for paths - * outside the presence mask's fixed list. - * `@supabase/config/io`'s `loadCliConfig` supplies a `document`; - * `saveCliConfig`'s returned `LoadedCliConfig` does NOT (there is no raw - * file being re-read on a save) — passing that result here silently falls - * back to `"absent-is-default"`. + * @throws {@link ProjectConfigParseError} if `config` is not an object, or a normalized value is malformed. */ export function fromConfigDocument(config: EffectiveConfig): ProjectConfig; export function fromConfigDocument(loaded: CliConfigWithRawPresence): ProjectConfig; -// A third, union-typed overload purely for internal callers that already -// hold a `EffectiveConfig | CliConfigWithRawPresence` value (the dispatcher -// below): TypeScript does not distribute an overload set over a union-typed -// argument the way it does for a generic conditional type, so a call site -// typed exactly as the union needs a matching overload of its own — the two -// above stay the documented public contract for callers with a concrete -// operand type. +// TypeScript doesn't distribute an overload set over a union-typed argument, so an internal caller +// holding the union type needs this explicit third overload. export function fromConfigDocument( source: EffectiveConfig | CliConfigWithRawPresence, ): ProjectConfig; -// The implementation signature stays untyped for the same reason as -// `subtractCliConfig` (`../sparse.ts`): TypeScript cannot verify that a -// structural pick over dynamically-iterated keys reconstructs a -// `ProjectConfig`; the overloads above are the contract, pinned by the unit -// tests. +// The implementation signature stays untyped: TypeScript can't verify a structural pick over +// dynamically-iterated keys reconstructs a `ProjectConfig`. The overloads above are the pinned contract. export function fromConfigDocument(input: unknown): unknown { - // A JavaScript caller can hand this public normalizer null/undefined/an - // array despite the compile-time type; guarding before Object.hasOwn keeps - // the failure inside the documented typed-error contract (with the - // caller-misuse reason) instead of a native TypeError or a silent `{}`. if (!isObject(input)) { throw callerMisuseError( `fromConfigDocument operand must be an object, got ${nonObjectDescription(input)}`, @@ -704,10 +300,8 @@ export function fromConfigDocument(input: unknown): unknown { } const { config, document } = unwrapConfigDocumentSource(input); if (!isObject(config)) { - // The OPERAND was an object (checked above) — it's specifically its - // "config" property, in the { config, document } pair shape, that - // isn't. A bare EffectiveConfig operand (no own "config" key) can never - // reach this branch, since `config` is then `input` itself. + // Only the pair shape's own "config" property can be non-object here; a bare operand's + // `config` is `input` itself. throw callerMisuseError( `fromConfigDocument operand's "config" property must be an object, got ${nonObjectDescription(config)}`, ); @@ -715,10 +309,8 @@ export function fromConfigDocument(input: unknown): unknown { const result: Record<string, unknown> = {}; for (const key of HOSTED_SECTION_KEYS) { if (Object.hasOwn(config, key)) { - // The section read AND the recursive copy both evaluate caller - // properties (the copy via Object.entries at every depth), so a - // throwing getter anywhere in the operand is translated here — plain - // data never carries accessors, making this programmatic caller input. + // Translate a throwing getter anywhere in the operand into the documented failure type; + // plain data never carries accessors. let section: unknown; let copied: unknown; try { @@ -734,11 +326,8 @@ export function fromConfigDocument(input: unknown): unknown { reason: "caller_misuse", }); } - // Same emptied-by-the-copy prune as `copyHostedValueForDocument`'s - // own recursion, applied at the section boundary: a section that turns - // out to contain nothing but secrets must disappear from the projection - // entirely, while a section the document genuinely declared empty - // survives as declared. + // A section emptied entirely by secret-stripping is dropped; one the document declared + // empty survives as declared. if ( isObject(copied) && Object.keys(copied).length === 0 && @@ -760,15 +349,9 @@ export function fromConfigDocument(input: unknown): unknown { } /** - * DOCUMENT-arm only: at most one SMS provider can be live on the platform — - * the push switch selects the FIRST enabled provider in its fixed order and - * sends only that one (`switch (true)`, auth.sync.ts:2498-2539), so a - * document enabling several providers converges, after any push, on a hosted - * state where only the first is enabled. Later `enabled: true` flags flip to - * `false` here, and the entry sweep in {@link applyDisabledSentinels} (which - * runs next) prunes their siblings — matching what `fromApiProjectConfig` - * reports for that hosted state. The API arm never needs this: its five - * flags all derive from the single `sms_provider` discriminator. + * At most one SMS provider can be live on the platform; a push selects the first enabled provider + * in this order and drops the rest, so this flips every later `enabled: true` to `false` to match. + * Document-arm only — the API arm's five flags all derive from a single discriminator. */ export const SMS_PROVIDER_PUSH_PRECEDENCE = [ "twilio", @@ -798,128 +381,55 @@ function applySmsProviderPrecedence(result: Record<string, unknown>): void { } /** - * Sibling fields of a container that go inert (retained-but-not-served, or - * structurally implied by the same wire fact) the moment that container's - * OWN `enabled` is `false` — projecting them would fabricate drift between - * two representations of the identical disabled state. Each entry below - * carries its own comment re-deriving its specific justification from the - * platform's actual data model (CLI-2314) rather than from what the legacy - * `config push` pipeline (deleted by CLI-2313, commit `c7bf0ecd3`) happened - * to send — do not reintroduce a blanket rationale here; read the entry. - * - * This constant handles container-scalar siblings only. Record-keyed - * per-entry sweeps (`auth.external.*`, `auth.hook.*`, `auth.sms.*` — each - * entry's own `enabled` gates ITS OWN siblings) are a separate mechanism, - * {@link DISABLED_SENTINEL_ENTRY_SWEEPS}, below. Applied to BOTH - * normalizers' outputs: the mapped shape is identical on the document and - * API arms, so one pass keeps the two symmetric by construction (pinned by - * the cross-arm symmetry test in this file's `.unit.test.ts`). + * Sibling fields of a container that go inert the moment that container's own `enabled` is + * `false` — projecting them would fabricate drift between two representations of the identical + * disabled state. Handles container-scalar siblings only; record-keyed per-entry sweeps + * (`auth.external.*`, `auth.hook.*`, `auth.sms.*`) are {@link DISABLED_SENTINEL_ENTRY_SWEEPS} + * below. Applied to both {@link fromConfigDocument} and {@link fromApiProjectConfig} output, so + * the two stay symmetric. * - * Does NOT include `auth`/`storage`'s own top-level `enabled` (CLI-2314, - * correcting a PR #6339 mistake): unlike every entry below, that flag is - * "Enable the local GoTrue/Storage service" (`../auth/index.ts`, - * `../storage.ts`) — a pure local-Docker toggle for `supabase start`, with no - * row in the API-mapping registry and no Management API write path gated on - * it. Treating it as one more disabled-sentinel container dropped the ENTIRE - * rest of a genuinely-configured `auth`/`storage` section (SMTP, external - * providers, captcha, …) the moment a user turned off the local service — - * extremely common, since most setups don't run every local service — even - * though the hosted project's real config is unrelated to that toggle and - * may still fully exist and differ from it. - * `pushResourceEnabled` (`apps/cli/src/commands/config/push/ - * push.plan.ts`) no longer gates the whole `auth`/`storage` resource on this - * flag either, for the same reason. + * Excludes `auth`/`storage`'s own top-level `enabled`: that flag toggles the local Docker service, + * not anything hosted, so pruning on it would hide a genuinely-configured section's real hosted + * state whenever a user simply isn't running that service locally. */ export const DISABLED_SENTINEL_PRUNES: ReadonlyArray<{ readonly containerPath: ReadonlyArray<string>; /** Keys to drop when `enabled === false`; absent = drop every key but `enabled`. */ readonly dropKeys?: ReadonlyArray<string>; }> = [ - // `api.enabled` is not an independent wire field: `./registry.ts`'s - // `["api", "enabled"]` row (registry.ts:122-130) derives it from - // `db_schema.length > 0` via `remoteDataApiDisabled` (registry.ts:54-57) - // — the same underlying value `schemas` itself reads, just viewed as a - // boolean, not a second fact push happened to send. `extra_search_path`/ - // `max_rows` are PostgREST-serving config for a PostgREST that isn't - // exposed while the Data API is off. The API arm already row-gates all - // three siblings on that same `remoteDataApiDisabled` check independently - // (registry.ts:98-149) — this document-arm rule is symmetry with - // something the API arm enforces on its own, not push imitation. + // `api.enabled` derives from `schemas.length > 0`; `extra_search_path`/`max_rows` configure a + // PostgREST that isn't exposed while the Data API is off. { containerPath: ["api"], dropKeys: ["schemas", "extra_search_path", "max_rows"] }, - // `db.network_restrictions.enabled`'s own schema description - // (`../db.ts:170`) is "Enable management of network restrictions.", not - // "Enable network restrictions" — a deliberate, actor-independent - // management opt-out ("don't let any tool touch my CIDR list"), not a - // push-pipeline artifact. Confirmed on the API arm too: there is no - // `network_restrictions.enabled` field on the v2 contract at all - // (registry.ts:332-337) — the platform has no hosted concept of this - // toggle to disable or re-enable, so unlike every other entry here, this - // document-arm prune has no API-side counterpart to be symmetric with. + // `enabled` here means "manage network restrictions", a management opt-out — the platform has + // no hosted `network_restrictions.enabled` concept to be symmetric with. { containerPath: ["db", "network_restrictions"], dropKeys: ["allowed_cidrs", "allowed_cidrs_v6"], }, - // Same shape as `api` above: on the API arm, `smtp.enabled` is derived - // from `smtp_host.length > 0` (registry-auth.ts's `smtpRows`, :839-841), - // so `enabled === false` there structurally implies `host` is already - // empty — nothing to prune. On the DOCUMENT arm, `enabled` and `host` are - // independent schema fields; a document can declare `enabled = false` - // while still recording a stale `host`. `host` IS included in `dropKeys` - // below (not excluded) so that case still converges to the identical - // `{enabled: false}` shape the API arm produces — with no SMTP server to - // send through, `port`/`user`/`admin_email`/`sender_name` are inert too. - // `pass` is a secret row and already omitted on both arms regardless of - // this rule. The API arm already row-gates the four non-`host` siblings - // independently via `smtpExplicitlyDisabledInAttributes` - // (registry-auth.ts:888-903, consumed by `smtpSiblingStringRow`) — same - // "symmetry with an API-arm rule" shape as `api` above, not push - // imitation. + // On the document arm, `enabled` and `host` are independent fields, so a stale `host` can + // survive `enabled = false`; drop it (and its now-inert siblings) to converge on the same + // `{enabled: false}` shape the API arm produces. { containerPath: ["auth", "email", "smtp"], dropKeys: ["host", "port", "user", "pass", "admin_email", "sender_name"], }, - // Unlike the three entries above, `auth.captcha.provider` is genuine - // retained-but-inert hosted state, not a document-arm-only artifact: a - // recorded platform fixture reports `security_captcha_enabled: false` - // together with a still-populated `security_captcha_provider` (this - // file's own "the API arm prunes unmanaged fields behind disabled - // toggles too" test, below) — the platform keeps a stale provider choice - // around after captcha is turned off. The API arm's own `provider` row - // (registry-auth.ts:1019-1035) is NOT independently gated on - // `security_captcha_enabled` the way `api`'s/SMTP's siblings are, so this - // rule is load-bearing on BOTH arms for genuine phantom-drift - // suppression — an even stronger case than `api`/`auth.email.smtp` - // above. `secret` is a secret row and already omitted regardless. + // The platform genuinely retains a stale `provider` after captcha is turned off; this rule is + // load-bearing on both arms to suppress that phantom drift. { containerPath: ["auth", "captcha"], dropKeys: ["provider", "secret"] }, - // `oauth_server.enabled=false` means the platform serves no OAuth - // consent-UI/dynamic-registration behavior, but genuinely retains - // `allow_dynamic_registration`/`authorization_url_path` as - // stored-but-inert state — same shape as `auth.captcha` above. This isn't - // just theoretical: the stock `supabase init` project template - // (`project-init.templates.ts`) declares `[auth.oauth_server] enabled = - // false` together with `authorization_url_path = "/oauth/consent"` — - // without this prune, every stock project would show a fabricated drift - // line purely from `supabase init`'s own template. + // `oauth_server.enabled=false` retains `allow_dynamic_registration`/`authorization_url_path` as + // inert state; without this prune, every stock `supabase init` project would show a fabricated + // drift line from its own template. { containerPath: ["auth", "oauth_server"], dropKeys: ["allow_dynamic_registration", "authorization_url_path"], }, - // `storage.analytics.enabled=false` means no Iceberg catalog is - // provisioned, so `max_namespaces`/`max_tables`/`max_catalogs` are - // retained-but-inert ceilings on a non-existent resource — same shape as - // `auth.captcha` above. The stock `supabase init` template declares - // `[storage.analytics] enabled=false, max_namespaces=5, max_tables=10, - // max_catalogs=2`, while a recorded platform fixture reports - // `max_namespaces=10` for a fresh project — without this prune, EVERY - // stock project would show a declared "lower the Iceberg quota" diff - // purely from the template, and `config push` would act on it. + // No Iceberg catalog is provisioned while disabled, so these ceilings are inert; without this + // prune, the stock `supabase init` template would show a fabricated quota-lowering diff. { containerPath: ["storage", "analytics"], dropKeys: ["max_namespaces", "max_tables", "max_catalogs"], }, - // Same shape as `storage.analytics` above: `storage.vector.enabled=false` - // means no Vector catalog is provisioned, so `max_buckets`/`max_indexes` - // are retained-but-inert ceilings on a non-existent resource. + // Same shape as `storage.analytics` above: no Vector catalog is provisioned while disabled. { containerPath: ["storage", "vector"], dropKeys: ["max_buckets", "max_indexes"] }, ]; @@ -931,9 +441,7 @@ export const DISABLED_SENTINEL_ENTRY_SWEEPS: ReadonlyArray<{ }> = [ { containerPath: ["auth", "external"] }, { containerPath: ["auth", "hook"] }, - // Same five provider names as SMS_PROVIDER_PUSH_PRECEDENCE — one list, not - // two hand-kept in sync (order doesn't matter for a sweep, unlike the - // precedence table's own order-pinned test). + // Reuses SMS_PROVIDER_PUSH_PRECEDENCE's provider list rather than duplicating it. { containerPath: ["auth", "sms"], entryKeys: SMS_PROVIDER_PUSH_PRECEDENCE }, ]; @@ -968,15 +476,8 @@ function applyDisabledSentinels(result: Record<string, unknown>): void { } } } - // Cross-section rule: the email rate limit is only managed while SMTP is - // enabled (authToUpdateBody sends rate_limit_email_sent solely under - // local.email.smtp.enabled, auth.sync.ts:2310-2313) — but pruning only - // fires on an EXPLICIT `smtp.enabled === false`, never on absence: the - // legacy push always knows local `smtp.enabled` (the document is fully - // defaulted before push ever runs), so an ABSENT flag here can only happen - // on the API arm, where it follows the same absent-says-nothing rule as - // its sibling fields (`smtpExplicitlyDisabledInAttributes`, - // `./registry-auth.ts`) — a sparse response that never mentioned + // The email rate limit is only managed while SMTP is enabled, but pruning fires only on an + // explicit `smtp.enabled === false`, never on absence — a sparse response that never mentioned // `smtp_host` must not have this value pruned either. const authSection = result["auth"]; if (isObject(authSection)) { @@ -989,9 +490,7 @@ function applyDisabledSentinels(result: Record<string, unknown>): void { if (Object.keys(rateLimit).length === 0) { delete authSection["rate_limit"]; } - // This is the one sentinel that can empty its whole section (every - // other rule keeps at least the `enabled` flag) — a section emptied by - // pruning is unmanaged noise, unlike an originally-empty one. + // The only sentinel that can empty its whole section; every other rule keeps at least `enabled`. if (Object.keys(authSection).length === 0) { delete result["auth"]; } @@ -1000,53 +499,21 @@ function applyDisabledSentinels(result: Record<string, unknown>): void { } /** - * DOCUMENT-ARM ONLY, and only when {@link fromConfigDocument} was called - * with a {@link CliConfigWithRawPresence} pair (human review round on PR - * #6339, thread 1) — never called from {@link fromApiProjectConfig}, which - * has no analogous raw-document concept. This is the mechanism that - * implements the {@link ConfigAbsencePolicy} `"absent-is-hands-off"` policy: - * it removes a subtree from the projection when the raw file never declared - * it, rather than letting the decoded (schema-defaulted) value stand in for - * the caller's intent. + * Implements the `"absent-is-hands-off"` {@link ConfigAbsencePolicy}: removes a subtree from the + * projection when the raw file never declared it, rather than letting the decoded schema default + * stand in. Document-arm only, and only when `document` is supplied. * - * Its coverage is a FIXED, HARD-CODED list of paths — `db.ssl_enforcement`, - * `storage.image_transformation`, `storage.s3_protocol`, `auth.captcha`, the - * six `auth.hook.*` names, `auth.email.smtp` (cascading to - * `auth.rate_limit.email_sent`), and `auth.external.*` providers the raw file - * never declared (`apple` excepted — always retained regardless of presence) - * — NOT a general rule over every comparable path. Every comparable path - * OUTSIDE this list is covered only by the separate, GENERIC `declared` - * mechanism in `../config-diff.ts` (`diffProjectConfig`'s `isDeclaredAtPath`), - * which changes classification (`update` vs. `remote_only`) rather than - * removing anything from the projection. Do not read this function's fixed - * list as the full extent of the safety net this package provides — see - * {@link ConfigAbsencePolicy}'s docstring for how the two mechanisms combine - * and the one cell neither alone would close. - * - * Distinct from {@link applyDisabledSentinels} (reads the DECODED `enabled` - * flag — can only ever say "explicitly disabled", never "never mentioned", - * and runs even without a `document`): this drops a container/entry - * specifically because the RAW FILE never declared it — a stronger, - * independent signal only available with `document`, so it runs last and - * can remove a subtree {@link applyDisabledSentinels} already touched or - * left alone. - * - * Values that DO survive still come from the DECODED `result` — masking - * only decides presence/absence of a subtree, never substitutes a raw - * value: a document that declares `[auth.external.google]` with only - * `client_id` set still projects `google`'s decoded `enabled: false` default - * alongside it. + * Covers a fixed list of paths — `db.ssl_enforcement`, `storage.image_transformation`, + * `storage.s3_protocol`, `auth.captcha`, the `auth.hook.*` names, `auth.email.smtp`, and + * `auth.external.*` providers the raw file never declared (`apple` excepted) — not every + * comparable path. Values that survive still come from the decoded `result`; masking only removes + * a subtree, it never substitutes a raw value. */ function applyRawPresenceMask( result: Record<string, unknown>, document: Record<string, unknown>, ): void { - // Matches `presenceIn`/`authPresenceIn`'s own predicate EXACTLY - // (`x?.["key"] !== undefined`) — a VALUE comparison, not `Object.hasOwn` - // (engineer review round on PR #6339, item 3): a raw document with an own - // key set to an explicit `undefined` (`{ auth: { captcha: undefined } }`) - // reads as ABSENT on both sides this way, keeping the docstring's - // "mirrors ... exactly" claim literally true. + // A value comparison, not `Object.hasOwn`: an explicit `undefined` reads as absent too. const isPresent = (container: unknown, key: string): boolean => isObject(container) && container[key] !== undefined; @@ -1078,21 +545,12 @@ function applyRawPresenceMask( const email = isObject(auth) ? auth["email"] : undefined; if (!isPresent(email, "smtp")) { removePathAndEmptiedAncestors(result, ["auth", "email", "smtp"]); - // The push mapper skips `rate_limit_email_sent` too when the raw file - // never declares `[auth.email.smtp]` at all (auth.sync.ts:2310-2313) — - // with raw presence available, this is exact, where the - // `applyDisabledSentinels` explicit-false rule above can only ever say - // "explicitly disabled", never "never mentioned". + // Also drop the rate limit that only applies while SMTP is configured at all. removePathAndEmptiedAncestors(result, ["auth", "rate_limit", "email_sent"]); } - // Every provider decodes present (schema-defaulted `enabled: false`), but - // push only ever sends the raw-declared providers PLUS the always-sent - // `apple` default (auth.sync.ts:1075-1084) — keep exactly that set. - // `Object.keys`, not `isPresent`, deliberately: `authPresenceIn`'s own - // `externalProviders: Object.keys(external)` line uses own-key existence - // here too, unlike its five `!== undefined` checks above — this is the - // one gate that is genuinely keyed on `Object.hasOwn` semantics upstream. + // Keep only the raw-declared providers plus the always-retained `apple` default; every other + // provider decodes present with a schema default that should not survive. const external = isObject(auth) ? auth["external"] : undefined; const declaredProviders = isObject(external) ? new Set(Object.keys(external)) : new Set<string>(); const projectedExternal = readPath(result, ["auth", "external"]); @@ -1106,24 +564,10 @@ function applyRawPresenceMask( } /** - * Unwraps the three shapes a caller might hand `fromApiProjectConfig`: the - * full envelope (`{data: {type, attributes}}`), the `data` object itself - * (`{type, attributes}`), or bare `attributes`. Presence of an own `data` or - * `attributes` key decides which shape was intended, and each of those two - * shapes is then validated strictly: a malformed envelope (e.g. `{data: - * {attributes: 5}}`) throws rather than silently falling through to "bare - * attributes", which would map to an empty {@link ProjectConfig} — read by a - * diff consumer as "the remote manages nothing", a confidently wrong result - * for what is actually a decode failure. Only the *absence* of both an own - * `data` and an own `attributes` key is treated as "this is bare attributes - * already" — an API-ahead section literally named `data` or `attributes` - * inside a real attributes object is deliberately foreclosed as a - * possibility here, since a truncated or malformed envelope reaching this - * function is far likelier than the platform ever naming a project-config - * section either of those two words. This is the one documented trade - * behind {@link ProjectConfigParseError}'s "unknown keys never cause this" - * claim: an unknown key collides with envelope detection only when it is - * spelled exactly `data` or `attributes` at the top level. + * Unwraps the three shapes a caller might hand `fromApiProjectConfig`: the full envelope + * (`{data: {type, attributes}}`), the `data` object itself, or bare `attributes` — decided by + * presence of an own `data` or `attributes` key. A malformed envelope throws rather than falling + * through to "bare attributes", which would silently map to an empty {@link ProjectConfig}. */ function unwrapApiResponse(input: unknown): Record<string, unknown> { if (!isObject(input)) { @@ -1158,13 +602,9 @@ function unwrapApiResponse(input: unknown): Record<string, unknown> { } /** - * Reads one envelope property, translating a throwing accessor into the - * documented failure type: parsed JSON never carries getters, so an accessor - * that throws during unwrapping is programmatic caller input — the same - * taxonomy as the non-plain-object rejection in the validation walk. Each - * envelope property is read exactly ONCE through this helper, so a getter - * cannot return one value for a shape check and another (or a throw) for the - * actual read. + * Reads one envelope property, translating a throwing accessor into {@link ProjectConfigParseError}. + * Each property is read exactly once through this helper, so a getter can't answer a shape check + * with one value and the actual read with another. */ function readEnvelopeProperty(container: Record<string, unknown>, key: string): unknown { try { @@ -1179,12 +619,8 @@ function readEnvelopeProperty(container: Record<string, unknown>, key: string): } /** - * An envelope carrying an explicit `type` must carry THIS resource's type — - * the generated contract's discriminator is `"project_config"`, so e.g. a - * mixed-up response for another resource fails loudly instead of being - * partially mapped wherever its attribute names happen to overlap. An absent - * `type` stays tolerated (lenient toward trimmed-down callers that pass only - * `{data:{attributes}}`). + * An envelope carrying an explicit `type` must be `"project_config"`, so a mixed-up response for + * another resource fails loudly instead of partially mapping. An absent `type` is tolerated. */ function assertProjectConfigResourceType(envelope: Record<string, unknown>): void { if (!Object.hasOwn(envelope, "type")) { @@ -1192,9 +628,7 @@ function assertProjectConfigResourceType(envelope: Record<string, unknown>): voi } const resourceType = readEnvelopeProperty(envelope, "type"); if (resourceType !== "project_config") { - // Rendered defensively: JSON.stringify throws on a bigint discriminator, - // which would escape the typed-error contract from inside the error - // builder itself. + // JSON.stringify throws on a bigint discriminator, so render non-strings separately. const rendered = typeof resourceType === "string" ? JSON.stringify(resourceType) @@ -1222,34 +656,15 @@ function envelopeError(detail: string): ProjectConfigParseError { }); } -// Sync decode is an accepted exception (repo `CLAUDE.md`'s "Schema decoding -// and encoding" section): this is an explicitly synchronous outer boundary -// (`fromApiProjectConfig` is a plain throwing function, not an `Effect`), -// `ProjectConfigApiAttributesSchema` is service-free (no `Effect.gen`/context -// requirements — see `./api-attributes.ts`), and the thrown -// `ProjectConfigParseError` below is the documented, intentional contract for -// a decode failure here. +// Sync decode is fine here: this is an explicitly synchronous boundary, and the schema is +// service-free. const decodeApiAttributes = Schema.decodeUnknownSync(ProjectConfigApiAttributesSchema); /** - * Builds the `message`/`apiPath`/`detail` triple for a schema decode failure - * from the thrown `SchemaError`, via the v4 `SchemaIssue` formatters: the - * first flattened issue's path becomes `apiPath` (stringified — a - * `SchemaIssue` path segment is a `PropertyKey`, and an array index arrives - * as a `number`) and its message becomes the short summary rendered into - * `message`; the full multi-issue rendering - * (`SchemaIssue.makeFormatterDefault()`, the same formatter `SchemaError`'s - * own `.message` uses) becomes `detail`. Falls back to the bare - * `SchemaError` message when `cause` isn't a `SchemaError` at all — should - * not happen given `decodeApiAttributes` is the only caller, but this - * function must not itself throw while building an error message. - * - * Normalizes an empty issue path to `undefined`: an issue at the attributes - * ROOT (the envelope/shape itself, rather than any specific field within it) - * reports a zero-length path, and {@link ProjectConfigParseError}'s own - * `apiPath` docstring promises `undefined` for exactly that case — an empty - * array reads to a consumer as "the offending path is the empty path", - * which is a different (and wrong) claim. + * Builds the `message`/`apiPath`/`detail` triple for a schema decode failure. `apiPath` is the + * first issue's path (stringified), `message` its summary, and `detail` the full multi-issue + * rendering. An empty issue path (the failure is at the attributes root) normalizes to `undefined`, + * matching {@link ProjectConfigParseError}'s own `apiPath` contract. */ function schemaDecodeFailureMessage(cause: unknown): { readonly message: string; @@ -1265,12 +680,7 @@ function schemaDecodeFailureMessage(cause: unknown): { } const { issues } = SchemaIssue.makeFormatterStandardSchemaV1()(cause.issue); const [firstIssue] = issues; - // A `StandardSchemaV1.Issue` path entry is a `PropertyKey` OR a - // `{ key: PropertyKey }` `PathSegment` object per the spec; effect's own - // formatter only ever emits the former (`SchemaIssue.ts`'s internal - // `DefaultIssue.path: ReadonlyArray<PropertyKey>`), but the public type is - // the wider spec shape, so this reads `.key` off an object segment rather - // than stringifying it directly. + // A path entry may be a bare `PropertyKey` or a `{ key: PropertyKey }` segment object per spec. const rawApiPath = firstIssue?.path?.map((segment) => String(typeof segment === "object" ? segment.key : segment), ); @@ -1334,13 +744,10 @@ function writePath( } /** - * Walks {@link projectConfigMappingRows} against `decodedAttributes` and - * writes every surviving mapped value into `output`. Per - * `./registry-row.ts`'s null convention: a row whose `apiPath` is absent - * (`undefined`) from `decodedAttributes` is always skipped, and one whose - * value is `null` is skipped unless the row declares a `transform` (which - * receives the `null` and decides). `isSecret` rows are never emitted — the - * API only ever reports an HMAC digest for them (ADR 0019 rule 5). + * Walks {@link projectConfigMappingRows} against `decodedAttributes` and writes every surviving + * mapped value into `output`. A row whose value is `undefined` is skipped; one whose value is + * `null` is skipped unless it declares a `transform`. `isSecret` rows are never emitted — the API + * only ever reports an HMAC digest for them. */ function applyMappingRows( decodedAttributes: ProjectConfigApiAttributes, @@ -1348,11 +755,8 @@ function applyMappingRows( ): void { for (const row of projectConfigMappingRows) { if (row.isSecret) { - // The value is never emitted (ADR 0019 rule 5 — the API only reports - // an HMAC digest), but a present non-string is still a malformed - // platform response and must not vanish silently: the path is in the - // consumed set, so without this check `unmappedApiFields` would hide - // the malformed value too. + // Never emitted, but a present non-string is still a malformed response that must not + // vanish silently from `unmappedApiFields`. const secretValue = readPath(decodedAttributes, row.apiPath); if (secretValue !== undefined && secretValue !== null) { expectString(secretValue, row.apiPath); @@ -1362,10 +766,8 @@ function applyMappingRows( const rawValue = readPath(decodedAttributes, row.apiPath); if (rawValue === undefined) { - // A row that also consumes sibling paths must still run when a sibling - // is present despite the absent anchor: the sibling is in the consumed - // set, so skipping here would silently swallow a malformed sibling - // without it ever being validated (or reported unmapped). + // Still run when a sibling path is present, even with the anchor absent, so a malformed + // sibling doesn't silently vanish. const siblingPresent = row.alsoConsumes?.some( (alsoPath) => readPath(decodedAttributes, alsoPath) !== undefined, ); @@ -1386,11 +788,8 @@ function applyMappingRows( writePath(output, row.configPath, mapped); } - // The orphan secret paths (`unmappedSecretApiPaths`) get the same - // present-non-null validation as `isSecret` rows above: they too are in the - // consumed set, so a malformed platform value (the contract permits only - // string or null) would otherwise vanish — never emitted AND suppressed - // from `unmappedApiFields`. + // Orphan secret paths get the same present-non-null validation as `isSecret` rows above, so a + // malformed value doesn't vanish from `unmappedApiFields` unvalidated. for (const secretPath of unmappedSecretApiPaths) { const secretValue = readPath(decodedAttributes, secretPath); if (secretValue !== undefined && secretValue !== null) { @@ -1400,43 +799,18 @@ function applyMappingRows( } /** - * Guards {@link walkUnmapped} and {@link assertRawAttributesDepthWithinBound} - * against a pathologically (or maliciously) deep response body — an object - * graph deeper than this could otherwise overflow the call stack with an - * uncaught `RangeError` instead of the package's own documented failure - * type. + * Depth bound for {@link walkUnmapped} and the raw-attributes validation walk, so a + * pathologically deep or cyclic response fails as a typed error instead of overflowing the call + * stack. A self-referential object has no finite depth, so this also catches cycles without a + * separate visited-set. */ const MAX_UNMAPPED_WALK_DEPTH = 64; /** - * Pre-clone depth guard for {@link attachFrozenApiResponse} (CLI-2230's - * clone/freeze finding): `structuredClone` and {@link deepFreeze} are both - * naive recursive walks with no depth limit of their own, so a - * pathologically deep `rawAttributes` — say, ~50k levels of nesting under an - * API-ahead-of-package key (unmapped fields reach `attachFrozenApiResponse` - * verbatim; decode's own leniency never prunes them) — overflows the call - * stack with a raw, uncaught `RangeError` from inside `structuredClone` - * itself, before this package ever gets a chance to turn it into a {@link - * ProjectConfigParseError}. Walking (and throwing) here, before either - * function ever runs, catches that case first. This also bounds cycles as a - * side effect, with no separate visited-set needed: a self-referential - * object has no finite depth, so re-encountering the same node at every - * increasing `depth` still exceeds {@link MAX_UNMAPPED_WALK_DEPTH} - * deterministically, well before either function's own recursion could - * overflow the stack. - */ -/** - * Total node visits the raw-attributes validation walk tolerates before - * declaring the structure pathological. A real project-config response holds - * a few hundred nodes; this bound exists for programmatic callers handing - * `attachApiResponse` a shared-reference DAG, where ~40 objects arranged - * with two properties each pointing at the same next node cost ~2^40 visits - * while staying inside the depth bound — bounding *work* (not memoizing - * subtrees) keeps the rejection typed and also keeps the later - * path-dependent {@link walkUnmapped} safe, since any structure that passes - * here costs `walkUnmapped` at most the same bounded number of visits. - * (JSON parsed off a real network response can never share references, so - * nothing legitimate is anywhere near this bound.) + * Total node visits the raw-attributes validation walk tolerates before declaring the structure + * pathological. A real response holds a few hundred nodes; this bound catches a shared-reference + * DAG (a depth-bounded structure that still explodes combinatorially through repeated visits) from + * a programmatic `attachApiResponse` caller. */ const MAX_RAW_ATTRIBUTES_NODE_VISITS = 100_000; @@ -1444,13 +818,9 @@ function assertRawAttributesDepthWithinBound( value: unknown, depth = 0, visits: { count: number } = { count: 0 }, - // Call-site provenance for the depth/visit bounds: via fromApiProjectConfig - // a pathological structure is a platform-response problem (upgrade - // suggestion applies); via attachApiResponse the structure is the CALLER's - // own data, and reporting it as an external api_status failure would - // corrupt the KPI. Non-JSON primitives and non-plain objects stay - // caller_misuse unconditionally — parsed JSON cannot produce them on any - // path. + // Via fromApiProjectConfig a pathological structure is a platform-response problem; via + // attachApiResponse it's the caller's own data, so it must not be reported as an external + // failure. reason: "api_response" | "caller_misuse" = "api_response", ): void { if (depth > MAX_UNMAPPED_WALK_DEPTH) { @@ -1463,18 +833,9 @@ function assertRawAttributesDepthWithinBound( : { suggestion: PROJECT_CONFIG_PARSE_ERROR_SUGGESTION }), }); } - // Bigint is structured-cloneable and freezable but not JSON — it would - // land under a ReadonlyJsonValue-typed _apiResponse and blow up the first - // JSON.stringify a consumer runs on an unmappedApiFields report. Parsed - // JSON never produces one; programmatic caller input. Same for an - // undefined-valued key (silently vanishes under JSON.stringify) and NaN - // (no JSON literal exists for it). ±Infinity is NOT in this set (ADR 0019 - // rule 2 addendum): `JSON.parse('{"x":1e400}')` yields `Infinity`, so a - // real platform payload can carry it in a field nothing reads — rejecting - // it here would mis-bucket that payload as caller misuse. `walkUnmapped` - // below converts a tolerated ±Infinity leaf to `null` (JSON.stringify's own - // rendering) for `unmappedApiFields`, and `expectNumber` still rejects it - // on any MAPPED field via the registry. + // Bigint, undefined, and NaN have no JSON spelling and would break JSON.stringify on an + // `unmappedApiFields` report, so they're rejected as caller input. ±Infinity is tolerated + // instead — `JSON.parse` can legitimately produce it — and `walkUnmapped` renders it as `null`. if ( typeof value === "bigint" || value === undefined || @@ -1505,16 +866,10 @@ function assertRawAttributesDepthWithinBound( return; } if (isObject(value)) { - // Only PLAIN objects pass: a Map/Set/Date/typed array is - // structured-cloneable, but Object.freeze only freezes its wrapper — its - // internal mutators (map.set, date.setTime) still work afterwards, so it - // would punch a mutable hole through the deep-frozen metadata. Parsed - // JSON never produces one. The identity check alone would also reject a - // plain JSON payload parsed in ANOTHER REALM (an iframe handing its - // JSON.parse result to the parent has that realm's Object.prototype), so - // the cross-realm-safe brand check backs it up — built-ins carry their - // own tags ("[object Map]"), a plain object reports "[object Object]" - // from any realm. + // Only plain objects pass: Object.freeze only freezes a Map/Set/Date's wrapper, leaving its + // mutators still working, which would punch a mutable hole through the deep-frozen metadata. + // The brand check (not just prototype identity) also accepts a plain object parsed in another + // realm. const prototype = Object.getPrototypeOf(value); if ( prototype !== Object.prototype && @@ -1535,14 +890,8 @@ function assertRawAttributesDepthWithinBound( } /** - * Wraps `structuredClone` for {@link attachFrozenApiResponse}: a - * function-valued or symbol-valued raw attribute (never a shape a real API - * response should carry, but not excluded by this package's otherwise - * maximally-lenient decode either — `Schema.Unknown` accepts it) fails - * `structuredClone` with an untyped, un-tagged `DOMException` ("The object - * can not be cloned"). Translating it here keeps that failure inside this - * package's documented `ProjectConfigParseError` contract instead of leaking - * a raw `DOMException` to every caller. + * Wraps `structuredClone` for {@link attachFrozenApiResponse}, translating its untyped + * `DOMException` (thrown on a function/symbol-valued attribute) into {@link ProjectConfigParseError}. */ function cloneRawAttributes( rawAttributes: Record<string, unknown>, @@ -1551,10 +900,8 @@ function cloneRawAttributes( try { return structuredClone(rawAttributes); } catch (cause) { - // Non-cloneable values (functions/symbols) can only be programmatic, but - // structuredClone ALSO throws on sufficiently deep plain JSON — which a - // platform response genuinely can be — so provenance follows the call - // site rather than assuming misuse. + // structuredClone also throws on sufficiently deep plain JSON, which a platform response can + // genuinely be, so provenance follows the call site rather than assuming misuse. const detail = "raw attributes hold a value structuredClone cannot copy (a non-JSON value, or pathologically deep nesting)"; throw new ProjectConfigParseError({ @@ -1568,78 +915,46 @@ function cloneRawAttributes( } /** - * Attaches a deep-cloned, deep-frozen copy of `rawAttributes` to a fresh - * shallow copy of `enumerableProps`'s own enumerable properties, as a - * non-enumerable `_apiResponse` (ADR 0019 rule 1). Shared by - * {@link fromApiProjectConfig} and the exported {@link attachApiResponse} so - * both go through one clone+freeze path. Cloning (rather than aliasing the - * caller's object) and freezing means neither this package nor a caller can - * mutate the attached raw attributes after the fact — including through the - * very reference `rawAttributes` was passed in by. Every failure mode this - * function can hit — pathological depth/cycles ({@link - * assertRawAttributesDepthWithinBound}) and non-cloneable values ({@link - * cloneRawAttributes}) — is translated into {@link ProjectConfigParseError} - * rather than left to surface as a raw `RangeError`/`DOMException`, per this - * package's documented failure-type contract. + * Attaches a deep-cloned, deep-frozen copy of `rawAttributes` to a fresh shallow copy of + * `enumerableProps` as a non-enumerable `_apiResponse`. Shared by {@link fromApiProjectConfig} and + * {@link attachApiResponse} so both go through one clone+freeze path; cloning means neither this + * package nor a caller can mutate the attached raw attributes afterward. */ function attachFrozenApiResponse<T extends Record<string, unknown>>( enumerableProps: T, rawAttributes: Record<string, unknown>, reason: "api_response" | "caller_misuse" = "api_response", ): T { - // Clone FIRST, then validate the CLONE: validating the live input leaves a - // time-of-check/time-of-use gap for accessor properties (a getter can - // answer the validation walk with a plain value and hand structuredClone a - // bigint). The clone is inert data — getters are resolved exactly once by - // structuredClone — so what gets validated is what gets attached. A - // pathologically deep input failing inside structuredClone itself is - // caught and typed by cloneRawAttributes. + // Clone first, then validate the clone: validating live input first would leave a + // time-of-check/time-of-use gap for a getter that answers differently on each read. const cloned = cloneRawAttributes(rawAttributes, reason); assertRawAttributesDepthWithinBound(cloned, 0, undefined, reason); return attachOwnedSnapshot(enumerableProps, cloned); } /** - * Maps a Management API v2 project-config response into a {@link - * ProjectConfig}, per ADR 0019: (1) unwraps whichever of the three envelope - * shapes `input` is, (2) decodes the unwrapped attributes leniently — an - * API-ahead-of-package field never fails this decode, only a genuinely - * malformed mapped field does — (3) walks the mapping registry - * (`./registry.ts`) to populate the typed sections, and (4) attaches a - * deep-cloned, deep-frozen copy of the raw, unwrapped attributes as a - * non-enumerable `_apiResponse` ({@link attachFrozenApiResponse}) so - * `unmappedApiFields` and forward-compatible consumers can still reach - * whatever the registry didn't map. Throws {@link ProjectConfigParseError} - * when `input` isn't an object, when the envelope is malformed, or when - * decoding/mapping a value fails. + * Maps a Management API v2 project-config response into a {@link ProjectConfig}: unwraps whichever + * envelope shape `input` is, decodes the attributes leniently (an API-ahead-of-package field never + * fails decode, only a genuinely malformed mapped field does), walks the mapping registry + * (`./registry.ts`) to populate the typed sections, and attaches a deep-frozen copy of the raw + * attributes as a non-enumerable `_apiResponse` so {@link unmappedApiFields} can still reach + * whatever the registry didn't map. * - * Also NOT a verbatim projection of the response (ADR 0021): a `null` on a - * gating boolean canonicalizes to `enabled: false` rather than being skipped - * (`gatedBoolRow`/the SMTP host anchor, `./registry-auth.ts`), the - * same {@link applyDisabledSentinels} pruning `fromConfigDocument` applies - * runs here too, and an out-of-domain value on a mapped field (e.g. a - * negative `storage.file_size_limit`) throws rather than canonicalizing to a - * wrong value. This makes an API-sourced and a document-sourced - * `ProjectConfig` comparable for the same hosted state, at the cost of this - * function's output also not being a byte-for-byte echo of what the API - * reported. + * Not a verbatim projection of the response (ADR 0021): a `null` on a gating boolean canonicalizes + * to `enabled: false`, {@link applyDisabledSentinels} pruning runs here too, and an out-of-domain + * value on a mapped field throws rather than canonicalizing to a wrong value — so an API-sourced + * and a document-sourced `ProjectConfig` compare like for like. + * + * @throws {@link ProjectConfigParseError} if `input` isn't an object, the envelope is malformed, or a value fails to decode or map. */ export function fromApiProjectConfig(input: unknown): ProjectConfig; -// Untyped for the same reason as `fromConfigDocument` above: the mapping -// walk builds its result dynamically from `./registry.ts`'s rows, which -// TypeScript cannot verify reconstructs a `ProjectConfig`; the overload above -// is the contract, pinned by the unit tests. +// Untyped for the same reason as `fromConfigDocument`: the mapping walk builds its result +// dynamically, which TypeScript can't verify reconstructs a `ProjectConfig`. export function fromApiProjectConfig(input: unknown): unknown { const rawAttributes = unwrapApiResponse(input); - // ONE inert snapshot for everything: clone first (getters resolve exactly - // once — decode, mapping, and the attached metadata all read the same - // data, so no accessor can desynchronize them), then depth/work-bound the - // snapshot BEFORE schema decoding — the mirror's `auth` record is - // `Schema.Json`, whose decode recurses through arbitrary nesting, so a - // pathologically deep value would otherwise overflow with a raw RangeError - // inside the decode, escaping the typed-error contract. structuredClone's - // own failure modes (non-cloneables, extreme depth) are already typed by - // cloneRawAttributes. + // One inert snapshot for everything: clone first so decode, mapping, and the attached metadata + // all read the same data, then bound its depth before schema decoding, since a pathologically + // deep value would otherwise overflow the decode itself with a raw RangeError. const snapshot = cloneRawAttributes(rawAttributes); assertRawAttributesDepthWithinBound(snapshot); const decodedAttributes = decodeAttributes(snapshot); @@ -1648,17 +963,11 @@ export function fromApiProjectConfig(input: unknown): unknown { applyMappingRows(decodedAttributes, output); applyDisabledSentinels(output); - // The snapshot is already validated and exclusively owned here, so it is - // frozen and attached directly — no second clone/validation pass. + // Already validated and exclusively owned here, so freeze and attach directly — no second pass. return attachOwnedSnapshot(output, snapshot); } -/** - * Freezes and attaches an ALREADY-validated, exclusively-owned snapshot — - * the tail of {@link attachFrozenApiResponse} without the clone/validate - * steps, for the one caller ({@link fromApiProjectConfig}) that has already - * done both on the same object. - */ +/** Freezes and attaches an already-validated, exclusively-owned snapshot — the tail of {@link attachFrozenApiResponse} without its clone/validate steps. */ function attachOwnedSnapshot<T extends Record<string, unknown>>( enumerableProps: T, snapshot: Record<string, unknown>, @@ -1674,10 +983,8 @@ function attachOwnedSnapshot<T extends Record<string, unknown>>( reason: "caller_misuse", }); } - // The spread evaluates every enumerable own property, so a getter on a - // caller-supplied props object (attachApiResponse) would otherwise leak - // its raw throw past the typed-error contract; the API arm's props are - // built internally as plain data and can never take this branch. + // A getter on a caller-supplied props object (attachApiResponse) could otherwise leak a raw + // throw here; the API arm's own props are always plain data. let result: T; try { result = { ...enumerableProps }; @@ -1699,21 +1006,12 @@ function attachOwnedSnapshot<T extends Record<string, unknown>>( } /** - * Re-attaches `_apiResponse` to `config` after a caller's own spread, - * `structuredClone`, or state-store round-trip already dropped it — ADR - * 0019 rule 1 promises the attach step exists precisely because those - * operations are non-enumerable-property-blind by design, and a consumer - * that legitimately needs to carry the raw attributes across such a - * boundary (a state store, a serialized cache entry it then rehydrates) must - * be able to restore them explicitly rather than losing `unmappedApiFields` - * access permanently. Returns a NEW object: a shallow copy of `config`'s own - * enumerable properties, plus `rawAttributes` attached via the same - * clone-and-freeze path {@link fromApiProjectConfig} uses internally - * ({@link attachFrozenApiResponse}) — never mutates `config` in place. Throws - * {@link ProjectConfigParseError} when `config` is not an object, matching - * {@link toProjectConfig}'s own strictness — a non-object `config` used to - * silently substitute `{}`, discarding whatever the caller actually passed - * instead of surfacing the misuse. + * Re-attaches `_apiResponse` to `config` after a spread, `structuredClone`, or state-store + * round-trip drops it, since those operations are blind to non-enumerable properties. Returns a + * new object — a shallow copy of `config` plus `rawAttributes` attached via the same + * clone-and-freeze path {@link fromApiProjectConfig} uses — and never mutates `config` in place. + * + * @throws {@link ProjectConfigParseError} if `config` is not an object. */ export function attachApiResponse( config: ProjectConfig, @@ -1738,12 +1036,9 @@ export function attachApiResponse( } /** - * Either operand `toProjectConfig` accepts: a local {@link EffectiveConfig} - * — or a {@link CliConfigWithRawPresence} pair, the RECOMMENDED form - * whenever a `document` is available (see {@link fromConfigDocument}'s own - * docstring) — to project down to the hosted subset, or a raw, - * not-yet-decoded Management API v2 project-config response (in any of the - * three envelope shapes {@link fromApiProjectConfig} accepts) to map. + * Either operand `toProjectConfig` accepts: a local {@link EffectiveConfig} or + * {@link CliConfigWithRawPresence} pair to project down to the hosted subset, or a raw, + * not-yet-decoded Management API v2 project-config response to map. */ export type ToProjectConfigSource = | { readonly cliConfig: EffectiveConfig | CliConfigWithRawPresence } @@ -1762,11 +1057,8 @@ function hasCliConfig( } /** - * Caller misuse — a programming error in the consumer, not a malformed - * platform response: the message is plain (no "Management API response" - * framing), the upgrade `suggestion` is omitted (upgrading fixes nothing), - * and `reason: "caller_misuse"` lets apps/cli's error-actionability adapter - * bucket it as invalid input instead of an external `api_status` failure. + * Builds a caller-misuse error: a programming error in the consumer, not a malformed platform + * response, so the message omits the "Management API response" framing and upgrade suggestion. */ function callerMisuseError(detail: string): ProjectConfigParseError { return new ProjectConfigParseError({ @@ -1777,21 +1069,15 @@ function callerMisuseError(detail: string): ProjectConfigParseError { } /** - * Thin dispatcher over the two normalizers above: routes to - * {@link fromApiProjectConfig} when `source` carries an own `apiResponse` - * property, otherwise to {@link fromConfigDocument} when it carries an own - * `cliConfig` property. A full `CliConfig` fits the `cliConfig` arm - * directly, since `CliConfig` is assignable to {@link EffectiveConfig}. - * Throws {@link ProjectConfigParseError} when `source` carries neither own - * key or both — `{}` and `{ cliConfig: x, apiResponse: y }` are equally - * meaningless dispatch requests, and failing loudly here beats a raw - * `TypeError` from reaching into a property that isn't there. + * Thin dispatcher over the two normalizers above: routes to {@link fromApiProjectConfig} when + * `source` carries an own `apiResponse` property, otherwise to {@link fromConfigDocument} for an + * own `cliConfig` property. + * + * @throws {@link ProjectConfigParseError} if `source` carries neither key or both. */ export function toProjectConfig(source: ToProjectConfigSource): ProjectConfig { - // A JavaScript caller can hand this public dispatcher null/undefined - // despite the compile-time type; guarding before the own-property - // predicates keeps the failure inside the documented typed-error contract - // instead of a native TypeError from Object.hasOwn. + // Guard before the own-property checks so a non-object caller input surfaces as the documented + // typed error, not a native TypeError from Object.hasOwn. if (!isObject(source)) { throw callerMisuseError( `toProjectConfig source must be an object carrying exactly one of "cliConfig" or "apiResponse", got ${nonObjectDescription(source)}`, @@ -1813,13 +1099,7 @@ export function toProjectConfig(source: ToProjectConfigSource): ProjectConfig { ); } -/** - * Reads the dispatcher's selected source property through the same guarded - * boundary as the envelope reads ({@link readEnvelopeProperty}): plain data - * never carries getters, so an accessor that throws here is programmatic - * caller input and must surface as the documented failure type — not leak a - * raw `Error` past the telemetry classification. - */ +/** Reads the dispatcher's selected source property, translating a throwing accessor into {@link ProjectConfigParseError}. */ function readSourceProperty<T>(read: () => T, key: string): T { try { return read(); @@ -1833,35 +1113,17 @@ function readSourceProperty<T>(read: () => T, key: string): T { } function pathKey(path: ReadonlyArray<string>): string { - // JSON-encoded, not joined — a raw API key can legitimately contain any - // candidate separator (a literal dot, even an escaped NUL), so no join - // delimiter is collision-free. Encoding the segment array itself is - // unambiguous for every representable key. + // JSON-encoded rather than joined, since a raw API key can contain any candidate separator. return JSON.stringify(path); } /** - * Every API path this registry version "knows about" — a row's own - * `apiPath`, everything its `alsoConsumes` names, and every - * `unmappedSecretApiPaths` entry (`./registry-auth.ts`: secret-shaped GoTrue - * keys with no row of their own, so they'd otherwise leak an HMAC digest - * into `unmappedApiFields`). "Consumed" here means "known to this registry - * version", not "mapped on this run": an `alsoConsumes` sibling is suppressed - * even on a run where its anchor row's own value was absent (e.g. Apple's - * `external_apple_additional_client_ids` when `external_apple_client_id` - * itself is missing) — the raw value is still there in `_apiResponse`, only - * `unmappedApiFields` treats it as accounted for. This is intentional, not a - * gap: the alternative (only suppress when the anchor row actually fired) - * would report the sibling as "unmapped" even though a future run where the - * anchor IS present would fold it in identically, which is noise, not signal. - * Consumption is subtree-wide, not leaf-only, for the same reason: a - * platform-added key nested INSIDE a consumed value's own structure (e.g. a - * `comment` field added to an entry of `database.network_restrictions. - * allowed_cidrs`, itself one row's `apiPath`) is never itemized either — - * `walkUnmapped` prunes the whole subtree at the row's declared `apiPath` - * before ever descending into it, so a mapped container's internal shape is - * this registry version's business, not `unmappedApiFields`'s to re-report - * field-by-field; `_apiResponse` still carries it verbatim. + * Every API path this registry version "knows about" — a row's own `apiPath`, everything its + * `alsoConsumes` names, and every `unmappedSecretApiPaths` entry. "Consumed" means known to this + * registry version, not mapped on this run: a sibling stays suppressed even when its anchor row's + * value was absent, since it would otherwise flip between "unmapped" and mapped from run to run. + * Consumption is subtree-wide, not leaf-only, so a platform-added key nested inside a mapped + * container's own structure is never itemized either — `_apiResponse` still carries it verbatim. */ const consumedApiPathKeys: ReadonlySet<string> = (() => { const keys = new Set<string>(); @@ -1878,12 +1140,10 @@ const consumedApiPathKeys: ReadonlySet<string> = (() => { })(); /** - * Every PROPER prefix of a consumed path, plus the six top-level sections the - * mirror schema declares — the containers this registry version already - * "knows". {@link walkUnmapped} prunes a known container that is empty in the - * raw response (an empty `postgres_settings`/`auth` carries nothing unknown - * to report), while an empty object at an UNKNOWN path survives as drift - * signal — a newly introduced, not-yet-populated API section. + * Every proper prefix of a consumed path, plus the six top-level sections the mirror schema + * declares — the containers this registry version already "knows". {@link walkUnmapped} prunes a + * known container that is empty in the raw response, while an empty object at an unknown path + * survives as drift signal — a newly introduced, not-yet-populated API section. */ const knownApiContainerKeys: ReadonlySet<string> = (() => { const keys = new Set<string>(); @@ -1908,30 +1168,12 @@ const knownApiContainerKeys: ReadonlySet<string> = (() => { })(); /** - * Deep-sanitizes a non-finite number anywhere inside an unmapped ARRAY - * leaf — an element, or a leaf inside a plain object nested within the - * array — into `null`, the same JSON.stringify-shaped collapse - * {@link walkUnmapped}'s own scalar check applies to a bare non-finite - * value. An array is returned wholesale by `walkUnmapped` (never walked - * element-by-element for the consumed-path pruning that governs objects), so - * a non-finite number hiding inside one would otherwise reach - * `unmappedApiFields`'s return unsanitized — `Infinity`/`-Infinity`/`NaN` are - * `number`s (the type checker admits them into `ReadonlyJsonValue` just - * fine), but none of them has a JSON spelling: `JSON.stringify` collapses - * every one of them to `null`, so a caller round-tripping the report through - * JSON would silently see a different value than `toEqual` does in-process. - * - * Returns the SAME reference, not a copy, when nothing needed sanitizing — - * the common all-finite case — so `unmappedApiFields`'s "leaf arrays stay - * frozen" contract (the array is a subtree of the deep-frozen `_apiResponse`) - * keeps holding for it; only an array that actually contains a non-finite - * number pays for a fresh, unfrozen copy. + * Deep-sanitizes a non-finite number anywhere inside an unmapped array leaf into `null`, matching + * `JSON.stringify`'s own collapse — an array is returned wholesale by `walkUnmapped`, so a + * non-finite value hiding inside one would otherwise reach `unmappedApiFields` unsanitized. * - * Depth-capped the same way every other walk over `_apiResponse`-reachable - * data is (`walkUnmapped` above, `assertRawAttributesDepthWithinBound`): the - * raw attributes are already depth/cycle-bounded pre-decode, so this can - * never actually trip in practice, but each recursive walk keeps its own - * explicit bound rather than relying on a guarantee proven elsewhere. + * Returns the same reference, not a copy, when nothing needed sanitizing, so an all-finite array + * stays a subtree of the deep-frozen `_apiResponse` rather than paying for a fresh copy. */ function sanitizeNonFiniteArrayLeaf(value: unknown, depth: number): unknown { if (depth > MAX_UNMAPPED_WALK_DEPTH) { @@ -1983,27 +1225,20 @@ function walkUnmapped(value: unknown, path: ReadonlyArray<string>, depth = 0): u if (consumedApiPathKeys.has(pathKey(path))) { return undefined; } - // A tolerated ±Infinity leaf (Fix 1 above) has no JSON spelling — collapse - // it to `null`, matching what JSON.stringify itself would render. + // A tolerated ±Infinity leaf has no JSON spelling; collapse it to `null`, matching JSON.stringify. if (typeof value === "number" && !Number.isFinite(value)) { return null; } - // An array is returned wholesale below (never walked element-by-element for - // the consumed-path pruning objects get) — sanitize it separately so a - // non-finite number hiding inside one still surfaces as `null` (its - // JSON.stringify rendering) instead of silently riding along unsanitized. + // Arrays are returned wholesale, never walked element-by-element, so sanitize non-finite + // numbers inside them separately. if (Array.isArray(value)) { return sanitizeNonFiniteArrayLeaf(value, depth); } if (!isObject(value)) { return value; } - // An empty object at an UNKNOWN path is itself information — a newly - // introduced, not-yet-populated section would otherwise vanish here and - // make the response look fully mapped. A KNOWN container that happens to - // be empty (`postgres_settings: {}`, `auth: {}`) is pruned instead: there - // is nothing unknown in it to report, and preserving it would fabricate - // drift for perfectly ordinary responses. + // An empty object at an unknown path is itself drift signal (a newly introduced section); an + // empty known container is pruned, since it has nothing unknown to report. if (Object.keys(value).length === 0) { return knownApiContainerKeys.has(pathKey(path)) ? undefined : {}; } @@ -2020,52 +1255,29 @@ function walkUnmapped(value: unknown, path: ReadonlyArray<string>, depth = 0): u } /** - * The subtree of `config._apiResponse` that {@link projectConfigMappingRows} - * does not map — `{}` when `config` carries no `_apiResponse` at all - * (file-sourced config, or a `ProjectConfig` that was never built from an API - * response), which per ADR 0019 rule 1 does NOT mean "fully mapped". - * Registry-derived, not a second hand-maintained field list (ADR 0019 rule - * 5): a path is "mapped" when some row's `apiPath` or `alsoConsumes` names it - * exactly, including every `isSecret` row (deliberately omitted, but known) - * and every `unmappedSecretApiPaths` entry (deliberately omitted despite - * having no row at all). Empty objects are pruned from the result, so a - * subtree that is entirely mapped never shows up as `{}` noise. + * The subtree of `config._apiResponse` that {@link projectConfigMappingRows} does not map — + * `{}` when `config` carries no `_apiResponse` at all, which does not mean "fully mapped". + * Registry-derived, not a hand-maintained field list: a path is "mapped" when some row's `apiPath` + * or `alsoConsumes` names it. Empty objects are pruned, so a fully-mapped subtree never shows up + * as `{}` noise. * - * Reports at REGISTRY `apiPath` granularity, not full recursive fidelity: a - * key nested INSIDE a consumed subtree — including inside an element of a - * consumed array, e.g. an unexpected `comment` field on a - * `database.network_restrictions.allowed_cidrs` entry — is not itemized here - * either, since the whole subtree at that `apiPath` is already "known" to - * this registry version (`consumedApiPathKeys`'s own docstring). This is - * never lossy for the CALLER, only for this report: `_apiResponse` still - * carries every such key verbatim, so a consumer that needs full recursive - * fidelity reads it directly instead of relying on this helper. + * Reports at registry `apiPath` granularity, not full recursive fidelity: a key nested inside a + * consumed subtree is not itemized here either, though `_apiResponse` still carries it verbatim + * for a consumer that needs full fidelity. * - * The result can include the HMAC digest the API reports for a secret-typed - * key neither a row nor `unmappedSecretApiPaths` knows about yet — a future - * GoTrue secret, say, added on the platform side before this package's - * `isSecret` rows catch up. Callers must not render this result blindly — an - * HMAC digest is not a value a user should see echoed back at them. Throws - * {@link ProjectConfigParseError} if `_apiResponse` is nested more than 64 - * levels deep, or if `config` is not a plain object (`reason: - * "caller_misuse"`). + * The result can include the HMAC digest the API reports for a secret-typed key this package + * doesn't know about yet. Callers must not render this result blindly. + * + * @throws {@link ProjectConfigParseError} if `_apiResponse` is nested more than 64 levels deep, or `config` is not a plain object. */ export function unmappedApiFields(config: ProjectConfig): { readonly [key: string]: ReadonlyJsonValue; }; -// Untyped for the same reason as `attachApiResponse` above (a JavaScript -// caller can hand this public reader anything despite the compile-time -// type), AND because the report's containers are rebuilt fresh while its -// leaf arrays/objects are shared BY REFERENCE with the deep-frozen -// `_apiResponse` — a mutable return type would compile `.push(...)` that -// throws at runtime. TypeScript cannot verify the structural walk either -// way; the overload above is the contract, pinned by the unit tests. +// Untyped because the report's containers are rebuilt fresh while its leaf arrays/objects are +// shared by reference with the deep-frozen `_apiResponse`; a mutable return type would compile +// a `.push(...)` that throws at runtime. export function unmappedApiFields(config: unknown): unknown { - // Guards the same boundary the other public entry points do: a non-object - // operand (or one whose `_apiResponse` getter throws, translated by - // `readApiResponseProperty` below) must surface as the documented typed - // failure instead of a raw TypeError/Error escaping this package's - // contract. + // A non-object operand must surface as the documented typed failure, not a raw TypeError. if (!isObject(config)) { throw callerMisuseError( `unmappedApiFields config must be an object, got ${nonObjectDescription(config)}`, @@ -2079,15 +1291,7 @@ export function unmappedApiFields(config: unknown): unknown { return isObject(result) ? result : {}; } -/** - * Reads `config._apiResponse` through the same guarded pattern as the - * envelope/dispatcher reads ({@link readEnvelopeProperty}, - * {@link readSourceProperty}): plain data never carries getters, so an - * accessor that throws here is programmatic caller input (e.g. a foreign - * object with a throwing `_apiResponse` getter) and must surface as the - * documented failure type rather than a raw `Error` escaping past the - * telemetry classification. - */ +/** Reads `config._apiResponse`, translating a throwing accessor into {@link ProjectConfigParseError}. */ function readApiResponseProperty(config: Record<string, unknown>): unknown { try { return config["_apiResponse"]; @@ -2102,31 +1306,14 @@ function readApiResponseProperty(config: Record<string, unknown>): unknown { } /** - * The deduped `configPath`s of every non-`isSecret` row in - * {@link projectConfigMappingRows}, in registry order — the fields - * `fromApiProjectConfig` can actually speak for. Exists so a diff consumer - * (CLI-2156/Studio) never hand-maintains an equivalent field list: as rows - * are added, removed, or renamed, this set moves with them automatically. - * Excludes secret rows (an API-sourced value for one is never populated, so - * it can never meaningfully participate in a comparison) and every field - * with no row at all (`realtime` in full, `workers`/`experimental`, and - * every "Deliberately unmapped" field the sibling registries document). + * The deduped `configPath`s of every non-`isSecret` row in {@link projectConfigMappingRows} — the + * fields `fromApiProjectConfig` can actually speak for. Exists so a diff consumer never + * hand-maintains an equivalent field list; excludes secret rows and every field with no row at all. * - * This ONLY remedies the whole-SECTION-granularity gap (e.g. `realtime` in - * full never showing up as phantom drift just because it has zero rows). It - * does NOT remedy the finer, per-path granularity gap this file's own - * {@link ProjectConfig} docstring describes: `["auth", "email", "smtp", - * "enabled"]` IS a member of this list (`isComparableProjectConfigPath` - * returns `true` for it) and yet still fabricates drift against a document - * operand that never declared `[auth.email.smtp]` at all, because - * `subtractCliConfig`'s baseline has no `smtp` key to compare against and - * therefore keeps the API side's value verbatim (pinned by - * `project-config.unit.test.ts`'s "does NOT rescue a diff against a document - * operand that never declared the sub-section at all" test). A caller doing - * that comparison must additionally intersect with what the document-side - * operand actually declared — or accept that every field a row maps - * unconditionally will read as a remote-only statement whenever the document - * side is silent on it, never as neutral "no opinion". + * Only remedies the whole-section granularity gap, not the finer per-path gap: a path can be a + * member of this list and still fabricate drift against a document operand that never declared its + * containing sub-section at all. A caller doing that comparison must additionally intersect with + * what the document side actually declared. */ export const comparableProjectConfigPaths: ReadonlyArray<ReadonlyArray<string>> = (() => { const seenKeys = new Set<string>(); @@ -2150,13 +1337,9 @@ const comparableProjectConfigPathKeys: ReadonlySet<string> = new Set( ); /** - * Whether `path` is a member of {@link comparableProjectConfigPaths} — or a - * DESCENDANT of one: a row that maps a container (e.g. `sms.test_otp`'s - * record) yields diff leaves like `["auth","sms","test_otp","<phone>"]` from - * a leaf-path traversal, and those entries are exactly as comparable as the - * mapped container itself. A bare PREFIX of a mapped path (e.g. - * `["auth","sms"]`) is still not comparable — it names a section, not a - * mapped value. + * Whether `path` is a member of {@link comparableProjectConfigPaths}, or a descendant of one (a + * row mapping a container, e.g. a record, makes each of its leaves comparable too). A bare prefix + * of a mapped path is not comparable — it names a section, not a mapped value. */ export function isComparableProjectConfigPath(path: ReadonlyArray<string>): boolean { for (let length = path.length; length >= 1; length--) { @@ -2168,14 +1351,11 @@ export function isComparableProjectConfigPath(path: ReadonlyArray<string>): bool } /** - * Deduped `configPath`s of every `dualScope` row in - * {@link projectConfigMappingRows}, in registry order (CLI-2064) — the - * fields with a legitimate DIFFERENT correct value for the local stack than - * the hosted project (`./registry-row.ts`'s `dualScope` docstring). `config - * pull` uses this list to warn before silently overwriting one of these - * fields at the config ROOT, since doing so would reconfigure `supabase - * start` rather than merely record the hosted project's own setting; a write - * into a `[remotes.*]` block is unaffected. + * Deduped `configPath`s of every `dualScope` row in {@link projectConfigMappingRows} — fields with + * a legitimate different correct value for the local stack than the hosted project. `config pull` + * uses this list to warn before overwriting one of these fields at the config root, since that + * would reconfigure `supabase start` rather than record the hosted project's setting; a write into + * a `[remotes.*]` block is unaffected. */ export const dualScopeProjectConfigPaths: ReadonlyArray<ReadonlyArray<string>> = (() => { const seenKeys = new Set<string>(); From f6ee33023da7d32329e8bd2a3d4ce7c6d247cbc0 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:06:38 +0100 Subject: [PATCH 38/56] chore(repo): trim comments, shard 48 (330 -> 246 comment lines) --- packages/stack/src/state/StackStateStore.ts | 17 ++-- packages/stack/src/supervisor/HostListener.ts | 9 +- packages/stack/src/supervisor/Launcher.ts | 2 - packages/stack/src/supervisor/Lifecycle.ts | 9 +- tools/config-api-compare.ts | 78 +++++------------ tools/config-release-gate.ts | 86 +++++++------------ tools/lib/dts-diff.ts | 14 ++- tools/release/local-registry.ts | 4 +- tools/release/local-release.ts | 32 ++----- 9 files changed, 81 insertions(+), 170 deletions(-) diff --git a/packages/stack/src/state/StackStateStore.ts b/packages/stack/src/state/StackStateStore.ts index 76ed5942a1..23d95bea83 100644 --- a/packages/stack/src/state/StackStateStore.ts +++ b/packages/stack/src/state/StackStateStore.ts @@ -134,8 +134,7 @@ const decodeState = ( } if (!isRecord(raw.secrets)) return Effect.fail(stateError("Persisted secret values must be a record")); - // Schema.Record validates values but does not enforce dynamic object-key checks - // while decoding JSON, so retain this format-level slot-name guard. + // Schema.Record doesn't enforce key-format checks while decoding JSON, so validate slot names here. for (const slot of Object.keys(raw.secrets)) if (!/^[A-Za-z0-9_.:/-]+$/.test(slot)) return Effect.fail(stateError(`Persisted secret slot key is invalid: ${slot}`)); @@ -313,9 +312,7 @@ export const withRegistryLock = <A, E, R>( ); const install = Effect.gen(function* () { const token = yield* Effect.try({ - // Registry tokens are ephemeral identity labels; ownership is proven - // by the held loopback lease and atomic canonical link. - // oxlint-disable-next-line effecttsgo/crypto-random-uuid-in-effect + // oxlint-disable-next-line effecttsgo/crypto-random-uuid-in-effect -- ephemeral label; ownership is proven by the held lease, not this ID. try: () => globalThis.crypto.randomUUID(), catch: (cause) => stateError(`Unable to allocate registry lock token: ${String(cause)}`), @@ -341,8 +338,7 @@ export const withRegistryLock = <A, E, R>( ), Effect.retry({ while: (error) => Predicate.isTagged(error, "RegistryBusyError"), - // Registry transactions include port leasing and atomic state writes; allow a few - // seconds for a concurrent owner to finish before failing closed. + // Allow a few seconds for a concurrent owner's port lease and state write to finish. schedule: Schedule.spaced("25 millis").pipe(Schedule.upTo({ times: 80 })), }), Effect.mapError((error) => @@ -517,10 +513,9 @@ export const makeStackStateStore = (options: { FileSystem.FileSystem | Path.Path | Crypto.Crypto > => withRegistryLock(options.stateRoot, replaceUnlocked(stackId, next)); - // FileSystem.remove({ recursive: false }) maps to fs.rm, which refuses to - // remove directories on Node. Native rmdir is intentionally used here so - // a concurrent child creation fails with ENOTEMPTY instead of recursively - // deleting a newly-created lease or control file. + // fs.remove({ recursive: false }) maps to Node's fs.rm, which refuses to remove directories. + // Native rmdir is used instead so a concurrent child creation fails with ENOTEMPTY rather + // than recursively deleting a newly created lease or control file. const removeEmptyDirectory = (directory: string, label: string) => Effect.tryPromise({ try: () => rmdir(directory), diff --git a/packages/stack/src/supervisor/HostListener.ts b/packages/stack/src/supervisor/HostListener.ts index 7a1284ae5a..570cb19c7f 100644 --- a/packages/stack/src/supervisor/HostListener.ts +++ b/packages/stack/src/supervisor/HostListener.ts @@ -247,10 +247,9 @@ const bind = <T extends HttpServer | NetServer>( return Effect.sync(() => { if (settled) return; settled = true; - // `listen` may still be completing when the waiting fiber is interrupted. Keep a temporary - // error sink while asking Node to close the exact server so a late bind failure cannot become - // an uncaught process error. `close` throws synchronously when no handle exists; in that case - // the server has no owned resources left to release. + // `listen` may still be completing when the fiber is interrupted. Close the server with a + // temporary error sink so a late bind failure can't become an uncaught process error; + // `close` throws synchronously if no handle exists, meaning there's nothing left to release. teardown(); }); }); @@ -362,7 +361,7 @@ const isIpv6 = (address: string): boolean => isIP(address) === 6; export const hostListenerCoversAddress = (listener: HostListener, address: string): boolean => { if (listener.address === address) return true; if (listener.address === "0.0.0.0" && isIpv4(address)) return true; - // IPv6 wildcard listeners are explicitly dual-stack in bind(). + // IPv6 wildcard listeners are dual-stack in bind(). if (listener.address === "::" && (isIpv6(address) || isIpv4(address))) return true; return false; }; diff --git a/packages/stack/src/supervisor/Launcher.ts b/packages/stack/src/supervisor/Launcher.ts index 9ad3cef140..ec79081ddc 100644 --- a/packages/stack/src/supervisor/Launcher.ts +++ b/packages/stack/src/supervisor/Launcher.ts @@ -56,8 +56,6 @@ export const supervisorEntrypointFor = (moduleUrl: string): string => { /** Default host values are resolved only at the process composition boundary. */ export const defaultRuntimeEnvironment = (): StackRuntimeEnvironmentValue => { - // This synchronous helper is the composition boundary where host environment - // variables are read. The runtime itself receives a fully materialized value. // oxlint-disable-next-line effecttsgo/process-env -- composition-boundary environment read const home = process.env.SUPABASE_HOME ?? `${process.env.HOME ?? homedir()}/.supabase`; return { diff --git a/packages/stack/src/supervisor/Lifecycle.ts b/packages/stack/src/supervisor/Lifecycle.ts index 74bd71ab68..95752155f1 100644 --- a/packages/stack/src/supervisor/Lifecycle.ts +++ b/packages/stack/src/supervisor/Lifecycle.ts @@ -20,8 +20,8 @@ import { } from "../state/SecretStore.ts"; /** - * The runtime-facing contract deliberately contains no Docker/native concepts. Concrete drivers - * own resources; this controller owns accepted durable intent and lifecycle transitions. + * The runtime-facing contract has no Docker/native concepts. Concrete drivers own resources; + * this controller owns accepted durable intent and lifecycle transitions. */ export interface LifecycleInput { readonly stackId: StackId; @@ -55,10 +55,9 @@ export interface LifecycleController { readonly start: ( options?: LifecycleStartOptions, ) => Effect.Effect<PersistedStackState, StackError, LifecycleRequirements>; - // Each invocation builds a fresh read/transition effect while sharing the controller semaphore. - // oxlint-disable-next-line effecttsgo/lazy-effect + // oxlint-disable-next-line effecttsgo/lazy-effect -- fresh effect per call; callers serialize with their own semaphore. readonly stop: () => Effect.Effect<PersistedStackState, StackError, LifecycleRequirements>; - // oxlint-disable-next-line effecttsgo/lazy-effect + // oxlint-disable-next-line effecttsgo/lazy-effect -- fresh effect per call; callers serialize with their own semaphore. readonly destroy: () => Effect.Effect<void, StackError, LifecycleRequirements>; } diff --git a/tools/config-api-compare.ts b/tools/config-api-compare.ts index a0667350ab..5339d5b096 100644 --- a/tools/config-api-compare.ts +++ b/tools/config-api-compare.ts @@ -1,32 +1,21 @@ /** - * Diffs `@supabase/config`'s compiled `.d.ts` surface between a PR's base and - * head commits — a per-PR type-surface signal with zero committed artifacts - * (CLI-2234; replaces the checked-in `packages/config/api-report/` mirror). + * Diffs `@supabase/config`'s compiled `.d.ts` surface between a PR's base and head commits. * * Usage: * bun tools/config-api-compare.ts [--base <ref>] * - * Base ref resolution, in order: `--base`, then `GITHUB_BASE_REF` (prefixed - * `origin/`), then `origin/develop`. Resolves `git merge-base HEAD <base>`, - * fetching `origin/<branch>` at depth 1 first when the ref is missing locally - * (a shallow CI clone only has the PR's own commits). If HEAD's own checkout - * is also shallow, a depth-1 base fetch still can't produce a common - * ancestor — the tool then unshallows (or deepens) the checkout and retries - * once more before giving up and skipping the compare. + * Base ref resolution, in order: `--base`, then `GITHUB_BASE_REF` under `origin/`, then + * `origin/develop`. Resolves `git merge-base HEAD <base>`, fetching a missing base ref at depth 1 + * first; if HEAD's own checkout is also shallow, unshallows (or deepens) and retries once before + * skipping the compare. * - * Emits declarations twice with the same compiler settings — head from - * `packages/config/src` directly, base from a `git archive` of the - * merge-base extracted into `packages/config/.api-compare/base/` (so - * dependency resolution walks up to `packages/config/node_modules` using the - * CURRENT install, no second `pnpm install` needed) — then diffs the two - * `.d.ts` trees. + * Emits declarations twice with the same compiler settings — head from `packages/config/src` + * directly, base from a `git archive` of the merge-base extracted under `packages/config` (so + * dependency resolution reuses the current install) — then diffs the two `.d.ts` trees. * - * Advisory at PR time (a base-vs-head diff has no acceptance artifact to - * gate on); the hard release-time gate is `tools/config-release-gate.ts`, - * run by `.github/workflows/release-config.yml` (CLI-2233). + * Advisory only; the hard release-time gate is `tools/config-release-gate.ts`. * - * Exit codes: 0 identical (or compare skipped), 1 surface differs, 2 tool - * failure. + * Exit codes: 0 identical (or compare skipped), 1 surface differs, 2 tool failure. */ import { cp, mkdir, mkdtemp, rm } from "node:fs/promises"; @@ -87,23 +76,8 @@ type MergeBaseResolution = | { readonly kind: "skip"; readonly reason: string }; /** - * Resolves `git merge-base HEAD <baseRef>`. A shallow CI checkout only has - * the PR's own commits, so `<baseRef>` can be locally unresolvable — when - * it's an `origin/<branch>` ref, fetch that branch at depth 1 and retry - * before giving up. - * - * A depth-1 base fetch only helps when the base ref itself was simply never - * fetched; it cannot produce a common ancestor when HEAD's own checkout is - * shallow too (the `check` job's default `actions/checkout` depth), since - * neither side's shallow history reaches the other's. In that case, unshallow - * (or deepen, if `--unshallow` errors because the checkout is already - * complete) the repository, refetch the base ref in full, and retry once - * more. If a merge-base still can't be resolved, this is an advisory check — - * skip the compare instead of failing the tool. - * - * A non-`origin/` ref (e.g. an explicit `--base <sha>`) that doesn't resolve - * locally is a caller error, not something this tool can fetch its way out - * of. + * Resolves `git merge-base HEAD <baseRef>`. Fetches a missing `origin/<branch>` ref at depth 1, + * unshallowing the checkout if that still isn't enough to find a common ancestor. */ async function resolveMergeBase(baseRef: string): Promise<MergeBaseResolution> { const attempt = await runGit(["merge-base", "HEAD", baseRef], repoRoot); @@ -240,18 +214,10 @@ async function archiveAndExtract( } /** - * Materializes the base revision's `packages/config/src` (plus its - * declaration-emit config) under `baseExtractDir`, INSIDE `packages/config`, - * so tsc's node_modules walk from there reaches `packages/config/node_modules` - * with the CURRENT install — no second `pnpm install` needed. - * - * `tsconfig.build.json` is always the HEAD copy (tooling, not part of the - * compared surface, and required so `tsconfig.declarations.json`'s own - * `"extends": "./tsconfig.build.json"` resolves inside the extracted tree). - * `tsconfig.declarations.json` is the base revision's own copy when it has - * one; a merge-base that predates this file (e.g. still on the checked-in - * `api-report/` mirror, or older) falls back to the HEAD copy for the emit - * settings. + * Materializes the base revision's `packages/config/src` under `baseExtractDir`, inside + * `packages/config`, so tsc's node_modules walk reaches the current install. `tsconfig.build.json` + * stays the head copy so `extends` resolves; the declarations config uses the base's own copy + * when present, else the head copy. */ async function extractBaseTree(mergeBase: string, baseExtractDir: string): Promise<void> { await mkdir(baseExtractDir, { recursive: true }); @@ -293,14 +259,10 @@ interface EmitResult { } /** - * Spawns this package's own `node_modules/.bin/tsc` directly rather than - * `pnpm exec tsc` (the same corepack-avoidance lesson as the old - * `api-report.unit.test.ts`: a bun-shimmed `PATH` can route `pnpm`'s launcher - * through Bun's `node:sqlite`-less Node-compat layer). `noEmitOnError` - * defaults to false, so declarations are emitted even when the base tree's - * old source doesn't type-check cleanly against the current install's - * (newer) dependencies — a genuinely empty output is the only signal treated - * as a hard failure by the caller. + * Spawns this package's own `node_modules/.bin/tsc` directly: a bun-shimmed `PATH` can route + * `pnpm exec tsc` through Bun's `node:sqlite`-less Node-compat layer. `noEmitOnError` defaults to + * false, so declarations still emit against an old, non-type-checking base tree; only a fully + * empty output counts as failure. */ async function emitDeclarations( projectPath: string, diff --git a/tools/config-release-gate.ts b/tools/config-release-gate.ts index 8973555c31..b26b8b01cf 100644 --- a/tools/config-release-gate.ts +++ b/tools/config-release-gate.ts @@ -1,36 +1,28 @@ /** - * Gates an `@supabase/config` npm release on its compiled `.d.ts` surface — - * diffs the freshly built `packages/config/dist/**\/*.d.ts` against the - * previously published npm tarball's declarations, so a human approving - * `npm publish` sees the surface diff before signing off (CLI-2233; the - * release-time counterpart to the PR-time advisory compare in - * `tools/config-api-compare.ts`). + * Gates an `@supabase/config` npm release on its compiled `.d.ts` surface — diffs the freshly + * built `packages/config/dist/**\/*.d.ts` against the previously published npm tarball's + * declarations, so a human approving `npm publish` sees the surface diff before signing off. The + * PR-time counterpart is the advisory compare in `tools/config-api-compare.ts`. * * Usage: * bun tools/config-release-gate.ts --version <next version> [--registry <url>] [--tarball <path>] [--local-dist <dir>] * - * `--version` is the version semantic-release computed for this release. - * `--registry` defaults to `npm config get registry` (the same - * probe-matches-publish-target alignment `apps/cli/scripts/publish.ts` uses, - * so the local Verdaccio harness works here too), falling back to the public - * npm registry. `--tarball` points at a local `.tgz` to use as the - * "published" side instead of querying the registry — for local testing and - * pipeline rehearsal. A registry-downloaded tarball is verified against the - * registry's `dist.integrity` and refused if its URL points off-registry. - * `--local-dist` overrides the "next release" side (default: - * `packages/config/dist`) — the release workflow points it at the dist tree - * extracted from the packed release tarball, so the approver's evidence is - * generated from the exact artifact the publish job ships. + * `--version` is the version semantic-release computed for this release. `--registry` defaults to + * `npm config get registry` (so a local Verdaccio harness works here too), falling back to the + * public npm registry. `--tarball` points at a local `.tgz` to use as the "published" side instead + * of querying the registry; a registry-downloaded tarball is verified against its + * `dist.integrity` and refused if its URL points off-registry. `--local-dist` overrides the "next + * release" side (default: `packages/config/dist`); the release workflow points it at the dist tree + * extracted from the packed release tarball, so the evidence matches the exact artifact shipped. * - * This tool never builds `packages/config/dist` itself — run the package - * build first. When the package has never been published (npm view returns - * E404), the full surface ships as-is and there is nothing to diff against. + * This tool never builds `packages/config/dist` itself — run the package build first. When the + * package has never been published (npm view returns E404), the full surface ships as-is. * - * Deliberately never exits 1 on a surface diff: the gate IS the human - * approval step reading this summary, not an automatic pass/fail check. + * Never exits 1 on a surface diff: the gate is the human approval step reading this summary, not + * an automatic pass/fail check. * - * Exit codes: 0 the gate ran (including a first release with nothing - * published yet), 2 tool failure. + * Exit codes: 0 the gate ran (including a first release with nothing published yet), 2 tool + * failure. */ import { mkdir, mkdtemp, readFile, rm, stat } from "node:fs/promises"; @@ -79,9 +71,8 @@ function requireBinaries(names: readonly string[]): void { } /** - * Same probe-matches-publish-target alignment as `apps/cli/scripts/publish.ts`: - * the local Verdaccio harness (`pnpm local-registry`) rewrites npm's registry - * config, and the gate must read the registry the publish would target. + * Reads the ambient npm registry so a local Verdaccio harness, which rewrites npm's registry + * config, is honored instead of assuming the public registry. */ async function ambientNpmRegistry(): Promise<string> { const result = await runCommand(["npm", "config", "get", "registry"]); @@ -194,13 +185,9 @@ type PublishedSide = | { readonly kind: "resolved"; readonly version: string; readonly distDir: string }; /** - * Resolves the "published" side of the diff: an explicit `--tarball` wins; - * otherwise queries `<registry>` for the current `dist-tags.latest` and - * downloads that tarball, verifying it against the registry's own - * `dist.integrity` and refusing a tarball URL pointing off-registry. An - * `E404` (or an existing package with no `latest` dist-tag) means there is - * nothing published to compare against — reported as `"first-publish"` - * rather than an error. + * Resolves the "published" side of the diff: an explicit `--tarball` wins; otherwise downloads + * `dist-tags.latest` from `<registry>`, verified against its `dist.integrity` and refused if its + * URL points off-registry. No published version (or no `latest` tag) resolves to `"first-publish"`. */ async function resolvePublishedSide( registry: string, @@ -275,14 +262,10 @@ function parseVersionParts(version: string): VersionParts | null { } /** - * Numeric major/minor/patch comparison only — this train is stable-only, so - * plain "x.y.z" is the expected shape on both sides. The published side comes - * from outside this pipeline though (`dist-tags.latest`, or `--tarball`), so - * an unparseable version degrades to `"unknown"` (warnings skipped, noted in - * the summary) instead of failing the plan job. Ordering matters, not just - * inequality: an equal or LOWER next version means tag/registry skew (a - * hand-pushed or deleted `config-v*` tag), which deserves its own warning - * rather than a spurious "major bump". + * Numeric major/minor/patch comparison; an unparseable version (the published side can come from + * outside this pipeline) degrades to `"unknown"` rather than failing the job. Ordering matters, + * not just inequality: an equal or lower next version signals tag/registry skew (a hand-pushed or + * deleted `config-v*` tag), which gets its own warning instead of a spurious "major bump". */ function computeBumpClass(publishedVersion: string, nextVersion: string): BumpClass { const published = parseVersionParts(publishedVersion); @@ -301,10 +284,9 @@ function hasRemovedDeclarationLines(entries: readonly FileEntry[]): boolean { } /** - * Only lines inside hunks (after the first `@@`) count — a prefix test alone - * would both miss a removed content line that itself starts with `--` (its - * hunk rendering starts with `---`) and false-positive on the old-file - * header. + * Only lines inside hunks (after the first `@@`) count — a bare prefix test would miss a removed + * content line that itself starts with `--` (its hunk rendering is `---`) and false-positive on + * the old-file header. */ function unifiedDiffRemovesLines(diff: string): boolean { let inHunk = false; @@ -485,13 +467,9 @@ async function main(): Promise<number> { return 0; } - // A published tarball without declarations means "no baseline", not a - // tool failure — the .gitignore/packlist trap that motivated - // packages/config/.npmignore (CLI-2234) is exactly how such a tarball - // could exist, and it must not block every subsequent release. Diff - // against an empty tree instead of skipping, so the approver still sees - // the next release's full surface (as additions) rather than approving - // sight unseen. + // A published tarball without declarations means "no baseline", not a tool failure — diff + // against an empty tree instead of skipping, so the approver still sees the next release's + // full surface (as additions) rather than approving sight unseen. let publishedDistDir = published.distDir; const extraWarnings: string[] = []; if ((await countDeclarationFiles(publishedDistDir)) === 0) { diff --git a/tools/lib/dts-diff.ts b/tools/lib/dts-diff.ts index 4869e5b115..23d8b8be3e 100644 --- a/tools/lib/dts-diff.ts +++ b/tools/lib/dts-diff.ts @@ -143,10 +143,9 @@ function escapeHtml(text: string): string { } /** - * A fence long enough that no backtick run inside `content` can close it — - * the diffed `.d.ts` text is untrusted (published-tarball side) and a JSDoc - * `@example` with its own fenced block would otherwise break out and render - * as live markdown in the approver's step summary. + * A fence long enough that no backtick run inside `content` can close it — the diffed `.d.ts` + * text is untrusted, and a JSDoc `@example` with its own fenced block would otherwise break out + * and render as live markdown in the approver's step summary. */ function fenceFor(content: string): string { const longestRun = Math.max(0, ...[...content.matchAll(/`+/g)].map((match) => match[0].length)); @@ -157,10 +156,9 @@ function fenceFor(content: string): string { const MAX_RENDERED_DIFF_LENGTH = 20_000; /** - * Aggregate cap across all rendered blocks — GITHUB_STEP_SUMMARY rejects - * uploads past 1024 KiB (the whole summary is dropped, losing the approver's - * evidence entirely), and the per-file cap alone doesn't bound how many - * capped files there are. Headroom is left for the surrounding summary text. + * Aggregate cap across all rendered blocks — GITHUB_STEP_SUMMARY rejects uploads past 1024 KiB + * (dropping the whole summary, losing the approver's evidence), and the per-file cap alone doesn't + * bound how many capped files there are. Headroom is left for the surrounding summary text. */ const MAX_RENDERED_TOTAL_LENGTH = 800_000; diff --git a/tools/release/local-registry.ts b/tools/release/local-registry.ts index e8e765ade4..9520dcf7cb 100644 --- a/tools/release/local-registry.ts +++ b/tools/release/local-registry.ts @@ -91,12 +91,10 @@ async function main() { await rm(tmpDir, { recursive: true, force: true }); await mkdir(tmpDir, { recursive: true }); - // Resolve {root} placeholder in the config template. const template = await Bun.file(path.join(root, "verdaccio.yaml")).text(); const resolved = template.replaceAll("{root}", root); await writeFile(configPath, resolved, "utf-8"); - // Start Verdaccio, piping output to a log file. const logFd = openSync(logPath, "w"); const verdaccioBin = path.join(root, "node_modules", ".bin", "verdaccio"); const proc = Bun.spawn([verdaccioBin, "--config", configPath], { @@ -117,7 +115,7 @@ async function main() { await waitForRegistry(); process.stdout.write(" ready.\n"); - // Create a publish user and persist the token so local-release.ts can use it. + // Persisted so local-release.ts can read it. const token = await createUser(); await writeFile(tokenPath, token, "utf-8"); diff --git a/tools/release/local-release.ts b/tools/release/local-release.ts index c63b4fc93d..f3f2efc0b0 100644 --- a/tools/release/local-release.ts +++ b/tools/release/local-release.ts @@ -172,7 +172,6 @@ async function main() { ); } - // All build output goes into a system temp directory — never into the git repo. const tmpDir = await mkdtemp(path.join(tmpdir(), "supabase-local-release-")); try { @@ -182,8 +181,6 @@ async function main() { console.log(`\nBuilding ${umbrellaName}@${version} (${platform.platformPkg})...\n`); - // ── Build platform package ──────────────────────────────────────────── - const tmpPlatformDir = path.join(tmpDir, platform.platformPkg); const tmpPlatformBinDir = path.join(tmpPlatformDir, "bin"); await mkdir(tmpPlatformBinDir, { recursive: true }); @@ -198,10 +195,9 @@ async function main() { { const goBinary = path.join(tmpPlatformBinDir, `supabase-go${platform.ext}`); console.log(`[2/3] Compiling Go CLI binary (${platform.goos}/${platform.goarch})...`); - // Run go build from within the Go source directory so Go can find - // the go.mod there. Passing an absolute path as a positional arg - // causes Go to resolve the module from CWD instead, which fails - // because the repo root has no go.mod. + // go build must run from the Go source directory: passing an absolute path as a positional + // arg makes Go resolve the module from CWD instead, which fails because the repo root has + // no go.mod. await $`go build -trimpath -ldflags="-s -w" -o ${goBinary} .`.cwd(goSource).env({ ...process.env, GOOS: platform.goos, @@ -210,8 +206,6 @@ async function main() { }); } - // ── Build umbrella package shim ─────────────────────────────────────── - const tmpCliDir = path.join(tmpDir, "cli"); const tmpCliDistDir = path.join(tmpCliDir, "dist"); await mkdir(tmpCliDistDir, { recursive: true }); @@ -221,9 +215,6 @@ async function main() { console.log("[3/3] Building Node.js shim..."); await $`bun build ${shimSrc} --outfile=${shimOut} --target=node`; - // ── Write package.json files ────────────────────────────────────────── - - // Platform package: copy as-is, bump version. const platformPkgJson = await Bun.file( path.join(root, "packages", platform.platformPkg, "package.json"), ).json(); @@ -233,10 +224,8 @@ async function main() { `${JSON.stringify(platformPkgJson, null, "\t")}\n`, ); - // Umbrella package: build a minimal package.json. - // The shim only uses Node built-ins — all @supabase/* and catalog: deps - // are bundled in the platform binary and must not appear in the published - // package.json (catalog: and workspace:* are invalid outside pnpm workspaces). + // The shim only uses Node built-ins; @supabase/* and catalog: deps are bundled in the platform + // binary and must not appear here (catalog: and workspace:* are invalid outside pnpm workspaces). const resolvedOptionalDeps: Record<string, string> = {}; for (const pkg of PLATFORM_PACKAGES) { resolvedOptionalDeps[`@supabase/${pkg}`] = version; @@ -256,20 +245,16 @@ async function main() { `${JSON.stringify(publishPkgJson, null, "\t")}\n`, ); - // ── Write .npmrc with registry and auth token ───────────────────────── - const npmrc = [`registry=${REGISTRY}`, `//localhost:${PORT}/:_authToken=${token}`, ""].join( "\n", ); await Bun.write(path.join(tmpPlatformDir, ".npmrc"), npmrc); await Bun.write(path.join(tmpCliDir, ".npmrc"), npmrc); - // ── Publish ─────────────────────────────────────────────────────────── - console.log(`\nPublishing @supabase/${platform.platformPkg}@${version} to local registry...`); - // Use bun publish for the platform binary package: pnpm normalises file - // modes in tarballs and strips the execute bit from files not in the - // package's `bin` field. bun publish preserves modes, matching production. + // bun publish (not pnpm) for the platform binary package: pnpm normalizes tarball file modes + // and strips the execute bit from files outside the package's `bin` field; bun publish + // preserves modes, matching production. await $`bun publish --access public --tag local --registry ${REGISTRY} --no-git-checks`.cwd( tmpPlatformDir, ); @@ -290,7 +275,6 @@ Or install globally: supabase --version `); } finally { - // Always remove the temp directory — even on failure. await rm(tmpDir, { recursive: true, force: true }); } } From afca53ddb9ae1dd0de71550105a90e81794ae123 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:06:47 +0100 Subject: [PATCH 39/56] chore(repo): trim comments, shard 47 (299 -> 282 comment lines) --- packages/stack/src/public/EffectStack.ts | 2 +- packages/stack/src/public/PromiseStack.ts | 5 +---- .../public/effect-stack.integration.test.ts | 3 +-- .../src/public/testing.integration.test.ts | 4 +--- .../stack/src/public/whole-stack.e2e.test.ts | 5 ++--- .../src/runtime/ContainerEngineResolver.ts | 6 +++--- .../stack/src/runtime/ContainerRuntime.ts | 2 +- .../src/runtime/DatabaseBootstrapCatalog.ts | 5 ++--- packages/stack/src/runtime/NativeRuntime.ts | 15 ++++++-------- .../src/runtime/PostgresDatabaseSession.ts | 4 ++-- packages/stack/src/runtime/ReadinessProbe.ts | 4 ++-- .../stack/src/runtime/WorkloadRuntimeSpec.ts | 5 ++--- packages/stack/src/runtime/native-launcher.ts | 20 ++++++++----------- .../native-runtime.integration.test.ts | 3 +-- packages/stack/src/state/Ownership.ts | 6 +++--- 15 files changed, 36 insertions(+), 53 deletions(-) diff --git a/packages/stack/src/public/EffectStack.ts b/packages/stack/src/public/EffectStack.ts index 8ba62a1c55..728294b696 100644 --- a/packages/stack/src/public/EffectStack.ts +++ b/packages/stack/src/public/EffectStack.ts @@ -151,7 +151,7 @@ export interface PrepareStackResult { export interface EffectStack { readonly id: StackId; - // These methods intentionally create a fresh scoped RPC invocation per call. + // Each of these methods opens a fresh scoped RPC invocation per call. // oxlint-disable-next-line effecttsgo/lazy-effect readonly status: () => Effect.Effect<StackStatus, StackStatusError>; // oxlint-disable-next-line effecttsgo/lazy-effect diff --git a/packages/stack/src/public/PromiseStack.ts b/packages/stack/src/public/PromiseStack.ts index 069302b491..a3b9855017 100644 --- a/packages/stack/src/public/PromiseStack.ts +++ b/packages/stack/src/public/PromiseStack.ts @@ -24,7 +24,6 @@ import type { PreparedCapability, PrepareStackResult } from "./EffectStack.ts"; import { InvalidStackConfigError } from "./Errors.ts"; import { StackRuntimeEnvironment, type StackRuntimeEnvironmentValue } from "../state/Ownership.ts"; -// Promise methods are the deliberate outer boundary of this package. // oxlint-disable effecttsgo/async-function -- Promise facade methods must expose Promise/AsyncIterable APIs. // oxlint-disable effecttsgo/any-unknown-in-error-context -- Promise callers receive native rejection values. @@ -73,9 +72,7 @@ type RuntimeRequirements = | Crypto.Crypto | ChildProcessSpawner.ChildProcessSpawner; -// Canonical JSON decoding follows all schema transformations, including Redacted -// declarations nested in records and arrays. At this explicit Promise boundary, -// plain JSON strings become Effect Redacted values for the Effect handle. +// Turns plain JSON config values into the Redacted values the Effect handle expects. const stackConfigJsonCodec = Schema.toCodecJson(StackConfigSchema); const decodePromiseConfig = ( input: PromiseStackConfig, diff --git a/packages/stack/src/public/effect-stack.integration.test.ts b/packages/stack/src/public/effect-stack.integration.test.ts index 60c18af02b..7a851cb977 100644 --- a/packages/stack/src/public/effect-stack.integration.test.ts +++ b/packages/stack/src/public/effect-stack.integration.test.ts @@ -2030,8 +2030,7 @@ describe("Effect stack lifecycle handoff", () => { }), ), ); - // Register exact cleanup before launching the replacement. The cached effect makes the - // explicit assertion below and scope finalization share one stop/destroy transition. + // Cache the stop/destroy effect so the assertion below and the finalizer share one transition. yield* Effect.addFinalizer(() => cleanupRecovered.pipe(Effect.ignore)); yield* replaced.start(); expect((yield* readOwnerMetadata(env.stateRoot, openId, env))?.rpcRelease).toBe( diff --git a/packages/stack/src/public/testing.integration.test.ts b/packages/stack/src/public/testing.integration.test.ts index e04ebca0b6..d39063fe69 100644 --- a/packages/stack/src/public/testing.integration.test.ts +++ b/packages/stack/src/public/testing.integration.test.ts @@ -9,8 +9,7 @@ import { CAPABILITY_NAMES } from "./Capability.ts"; import { StackIdSchema } from "./StackId.ts"; import type { StackStatus } from "./Status.ts"; -// Promise facade fixtures intentionally model async operations. -// oxlint-disable effecttsgo/async-function +// oxlint-disable effecttsgo/async-function -- fixtures model the Promise facade's async operations. const stackId = StackIdSchema.make( "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789", @@ -287,7 +286,6 @@ describe("test stack resource", () => { it("uses the managed runtime state root without mutating process environment", async () => { const events: Array<string> = []; - // This read is the assertion that createTestStackWith leaves global environment untouched. // oxlint-disable-next-line effecttsgo/process-env -- test-only environment immutability assertion. const originalHome = process.env.SUPABASE_HOME; let environment: Parameters<NonNullable<TestStackOperations["createStack"]>>[1] | undefined; diff --git a/packages/stack/src/public/whole-stack.e2e.test.ts b/packages/stack/src/public/whole-stack.e2e.test.ts index cdc3d8a5c1..71cf0cdd51 100644 --- a/packages/stack/src/public/whole-stack.e2e.test.ts +++ b/packages/stack/src/public/whole-stack.e2e.test.ts @@ -1,4 +1,4 @@ -// This E2E intentionally exercises the public Promise facade and real host APIs. +// This E2E exercises the public Promise facade and real host APIs. // oxlint-disable effecttsgo/async-function -- user-facing Promise facade scenario. // oxlint-disable effecttsgo/new-promise -- WebSocket event handoff uses the platform Promise API. // oxlint-disable effecttsgo/global-fetch -- user-shaped HTTP requests use global fetch. @@ -44,8 +44,7 @@ const ALL_RUNTIME_CASES = [ { name: "native", runtime: { kind: "native" as const } }, { name: "Docker", runtime: { kind: "container" as const, engine: "docker" as const } }, ] as const; -// Test registration must select the CI matrix case before an Effect program exists. -// oxlint-disable-next-line effecttsgo/process-env +// oxlint-disable-next-line effecttsgo/process-env -- selects the CI runtime matrix before an Effect program exists. const SELECTED_RUNTIME = process.env["SUPABASE_STACK_E2E_RUNTIME"]; if ( SELECTED_RUNTIME !== undefined && diff --git a/packages/stack/src/runtime/ContainerEngineResolver.ts b/packages/stack/src/runtime/ContainerEngineResolver.ts index 92abf8d55d..69123b7fc3 100644 --- a/packages/stack/src/runtime/ContainerEngineResolver.ts +++ b/packages/stack/src/runtime/ContainerEngineResolver.ts @@ -11,9 +11,9 @@ import { makeDockerEngine } from "./DockerEngine.ts"; import { makePodmanEngine } from "./PodmanEngine.ts"; /** - * Host-composition seam for selecting one concrete container engine. The - * service is intentionally narrow so createStack can be tested without a - * local daemon while production uses the real Docker/Podman adapters. + * Host-composition seam for selecting one concrete container engine, kept narrow so + * createStack can be tested without a local daemon while production uses the real Docker/Podman + * adapters. */ export interface ContainerEngineResolverShape { readonly resolve: ( diff --git a/packages/stack/src/runtime/ContainerRuntime.ts b/packages/stack/src/runtime/ContainerRuntime.ts index d94284705e..d8c506c19a 100644 --- a/packages/stack/src/runtime/ContainerRuntime.ts +++ b/packages/stack/src/runtime/ContainerRuntime.ts @@ -187,7 +187,7 @@ const sameLabels = (left: ContainerLabels, right: ContainerLabels): boolean => (left.role === "network" || ("workloadId" in left && "workloadId" in right && left.workloadId === right.workloadId))); -/** Workload identity deliberately excludes ownerSessionId; stack ownership is fenced externally. */ +/** Workload identity excludes ownerSessionId; stack ownership is fenced externally. */ const sameWorkloadIdentity = (left: ContainerLabels, right: ContainerWorkloadLabels): boolean => left.role === "workload" && left.stackId === right.stackId && diff --git a/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts b/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts index fc279b33d1..cb15e3357f 100644 --- a/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts +++ b/packages/stack/src/runtime/DatabaseBootstrapCatalog.ts @@ -14,9 +14,8 @@ const secretValue = (state: PersistedStackState, slot: string): string | undefin /** * Builds the initial database bootstrap from fully materialized state. * - * This helper intentionally does not read artifact SQL files or perform - * caller-driven reset/migration/seed work. It returns only the managed - * material required by the fixed runtime bootstrap reconciliation. + * Returns only the managed material the fixed runtime bootstrap reconciliation needs; it does + * not read artifact SQL files or perform caller-driven reset/migration/seed work. */ export const databaseBootstrapPlan = ( state: PersistedStackState, diff --git a/packages/stack/src/runtime/NativeRuntime.ts b/packages/stack/src/runtime/NativeRuntime.ts index ff7e8b3968..a06bf2a3fd 100644 --- a/packages/stack/src/runtime/NativeRuntime.ts +++ b/packages/stack/src/runtime/NativeRuntime.ts @@ -284,11 +284,9 @@ export const makeNativeRuntime = ( ), ); const outputFiber = yield* Effect.forkIn(consumed, startupScope); - // A one-shot process is complete only after both its exit and output - // streams have completed. Effect.all keeps the stream drain alive - // when the child exits before its pipes finish, while a stream - // failure interrupts the exit waiter and startup cleanup kills the - // exact child. + // A one-shot process finishes only once both its exit and output streams complete: + // Effect.all keeps draining after the child exits, and a drain failure interrupts the + // exit waiter while startup cleanup kills the exact child. const completed = Effect.all([exitCode.pipe(Effect.asVoid), Fiber.join(outputFiber)], { concurrency: "unbounded", discard: true, @@ -379,10 +377,9 @@ export const makeNativeRuntime = ( Effect.mapError((error) => processError(error, key, "spawn")), ); resource.process = process; - // Keep one process-exit observation owned by the resource scope. The - // readiness race has waiter-owned fibers which may be interrupted; - // sharing an Effect.cached waiter would let that interruption poison - // the observation used by the long-lived workload watcher. + // The readiness race's waiter fibers can be interrupted, so exit observation is forked + // into the resource scope rather than shared via Effect.cached, which would let that + // interruption poison the long-lived workload watcher's view. const exitFiber = yield* Effect.forkIn(process.exitCode, resource.scope); const exitCode = Fiber.join(exitFiber); yield* Effect.forkIn(watchProcess(resource, process, exitCode), resource.scope); diff --git a/packages/stack/src/runtime/PostgresDatabaseSession.ts b/packages/stack/src/runtime/PostgresDatabaseSession.ts index 35822292fb..0a313b1515 100644 --- a/packages/stack/src/runtime/PostgresDatabaseSession.ts +++ b/packages/stack/src/runtime/PostgresDatabaseSession.ts @@ -159,8 +159,8 @@ const INTERNAL_SCHEMAS = ["_analytics", "_supavisor"] as const; /** * Ensures the private database and service-owned schemas exist before any - * dependent workload is started. Database creation is deliberately outside a - * transaction because PostgreSQL does not support CREATE DATABASE there. + * dependent workload is started. Database creation happens outside a transaction because + * PostgreSQL does not support CREATE DATABASE there. */ export const ensureInternalDatabase = ( postgres: PostgresDatabaseSession, diff --git a/packages/stack/src/runtime/ReadinessProbe.ts b/packages/stack/src/runtime/ReadinessProbe.ts index cfde523cb2..df89653e5b 100644 --- a/packages/stack/src/runtime/ReadinessProbe.ts +++ b/packages/stack/src/runtime/ReadinessProbe.ts @@ -197,8 +197,8 @@ export const probeReadiness = ( "Readiness deadline", ); const attempt = target.mode === "http" ? httpAttempt(target) : tcpAttempt(target); - // A zero budget preserves the legacy `0s` meaning: perform one immediate probe, with no - // retry delay. A positive budget interrupts whichever owned request/socket is still active. + // A zero deadline runs one immediate probe with no retry delay; a positive deadline can + // interrupt whichever owned request/socket is still active. if (Duration.isZero(deadline)) return yield* attempt; const schedule = retries === undefined diff --git a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts index 0d2c61ff46..066b9c11b3 100644 --- a/packages/stack/src/runtime/WorkloadRuntimeSpec.ts +++ b/packages/stack/src/runtime/WorkloadRuntimeSpec.ts @@ -667,9 +667,8 @@ const apiGatewayUrl = (state: PersistedStackState, inputs?: WorkloadRuntimeInput const authSmsProvider = (state: PersistedStackState): string => { const sms = settingsFor(state, "auth"); if (!isRecord(sms) || !isRecord(sms.sms)) return ""; - // Keep the same fixed priority as the legacy GoTrue builder. If multiple - // providers are enabled, the first one wins and only its credentials are - // consumed by GoTrue. + // Providers are checked in this fixed order; if multiple are enabled, the first one wins + // and only its credentials are passed to GoTrue. const providers = ["twilio", "twilio_verify", "messagebird", "textlocal", "vonage"]; for (const provider of providers) { const value = sms.sms[provider]; diff --git a/packages/stack/src/runtime/native-launcher.ts b/packages/stack/src/runtime/native-launcher.ts index 8364a4a1a7..21ab3d945d 100644 --- a/packages/stack/src/runtime/native-launcher.ts +++ b/packages/stack/src/runtime/native-launcher.ts @@ -134,9 +134,8 @@ export const runNativeLauncher = (): void => { } }; - // The supervisor normally signals the launcher process group, but forwarding - // graceful signals to the workload keeps the launcher alive long enough to - // observe the workload's exit and reap it. SIGKILL remains handled by the + // Forwarding graceful signals to the workload rather than the whole process group keeps the + // launcher alive long enough to observe and reap its exit. SIGKILL still goes through the // owner-pipe path and cannot be intercepted here. const forwardSignal = (signal: NodeJS.Signals): void => { if (groupTerminated || gracefulForwarded) return; @@ -179,11 +178,9 @@ export const runNativeLauncher = (): void => { gracefulTimeout = setTimeout(() => terminateGroup("SIGKILL"), specGracefulStopTimeoutMs); }; - // Register the owner pipe before waiting for the launch payload. EOF means - // the owner process disappeared without running a normal scope finalizer. - // Bun does not reliably surface EOF for a net.Socket created from this - // descriptor, while Node's filesystem stream can hold a libuv worker open - // after its owner exits. Select the descriptor adapter for the host runtime. + // Register the owner pipe before the launch payload: EOF means the owner vanished without + // running a scope finalizer. Bun doesn't reliably surface EOF for an fd-based Socket, while + // Node's fs stream can hold a libuv worker open after exit, so pick the adapter per runtime. const ownerPipe = process.versions.bun === undefined ? new Socket({ fd: 3, readable: true, writable: false }) @@ -203,10 +200,9 @@ export const runNativeLauncher = (): void => { let payload: Buffer; try { - // The parent writes a finite JSON payload and closes fd4. A synchronous - // read avoids a Bun pipe-read stream that can fail to deliver `end` after - // the parent closes the sink, while the owner stream remains registered for - // loss detection once the workload is running. + // The parent writes a finite JSON payload and closes fd4; a synchronous read avoids a Bun + // pipe-read stream that can fail to deliver `end` once the sink closes, while the owner-pipe + // stream stays registered for loss detection once the workload is running. payload = readFileSync(4); } catch { process.exit(127); diff --git a/packages/stack/src/runtime/native-runtime.integration.test.ts b/packages/stack/src/runtime/native-runtime.integration.test.ts index 53633daf2d..6152781d8b 100644 --- a/packages/stack/src/runtime/native-runtime.integration.test.ts +++ b/packages/stack/src/runtime/native-runtime.integration.test.ts @@ -488,8 +488,7 @@ describe("native runtime", { timeout: 15_000 }, () => { resolveProcess: () => Effect.succeed({ startup: [oneShotProcess("startup-failed", 7)], - // This path is intentionally invalid: a correct runtime must - // fail on the startup process before attempting to spawn it. + // Invalid on purpose: the runtime must fail the startup process before spawning it. main: { executable: "/missing/native-main" }, }), waitForReadiness: () => diff --git a/packages/stack/src/state/Ownership.ts b/packages/stack/src/state/Ownership.ts index 691318aff2..67bad913a0 100644 --- a/packages/stack/src/state/Ownership.ts +++ b/packages/stack/src/state/Ownership.ts @@ -18,7 +18,7 @@ import { StackOwnershipConflictError, StackStateInvalidError } from "../public/E import { OwnerSessionIdSchema } from "../control/MaintenanceProtocol.ts"; import { NetworkPortSchema } from "../public/Status.ts"; -/** The owner metadata format is deliberately fail-closed. */ +/** Bump this format string so owner metadata version mismatches fail closed. */ const OWNERSHIP_FORMAT = "supabase-stack-owner-v1" as const; export const OWNER_LOCK_FORMAT = "supabase-stack-lease-v1" as const; @@ -248,8 +248,8 @@ const metadataFrom = (value: unknown): Effect.Effect<OwnerMetadata, StackStateIn /** * Computes the one local endpoint for an identity. The complete digest is used - * so two identities can never alias. The caller supplies a deliberately short - * IPC root; no project path is embedded in the endpoint. + * so two identities can never alias. The caller supplies a short IPC root; + * no project path is embedded in the endpoint. */ export const controlEndpointFor = ( stackId: StackId | string, From 52c7f197cab4b737131ba68fa24e91f06254d234 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:07:11 +0100 Subject: [PATCH 40/56] chore(repo): trim comments, shard 26 (1591 -> 694 comment lines) --- .../experimental/workers/workers.errors.ts | 22 +- .../experimental/workers/workers.format.ts | 22 +- .../workers.output.integration.test.ts | 7 +- .../experimental/workers/workers.output.ts | 73 ++--- .../experimental/workers/workers.shared.ts | 115 +++----- .../delete/delete.integration.test.ts | 12 +- .../functions/deploy/deploy.e2e.test.ts | 23 +- .../functions/deploy/deploy.handler.ts | 15 +- .../deploy/deploy.integration.test.ts | 98 ++----- .../functions/download/download.e2e.test.ts | 40 +-- .../functions/download/download.handler.ts | 26 +- .../download/download.integration.test.ts | 253 +++++------------- .../functions/download/download.live.test.ts | 19 +- .../src/commands/functions/new/new.errors.ts | 12 +- .../src/commands/functions/new/new.handler.ts | 32 +-- .../functions/new/new.integration.test.ts | 13 +- .../functions/serve/serve.integration.test.ts | 132 +++------ .../gen/bearer-jwt/bearer-jwt.claims.ts | 221 +++++---------- .../bearer-jwt/bearer-jwt.claims.unit.test.ts | 53 +--- .../gen/bearer-jwt/bearer-jwt.command.ts | 17 +- .../gen/bearer-jwt/bearer-jwt.errors.ts | 25 +- .../gen/bearer-jwt/bearer-jwt.flags.ts | 148 +++------- .../bearer-jwt/bearer-jwt.flags.unit.test.ts | 45 +--- .../gen/bearer-jwt/bearer-jwt.handler.ts | 36 +-- .../bearer-jwt/bearer-jwt.integration.test.ts | 159 ++--------- .../gen/bearer-jwt/bearer-jwt.signing-key.ts | 207 ++++---------- 26 files changed, 464 insertions(+), 1361 deletions(-) diff --git a/apps/cli/src/commands/experimental/workers/workers.errors.ts b/apps/cli/src/commands/experimental/workers/workers.errors.ts index 8832b32aec..e30b7db2ef 100644 --- a/apps/cli/src/commands/experimental/workers/workers.errors.ts +++ b/apps/cli/src/commands/experimental/workers/workers.errors.ts @@ -8,11 +8,8 @@ import { /** * `--output env` cannot represent a payload containing a list. * - * `encodeEnv` reproduces `godotenv.Marshal`, whose flattening does not descend - * into slices — a `workers` array would land as a single `WORKERS=""` line - * rather than one entry per worker. Refusing is the same call `functions list` - * makes for the same reason, rather than emitting output that silently omits - * the data. + * `encodeEnv`'s flattening does not descend into slices, so a `workers` array + * would land as a single empty `WORKERS=""` line instead of one entry per worker. */ export class WorkersEnvNotSupportedError extends Data.TaggedError("WorkersEnvNotSupportedError")<{ readonly message: string; @@ -23,16 +20,11 @@ export class WorkersEnvNotSupportedError extends Data.TaggedError("WorkersEnvNot } /** - * `--follow` was asked for alongside an output format that cannot express a - * stream. - * - * `-o json|yaml|toml` and `--output-format json` each promise exactly one - * terminal payload, and an unbounded tail has no last element to put in it. - * Refused up front rather than at the first emission, for the same reason - * {@link WorkersEnvNotSupportedError} is: discovering it later means - * failing after the first query has been paid for. - * - * `--output-format stream-json` is the streaming machine format and is allowed. + * `--follow` cannot be combined with an output format that promises exactly + * one terminal payload (`-o json|yaml|toml`, `--output-format json`) — an + * unbounded tail has no last element to put in it. Refused up front so a + * query isn't paid for only to fail on the first emission. `--output-format + * stream-json` is unaffected. */ export class WorkersFollowNotSupportedError extends Data.TaggedError( "WorkersFollowNotSupportedError", diff --git a/apps/cli/src/commands/experimental/workers/workers.format.ts b/apps/cli/src/commands/experimental/workers/workers.format.ts index badaf29e26..3a4064cc05 100644 --- a/apps/cli/src/commands/experimental/workers/workers.format.ts +++ b/apps/cli/src/commands/experimental/workers/workers.format.ts @@ -1,26 +1,18 @@ /** * Text rendering for the workers commands. * - * Two conventions this shell holds and `supabase experimental workers` follows rather than - * inventing its own: results are written with `output.raw` as plain text, with - * no `intro`/`outro` framing, which no other handler here uses, and tabular - * output goes through `renderGlamourTable`, so `workers list` sits beside - * `functions list` and `projects list` looking like them. + * Results are written with `output.raw` as plain text with no `intro`/`outro` + * framing, and tabular output goes through `renderGlamourTable`, matching + * `functions list` and `projects list`. */ /** * `Label value` detail lines for a single worker. * - * Vertical rather than a one-row `renderGlamourTable` because a worker's values - * include a URL and a source path: `branches get` gets away with laying its - * seven narrow columns out horizontally, and these would not fit. Labels are - * Title Case to match the other vertical key/value view this CLI renders, - * `supabase status` (`status-pretty.ts`), rather than inventing a third - * casing. - * - * Rows whose value is empty are dropped: several fields are optional strings in - * the API contract (`state_reason`, for one), so an empty one would otherwise - * render as a label, two spaces of padding and nothing else. + * Vertical rather than a `renderGlamourTable` row because a worker's values + * include a URL and a source path that would not fit in narrow columns. Rows + * with an empty value (e.g. `state_reason`, an optional API field) are + * dropped rather than rendered as a bare label. */ export function renderWorkerDetails(rows: ReadonlyArray<readonly [string, string]>): string { const present = rows.filter(([, value]) => value !== ""); diff --git a/apps/cli/src/commands/experimental/workers/workers.output.integration.test.ts b/apps/cli/src/commands/experimental/workers/workers.output.integration.test.ts index da94bb2b33..e09a32ec9a 100644 --- a/apps/cli/src/commands/experimental/workers/workers.output.integration.test.ts +++ b/apps/cli/src/commands/experimental/workers/workers.output.integration.test.ts @@ -6,11 +6,8 @@ import { makeWorkersProject, setupWorkers } from "../../../../tests/helpers/work import { emitWorkersMachineOutput } from "./workers.output.ts"; /** - * Every workers command refuses `-o env` up front, before it touches the - * network, so the encoder's own env branch is a backstop rather than a path a - * user reaches. It is worth pinning anyway: a new command that forgets the - * refusal must not silently emit TOML under a flag that asked for env — it - * raises the same refusal instead. + * The env branch here is unreachable in production (commands refuse `-o env` + * earlier); this pins it as a backstop against silently falling through to TOML. */ describe("emitWorkersMachineOutput", () => { it.live("refuses -o env rather than falling through to the TOML encoder", () => { diff --git a/apps/cli/src/commands/experimental/workers/workers.output.ts b/apps/cli/src/commands/experimental/workers/workers.output.ts index 6fbd7eb0c1..6125f77b19 100644 --- a/apps/cli/src/commands/experimental/workers/workers.output.ts +++ b/apps/cli/src/commands/experimental/workers/workers.output.ts @@ -11,32 +11,15 @@ import { WorkersEnvNotSupportedError } from "./workers.errors.ts"; /** * Emits a command's payload in the format `-o`/`--output` asked for. * - * `-o` is a global flag nearly every command family on this shell honours, so - * ignoring it would print human text to a stdout the user asked to be - * machine-readable. - * - * The struct-shaped encoders elsewhere reproduce a payload shape their command - * is required to match. `workers` has none, so it serialises through the generic - * encoders and shapes its payload as the command reads best. - * - * Returns whether it emitted anything, so the caller can skip its text - * rendering — `output.success` writes to stdout in text mode and would corrupt - * the payload otherwise. + * Returns whether it emitted anything, so the caller can skip its own text + * rendering, which would otherwise also write to stdout and corrupt the payload. */ /** * Which `-o` values these commands answer with a payload. * - * An allowlist, because the emitter's last branch is TOML: a denylist made every - * value it had not heard of serialise as TOML, so the next format the global - * flag learns would silently emit TOML from every workers command until somebody - * remembered to exclude it. `pretty` is the human default, and `table`/`csv` are - * accepted by the global flag only because `db query` reads them — every - * resource command falls through to its own text rendering for those, which is - * what an unrecognised value should do too. - * - * `env` is in the set so it reaches the refusal below rather than falling - * through to text: it is a format these commands *recognise* and cannot encode, - * which is a different answer from one they have never heard of. + * An allowlist, not a denylist, so an unrecognized future `-o` value falls + * through to text instead of silently serializing as TOML. `env` is included + * so it reaches the refusal below rather than being treated as unrecognized. */ const PAYLOAD_FORMATS = new Set(["json", "yaml", "toml", "env"]); @@ -55,9 +38,8 @@ export const emitWorkersMachineOutput = Effect.fnUntraced(function* ( } if (goFormat === "env") { - // Unreachable when the command called `rejectWorkersEnvOutput` first, - // which is where the refusal belongs; here as the backstop that stops a new - // command silently emitting TOML for `-o env`. + // Unreachable once a command has already called `rejectWorkersEnvOutput`; + // this is the backstop for a command that forgets to. return yield* new WorkersEnvNotSupportedError({ message: "--output env flag is not supported", }); @@ -88,14 +70,10 @@ export const workersMachineOutputRequested = Effect.fnUntraced(function* () { * The format a run actually renders in, with `-o` given priority over * `--output-format`. * - * `-o pretty|table|csv` encode nothing and fall through to the text rendering, - * and an explicit `-o` outranks `--output-format` when both are set. Branching - * on `output.format` alone therefore emitted JSON for `-o pretty - * --output-format json`, which asked for exactly the opposite. - * - * `-o json|yaml|toml|env` are absent from the result on purpose: those are - * handled by `emitWorkersMachineOutput`, which runs before any of this and - * owns its own stdout. + * `-o pretty|table|csv` fall through to text, so branching on `output.format` + * alone would wrongly emit JSON for `-o pretty --output-format json`. + * `-o json|yaml|toml|env` are excluded because `emitWorkersMachineOutput` + * already owns those and their stdout. */ export const workersRenderFormat = Effect.fnUntraced(function* () { const output = yield* Output; @@ -105,13 +83,11 @@ export const workersRenderFormat = Effect.fnUntraced(function* () { }); /** - * Refuse `-o env` before the command does anything. + * Refuses `-o env` before the command does anything. * - * `env` is a flat `KEY=value` list and every workers payload has structure a - * flat list cannot hold — a collection, or a nested instance tally. So it is - * refused for the whole command family rather than per payload, and refused up - * front: discovering it at emit time means failing after the work is done, which - * for `push` is after the remote project has already changed. + * Every workers payload has structure a flat `KEY=value` list cannot hold. + * Refused up front rather than at emit time, since for `push` that would mean + * failing only after the remote project has already changed. */ export const rejectWorkersEnvOutput = Effect.fnUntraced(function* () { if (Option.getOrUndefined(yield* OutputFlag) === "env") { @@ -122,21 +98,14 @@ export const rejectWorkersEnvOutput = Effect.fnUntraced(function* () { }); /** - * The `--project-ref` a retry suggestion has to carry, or `""` when the ref came + * The `--project-ref` a retry suggestion has to carry, or `""` when it came * from the link. * - * A suggested command is copy-pasted verbatim, so one that drops an explicit - * `--project-ref` re-resolves to whatever *this* checkout is linked to. On - * `delete --yes` that is a same-named worker in a project the user never named, - * removed without a prompt. - * - * Keyed off the flag rather than the resolved ref: when the link supplied it, - * appending it again is noise on a command that already resolves correctly. - * - * An empty `--project-ref ""` counts as "not supplied", the same reading - * `ProjectRefResolver` gives it before falling back to the environment or - * the linked-project file. Carrying it through would suggest a command ending - * in a valueless `--project-ref`, which cannot be pasted back. + * Dropping an explicit `--project-ref` from a copy-pasted suggestion would + * re-resolve to whichever project is currently linked — for `delete --yes`, + * a same-named worker deleted without a prompt. Keyed off the flag, not the + * resolved ref, so a link-supplied ref isn't echoed back as noise; an empty + * flag value counts as "not supplied", matching `ProjectRefResolver`. */ export const workersProjectRefSuffix = (projectRef: Option.Option<string>): string => Option.isSome(projectRef) && projectRef.value.length > 0 diff --git a/apps/cli/src/commands/experimental/workers/workers.shared.ts b/apps/cli/src/commands/experimental/workers/workers.shared.ts index 15999ed127..14783fb4c2 100644 --- a/apps/cli/src/commands/experimental/workers/workers.shared.ts +++ b/apps/cli/src/commands/experimental/workers/workers.shared.ts @@ -13,15 +13,13 @@ import { validateWorkerNameMessage } from "../../../shared/workers/worker-runtim import { InvalidWorkerNameError } from "../../../shared/workers/workers.errors.ts"; /** - * What every `supabase experimental workers` command needs before it does anything: where - * the project is, what `[workers]` says, and which worker is being acted on. + * What every `supabase experimental workers` command needs before it does + * anything: where the project is, what `[workers]` says, and which worker is + * being acted on. * - * The project directory is `CommandSettings.workdir` rather than an ancestor - * walk from the current directory. That is the resolved workdir every other - * command acts on — `--workdir`/`SUPABASE_WORKDIR` when given, else the - * ancestor walk Go's own `getProjectRoot` performs — so `supabase experimental workers` - * answers to the same flag as its siblings instead of inventing a second notion - * of "which project". + * The project directory is `CommandSettings.workdir`, the same resolved + * workdir every other command acts on, so `workers` answers to the same + * `--workdir`/`SUPABASE_WORKDIR` flag as its siblings. */ export interface WorkersProject { @@ -38,21 +36,12 @@ const loadWorkersProjectWith = Effect.fnUntraced(function* (options: { }) { const settings = yield* CommandSettings; - // tomlOnly (the [workers.*] entry writer) keeps `search: false` unconditionally: - // it and the workdir's own default resolution probe the same config.toml, so the - // second climb is redundant. The JSON-capable read must thread the predicate — the - // default workdir resolution only probes config.toml, so a config.json-only project - // invoked from a subdirectory relies on this climb to be found at all (CLI-2285). - // - // `workers new api --workdir ./bare-dir` inside another project is why - // `projectRoot` must be DERIVED from this same search rather than always - // `settings.workdir`: with an explicit workdir the predicate yields `false` - // (see `shouldSearchAncestors`), so `paths` is null and `projectRoot` - // falls back to `settings.workdir` exactly as before — that scaffold-into-a- - // bare-directory behavior is preserved verbatim. Only a DEFAULTED workdir can - // ever climb here, and when it does, `projectRoot` must climb WITH `configPath` - // — otherwise a discovered ancestor's `[workers.*]` entries would resolve their - // `source` against a non-project directory. + // `tomlOnly` skips the ancestor search (redundant with the default + // resolution); the JSON-capable read needs it so a config.json-only project + // run from a subdirectory is still found. `projectRoot` is derived from the + // same search, not `settings.workdir`, so a climb takes `configPath` with it + // — otherwise a discovered ancestor's `[workers.*]` entries would resolve + // `source` against the wrong directory. const search = options.tomlOnly ? false : shouldSearchAncestors(settings); const paths = yield* findCliProjectPaths(settings.workdir, { search }); const projectRoot = paths?.projectRoot ?? settings.workdir; @@ -74,11 +63,11 @@ const loadWorkersProjectWith = Effect.fnUntraced(function* (options: { /** * The project as a reader sees it, following the loader's normal - * JSON-over-TOML selection. `config.json` is a supported project format, so a - * command that only reads `[workers.*]` has to honour it — otherwise a JSON - * project deploys with a guessed runtime and default size and instance counts - * instead of the ones it configured, and a worker whose `source` sits outside - * `supabase/workers/` is not discovered at all. + * JSON-over-TOML selection. + * + * A command that only reads `[workers.*]` still has to honour `config.json`, + * or a JSON project deploys with a guessed runtime and default size/instance + * counts instead of the ones it configured. */ export const loadWorkersProject = () => loadWorkersProjectWith({ tomlOnly: false }); @@ -86,33 +75,20 @@ export const loadWorkersProject = () => loadWorkersProjectWith({ tomlOnly: false * The project as the `[workers.<name>]` entry writer needs to see it: TOML * only. * - * `commitWorkerEntry` is a TOML text editor. Without `tomlOnly` the loader - * prefers `supabase/config.json` when one exists, `configPath` becomes the JSON - * file, and the writer appends a `[workers.<name>]` table to it — leaving the - * project config unparseable after the scaffold is already on disk. - * `functions new` avoids the same trap by resolving `supabase/config.toml` - * directly; this is that, through the loader. - * - * A JSON project therefore gets a `config.toml` written beside its - * `config.json`, which the loader lists in `ignoredPaths`. That gap is the - * writer's alone — reads go through {@link loadWorkersProject} — and it - * closes when config writing is overhauled. + * `commitWorkerEntry` is a TOML text editor; without `tomlOnly` a JSON + * project's `configPath` would resolve to `config.json`, and appending a + * `[workers.<name>]` table there would make the file unparseable. */ export const loadWorkersProjectForEntryWrite = () => loadWorkersProjectWith({ tomlOnly: true }); /** * As {@link loadWorkersProject}, but never failing on the project config. * - * For commands that only *report* on local state — `status` and `delete` — - * which act on the remote worker and consult the project purely to add the - * optional source detail. Making it a prerequisite stranded a deployed worker - * behind an unrelated local parse error, even when `--project-ref` named the - * project explicitly and nothing local was going to be touched. - * - * A config that will not load reads the same as a project with no - * `[workers.*]` entries: no entry, no configured source, so no source row. - * Same degrade-rather-than-fail shape as - * {@link describeWorkerForReporting}, which does it for the source path. + * `status` and `delete` act on the remote worker and consult the project only + * for the optional source detail; making config loading a prerequisite would + * strand a deployed worker behind an unrelated local parse error. A config + * that fails to load degrades to "no `[workers.*]` entries", same shape as + * {@link describeWorkerForReporting} for the source path. */ export const loadWorkersProjectForReporting = Effect.fnUntraced(function* () { const loaded = yield* loadWorkersProject().pipe(Effect.option); @@ -142,10 +118,9 @@ interface ResolvedWorker { /** * Whether anything local actually establishes {@link sourceDir}. * - * `sourceDir` is always computable — with no entry it falls back to the default - * directory — so it cannot on its own tell a worker whose code is on this - * machine from one deployed out of another checkout. Commands that print local - * paths need that difference before they state one as fact. + * `sourceDir` is always computable — with no entry it falls back to the + * default directory — so on its own it can't distinguish local code from a + * worker deployed out of another checkout. */ readonly sourceExists: boolean; /** @@ -159,22 +134,17 @@ interface ResolvedWorker { } /** - * Effectful because resolving `sourceDir` confines it to the project, and that - * verdict needs the filesystem: `source` comes from a committed `config.toml`, - * and a directory inside the project can symlink anywhere outside it. + * Effectful because confining `sourceDir` to the project needs the + * filesystem: `source` comes from a committed `config.toml`, and an + * in-project directory can symlink outside it. */ /** * As {@link describeWorker}, but never failing on the source path. * - * For commands that only *report* on local state — `status` and `delete` — where - * the source is a detail of the output, not a prerequisite. Making confinement - * mandatory there stranded the remote worker: a `source` that resolves outside - * the project (an in-project directory that became a symlink, say) failed the - * describe before either API call, so `delete` could not remove a worker whose - * local files it was never going to touch. - * - * `push` keeps the strict version, because there the source *is* what gets - * packaged and uploaded. + * `status` and `delete` treat the source as an output detail, not a + * prerequisite, so a `source` resolving outside the project (e.g. a symlink) + * degrades instead of blocking them. `push` keeps the strict version since + * there the source is what gets packaged and uploaded. */ export const describeWorkerForReporting = Effect.fnUntraced(function* ( project: WorkersProject, @@ -184,10 +154,8 @@ export const describeWorkerForReporting = Effect.fnUntraced(function* ( if (Option.isSome(described)) { return described.value; } - // The path is unusable, which for reporting purposes reads the same as having - // nothing local at all. `sourceResolved: false` keeps callers from printing - // this stand-in as the source the entry names — it is the default directory, - // not the path that failed. + // Unusable path reads the same as no local source. `sourceResolved: false` + // stops callers from printing this default-directory stand-in as the entry's source. return { name, entry: project.section.workers[name], @@ -245,12 +213,9 @@ export const validateWorkerName = Effect.fnUntraced(function* (name: string) { export const discoverWorkerNames = Effect.fnUntraced(function* (project: WorkersProject) { const fs = yield* FileSystem.FileSystem; - // No workers root at all is a project that has never scaffolded one, and the - // config entries below may still name workers living elsewhere — so absence - // reads as nothing here. Any other reason propagates: a root the CLI cannot - // list is not a project with no workers in it, and answering a bare `push` - // with "deployed everything" after silently skipping them is the worst - // possible reading of it. + // Missing workers root reads as "no workers here", since a project can still + // name workers elsewhere via `source`. Any other read error propagates rather + // than silently reporting an empty `push` as "deployed everything". const entries = yield* fs .readDirectory(project.workersDir) .pipe( diff --git a/apps/cli/src/commands/functions/delete/delete.integration.test.ts b/apps/cli/src/commands/functions/delete/delete.integration.test.ts index 56076728c5..4791afaaa4 100644 --- a/apps/cli/src/commands/functions/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/functions/delete/delete.integration.test.ts @@ -18,11 +18,8 @@ import { functionsDelete } from "./delete.handler.ts"; const tempRoot = useTempWorkdir("supabase-functions-delete-legacy-"); -// `withCommandTelemetry` threads `flags`/`command`/etc. through -// `CurrentAnalyticsContext`, not the direct `capture()` call args — mirrors -// the identical local helper in `command-telemetry.unit.test.ts`. -// The shared `mockAnalytics()` in tests/helpers/mocks.ts deliberately doesn't -// merge this context (most callers don't need it). +// `withCommandTelemetry` threads `flags`/`command` through +// `CurrentAnalyticsContext`, not `capture()`'s own args, so this merges it manually. function mockContextualAnalytics() { const captured: Array<{ event: string; properties: Record<string, unknown> }> = []; const layer = Layer.succeed( @@ -41,8 +38,7 @@ function mockContextualAnalytics() { return { layer, captured }; } -// Strip ANSI SGR (aqua slug/ref via `aqua`) so byte-assertions are -// stable whether or not the test stdout supports color. +// Strips ANSI color codes so assertions are stable regardless of color support. // eslint-disable-next-line no-control-regex const stripSgr = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); @@ -71,8 +67,6 @@ describe("functions delete", () => { expect(api.requests[0]?.url).toBe( "https://api.supabase.com/v1/projects/abcdefghijklmnopqrst/functions/hello-world", ); - // The slug and ref are wrapped in ANSI (aqua) in colour-capable - // environments — strip SGR so the byte assertion stays stable. expect(stripSgr(out.stdoutText)).toBe( "Deleted Function hello-world from project abcdefghijklmnopqrst.\n", ); diff --git a/apps/cli/src/commands/functions/deploy/deploy.e2e.test.ts b/apps/cli/src/commands/functions/deploy/deploy.e2e.test.ts index f88c93f4ca..85aa6d7c85 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.e2e.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.e2e.test.ts @@ -4,25 +4,15 @@ import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { makeTempHome, runSupabase } from "../../../../tests/helpers/cli.ts"; -// Argument-validation negatives for `functions deploy`. Both checks below are -// native TS today (deployFunctions in shared/functions/deploy.ts) — the -// bundler-mutex message byte-matches cobra's validateExclusiveFlagGroups -// template, and --jobs mirrors the established top-of-handler guard -// (`if useApi { ... } else if maxJobs > 1 { error }`). A black-box subprocess -// test still earns its keep here: asserting the SPECIFIC error text avoids a -// false pass from an unrelated non-zero exit (e.g. a missing build -// artifact), and exercises the real CLI entrypoint end to end. -// -// All cases fail before any network call (flag-group validation / the jobs -// check both run before project-ref resolution), so no auth or linked -// project is required. +// These flag-validation checks are native TS and fail before any network +// call, so no auth or linked project is required. Kept as e2e, not +// integration, to assert the exact error text through the real CLI +// entrypoint rather than risk a false pass from an unrelated non-zero exit. const E2E_TIMEOUT_MS = 30_000; const SLUG = "deploy-e2e-basic"; -// Valid-format token + ref to clear the auth and project-ref gates (both checked -// before the bundler-flag validation under test). These cases all fail before -// any network call (flag-group validation / the jobs check at the top of the -// handler), so neither value is ever used against a real API. +// Valid-format token + ref clear the auth and project-ref gates but are never +// used against a real API, since these cases fail before any network call. const FAKE_TOKEN = `sbp_${"0".repeat(40)}`; const FAKE_REF = "a".repeat(20); @@ -44,7 +34,6 @@ describe("supabase functions deploy — argument validation", () => { }, ); expect(exitCode).not.toBe(0); - // Byte-matches cobra's validateExclusiveFlagGroups (flag_groups.go:204). expect(stderr).toContain( "if any flags in the group [use-api use-docker legacy-bundle] are set none of the others can be", ); diff --git a/apps/cli/src/commands/functions/deploy/deploy.handler.ts b/apps/cli/src/commands/functions/deploy/deploy.handler.ts index ea7ad3f4fa..a5426e2986 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.handler.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.handler.ts @@ -20,8 +20,7 @@ export const functionsDeploy = Effect.fn("functions.deploy")(function* ( const api = yield* CommandPlatformApi; const cliSettings = yield* CommandSettings; const resolver = yield* ProjectRefResolver; - // `--yes` OR `SUPABASE_YES` inside the `--prune` confirm — the env var - // must auto-confirm too, not just the flag. + // Also honors `SUPABASE_YES`, not just the `--yes` flag, for the `--prune` confirm. const yes = yield* resolveYes; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; @@ -52,17 +51,11 @@ export const functionsDeploy = Effect.fn("functions.deploy")(function* ( }), ), ), - // Go: `fmt.Printf("Deployed Functions on project %s: %s\n", - // utils.Aqua(flags.ProjectRef), …)` (`internal/functions/deploy/deploy.go:70`) - // — stdout-bound, so the TTY gate must check stdout. + // Written to stdout, so the TTY color gate must check stdout. styleIdentifier: (text) => aqua(text, process.stdout), - // Go: `utils.Bold` on the `Bundling Function:` slug (`bundle.go:30`, stderr) - // and the no-functions error dir (`deploy.go:35`, rendered on stderr) — - // both stderr-bound, matching `bold`'s default TTY gate. + // Written to stderr, matching `bold`'s default TTY gate. styleEmphasis: (text) => bold(text), - // Go: `utils.Yellow` on the `WARNING:` token before "Docker is not - // running" (`deploy.go:60`, stderr) — matches `yellow`'s default - // TTY gate. + // Written to stderr, matching `yellow`'s default TTY gate. styleWarning: (text) => yellow(text), }).pipe( Effect.ensuring( diff --git a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts index 273f550175..6cfc36bfc6 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -62,8 +62,7 @@ async function writeLocalFunction( await writeFile(join(functionDir, "deno.json"), '{"imports":{}}\n'); } -// Strip ANSI SGR (color/bold) sequences — `bold` styles the pruned slugs -// only when stderr supports color, so byte-assertions normalize first. +// Strips ANSI SGR sequences so byte-assertions are stable regardless of color support. // eslint-disable-next-line no-control-regex const stripSgr = (text: string) => text.replace(/\x1b\[[0-9;]*m/gu, ""); @@ -76,11 +75,9 @@ function resolveDockerOutputPath(args: ReadonlyArray<string>): string { } /** - * Every `docker image inspect` call is a cache hit (exit 0) — no real pull, - * no real registry candidate fallback (that path has its own coverage in - * `functions/download`'s integration tests) — and every `docker run` - * synthesizes the eszip the bundler container would otherwise have produced, - * so `bundleFunctionWithDocker` can read it back and complete the deploy. + * Mocks `docker image inspect` as a cache hit and `docker run` as writing a + * fake eszip output, so `bundleFunctionWithDocker` completes without a real + * Docker pull or build. */ function mockDockerBundleSpawner() { const spawnerOpts: { @@ -171,9 +168,6 @@ describe("functions deploy", () => { }); it.live("prints a duplicated slug argument verbatim, matching Go's raw strings.Join", () => { - // The established join uses the raw CLI-arg slugs, not a deduped set, so - // a repeated slug prints twice even though only one deploy request is - // made for it. const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ handler: (request) => { @@ -438,16 +432,8 @@ describe("functions deploy", () => { }); it.live("rejects a bundled file whose workdir-relative name escapes with a `..` segment", () => { - // Established behavior: uploaded file names and the server-recorded - // `entrypoint_path` / `import_map_path` are anchored at `os.Getwd()` — - // the workdir — never at the git root. A monorepo import outside the - // workdir but inside the git root (allowed by the source-root - // containment check since #5755) would otherwise upload with a - // `../`-relative name. Every uploaded path is opened through an `fs.FS`, - // which rejects any path containing a `..` element (`fs.ValidPath`) - // before the read — and thus the upload — happens. This asserts the CLI - // hard-fails the same way instead of letting the `..`-relative name reach - // the server. + // Uploaded paths are anchored at the workdir, not the git root, so this + // monorepo import fails the `fs.FS` boundary check before upload. const repoRoot = tempRoot.current; const workdir = join(repoRoot, "app"); const multiparts: Array<{ metadata?: string; fileNames: ReadonlyArray<string> }> = []; @@ -697,9 +683,6 @@ describe("functions deploy", () => { yield* functionsDeploy({ ...baseFlags, prune: true }); expect(out.promptConfirmCalls).toHaveLength(0); - // Established behavior: the accepted prompt echoes to stderr under the - // global YES flag — byte-match `confirmPruneAll` + choices (each slug - // is bolded, so strip SGR codes first). expect(stripSgr(out.stderrText)).toContain( "Do you want to delete the following Functions from your project?\n • remote-only\n\n [y/N] y\n", ); @@ -712,10 +695,9 @@ describe("functions deploy", () => { ); }); - // INC-699: a `bundleOnly` upload bumps the remote version without persisting - // metadata, so a partially failed bulk deploy must still send the final PUT for - // whatever uploaded — otherwise the remote metadata is stranded and every later - // deploy conflicts. + // A `bundleOnly` upload bumps the remote version without persisting metadata, + // so a partially failed bulk deploy must still send the final PUT for whatever + // uploaded, or the remote metadata is stranded and later deploys conflict. describe("partial bulk upload failures (INC-699)", () => { function setupBulkDeploy(opts: { readonly deployStatuses: ReadonlyArray<number>; @@ -799,7 +781,6 @@ describe("functions deploy", () => { 'unexpected deploy status 409: {"message":"rejected bye-world"}', ); - // Both uploads ran — the 201 was not interrupted by the sibling 409. expect( api.requests.filter( (request) => request.method === "POST" && request.url.endsWith("/functions/deploy"), @@ -829,7 +810,6 @@ describe("functions deploy", () => { functionNames: ["hello-world", "bye-world"], }).pipe(Effect.flip); - // Established join behavior: one message per failed upload, in input order. expect((error as Error).message).toBe( [ 'unexpected deploy status 409: {"message":"rejected hello-world"}', @@ -945,9 +925,6 @@ describe("functions deploy", () => { }); it.live("rejects --jobs > 1 with --use-docker=false and no --use-api (Go parity gap)", () => { - // Divergence this test guards: previously the guard only fired when local - // bundling (Docker/legacy-bundle) was active, so `--use-docker=false --jobs 2` - // (no --use-api) silently passed where it should error. const { layer } = setupJobsTest([ "functions", "deploy", @@ -1089,11 +1066,8 @@ describe("functions deploy", () => { describe("bundler routing with --use-api=false (Go parity: cmd/functions.go:79-80)", () => { it.live("falls through to Docker bundling, not the API path, when --use-api=false", () => { - // Divergence this test guards: `if useApi { useDocker = false }` only forces - // the API path when the RESOLVED value is true. `--use-api=false` alone must leave - // `useDocker`'s own value (default true) in effect, routing to Docker — previously - // `useLocalBundler` keyed off flag *presence* (`explicitUseApi`), so typing - // `--use-api=false` silently forced the API path instead. + // `useDocker` forces the API path only when `useApi` resolves to true, so + // `--use-api=false` alone leaves `useDocker`'s own default (true) in effect. const out = mockOutput({ format: "text" }); const child = mockChildProcessSpawner({ exitCode: 1 }); const api = mockCommandPlatformApi({ @@ -1142,8 +1116,6 @@ describe("functions deploy", () => { useDocker: true, }); - // Docker was actually attempted (proves useLocalBundler resolved to true); - // it wasn't running, so the command fell back to the API and still succeeded. expect(child.spawned).toEqual([{ command: "docker", args: ["info"] }]); expect(out.stderrText).toContain("WARNING: Docker is not running\n"); expect(stripSgr(out.stdoutText)).toContain( @@ -1169,10 +1141,8 @@ describe("functions deploy", () => { }); describe("no-functions error styling (Go parity: deploy.go:35; structured output stays plain)", () => { - // Calls the shared `deployFunctions` with a marker `styleEmphasis` instead of - // going through `functionsDeploy`: the real hook (`bold`) is - // TTY-gated and therefore inert under vitest, so only an injected marker can - // deterministically observe which output formats apply the styling. + // Uses a marker `styleEmphasis` instead of `functionsDeploy`'s real `bold` + // hook, which is TTY-gated and inert under vitest. function setupNoFunctionsTest(format: "text" | "json") { const out = mockOutput({ format }); const api = mockCommandPlatformApi(); @@ -1291,9 +1261,6 @@ describe("functions deploy", () => { it.live( "fails before any Docker/API work on an unrelated Config.Validate branch (unsupported Postgres major version)", () => { - // Proves the WHOLE resolved config is validated, not just `project_id` - // — `db.major_version = 12` is a genuinely unrelated Go `Config.Validate` - // branch (`config.go:1034-1062`). const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const layer = Layer.mergeAll( @@ -1636,9 +1603,7 @@ describe("functions deploy", () => { "com.docker.compose.project=test-project", ]), ); - // Adjacent pairs, not merely present anywhere in argv — - // `buildFunctionsDockerRunArgs` emits the two `--label KEY=VALUE` - // pairs back-to-back, immediately before the image. + // Assert adjacent `--label KEY=VALUE` pairs, not merely present anywhere in argv. const cliLabelIndex = runCommand?.args.indexOf("--label") ?? -1; expect(runCommand?.args.slice(cliLabelIndex, cliLabelIndex + 4)).toEqual([ "--label", @@ -1646,9 +1611,7 @@ describe("functions deploy", () => { "--label", "com.docker.compose.project=test-project", ]); - // `-w <toDockerPath(projectRoot)>` — the bundler sets WorkingDir to - // the post-ChangeWorkDir cwd, which `deploy.ts`/`deploy.handler.ts` - // resolve to `cliSettings.workdir`, i.e. `tempRoot.current` in this test. + // `-w` sets WorkingDir to the resolved `cliSettings.workdir` (`tempRoot.current` here). const workingDirIndex = runCommand?.args.indexOf("-w") ?? -1; expect(runCommand?.args.slice(workingDirIndex, workingDirIndex + 2)).toEqual([ "-w", @@ -1666,11 +1629,6 @@ describe("functions deploy", () => { it.live( "does not climb to an ancestor project's config.toml for the Docker bundling path", () => { - // Established behavior: `supabase/config.toml` only ever resolves - // from the already-resolved workdir, with no ancestor climb — - // implemented via `loadFunctionsCliConfig`'s `search: false` (a - // real behavior change: deploy did NOT have this before CLI-1963, - // unlike download). const nestedWorkdir = join(tempRoot.current, "nested"); const out = mockOutput({ format: "text" }); const api = mockFunctionCreateApi(); @@ -1717,19 +1675,6 @@ describe("functions deploy", () => { it.live( "does not treat an ancestor project's deno.json as this project's own import map when --workdir names a config-less subdirectory of it", () => { - // CLI-2285: `inferFunctionsManifest`'s filesystem discovery previously - // climbed independently of the config load — no `search` option meant - // the package default (always climbing), regardless of `goConfigCompat`, - // while the config load (`loadFunctionsCliConfig`) has always used - // `search: false` for the CLI. Before this fix, a function - // directory with no `deno.json` of its own would still be reported as - // HAVING one — borrowed from an unrelated ANCESTOR project's own - // `deno.json` — because the manifest's filesystem walk climbed to find - // the ancestor's project root even though the config load never did. - // The resulting (wrong) import map path is then re-anchored under THIS - // project's own supabase dir, where no such file exists — failing the - // deploy outright with a spurious file-not-found, instead of correctly - // deploying the function with no import map. const nestedWorkdir = join(tempRoot.current, "nested"); const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ @@ -1770,14 +1715,12 @@ describe("functions deploy", () => { ); return Effect.gen(function* () { - // Ancestor project: a real config.toml plus a real function with - // BOTH an entrypoint and a deno.json. + // Ancestor project: a config.toml plus a function with an entrypoint and a deno.json. yield* Effect.tryPromise(() => writeCliConfig(tempRoot.current, 'project_id = "ancestor-project"\n'), ); yield* Effect.tryPromise(() => writeLocalFunction(tempRoot.current, "hello-world")); - // The sub-project actually deployed has its OWN entrypoint, but - // deliberately no deno.json of its own. + // The sub-project has its own entrypoint but no deno.json. yield* Effect.tryPromise(() => mkdir(join(nestedWorkdir, "supabase", "functions", "hello-world"), { recursive: true, @@ -1807,11 +1750,8 @@ describe("functions deploy", () => { describe("docker-not-running warning styling (Go parity: deploy.go:60; only WARNING: is styled)", () => { it.live("wraps only the WARNING token, not the rest of the fallback line", () => { - // Calls the shared `deployFunctions` with a marker `styleWarning` instead - // of going through `functionsDeploy`: the real hook (`yellow`) - // is TTY-gated and therefore inert under vitest, so only an injected - // marker can deterministically observe styling scope — same pattern as - // the "no-functions error styling" block above. + // Uses a marker `styleWarning` instead of `functionsDeploy`'s real + // `yellow` hook, which is TTY-gated and inert under vitest. const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ handler: (request) => { diff --git a/apps/cli/src/commands/functions/download/download.e2e.test.ts b/apps/cli/src/commands/functions/download/download.e2e.test.ts index 65831c7bd0..83718fdc19 100644 --- a/apps/cli/src/commands/functions/download/download.e2e.test.ts +++ b/apps/cli/src/commands/functions/download/download.e2e.test.ts @@ -1,14 +1,9 @@ import { describe, expect, test } from "vitest"; import { makeTempHome, runSupabase } from "../../../../tests/helpers/cli.ts"; -// Argument-validation negatives for `functions download`. A black-box -// subprocess test keeps these assertions valid regardless of where the -// mutex validation lives internally — it guards behavior, not -// implementation. Mirrors `deploy.e2e.test.ts`'s coverage for the sibling -// `--use-docker` default bug (CLI-1862). -// -// The mutex-conflict cases below fail before any network call (flag parsing -// / mutex validation), so no auth or linked project is required. +// Argument-validation negatives for `functions download`, mirroring +// `deploy.e2e.test.ts`. These fail before any network call, so no auth or +// linked project is required. const E2E_TIMEOUT_MS = 30_000; const SLUG = "download-e2e-basic"; @@ -37,28 +32,11 @@ describe("supabase functions download — argument validation", () => { }); } - // CLI-1862: `--use-docker` now defaults to `true`. Before the - // fix, that default was counted as "explicitly selected" by the mutex - // check, so passing `--use-api` alone was incorrectly rejected as - // conflicting with the (unpassed) `--use-docker` default. Covered in - // `download.integration.test.ts` ("does not treat the --use-docker default - // as conflicting with an explicit --use-api") via a mocked platform API - // instead of here: now that the mutex check is fixed, `--use-api` alone - // passes validation and proceeds to the native downloader, which calls the - // real Management API with the fake token/ref — an argument-validation - // test shouldn't depend on that network round-trip. + // `--use-api` alone (without --use-docker) is covered in + // download.integration.test.ts instead, since validating it here would + // require a real network round-trip to the Management API. - // CLI-1862: the TS→Go proxy call must not forward the now-defaulted - // `--use-docker` alongside an explicit `--legacy-bundle` — the Go binary - // re-parses this argv itself and enforces the same mutual exclusivity, so - // forwarding both breaks `--legacy-bundle` outright. Covered in - // `download.integration.test.ts` ("forwards only --legacy-bundle to the Go - // proxy...") via a mocked `GoProxy` instead of here: unlike - // `--use-api`, `--legacy-bundle` routes to the Go binary's `RunLegacy` - // downloader, which calls `InstallOrUpgradeDeno` before any network call - // (`apps/cli-go/internal/functions/download/download.go`). Each e2e run - // gets a fresh `SUPABASE_HOME`, so this would trigger a real, uncached - // Deno download from GitHub on every run — a real cross-boundary - // dependency this suite shouldn't take on to prove a pure TS-side routing - // decision. + // `--legacy-bundle` alone is covered in download.integration.test.ts + // instead: it routes to the Go binary's downloader, which would trigger a + // real Deno download from GitHub on every e2e run. }); diff --git a/apps/cli/src/commands/functions/download/download.handler.ts b/apps/cli/src/commands/functions/download/download.handler.ts index fdc5fd3c0a..38f9b941fd 100644 --- a/apps/cli/src/commands/functions/download/download.handler.ts +++ b/apps/cli/src/commands/functions/download/download.handler.ts @@ -40,14 +40,11 @@ export const functionsDownload = Effect.fn("functions.download")(function* ( rawArgs, goConfigCompat: functionsGoConfigCompat, edgeRuntimeVersion, - // Established styling: bold on the `Downloading function:` slug - // (stderr) — matches `bold`'s default TTY gate. + // Written to stderr, matching `bold`'s default TTY gate. styleEmphasis: (text) => bold(text), - // Established styling: aqua on the suggested `--legacy-bundle` command - // (stderr) — matches `aqua`'s default TTY gate. + // Written to stderr, matching `aqua`'s default TTY gate. styleAqua: (text) => aqua(text), - // Established styling: yellow on the `WARNING:` token before "Docker is - // not running" (stderr) — matches `yellow`'s default TTY gate. + // Written to stderr, matching `yellow`'s default TTY gate. styleWarning: (text) => yellow(text), resolveProjectRef: (projectRef) => resolver.resolve(projectRef).pipe( @@ -57,16 +54,10 @@ export const functionsDownload = Effect.fn("functions.download")(function* ( }), ), ), - // The delegated Go binary runs its own `Execute()` and would otherwise - // fire its own `cli_command_executed` on top of this command's own - // `withCommandTelemetry` wrapper. Suppress it so proxied - // invocations record exactly one event, matching Go (mirrors `db pull` / - // `db diff`'s delegated-call pattern). - // - // In machine-output mode the child's stdout is captured and discarded - // instead of inherited, matching `db pull`/`db diff`'s delegated-call - // pattern for the CLI-1546 "stdout is payload-only in machine mode" - // invariant — `downloadFunctions` emits the `Output` envelope itself. + // Suppresses the delegated binary's own `cli_command_executed` so a + // proxied invocation fires exactly one event. In machine-output mode its + // stdout is captured and discarded instead of inherited, since + // `downloadFunctions` emits the `Output` envelope itself. proxyDownload: (proxyFlags, projectRef, captureOutput) => { const args = makeGoProxyLegacyBundleArgs(proxyFlags.functionName, projectRef); const env = { SUPABASE_TELEMETRY_DISABLED: "1" }; @@ -78,8 +69,7 @@ export const functionsDownload = Effect.fn("functions.download")(function* ( }, }); - // `--legacy-bundle` emits its own final summary inside `downloadFunctions` - // (untouched by this refactor) — nothing left to do here on that path. + // `--legacy-bundle` emits its own final summary inside `downloadFunctions`. if (flags.legacyBundle) { return; } diff --git a/apps/cli/src/commands/functions/download/download.integration.test.ts b/apps/cli/src/commands/functions/download/download.integration.test.ts index 83a61980c2..018d917a4b 100644 --- a/apps/cli/src/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/commands/functions/download/download.integration.test.ts @@ -35,12 +35,9 @@ import { functionsDownload } from "./download.handler.ts"; const PROJECT_ID = "abcdefghijklmnopqrst"; /** - * Mutates the shared spawner options object from inside `onSpawn`, scoped to - * the `docker run ... unbundle` invocation specifically — every earlier - * Docker call (`info`, `network inspect`, `volume create`) in the same test - * already resolved by the time this fires, since `download.ts` awaits each - * child process sequentially, so this only ever affects the unbundle step's - * own exit code/stdio. + * Sets the spawner's exit code/stdio only for the `docker run ... unbundle` + * step; earlier Docker calls in the same test already resolved by the time + * this fires, since `download.ts` awaits each child process sequentially. */ function mockDockerUnbundle( opts: { @@ -66,18 +63,11 @@ function mockDockerUnbundle( } /** - * A real ENOENT-style spawn failure for the `docker run ... unbundle` step - * specifically — distinct from `mockDockerUnbundle`'s non-zero exit code, - * which models the container starting but the `unbundle` binary itself - * failing. This models `child_process.spawn` (or the container runtime - * binary) never starting at all, which `runChildProcess` surfaces as an - * `unknown` cause rather than an `{ exitCode, stdout, stderr }` result. - * Mirrors `container-cli.unit.test.ts`'s `mockSpawner({ bothMissing: - * true })`: failing both the `docker` and `podman` fallback attempts for the - * `run` step is what makes `spawnContainerCli` surface - * `containerRuntimeNotFoundMessage` instead of retrying indefinitely. - * Every other Docker call (`info`, `network inspect`, `volume create`) - * succeeds with exit code 0, so only the unbundle step itself fails. + * Models `docker run ... unbundle` never starting at all (ENOENT), distinct + * from `mockDockerUnbundle`'s non-zero exit code, which models the container + * starting but the `unbundle` binary failing. `runChildProcess` surfaces this + * as an `unknown` cause rather than `{ exitCode, stdout, stderr }`. Every + * other Docker call succeeds so only the unbundle step fails. */ function mockDockerRunSpawnFailure() { const spawned: Array<{ command: string; args: ReadonlyArray<string> }> = []; @@ -127,11 +117,8 @@ function mockDockerRunSpawnFailure() { const tempRoot = useTempWorkdir("supabase-functions-download-legacy-"); -// `withCommandTelemetry` threads `flags`/`command`/etc. through -// `CurrentAnalyticsContext`, not the direct `capture()` call args — mirrors -// the identical local helper in `command-telemetry.unit.test.ts`. -// The shared `mockAnalytics()` in tests/helpers/mocks.ts deliberately doesn't -// merge this context (most callers don't need it). +// `withCommandTelemetry` threads `flags`/`command` through +// `CurrentAnalyticsContext`, not `capture()`'s own args, so this merges it manually. function mockContextualAnalytics() { const captured: Array<{ event: string; properties: Record<string, unknown> }> = []; const layer = Layer.succeed( @@ -265,9 +252,8 @@ describe("functions download", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const proxy = mockProxy(); - // Non-empty stdout/stderr on the `docker run` step exercises both the - // text-mode stdout routing branch and the always-to-stderr container - // stderr branch in `downloadWithDockerUnbundle`. + // Non-empty stdout/stderr exercises both the text-mode stdout routing + // and always-to-stderr branches in `downloadWithDockerUnbundle`. const child = mockDockerUnbundle({ runStdout: ["unbundle: wrote index.ts"], runStderr: ["unbundle: warning about deno.json"], @@ -292,10 +278,6 @@ describe("functions download", () => { ); return Effect.gen(function* () { - // `useDocker: true` mirrors what the CLI parser now resolves to by - // default (CLI-1862) — no `--use-docker` flag appears in argv above. - // CLI-1963: this now runs the native Docker-unbundle path instead of - // delegating to the Go proxy. yield* functionsDownload({ ...baseFlags, useDocker: true }); expect(proxy.calls).toEqual([]); @@ -311,10 +293,8 @@ describe("functions download", () => { expect(out.stderrText).toContain("Downloading function: hello-world\n"); expect(out.stdoutText).toContain("unbundle: wrote index.ts\n"); expect(out.stderrText).toContain("unbundle: warning about deno.json\n"); - // Established behavior (CLI-1963 audit): unlike the server-side and - // `--legacy-bundle` paths, `downloadWithDockerUnbundle` never prints - // a "Downloaded Function ... from project ..." success line — - // guarded here against a future accidental regression. + // Unlike the server-side and --legacy-bundle paths, the native Docker + // path never prints a "Downloaded Function ..." success line. expect(out.stderrText).not.toContain("Downloaded Function"); // No `--debug` — the temp eszip file is removed after the run. expect( @@ -355,10 +335,8 @@ describe("functions download", () => { ); return Effect.gen(function* () { - // The CLI parser resolves `useDocker: true` here too (its default), - // even though only `--use-api` was passed explicitly. Neither the - // mutex check nor the routing decision should treat that default as - // if the user had asked for Docker. + // `useDocker: true` reflects the flag's own default, unaffected by + // the explicit `--use-api`. yield* functionsDownload({ ...baseFlags, useApi: true, useDocker: true }); expect(proxy.calls).toEqual([]); @@ -402,11 +380,8 @@ describe("functions download", () => { ); return Effect.gen(function* () { - // The override is value-based (`if useApi { useDocker = false }`), - // not presence-based. An explicit `--use-api=false` must not be - // treated like `--use-api` — it should leave the `--use-docker` - // default (true) in effect and still run the native Docker path - // (CLI-1963). + // The override is value-based, not presence-based: an explicit + // `--use-api=false` leaves `--use-docker`'s own default (true) in effect. yield* functionsDownload({ ...baseFlags, useApi: false, useDocker: true }); expect(proxy.calls).toEqual([]); @@ -427,8 +402,7 @@ describe("functions download", () => { const api = mockCommandPlatformApi(); const proxy = mockProxy(); // Non-empty container stdout exercises the machine-mode branch that - // routes it to stderr instead of stdout (CLI-1546: stdout stays - // payload-only in json/stream-json modes). + // routes it to stderr, keeping stdout payload-only. const child = mockDockerUnbundle({ runStdout: ["unbundle: wrote index.ts"] }); const layer = Layer.mergeAll( buildTestRuntime({ @@ -452,10 +426,6 @@ describe("functions download", () => { ); return Effect.gen(function* () { - // CLI-1963: `--use-docker` now runs the native Docker-unbundle path; - // this asserts the JSON envelope this command emits itself still - // shows up correctly, with no delegated Go child's stdout to worry - // about capturing. yield* functionsDownload({ ...baseFlags, useDocker: true }); expect(proxy.calls).toEqual([]); @@ -523,9 +493,8 @@ describe("functions download", () => { (spawned) => spawned.command === "docker" && spawned.args[0] === "run", ), ).toHaveLength(2); - // The edge-runtime image is resolved/pulled once for the whole - // invocation, not once per function — see `PulledEdgeRuntimeImage`'s - // doc comment in `download.ts`. + // The edge-runtime image is resolved/pulled once per invocation, not + // once per function; see `PulledEdgeRuntimeImage` in `download.ts`. expect( child.spawned.filter( (spawned) => @@ -574,9 +543,8 @@ describe("functions download", () => { return Effect.gen(function* () { yield* functionsDownload({ ...baseFlags, useDocker: true }); - // Go: `extractOne` (`download.go:260-266`) — bind order and network - // reuse the same primitives `deploy.ts`'s own Docker-bundling path - // already uses. + // Bind order and network reuse the same primitives as `deploy.ts`'s + // own Docker-bundling path. expect(child.spawned.find((spawned) => spawned.args[0] === "network")).toEqual({ command: "docker", args: ["network", "inspect", `supabase_network_${PROJECT_ID}`], @@ -611,7 +579,7 @@ describe("functions download", () => { expect(runCommand?.args).toContain(`${functionsDir}:/home/deno:rw`); expect(runCommand?.args).toContain("--network"); expect(runCommand?.args).toContain(`supabase_network_${PROJECT_ID}`); - // The unbundle tail is always the LAST 6 args regardless of whether + // The unbundle tail is always the last 6 args regardless of whether // `--add-host` (Linux-only) was inserted before it. expect(runCommand?.args.slice(-6)).toEqual([ `public.ecr.aws/${dockerfileServiceImage("edgeruntime")}`, @@ -625,12 +593,10 @@ describe("functions download", () => { }); it.live("omits the named Deno cache volume bind on Bitbucket", () => { - // Established behavior: the named-volume bind is dropped entirely on - // Bitbucket rather than just skipping its explicit creation — + // The bind is dropped entirely, not just its explicit creation step: // `docker run -v <name>:...` would otherwise still implicitly create the - // named volume, which Bitbucket's restricted Docker environment doesn't - // allow (review round on CLI-1963's `functions download` port; - // `deploy.ts`'s `buildDockerBinds` already applies this same carve-out). + // volume, which Bitbucket's restricted Docker environment disallows. + // `deploy.ts`'s `buildDockerBinds` applies the same carve-out. const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const proxy = mockProxy(); @@ -690,12 +656,9 @@ describe("functions download", () => { }); it.live("requests the raw eszip body instead of a negotiated JSON response", () => { - // `v1GetAFunctionBody`'s generated contract marks its response - // `kind: "json"`, so `executeRaw` would otherwise default to - // `Accept: application/json` (`buildRequest`'s unconditional `acceptJson` - // for json-kind operations) and risk a negotiated JSON response instead - // of the raw eszip body an un-overridden request receives (review round - // on CLI-1963's `functions download` port). + // The generated contract marks this response `kind: "json"`, so + // `executeRaw` would otherwise default to `Accept: application/json` and + // risk a negotiated response instead of the raw eszip body. const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const proxy = mockProxy(); @@ -756,9 +719,9 @@ describe("functions download", () => { ); return Effect.gen(function* () { - // `--network-id` is a persistent root flag (`cmd/root.go:328`), not - // registered on `functions download` itself — `lastExplicitLongFlagValue` - // scans the whole argv unscoped. + // `--network-id` is a persistent root flag, not registered on + // `functions download` itself, so `lastExplicitLongFlagValue` scans the + // whole argv unscoped. yield* functionsDownload({ ...baseFlags, useDocker: true }); expect(child.spawned.find((spawned) => spawned.args[0] === "network")).toEqual({ @@ -774,10 +737,6 @@ describe("functions download", () => { it.live( "falls back to the generated network name when --network-id is passed with an empty value", () => { - // The network is only overridden when the value is non-empty — an - // explicit-but-empty `--network-id=` must fall through to the - // generated network name just like an omitted flag (review round on - // CLI-1963's `functions download` port). const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const proxy = mockProxy(); @@ -817,12 +776,6 @@ describe("functions download", () => { ); it.live("honors the final occurrence of a repeated --network-id flag", () => { - // pflag/viper string flags are shared-variable, last-`Set()`-wins - // (confirmed empirically: `pflag.FlagSet.Parse` on - // `--network-id old --network-id custom-network` resolves to - // `custom-network`) — `lastExplicitLongFlagValue` must keep scanning past the - // first match instead of returning early (review round on CLI-1963's - // `functions download` port). const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const proxy = mockProxy(); @@ -863,10 +816,6 @@ describe("functions download", () => { it.live( "falls back to the generated network name when the final --network-id occurrence is empty", () => { - // Same last-wins rule as above, applied to the non-empty-value gate: a - // non-empty default followed by an explicit-but-empty override must - // fall through to the generated network name, not the earlier - // non-empty value. const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const proxy = mockProxy(); @@ -907,13 +856,9 @@ describe("functions download", () => { it.live( "does not climb to an ancestor project's config.toml for the Docker download path", () => { - // Established behavior: `supabase/config.toml` only ever resolves from - // the already-resolved workdir, with no ancestor climb — implemented - // here by `resolveEdgeRuntimeImage`'s `search: false` (review round on - // CLI-1963's `functions download` port). A nested workdir with no - // `supabase/config.toml` of its own must fall back to `--project-ref` - // for network/volume naming, not an ancestor project's configured - // `project_id`, even though `cliSettings.workdir` sits right inside one. + // No ancestor climb: `resolveEdgeRuntimeImage` uses `search: false`, so + // a nested workdir without its own config.toml falls back to + // `--project-ref` rather than an ancestor's `project_id`. const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const proxy = mockProxy(); @@ -965,12 +910,8 @@ describe("functions download", () => { ); it.live("prefers config.toml over a stray config.json for the Docker download path", () => { - // Established behavior: there is no concept of a JSON project config - // file — it always resolves `supabase/config.toml`, implemented here by - // `resolveEdgeRuntimeImage`'s `tomlOnly: true` (review round on - // CLI-1963's `functions download` port). A workdir with both files must resolve `project_id` from - // `config.toml`, not prefer the JSON file as the package loader - // otherwise would. + // Uses `resolveEdgeRuntimeImage`'s `tomlOnly: true`, so a workdir with + // both files resolves `project_id` from config.toml, not config.json. const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const proxy = mockProxy(); @@ -1021,13 +962,9 @@ describe("functions download", () => { }); it.live("skips network creation for a container: network mode", () => { - // Established behavior: user-defined network detection explicitly - // excludes container-mode networks — `--network-id container:redis` - // attaches to another container's network stack, so - // `DockerNetworkCreateIfNotExists` never inspects or creates a network - // for it, and the mode is passed straight through to - // `docker run --network` (review round on CLI-1963's `functions - // download` port). + // `--network-id container:redis` attaches to another container's network + // stack, so no network is inspected or created for it; the mode is + // passed straight through to `docker run --network`. const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const proxy = mockProxy(); @@ -1064,11 +1001,9 @@ describe("functions download", () => { }); it.live("does not double-prefix an already v-prefixed edge-runtime-version pin", () => { - // Established behavior: the pin file's raw content is appended verbatim - // after the image's `:`, so a pin already carrying its own `v` prefix (a - // legitimate form — see `edge-runtime-image.unit.test.ts`'s own - // `"v9.9.9"` fixture) must not be prepended with a second `v` (review - // round on CLI-1963's `functions download` port). + // The pin file's raw content is appended verbatim after the image's `:`, + // so an already-`v`-prefixed pin (see edge-runtime-image.unit.test.ts) + // must not get a second `v`. const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const proxy = mockProxy(); @@ -1146,10 +1081,6 @@ describe("functions download", () => { it.live( "removes the temporary eszip file when --debug=false overrides the flag's own presence", () => { - // This gates on the resolved boolean value, so an explicit - // `--debug=false` resolves to `false` (cleanup runs) — a presence-only - // check would get this backwards and treat `--debug=false` like - // `--debug` (review round on CLI-1963's `functions download` port). const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const proxy = mockProxy(); @@ -1188,11 +1119,6 @@ describe("functions download", () => { it.live( "fails on an invalid project config before falling back when Docker is not running", () => { - // Established behavior: the config load happens unconditionally at the - // very top, before checking whether Docker is running — an invalid - // `supabase/config.toml` must fail up front instead of silently - // falling through to the server-side path's API/filesystem side - // effects (review round on CLI-1963's `functions download` port). const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const proxy = mockProxy(); @@ -1285,10 +1211,8 @@ describe("functions download", () => { const proxy = mockProxy(); const child = mockDockerUnbundle({ runExitCode: 1, - // Established behavior: the scanner requires a full-line, - // case-insensitive match — a line merely containing the phrase as - // a substring (e.g. "error: invalid eszip v2 header") does not - // fire the suggestion. + // Full-line, case-insensitive match required; a substring like + // "error: invalid eszip v2 header" would not fire the suggestion. runStderr: ["invalid eszip v2"], }); const layer = Layer.mergeAll( @@ -1429,10 +1353,9 @@ describe("functions download", () => { ); expect(child.spawned.some((spawned) => spawned.args[0] === "volume")).toBe(false); expect(child.spawned.some((spawned) => spawned.args[0] === "run")).toBe(false); - // Fix (CLI-1963 review): `Effect.ensuring` wraps the whole - // Docker-extraction sequence, so the temp eszip written just before it - // is still cleaned up even though the failure happened before Docker - // ever ran — not only after a successful `runChildProcess` call. + // `Effect.ensuring` wraps the whole Docker-extraction sequence, so the + // temp eszip is cleaned up even though the failure happened before + // Docker ever ran. expect( existsSync(join(tempRoot.current, "supabase", ".temp", "output_hello-world.eszip")), ).toBe(false); @@ -1469,11 +1392,9 @@ describe("functions download", () => { return Effect.gen(function* () { const error = yield* functionsDownload({ ...baseFlags, useDocker: true }).pipe(Effect.flip); - // Distinct from `ensureDockerNetwork`/`ensureDockerNamedVolume` - // failures (asserted above), which already self-describe and must - // NOT gain this prefix — a bare spawn/runtime-not-found failure from - // `runChildProcess` itself carries no context of its own about which - // command was running, so `withDockerStepFailure` adds one. + // Distinct from the self-describing `ensureDockerNetwork`/ + // `ensureDockerNamedVolume` failures above: a bare spawn/runtime-not-found + // failure carries no context of its own, so `withDockerStepFailure` adds one. expect(error).toBeInstanceOf(Error); expect((error as Error).message).toBe( `failed to run the edge-runtime unbundle container: ${containerRuntimeNotFoundMessage}`, @@ -1500,10 +1421,9 @@ describe("functions download", () => { : Effect.succeed(jsonResponse(request, 200, {})), }); const proxy = mockProxy(); - // Deterministic stand-in for `emptyEnv()`'s real `ChildProcessSpawner` - // (via `BunServices`, pulled in by `buildTestRuntime`) — `useDocker: - // true` still probes `docker info` even though this project has no - // functions to download, so this must not spawn a real `docker` process. + // Stand-in for the real `ChildProcessSpawner`: `useDocker: true` still + // probes `docker info` even with no functions to download, so this must + // not spawn a real `docker` process. const child = mockChildProcessSpawner({ exitCode: 0 }); const layer = Layer.mergeAll( buildTestRuntime({ @@ -1526,10 +1446,6 @@ describe("functions download", () => { ); return Effect.gen(function* () { - // An empty project has nothing to delegate — this must match the - // native path's "No functions found." short-circuit instead of - // still invoking the Go/Docker child and reporting a misleading - // "Downloaded Edge Function source." success with an empty list. yield* functionsDownload({ ...baseFlags, functionName: Option.none(), @@ -1580,10 +1496,6 @@ describe("functions download", () => { ); return Effect.gen(function* () { - // The pre-flight list failure must be reported before any download - // side effect — the delegated proxy must never be invoked (CLI-1862 - // review: a listing failure after a successful delegated download - // must not mask that success). const exit = yield* functionsDownload({ ...baseFlags, functionName: Option.none(), @@ -1597,12 +1509,9 @@ describe("functions download", () => { }); it.live("fails loudly instead of silently dropping a malformed function-list entry", () => { - // Established behavior: a list entry with no "slug" key decodes to the - // zero value "" and then fails `ValidateFunctionSlug` in `downloadAll`, - // rather than being dropped from the list. A malicious/compromised API - // response returning `[{}]` must surface an error here too, never "No - // functions found." nor a silent partial download (review round on - // CLI-1963's `functions download` port). + // A missing "slug" key decodes to the empty string, then fails + // `ValidateFunctionSlug` in `downloadAll` rather than being silently + // dropped from the list. const out = mockOutput({ format: "json" }); const api = mockCommandPlatformApi({ handler: (request) => @@ -1650,14 +1559,9 @@ describe("functions download", () => { it.live( "attaches the first function's on-disk directory to the failure when a later function's download fails", () => { - // Reproduces a reviewer-reported repro on the shared download loop - // (`downloadFunctions`, `download.ts`): the first function downloads - // successfully, the second function's body request returns HTTP 500 — - // the resulting failure must carry the first function's already-written - // directory (via `writtenSoFar`) instead of reporting no progress at - // all, since a caller three steps up (`pull.aggregate.ts`'s - // `hasWrittenSoFar`) needs it to avoid claiming `written: []` on a - // partially-successful run. + // The failure must carry the first function's already-written directory + // via `writtenSoFar`, since `pull.aggregate.ts`'s `hasWrittenSoFar` + // needs it to avoid claiming `written: []` on a partial success. const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ handler: (request) => @@ -1685,8 +1589,8 @@ describe("functions download", () => { ); return Effect.gen(function* () { - // `useDocker: false` (the base flags' default) forces the native - // server-side path this repro targets, not the Docker-unbundle one. + // `useDocker: false` forces the native server-side path, not the + // Docker-unbundle one. const error = yield* functionsDownload({ ...baseFlags, functionName: Option.none(), @@ -1697,7 +1601,6 @@ describe("functions download", () => { expect((error as unknown as { writtenSoFar?: ReadonlyArray<string> }).writtenSoFar).toEqual( [resolve(tempRoot.current, "supabase", "functions", "hello-world")], ); - // The first function's files really are on disk despite the failure. expect( yield* Effect.tryPromise(() => readFile( @@ -1734,10 +1637,9 @@ describe("functions download", () => { ); return Effect.gen(function* () { - // `useDocker: true` mirrors the CLI parser's default (CLI-1862) even - // though only `--legacy-bundle` was passed explicitly. The Go proxy - // call must not forward both, or the Go binary's own - // MarkFlagsMutuallyExclusive rejects the combination. + // `useDocker: true` mirrors the flag's own default even though only + // `--legacy-bundle` was passed; forwarding both would make the Go + // binary's own MarkFlagsMutuallyExclusive reject the combination. yield* functionsDownload({ ...baseFlags, useDocker: true, legacyBundle: true }); expect(proxy.calls).toEqual([ @@ -1777,9 +1679,8 @@ describe("functions download", () => { ); return Effect.gen(function* () { - // `useDocker: true` is the real default (CLI-1862). Before this fix, - // slug validation only ran on the native path, so a malformed slug - // would sail past it and straight into the Go proxy argv. + // `useDocker: true` reflects the flag's own default; slug validation + // must run before the Go proxy sees this argv. const exit = yield* functionsDownload({ ...baseFlags, functionName: Option.some("../../etc"), @@ -1919,9 +1820,6 @@ describe("functions download", () => { it.live( "fails before any Docker/API work on an unrelated Config.Validate branch (unsupported Postgres major version)", () => { - // Proves the WHOLE resolved config is validated, not just `project_id` - // — `db.major_version = 12` is a genuinely unrelated Go `Config.Validate` - // branch (`config.go:1034-1062`). const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); const proxy = mockProxy(); @@ -2182,18 +2080,15 @@ describe("functions download", () => { (spawned) => spawned.args[0] === "image" && spawned.args[1] === "inspect", ), ).toHaveLength(1); - // Proves the registry came from the project dotenv file, not the - // ambient shell environment. expect(process.env["SUPABASE_INTERNAL_IMAGE_REGISTRY"]).toBeUndefined(); }).pipe(Effect.provide(layer)); }, ); it.live("falls back to the GHCR candidate when the ECR image cannot be inspected", () => { - // Simulates a registry-candidate fallback with no real pull/retry: the - // ECR `docker image inspect` MISSes cleanly (non-zero exit, "not found" - // stderr), so `hasLocalImage` moves straight to the next candidate - // instead of ever entering the sleeping pull-retry loop. + // Simulates a clean cache miss (non-zero exit, "not found" stderr) so + // `hasLocalImage` moves to the next candidate without entering the + // sleeping pull-retry loop. const spawnerOpts: { exitCode?: number; stderr?: string[]; @@ -2300,10 +2195,8 @@ describe("functions download", () => { describe("docker-not-running warning styling (Go parity: download.go:146; only WARNING: is styled)", () => { it.live("wraps only the WARNING token, not the rest of the fallback line", () => { - // Calls the shared `downloadFunctions` with a marker `styleWarning` - // instead of going through `functionsDownload`: the real hook - // (`yellow`) is TTY-gated and therefore inert under vitest, so - // only an injected marker can deterministically observe styling scope. + // Uses a marker `styleWarning` instead of `functionsDownload`'s real + // `yellow` hook, which is TTY-gated and inert under vitest. const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ handler: (request) => diff --git a/apps/cli/src/commands/functions/download/download.live.test.ts b/apps/cli/src/commands/functions/download/download.live.test.ts index f5515215a1..8fca161cbe 100644 --- a/apps/cli/src/commands/functions/download/download.live.test.ts +++ b/apps/cli/src/commands/functions/download/download.live.test.ts @@ -21,10 +21,6 @@ async function cleanupFunction( } describe("functions download (live)", () => { - // End to end artifact round trip: deploy a function whose source carries a - // unique marker, remove every local trace of it, download it back from the - // live project, and assert the marker survived — local source → platform - // bundle → unbundled back to disk. test("round-trips a deployed function's source through the live project", async ({ cli, project, @@ -52,22 +48,17 @@ describe("functions download (live)", () => { false, ); - // The unbundle container creates missing output directories as root, - // which the CI runner user cannot remove at workspace teardown. - // Pre-create the directory host-owned and world-writable so any - // container uid can write into it — the deploy bundler pre-creates its - // container output dir the same way. This works because the function is - // flat: root-written files land directly in the host-owned dir, where - // unlinking needs only write access on the parent. + // The unbundle container writes as root; pre-create the directory + // host-owned and world-writable (mirroring the deploy bundler's own + // pre-created output dir) so the CI runner can remove it afterward. await mkdir(directory, { recursive: true }); await chmod(directory, 0o777); const downloaded = await cli(["functions", "download", slug, "--project-ref", project.ref]); const downloadOutput = `stdout:\n${downloaded.stdout}\nstderr:\n${downloaded.stderr}`; expect(downloaded.exitCode, downloadOutput).toBe(0); - // The docker unbundle path banners "Downloading function:" (lowercase f) - // while the server-side fallback banners "Downloading Function:" — pin - // the docker journey positively so a silent downgrade fails loudly. + // Lowercase "function:" pins the docker unbundle path specifically, + // distinct from the server-fallback's "Downloading Function:". expect(downloaded.stderr, downloadOutput).toContain("Downloading function:"); expect(existsSync(entrypoint), downloadOutput).toBe(true); const roundTripped = await readFile(entrypoint, "utf8"); diff --git a/apps/cli/src/commands/functions/new/new.errors.ts b/apps/cli/src/commands/functions/new/new.errors.ts index b016822208..db7348dcee 100644 --- a/apps/cli/src/commands/functions/new/new.errors.ts +++ b/apps/cli/src/commands/functions/new/new.errors.ts @@ -35,10 +35,8 @@ export class FunctionsNewWriteError extends Data.TaggedError("FunctionsNewWriteE /** * The resolved `--workdir`/`SUPABASE_WORKDIR` doesn't exist or isn't a - * directory (`validateWorkdirIsDirectory`). Only reachable when the - * user explicitly set it — beats slug validation and every filesystem write, - * so a typo'd `--workdir` can never scaffold a fresh `supabase/functions/…` - * tree (plus a new `config.toml`) at the wrong path. + * directory. Checked before slug validation and any filesystem write, so a + * typo'd `--workdir` can never scaffold a fresh tree at the wrong path. */ export class FunctionsNewWorkdirError extends Data.TaggedError("FunctionsNewWorkdirError")<{ readonly message: string; @@ -49,10 +47,8 @@ export class FunctionsNewWorkdirError extends Data.TaggedError("FunctionsNewWork } /** - * Maps an arbitrary thrown cause from a filesystem write to a typed - * `FunctionsNewWriteError` tagged with the given `path`. Used by the IDE - * settings writers, where the same shape is needed for both the `.vscode` and - * `.idea/deno.xml` targets. + * Maps a thrown filesystem-write cause to a typed `FunctionsNewWriteError` + * tagged with `path`. Shared by the `.vscode` and `.idea/deno.xml` writers. */ export function mapFunctionsNewWriteError(path: string) { return (cause: unknown): FunctionsNewWriteError => diff --git a/apps/cli/src/commands/functions/new/new.handler.ts b/apps/cli/src/commands/functions/new/new.handler.ts index d012362184..ebaa08b690 100644 --- a/apps/cli/src/commands/functions/new/new.handler.ts +++ b/apps/cli/src/commands/functions/new/new.handler.ts @@ -38,13 +38,8 @@ function escapeRegExp(text: string): string { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -// Established behavior checks the *parsed* config map after a best-effort -// config load. This intentionally scans the raw TOML text instead: -// config loading here is non-fatal (a malformed `config.toml` must still -// allow scaffolding), so a raw-text section scan is the deterministic -// fallback that does not depend on a successful parse. The strict -// `^\s*\[functions\.<slug>\]\s*$` anchoring keeps this in practical -// lock-step with the parsed-map check for all well-formed configs. +// Scans the raw TOML text rather than a parsed config map, since a malformed +// config.toml here is non-fatal and must still allow scaffolding. function readDeclaredFunctionSlugs(contents: string): ReadonlySet<string> { const slugs = new Set<string>(); const pattern = /^\s*\[functions\.([^\]\s]+)\]\s*$/gm; @@ -109,19 +104,16 @@ const resolveTemplateInputs = Effect.fnUntraced(function* ( }; }); -// Console-driven IDE-settings prompt. Only invoked in text mode — the -// caller gates on `output.format === "text"` so json / stream-json runs stay payload-only and +// Only invoked in text mode: json/stream-json runs stay payload-only and // never scaffold IDE settings as an undisclosed side effect. const promptForIdeSettings = Effect.fnUntraced(function* (workdir: string) { const output = yield* Output; - // `--yes` OR `SUPABASE_YES`. + // Also honors `SUPABASE_YES`, not just the `--yes` flag. const yes = yield* resolveYes; - // Both questions route through `promptYesNo`: `--yes`/ - // `SUPABASE_YES` auto-accepts VS Code with the `[Y/n] y` stderr echo; a - // non-TTY stdin prints the label and scans one piped line (100ms), so - // `echo n | supabase functions new` declines VS Code and falls through to - // the IntelliJ question instead of hardcoding the default. + // `promptYesNo` auto-accepts VS Code under `--yes`/`SUPABASE_YES`, and + // otherwise scans one piped line from non-TTY stdin, so `echo n | ...` + // declines VS Code and falls through to the IntelliJ question. if (yield* promptYesNo(output, yes, "Generate VS Code settings for Deno?", true)) { yield* writeVscodeConfig(workdir).pipe(Effect.mapError(mapFunctionsNewWriteError(".vscode"))); return; @@ -151,10 +143,9 @@ const appendFunctionConfig = Effect.fnUntraced(function* ( return; } - // Append (never rewrite) the rendered section, matching Go's - // `os.OpenFile(ConfigPath, O_WRONLY|O_CREATE|O_APPEND)`: the existing file is left - // byte-for-byte untouched and a partial write can never truncate it. The template begins - // with a newline, so it attaches cleanly whether or not the file ends with one. + // Appends rather than rewrites, so the existing file is left byte-for-byte + // untouched and a partial write can never truncate it. The template starts + // with a newline, so it attaches cleanly regardless of the file's trailing newline. yield* fs .writeFileString(configPath, renderFunctionsNewConfig(slug, verifyJwt), { flag: "a" }) .pipe( @@ -263,8 +254,7 @@ export const functionsNew = Effect.fn("functions.new")(function* (flags: Functio ); } - // IDE scaffolding is a human-facing nicety: only offer it in text mode so json / - // stream-json runs stay payload-only and never write IDE files as an undisclosed side effect. + // Text mode only: json/stream-json runs stay payload-only. if (isFirstFunction && output.format === "text") { yield* promptForIdeSettings(cliSettings.workdir); } diff --git a/apps/cli/src/commands/functions/new/new.integration.test.ts b/apps/cli/src/commands/functions/new/new.integration.test.ts index 446a4140d0..92bc7a1dc9 100644 --- a/apps/cli/src/commands/functions/new/new.integration.test.ts +++ b/apps/cli/src/commands/functions/new/new.integration.test.ts @@ -238,8 +238,6 @@ describe("functions new integration", () => { const { layer, out, workdir } = setup({ yes: false }); return Effect.gen(function* () { yield* functionsNew({ functionName: "with-env-yes", auth: "apikey" }); - // Established `--yes` branch bytes, reached through the env var — - // not just the --yes flag. expect(out.stderrText).toContain("Generate VS Code settings for Deno? [Y/n] y"); expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(true); }).pipe( @@ -254,9 +252,8 @@ describe("functions new integration", () => { }); it.live("piped `n` then `y` declines VS Code and writes IntelliJ settings (Go parity)", () => { - // Established non-TTY prompt behavior scans one piped line per - // question, so `printf 'n\ny\n'` answers VS Code=no, IntelliJ=yes - // instead of hardcoding the VS Code default. + // Scans one piped line per question, so "n\ny\n" answers VS Code=no, + // IntelliJ=yes. const { layer, out, workdir } = setup({ stdinIsTty: false, stdinInput: "n\ny\n" }); return Effect.gen(function* () { yield* functionsNew({ functionName: "piped-idea", auth: "apikey" }); @@ -292,8 +289,6 @@ describe("functions new integration", () => { auth: "apikey", }); expect(out.stdoutText).toBe(""); - // Machine formats are payload-only: the IDE prompt is suppressed and no IDE settings - // are scaffolded as an undisclosed side effect. expect(out.stderrText).not.toContain("Generate VS Code settings"); expect(existsSync(join(workdir, ".vscode", "settings.json"))).toBe(false); expect(existsSync(join(workdir, ".idea", "deno.xml"))).toBe(false); @@ -350,10 +345,6 @@ describe("functions new integration", () => { it.live( "fails without scaffolding anything when --workdir names a directory that does not exist at all", () => { - // Before this fix, a typo'd --workdir would have silently created a - // fresh supabase/functions/... tree (plus a new config.toml) at the - // wrong path — the critical safety assertion here is that NOTHING was - // scaffolded once the workdir check fails first. const badWorkdir = join(tempRoot.current, "does-not-exist"); const { layer, telemetry } = setup({ workdir: badWorkdir, explicitWorkdir: true }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/functions/serve/serve.integration.test.ts b/apps/cli/src/commands/functions/serve/serve.integration.test.ts index 0b2928ebee..c752f9372e 100644 --- a/apps/cli/src/commands/functions/serve/serve.integration.test.ts +++ b/apps/cli/src/commands/functions/serve/serve.integration.test.ts @@ -101,14 +101,12 @@ vi.mock("../../../shared/functions/functions-docker.ts", async () => { Effect.sync(() => { deployMockState.volumeCalls.push({ volumeName, projectId }); }), - // Stubbed to the pure registry-mapping step only, skipping the actual - // cache-check/pull: the real implementation - // (`makeDockerImageResolver`) does `docker image inspect`/`docker - // pull` via the real `ChildProcessSpawner` directly (not through this - // file's mocked `runChildProcess` below), so leaving it real here would - // insert un-mocked spawns — and real 4s/8s retry backoffs on a miss — - // into every test that reaches container start. Registry - // resolution/retry has its own coverage in `functions-docker.unit.test.ts`. + // Stubbed to the pure registry-mapping step, skipping the real + // cache-check/pull (`docker image inspect`/`pull` via the real + // `ChildProcessSpawner`, not this file's mocked `runChildProcess`), + // which would otherwise insert real 4s/8s retry backoffs into every + // test that reaches container start. See `functions-docker.unit.test.ts` + // for that coverage. resolveFunctionsDockerImage: ( image: string, projectEnvValues?: Readonly<Record<string, string>>, @@ -720,11 +718,10 @@ describe("functions serve integration", () => { expect(dockerRun.args).toContain("supabase_network_test-project"); expect(dockerRun.args).toContain("--add-host"); expect(dockerRun.args).toContain("host.docker.internal:host-gateway"); - // The pin's content is applied VERBATIM as the tag (Go's - // `replaceImageTag`, `pkg/config/utils.go:81-84`) — a bare pin stays - // bare, no `v` synthesized. + // The pin's content is applied verbatim as the tag: a bare pin + // stays bare, no `v` synthesized. expect(dockerRun.args).toContain("public.ecr.aws/supabase/edge-runtime:1.73.13"); - // The main service is `docker cp`-streamed in, never a single-file host bind (#6254). + // The main service is `docker cp`-streamed in, never a single-file host bind. expect( extractFlagValues(dockerRun.args, "-v").some((value) => value.includes(":/root/index.ts"), @@ -770,7 +767,7 @@ describe("functions serve integration", () => { // The reload must carry bring-up's `--nginx-conf`; a bare `kong reload` // re-renders nginx.conf from Kong's default template and drops the - // `email_templates` server GoTrue fetches (issue #6059). + // `email_templates` server GoTrue fetches. expect(deployMockState.runCalls).toContainEqual({ command: "docker", args: [ @@ -955,7 +952,7 @@ describe("functions serve integration", () => { yield* Effect.promise(() => writeProjectFile(join("supabase", "functions", ".env"), ["HELLO=WORLD", ""].join("\n")), ); - // Simulate a stale directory left behind by an earlier run that DID have multiline secrets. + // Simulates a stale directory left behind by an earlier run that had multiline secrets. yield* Effect.promise(async () => { await mkdir(join(staleMultilineEnvDir, "values"), { recursive: true, mode: 0o700 }); await writeFile(join(staleMultilineEnvDir, "multiline-env.sh"), "stale script\n"); @@ -1661,19 +1658,6 @@ describe("functions serve integration", () => { it.live( "does not let an ancestor project's deno.json get misattributed to this project's own function when --workdir names a config-less subdirectory of it", () => { - // CLI-2285: `resolveServeConfig` used to pass NO `search` option to - // `inferFunctionsManifest`, so it always climbed ancestors (the - // package default) regardless of `goConfigCompat`, while the config - // load right next to it already used `search: false` for the legacy - // shell. A function directory with no deno.json of its own would - // still be reported as HAVING one — borrowed from an unrelated - // ANCESTOR project's own deno.json of the same slug — because the - // manifest's filesystem walk climbed to find the ancestor's project - // root even though the config load never did. The borrowed import map - // path is then re-anchored under THIS project's own supabase dir, - // where no such file exists. Same fix, same shape of regression test, - // as deploy.integration.test.ts's "does not treat an ancestor - // project's deno.json as this project's own import map…" test. deployMockState.runHandler = (command, args) => { if (command !== "docker") { throw new Error(`unexpected process: ${command}`); @@ -1697,18 +1681,16 @@ describe("functions serve integration", () => { const nestedWorkdir = join(tempRoot.current, "nested", "dir"); return Effect.gen(function* () { - // Ancestor project: a real config.toml plus a real function with - // BOTH an entrypoint and a deno.json, at the same slug the - // sub-project below serves. + // Ancestor project: a config.toml plus a function with an entrypoint + // and a deno.json, at the same slug the sub-project below serves. yield* Effect.promise(() => writeCliConfig('project_id = "ancestor-project"\n')); yield* Effect.promise(() => writeFunctionFile("hello", "index.ts", 'Deno.serve(() => new Response("ancestor"))\n'), ); yield* Effect.promise(() => writeFunctionFile("hello", "deno.json", '{"imports":{}}\n')); - // The sub-project actually served has its OWN entrypoint, but - // deliberately no deno.json of its own — and no config.toml either, - // which is what makes it "config-less" relative to the ancestor. + // The sub-project has its own entrypoint but no deno.json or + // config.toml, making it "config-less" relative to the ancestor. yield* Effect.promise(() => mkdir(join(nestedWorkdir, "supabase", "functions", "hello"), { recursive: true }), ); @@ -1741,9 +1723,8 @@ describe("functions serve integration", () => { const functionsConfig = JSON.parse( functionsConfigEntry.slice("SUPABASE_INTERNAL_FUNCTIONS_CONFIG=".length), ); - // The sub-project's own "hello" is still served — not silently - // dropped — but with no import map, since the ancestor's deno.json - // must never be borrowed for it. + // "hello" is still served, just with no import map, since the + // ancestor's deno.json must never be borrowed for it. expect(functionsConfig).toHaveProperty("hello"); expect(functionsConfig.hello).not.toHaveProperty("importMapPath"); }); @@ -1819,9 +1800,8 @@ describe("functions serve integration", () => { (call) => call.command === "docker" && call.args[0] === "create", ), ).toHaveLength(2); - // The file-change line prints the fsnotify op token Go prints - // (`event.Op.String()`, `internal/functions/serve/watcher.go:100`) — - // WRITE/CREATE/REMOVE — not the internal event-type name. + // Prints the fsnotify op token (WRITE/CREATE/REMOVE), not the + // internal event-type name. expect(out.stderrText).toContain( `File change detected: ${join(tempRoot.current, "supabase", "functions", "hello", "index.ts")} (WRITE)`, ); @@ -1829,8 +1809,7 @@ describe("functions serve integration", () => { `File change detected: ${join(tempRoot.current, "supabase", "functions", "hello", "helper.ts")} (CREATE)`, ); - // `functions serve`'s restart wrapper (`startEdgeRuntime`, Go's - // `restartEdgeRuntime`) reloads Kong after each successful bring-up — + // The restart wrapper reloads Kong after each successful bring-up: // once for the initial start, once for the file-change-triggered restart. expect( deployMockState.runCalls.filter( @@ -1902,12 +1881,10 @@ describe("functions serve integration", () => { it.live("does not remove the existing runtime when interrupted before startup owns it", () => { const processControl = mockQueuedProcessControl(); - // Block startup at the DB assertion (`container inspect`) — the last - // pre-ownership step under the established ordering (config load → - // assert DB → only THEN remove the existing container). The remote-JWKS - // fetch is post-assertion, so if the ordering ever regresses to - // fetch-first, this test hangs at the pending fetch instead of reaching - // the inspect and fails on the waitFor timeout. + // Blocks startup at the DB assertion (`container inspect`), the last + // pre-ownership step before removing the existing container. If JWKS + // resolution ever moves before this assertion, the pending fetch would + // hang here and this test would fail on the waitFor timeout instead. deployMockState.runHandler = (command, args) => { if (command !== "docker") { throw new Error(`unexpected process: ${command}`); @@ -1978,8 +1955,8 @@ describe("functions serve integration", () => { call.args.includes("supabase_edge_runtime_test-project"), ), ).toBe(false); - // No remote JWKS request either — Go resolves JWKS only after the DB - // assertion succeeds (`serve.go:141`). + // No remote JWKS request either — JWKS resolves only after the DB + // assertion succeeds. expect(fetchMock).not.toHaveBeenCalled(); expect(out.stdoutText).toContain("Stopped serving"); }); @@ -2003,9 +1980,8 @@ describe("functions serve integration", () => { return { exitCode: 0, stdout: "edge-runtime-id\n", stderr: "" }; } if (args[0] === "exec") { - // Hangs Kong reload so the interrupt below lands after bring-up succeeded (staged - // secrets already written, `startedRuntime` already assigned) but before this - // wrapper's own `reloadKong` call returns. + // Hangs Kong reload so the interrupt lands after bring-up succeeds + // (secrets staged, runtime started) but before `reloadKong` returns. return { pending: true }; } throw new Error(`unexpected docker args: ${args.join(" ")}`); @@ -2452,10 +2428,9 @@ describe("functions serve integration", () => { it.live( "does not fail startup on a malformed third-party provider config when auth is disabled", () => { - // `Auth.ThirdParty.validate()` (the "required field" check) only runs inside - // `Config.Validate`'s `if Auth.Enabled` block — `functions serve`'s own JWKS resolution - // discards `ResolveJWKS`'s error unconditionally, regardless of `auth.enabled`. So a - // workos provider enabled without an `issuer_url` must not block startup here. + // Config validation's "required field" check for third-party providers + // only runs when auth is enabled, and `functions serve`'s JWKS + // resolution discards its own error unconditionally either way. deployMockState.runHandler = (command, args) => { if (command !== "docker") { throw new Error(`unexpected process: ${command}`); @@ -2603,10 +2578,8 @@ describe("functions serve integration", () => { }); it.live("uppercases config secret names, skipping empty and unresolved values", () => { - // The established config loader uppercases every `[edge_runtime.secrets]` - // key with `strings.ToUpper` (viper #1014 workaround) before - // `set.ListSecrets` reads the map, and ListSecrets keeps only entries - // with a non-empty SHA256, i.e. it skips empty values and + // Config secret keys are uppercased before the map is read; only entries + // with a resolved (non-empty) value are kept, skipping empty or // still-unresolved `env(VAR)` literals. deployMockState.runHandler = (command, args) => { if (command !== "docker") { @@ -2922,11 +2895,9 @@ describe("functions serve integration", () => { }); it.live("surfaces a down docker daemon as the inspect failure with the install hint", () => { - // Go has no upfront docker precheck in `functions serve` — a down daemon - // surfaces from `AssertSupabaseDbIsRunning`'s container inspect as - // `failed to inspect service: <connection error>` with the Docker Desktop - // install hint attached as a suggestion (`internal/utils/misc.go:155-166`, - // `docker.go:350`), AFTER config load resolved the project id. + // No upfront docker precheck: a down daemon surfaces from the DB + // container inspect as `failed to inspect service: <connection error>`, + // with the Docker Desktop install hint attached as a suggestion. const daemonDownStderr = "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?"; deployMockState.runHandler = (command, args) => { @@ -2952,7 +2923,6 @@ describe("functions serve integration", () => { expect(error).toBeInstanceOf(Error); if (error instanceof Error) { expect(error.message).toBe(`failed to inspect service: ${daemonDownStderr}`); - // The old TS-only upfront precheck message must never come back. expect(error.message).not.toContain("failed to run docker"); } expect(error).toHaveProperty( @@ -2960,8 +2930,6 @@ describe("functions serve integration", () => { "Docker Desktop is a prerequisite for local development. Follow the official docs to install: https://docs.docker.com/desktop", ); - // Config load ran first (the inspect targets the config-resolved project - // id) and nothing after the failed assert touched docker. expect(deployMockState.runCalls).toEqual([ expect.objectContaining({ command: "docker", @@ -2974,12 +2942,9 @@ describe("functions serve integration", () => { }); it.live("keeps the install hint when no container runtime is installed at all", () => { - // With no `docker` or `podman` binary on PATH the inspect never spawns — - // the shell-out equivalent of a missing daemon socket, which - // `client.IsErrConnectionFailed` classifies as a connection failure and so - // gets the Docker Desktop install hint. The spawn-failure cause must - // survive into the `failed to inspect service: …` message instead of - // being blanked. + // Missing docker/podman binaries are treated the same as a missing + // daemon socket; the spawn-failure cause must survive into the + // `failed to inspect service: …` message instead of being blanked. const runtimeNotFoundMessage = "docker: command not found (podman also not found) — install Docker Desktop or Podman and ensure it is on PATH"; deployMockState.runHandler = (command, args) => { @@ -3005,7 +2970,6 @@ describe("functions serve integration", () => { expect(error).toBeInstanceOf(Error); if (error instanceof Error) { expect(error.message).toBe(`failed to inspect service: ${runtimeNotFoundMessage}`); - // The old TS-only upfront precheck message must never come back. expect(error.message).not.toContain("failed to run docker"); } expect(error).toHaveProperty( @@ -3016,9 +2980,6 @@ describe("functions serve integration", () => { }); it.live("fails with the config error, not a docker error, when both are broken", () => { - // Established ordering: `restartEdgeRuntime` sanity-checks config load - // first, `AssertSupabaseDbIsRunning` second — a malformed config wins - // over a down docker daemon. deployMockState.runHandler = () => ({ exitCode: 1, stdout: "", @@ -3038,11 +2999,8 @@ describe("functions serve integration", () => { }); it.live("makes no remote JWKS request when docker is down", () => { - // Go only fetches third-party JWKS inside `ServeFunctions` (`serve.go:141`, - // `ResolveJWKS`), strictly after `AssertSupabaseDbIsRunning` - // (`serve.go:110-113`) — with a down daemon the docker error surfaces - // immediately, without first waiting on OIDC/JWKS requests (two sequential - // 10s-timeout clients, `pkg/config/config.go:1727-1776`). + // JWKS is fetched only after the DB assertion, so a down daemon's error + // surfaces immediately without waiting on any OIDC/JWKS request. const daemonDownStderr = "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?"; deployMockState.runHandler = (command, args) => { @@ -3092,16 +3050,11 @@ describe("functions serve integration", () => { if (error instanceof Error) { expect(error.message).toBe(`failed to inspect service: ${daemonDownStderr}`); } - // The docker-down error must win without a single external request. expect(fetchMock).not.toHaveBeenCalled(); }); }); it.live("fails with the auth config error, not a docker error, when both are broken", () => { - // Established ordering: the `jwt_secret` ≥16-chars check runs during - // config load, BEFORE `AssertSupabaseDbIsRunning` — invalid auth config - // wins over a down docker daemon, so the local half of auth resolution - // must stay ahead of the DB assertion. deployMockState.runHandler = () => ({ exitCode: 1, stdout: "", @@ -3521,9 +3474,6 @@ describe("functions serve integration", () => { it.live( "fails before any Docker work on an unrelated Config.Validate branch (unsupported Postgres major version)", () => { - // Proves the WHOLE resolved config is validated, not just `project_id` - // — `db.major_version = 12` is a genuinely unrelated Go `Config.Validate` - // branch (`config.go:1034-1062`). return Effect.gen(function* () { yield* Effect.promise(() => writeCliConfig( diff --git a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.claims.ts b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.claims.ts index 5e32553cfa..9cd1dd67b5 100644 --- a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.claims.ts +++ b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.claims.ts @@ -4,86 +4,45 @@ import { goJsonKindName } from "../../../command-internal/go-json.ts"; import { addSecondsAndFloor, type BearerJwtInstant } from "./bearer-jwt.flags.ts"; /** - * Pure claims-building logic for `gen bearer-jwt`. Kept out of the - * handler/service tree (no `Effect`, no service dependencies) per - * `apps/cli/CLAUDE.md`'s `.format.ts`/`.encoders.ts` guidance — this is - * exactly that shape of file, just named `.claims.ts` for this command. + * Pure claims-building logic for `gen bearer-jwt` (no Effect, no service + * dependencies). * - * The claims object is always a `jwt.MapClaims`-shaped map, never a - * `CustomClaims` struct. `encoding/json`-style map encoding serializes keys - * in SORTED (alphabetical) order, unlike a struct's declaration order — see - * {@link encodeBearerJwtClaims}, which every caller MUST use to - * serialize the object this module builds (a plain `JSON.stringify` would - * preserve insertion order instead, which is correct for - * `generateAsymmetricGoJwt`'s struct-shaped claims but wrong here). + * The claims object is a map, so it must be serialized via + * {@link encodeBearerJwtClaims}, which key-sorts like Go's `encoding/json` + * does for a map — `JSON.stringify` would wrongly preserve insertion order. */ export interface BearerJwtClaimsInput { readonly role: string; readonly sub: Option.Option<string>; /** - * The parsed `--exp` instant (RFC3339), WITHOUT flooring — `Option.none()` when the - * flag was not given. An exact {@link BearerJwtInstant}, not a single float — - * see that type's own doc comment for why a single `number` cannot carry an - * epoch-scale whole-second count and nanosecond precision together without silent - * rounding. + * The parsed `--exp` instant (RFC3339), unfloored; `Option.none()` when the + * flag was not given. See {@link BearerJwtInstant} for why this can't be a + * single float. */ readonly expiresAt: Option.Option<BearerJwtInstant>; /** - * `--valid-for`, parsed from Go-duration syntax into seconds WITHOUT flooring — - * see {@link parseBearerJwtValidFor}'s own doc comment for why sub-second - * precision must survive until the final `exp`/`iat` computation below. + * `--valid-for`, parsed from Go duration syntax into seconds, unfloored — + * see {@link parseBearerJwtValidFor} for why sub-second precision must + * survive until the final `exp`/`iat` computation. */ readonly validForSeconds: number; /** - * `Date.now()`-derived instant, injected so callers (and tests) control "now" — an - * exact {@link BearerJwtInstant} built directly from `Date.now()`'s integer - * milliseconds (see `bearer-jwt.handler.ts`), NOT pre-floored to whole seconds. - * Flooring it before this module ever sees it would compute `exp = now + validFor` - * from an already-truncated `now`, shortening the token's lifetime by up to a second - * whenever `--valid-for` has a sub-second component: a run at - * `HH:MM:SS.900` with `--valid-for 200ms` must land in the NEXT second, - * adding the raw fractional time and truncating `iat`/`exp` separately - * afterward. + * `Date.now()`-derived instant, injected so callers (and tests) control + * "now"; not pre-floored to whole seconds. Flooring it before this module + * sees it would compute `exp` from an already-truncated `now`, shortening + * the token's lifetime by up to a second whenever `--valid-for` has a + * sub-second component. */ readonly nowInstant: BearerJwtInstant; } /** - * Established time/role computation: - * - `--exp` unset (zero `time.Time`): `iat = now`, `exp = now + validFor`. - * - `--exp` set: `exp = <parsed --exp>`, `iat = exp - validFor` (validFor is SUBTRACTED - * from the explicit expiry to derive `iat`, not added to `now`). - * - Both arithmetic branches use exact-nanosecond `time.Time`-style math and only floor - * the FINAL `exp`/`iat` to whole seconds — so `validForSeconds` (which may carry - * sub-second precision, see {@link BearerJwtClaimsInput.validForSeconds}) must - * be applied BEFORE flooring, not floored first and then applied: - * `--exp 2030-01-01T00:00:00Z --valid-for 1.5s` must yield - * `iat=1893455998` — flooring the 1.5s duration to 1s first (as this port previously - * did) would wrongly yield `1893455999`. - * - `expiresAt`/`nowInstant` are exact {@link BearerJwtInstant}s, not floats (see - * that type's own doc comment) — `addSecondsAndFloor` combines an instant with - * `validForSeconds` using exact integer nanosecond arithmetic and returns the - * correctly-floored whole-second result in one step, so neither branch below ever - * adds an epoch-scale whole-second count directly to a sub-second float (which plain - * float addition can get wrong: `--exp - * 2030-01-01T00:00:00.999999999Z` must floor to `1893456000`, not round UP to - * `1893456001`). `--exp - * 2030-01-01T00:00:00.9Z --valid-for 1.2s` must yield `iat=1893455999`, not the - * `1893455998` that flooring `expiresAt` before the subtraction would produce. - * - `role` is ALWAYS present (`json:"role"`, no `omitempty`), even `--role ""`. - * - `is_anonymous` is set only when `role` case-insensitively equals `"authenticated"` - * AND `--sub` was not given (`strings.EqualFold` + `len(claims.Subject) == 0`) — an - * explicitly-passed EMPTY `--sub ""` still counts as "not given" for this specific - * check (`len("") == 0`), even though the `sub` claim omission below is governed by - * the SAME emptiness check, not by whether the flag was passed at all; the `role` - * claim keeps its original casing regardless. - * - `sub`/`exp`/`iat` all carry the embedded `jwt.RegisteredClaims` `omitempty` tags — - * `sub` only when non-empty, `exp`/`iat` always (both are always-set `*NumericDate`s - * here, matching mapstructure's non-nil-pointer handling — see `go-jwt.ts`'s - * sibling doc comments for the general `omitempty`-in-mapstructure background). - * - `iss`/`ref`/`aud`/`nbf`/`jti` never appear — bearer-jwt has no flag that sets any of - * them, so they stay at their zero value and get `omitempty`-dropped. + * `--exp` unset: `iat = now`, `exp = now + validFor`; `--exp` set: `exp` is + * the parsed value, `iat = exp - validFor`. Both floor only the final + * `exp`/`iat` via {@link addSecondsAndFloor}, so sub-second precision + * survives until then. `is_anonymous` is set only when `role` is + * case-insensitively "authenticated" and `sub` is empty (including `""`). */ export function buildBearerJwtClaims(input: BearerJwtClaimsInput): Record<string, unknown> { let exp: number; @@ -122,10 +81,10 @@ function skipGoJsonWhitespace(value: string, index: number): number { } /** - * Index right after the closing (unescaped) `"` of the JSON string starting at - * `value[start]` (assumed to be `"`), or `undefined` when it never closes — - * The established scanner likewise just bails out to `"unexpected end of JSON input"` on a - * truncated string, so escape-sequence CORRECTNESS doesn't need validating here. + * Index right after the closing (unescaped) `"` of the JSON string starting + * at `value[start]`, or `undefined` when it never closes. Escape-sequence + * validity doesn't matter here, since a truncated string always reports + * "unexpected end of JSON input" regardless. */ function findJsonStringEnd(value: string, start: number): number | undefined { let i = start + 1; @@ -172,42 +131,26 @@ function findJsonContainerEnd(value: string, start: number): number | undefined const JSON_NUMBER_PATTERN = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/; -/** Established literal keywords, keyed by their first byte — matched char-by-char below, same as `encoding/json`'s scanner. */ +/** Literal keywords, keyed by their first byte; matched char-by-char below, like `encoding/json`'s scanner. */ const GO_JSON_LITERALS: Record<string, string> = { n: "null", t: "true", f: "false" }; /** - * Scans an ALREADY syntactically-valid JSON document (this only ever runs after - * `JSON.parse` on `value` has itself succeeded, so every string is properly closed - * and every number token is well-formed) for the first number literal that overflows - * a float64, e.g. `1e309` — returns that literal's exact source text, or `undefined` - * if every number in the document is finite. + * Finds the first JSON number literal in an already-valid JSON document that + * overflows a float64 (e.g. `1e309`), or `undefined` if none does. * - * `JSON.parse` silently converts an overflowing literal to `Infinity`/`-Infinity` - * (`JSON.parse('{"extra":1e309}')` yields `{ extra: Infinity }`, which - * {@link encodeBearerJwtClaims}'s Go-compatible encoder then serializes as - * `null`, per `encoding/json`'s own float64-to-JSON-number behavior for non-finite - * values) — but decoding into `jwt.MapClaims` (which decodes every JSON - * number as a Go `float64`) must fail outright: `strconv.ParseFloat` returns a range - * error for the same literal, and `encoding/json` surfaces that as `"json: cannot - * unmarshal number 1e309 into Go value of type float64"`. `--payload - * '{"extra":1e309}'` must exit 1 with exactly that message - * (wrapped by the caller's `"failed to parse payload: %w"`), rather than silently - * signing a token with a `null` custom claim. - * - * A left-to-right scan that skips over string contents (object keys and string - * values, via {@link findJsonStringEnd}) and matches a full number token at every - * other digit/`-` position reproduces the established decode order (a single-pass, - * depth-first token scan) closely enough to report the same FIRST offending literal - * the established decoder would stop at, without needing a full recursive-descent - * parse. + * `JSON.parse` silently converts an overflowing literal to `Infinity`, but + * decoding into `jwt.MapClaims` must fail instead, so `mergeBearerJwtPayload` + * uses this to reproduce that failure. A single left-to-right scan (skipping + * string contents via {@link findJsonStringEnd}) is enough, since only a full + * number-token match at a digit/`-` position needs checking. */ function findFirstNonFiniteJsonNumberLiteral(value: string): string | undefined { let i = 0; while (i < value.length) { const ch = value[i]!; if (ch === '"') { - // `value` is known-valid JSON, so every string closes; skip over it whole so - // digits inside a string value are never mistaken for a number token. + // Known-valid JSON, so every string closes; skip it whole so its + // digits are never mistaken for a number token. i = findJsonStringEnd(value, i)!; continue; } @@ -225,13 +168,10 @@ function findFirstNonFiniteJsonNumberLiteral(value: string): string | undefined } /** - * Reports the established `"invalid character '<c>' after top-level value"` - * when `trimmed` has non-whitespace content after its first - * `validPrefixLength` characters, or the generic fallback when there is - * none — reachable only via a leading byte JS's `\s` regex strips but the - * established scanner does not treat as whitespace (e.g. a vertical tab), - * so the ORIGINAL `JSON.parse(payload)` call still failed even though - * `trimmed` alone is one complete, valid JSON value. + * Reports `"invalid character '<c>' after top-level value"` when `trimmed` + * has non-whitespace content after `validPrefixLength`, or the generic + * fallback otherwise — reachable when a leading byte (e.g. a vertical tab) + * is whitespace to JS's `\s` regex but not to this scanner. */ function reportGoJsonTrailingGarbage(trimmed: string, validPrefixLength: number): string { const restStart = skipGoJsonWhitespace(trimmed, validPrefixLength); @@ -242,27 +182,14 @@ function reportGoJsonTrailingGarbage(trimmed: string, validPrefixLength: number) } /** - * Best-effort, single-pass (no repeated whole-string `JSON.parse` retries — see - * below) reproduction of the `encoding/json`-style scanner syntax-error text - * for a malformed `--payload` value, for every shape covered here: an - * empty/whitespace-only payload or a genuinely truncated value - * (`"unexpected end of JSON input"`), a byte that can never start a JSON - * value (`"invalid character '<c>' looking for beginning of value"`), a - * partial keyword match (`"invalid character '<c>' in literal <word> (expecting - * '<c2>')"`, e.g. `--payload 'not-json-at-all'` starts with `n` looking like - * `null`), and valid JSON followed by trailing garbage (`"invalid character '<c>' - * after top-level value"`, e.g. `--payload '{}{}'`). + * Reproduces `encoding/json`'s scanner syntax-error text for a malformed + * `--payload` value: truncated input, an invalid leading byte, a partial + * keyword match, or trailing garbage after valid JSON. * - * Dispatches on the first non-whitespace byte rather than building a full - * recursive-descent parser — each of the five shapes above only needs to know - * "where does the first top-level value end, and how did the byte after it (or - * the byte that broke a literal/number) fail" — and every helper below scans - * forward only, so a large malformed payload cannot make this pathological the - * way retrying `JSON.parse` on shrinking prefixes could. Genuinely malformed - * JSON with no recognizable failure shape above (e.g. a lone `-` with no digits) - * falls back to a generic message that is NOT verified byte-for-byte against - * the established scanner (accepted gap — no fixture exists for `--payload` - * parsing at all; see this command's own audit notes). + * Dispatches on the first non-whitespace byte and scans forward only, rather + * than parsing recursively or retrying `JSON.parse` on shrinking prefixes. An + * unrecognized shape (e.g. a lone `-`) falls back to a generic, unverified + * message. */ function goJsonSyntaxErrorMessage(raw: string): string { const trimmed = raw.replace(/^\s+/, ""); @@ -302,8 +229,8 @@ function goJsonSyntaxErrorMessage(raw: string): string { if (first === "-" || (first >= "0" && first <= "9")) { const match = JSON_NUMBER_PATTERN.exec(trimmed); if (match === null || match[0].length === 0) { - // Only reachable for a lone, digit-less `-` — the established scanner - // is still mid-number waiting for a digit when the input runs out. + // Only reachable for a lone, digit-less `-`: the scanner is still + // mid-number when the input runs out. return "unexpected end of JSON input"; } return reportGoJsonTrailingGarbage(trimmed, match[0].length); @@ -313,29 +240,14 @@ function goJsonSyntaxErrorMessage(raw: string): string { } /** - * Established `--payload` merge: - * `json.Unmarshal([]byte(payload), &custom)`-style semantics reuse the existing - * non-nil map and overwrite/add keys from the payload on top — so this merges - * `JSON.parse(payload)`'s own keys OVER `claims` (payload wins on any collision, - * e.g. `--payload '{"role":"override"}'` replaces the flag-derived `role`). + * Merges a parsed `--payload` JSON object over `claims`, payload values + * winning on any key collision. A JSON `null` payload is a no-op. * - * A JSON `null` payload is a no-op rather than clearing `claims` - * (map-into-map unmarshal semantics for a `null` source leave the - * destination untouched here in practice). A non-object, non-null, - * non-array top-level scalar (string/number/bool) or an array raises the - * established `"json: cannot unmarshal <kind> into Go value of type - * jwt.MapClaims"` runtime type-mismatch text — checked BEFORE any - * number-overflow scan below, since the top-level kind is rejected before - * ever attempting to decode a number inside it (a top-level overflowing - * scalar payload like `--payload '1e309'` still reports "cannot unmarshal - * number into Go value of type jwt.MapClaims", WITHOUT the literal). Once - * the top level genuinely is an object, an overflowing number ANYWHERE - * inside it, at any depth (e.g. `{"extra":1e309}` or `{"a":{"b":[1e309]}}`), - * raises the established `"json: cannot unmarshal number <literal> into Go - * value of type float64"` instead — see - * {@link findFirstNonFiniteJsonNumberLiteral}. Throws a bare `Error`; the caller - * (`bearer-jwt.handler.ts`) wraps it with the established `"failed to parse - * payload: %w"` prefix. + * A non-object top-level value raises a Go-style type-mismatch error before + * any number-overflow scan runs; only once the top level is an object does + * an overflowing number anywhere inside it raise the float64-overflow error + * from {@link findFirstNonFiniteJsonNumberLiteral}. Throws a bare `Error`, + * which the caller wraps with a `"failed to parse payload: %w"` prefix. */ export function mergeBearerJwtPayload( claims: Record<string, unknown>, @@ -355,16 +267,6 @@ export function mergeBearerJwtPayload( `json: cannot unmarshal ${goJsonKindName(parsed)} into Go value of type jwt.MapClaims`, ); } - // Only reachable once the top-level shape is already a map: a top-level - // scalar/array payload gets the structural mismatch above even when it - // overflows (e.g. `--payload '1e309'` still reports "cannot unmarshal - // number into Go value of type jwt.MapClaims", WITHOUT the literal, - // because the top-level kind is rejected before ever attempting to decode - // the number itself), whereas `--payload '[1e309]'` reports the - // array-kind mismatch. Once the top level genuinely is an object, - // decoding recurses into every value at any depth (including inside - // nested arrays/objects) as `interface{}`, which is where an overflowing - // number actually gets decoded as `float64` and fails. const overflowingLiteral = findFirstNonFiniteJsonNumberLiteral(payload); if (overflowingLiteral !== undefined) { throw new Error( @@ -375,14 +277,11 @@ export function mergeBearerJwtPayload( } /** - * Serializes the final claims object the way `jwt.MapClaims` (a real Go map) - * marshals via `encoding/json`: alphabetically key-sorted at every level, - * HTML + control-character escaping, no indentation, no trailing newline. - * Reuses `encodeGoStructJsonBody` (originally written for outbound API - * request bodies) — its behavior is the generic `json.Marshal`-of-a-map - * shape, which is exactly what a `jwt.MapClaims` payload needs too; - * introducing a second identical encoder under a different name would just - * be a rename, not a behavior difference. + * Serializes claims the way a Go map marshals via `encoding/json`: + * alphabetically key-sorted at every level, HTML + control-character + * escaping, no indentation or trailing newline. Reuses + * `encodeGoStructJsonBody`, since a map's `json.Marshal` shape is identical + * regardless of what produced it. */ export function encodeBearerJwtClaims(claims: Record<string, unknown>): string { return encodeGoStructJsonBody(claims); diff --git a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts index 10c9c5f9fa..5e06b95cb5 100644 --- a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts +++ b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.claims.unit.test.ts @@ -46,9 +46,6 @@ describe("buildBearerJwtClaims", () => { }); it("floors only the FINAL iat, applying a sub-second --valid-for before truncating (CLI-1961)", () => { - // `--exp 2030-01-01T00:00:00Z --valid-for 1.5s` must yield - // `iat=1893455998` — flooring the 1.5s duration to 1s BEFORE subtracting - // (this port's previous behavior) would wrongly yield 1893455999. const claims = buildBearerJwtClaims({ role: "anon", sub: Option.none(), @@ -61,9 +58,6 @@ describe("buildBearerJwtClaims", () => { }); it("preserves a fractional --exp through the iat subtraction, flooring only the final result (CLI-1961 Codex review finding)", () => { - // `--exp 2030-01-01T00:00:00.9Z --valid-for 1.2s` must yield - // `iat=1893455999`. Flooring `expiresAt` BEFORE the subtraction - // (this port's previous behavior) would wrongly yield `1893455998`. const claims = buildBearerJwtClaims({ role: "anon", sub: Option.none(), @@ -76,9 +70,6 @@ describe("buildBearerJwtClaims", () => { }); it("floors exp down (never rounds up) for a near-second nanosecond --exp fraction (CLI-1961 Codex review finding)", () => { - // `--exp 2030-01-01T00:00:00.999999999Z` must yield `exp=1893456000` - // — a naive float addition of `wholeSeconds + 0.999999999` (this port's previous - // behavior) rounds UP to the exact integer `1893456001` in plain JS arithmetic. const claims = buildBearerJwtClaims({ role: "anon", sub: Option.none(), @@ -90,12 +81,6 @@ describe("buildBearerJwtClaims", () => { }); it("adds a sub-second --valid-for to the unfloored current time when --exp is omitted (CLI-1961 Codex review finding)", () => { - // `exp = now.Add(validFor)` is computed on the RAW fractional `now`, - // then `iat`/`exp` are truncated separately — a run at `X.900` with - // `--valid-for 200ms` must land in the NEXT second (`exp = X + 1`), not - // stay in the current one. Computing `exp` from an already-floored - // `iat` (this port's previous behavior) would wrongly keep `exp = X`, - // shortening the token's lifetime to effectively zero. const claims = buildBearerJwtClaims({ role: "anon", sub: Option.none(), @@ -108,8 +93,6 @@ describe("buildBearerJwtClaims", () => { }); it("sets is_anonymous when --sub is explicitly passed as an empty string (CLI-1961)", () => { - // The gate is emptiness, not presence — an explicitly-passed EMPTY - // `--sub ""` still counts as "no subject", not just an omitted flag. const claims = buildBearerJwtClaims({ role: "authenticated", sub: Option.some(""), @@ -130,7 +113,6 @@ describe("buildBearerJwtClaims", () => { nowInstant: NOW_INSTANT, }); expect(claims["is_anonymous"]).toBe(true); - // Role keeps its original casing. expect(claims["role"]).toBe("AUTHENTICATED"); }); @@ -226,10 +208,6 @@ describe("mergeBearerJwtPayload", () => { }); it("rejects an overflowing number nested in an object payload instead of silently signing Infinity-as-null (CLI-1961 Codex review finding)", () => { - // Verified against the real binary: `--payload '{"extra":1e309}'` exits 1 with - // this exact message. `JSON.parse` alone would accept `1e309` as `Infinity`, - // which `encodeBearerJwtClaims`'s Go-compatible encoder then serializes as - // `null` — silently changing the claim instead of failing the command. expect(() => mergeBearerJwtPayload({ role: "anon" }, '{"extra":1e309}')).toThrow( "json: cannot unmarshal number 1e309 into Go value of type float64", ); @@ -253,11 +231,6 @@ describe("mergeBearerJwtPayload", () => { }); it("prioritizes the top-level type-mismatch message over an overflowing scalar payload", () => { - // A bare top-level overflowing scalar payload (`--payload '1e309'`) - // still reports the STRUCTURAL mismatch, WITHOUT the literal, because - // the top-level kind is rejected before ever attempting to decode the - // number itself — the array/scalar top-level check must run BEFORE the - // overflow scan. expect(() => mergeBearerJwtPayload({ role: "anon" }, "1e309")).toThrow( "json: cannot unmarshal number into Go value of type jwt.MapClaims", ); @@ -281,10 +254,6 @@ describe("mergeBearerJwtPayload", () => { }); it("reports a partial keyword match against Go's exact 'in literal' wording", () => { - // `not-json-at-all` starts with `n`, which the established scanner - // reads as the start of the `null` literal; the second byte `o` - // mismatches `null`'s `u` — the established scanner text for this is - // `invalid character 'o' in literal null (expecting 'u')`. expect(() => mergeBearerJwtPayload({ role: "anon" }, "not-json-at-all")).toThrow( "invalid character 'o' in literal null (expecting 'u')", ); @@ -327,8 +296,7 @@ describe("mergeBearerJwtPayload", () => { }); it("skips over an escaped quote inside a string when finding where it closes", () => { - // The actual payload bytes are: `"` `a` `\` `"` `b` `"` `c` — a string containing an - // escaped quote (`a"b`), followed by trailing garbage `c`. + // Payload bytes: `"a\"b"c` — a string with an escaped quote, then trailing garbage `c`. expect(() => mergeBearerJwtPayload({ role: "anon" }, '"a\\"b"c')).toThrow( "invalid character 'c' after top-level value", ); @@ -341,8 +309,6 @@ describe("mergeBearerJwtPayload", () => { }); it("reports trailing garbage after a FULLY matched literal keyword", () => { - // "null" matches completely; JSC's own tokenizer reports this generically (no - // position), unlike a partial mismatch — this exercises that specific fall-through. expect(() => mergeBearerJwtPayload({ role: "anon" }, "nullx")).toThrow( "invalid character 'x' after top-level value", ); @@ -367,12 +333,9 @@ describe("mergeBearerJwtPayload", () => { }); it("falls back to the generic message when the only valid prefix is the WHOLE trimmed string", () => { - // A leading vertical tab is in JS's `\s` regex class (so `trimmed` strips it) but is - // NOT valid JSON whitespace (so the original `JSON.parse(payload)` call still fails - // on it) — `trimmed` alone ("{}") is then a single complete, valid JSON value with - // nothing left over, which must fall through to the generic message rather than - // reporting on empty leftover content. Built via `String.fromCharCode` rather than a - // literal escape so no raw control byte sits in the source file. + // A vertical tab is JS `\s` whitespace but not valid JSON whitespace, so + // `JSON.parse` still fails even though the rest ("{}") is valid JSON. + // Built via `String.fromCharCode` to avoid a raw control byte in the source. const verticalTab = String.fromCharCode(11); expect(() => mergeBearerJwtPayload({ role: "anon" }, `${verticalTab}{}`)).toThrow( "invalid character looking for beginning of value", @@ -399,12 +362,8 @@ describe("encodeBearerJwtClaims", () => { }); it("keeps Go's true lexicographic order for numeric-looking custom claim keys (CLI-1961 Codex review finding)", () => { - // Go signs a real `jwt.MapClaims` map via `encoding/json`, which sorts string keys - // purely lexicographically ("10" before "2") — verified directly against the Go - // standard library. A plain JS object always reorders integer-like string keys into - // ascending NUMERIC order on enumeration regardless of insertion order, which would - // otherwise silently re-sort "10"/"2" back to "2" before "10" and change the signed - // bytes for these inputs. + // JS objects reorder integer-like string keys into ascending numeric order on + // enumeration, but Go's lexicographic string-key sort keeps "10" before "2". const claims = { role: "anon", 10: "a", 2: "b" }; expect(encodeBearerJwtClaims(claims)).toBe('{"10":"a","2":"b","role":"anon"}'); }); diff --git a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.command.ts b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.command.ts index f9c5866f28..ff80d29bcf 100644 --- a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.command.ts +++ b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.command.ts @@ -12,15 +12,9 @@ import { genBearerJwt } from "./bearer-jwt.handler.ts"; import { parseBearerJwtExp, parseBearerJwtValidFor } from "./bearer-jwt.flags.ts"; const config = { - // `--role` is required, but required-flag validation must run AFTER the - // telemetry context is installed and flushed on the return path, so a - // missing `--role` must still flush telemetry. The framework's own - // `MissingOption` parse-time rejection (`normalize-error.ts`) would - // short-circuit before this command's handler — and its - // `Effect.ensuring(telemetryState.flush)` — ever runs, so `role` stays - // optional at parse time and presence is enforced in the handler - // instead, same established pattern as `vanity-subdomains activate`'s - // `--desired-subdomain` (`activate.command.ts`/`activate.handler.ts`). + // `--role` stays optional at parse time so a missing value is enforced in + // the handler instead, after telemetry is wired up — see + // `GenBearerJwtRoleRequiredError`. role: Flag.string("role").pipe(Flag.withDescription("Postgres role to use."), Flag.optional), // The displayed default is cosmetically "anonymous" but the real default // stays "" — an omitted `--sub` never puts a `sub` claim in the token at @@ -55,9 +49,8 @@ const genBearerJwtRuntimeLayer = Layer.mergeAll( commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)), telemetryStateLayer, commandRuntimeLayer(["gen", "bearer-jwt"]), - // Branch A's stdin JWK prompt and Branch B's stdin kid prompt (`getSigningKey`, - // `bearerjwt.go:37-68`) both read piped stdin even on a non-TTY, same as - // `gen signing-key`'s overwrite confirmation. + // The stdin JWK/kid prompts (`getSigningKey`) read piped stdin even on a + // non-TTY, same as `gen signing-key`'s overwrite confirmation. stdinLayer, ); diff --git a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.errors.ts b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.errors.ts index c6ff40a348..2a06a184c8 100644 --- a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.errors.ts +++ b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.errors.ts @@ -7,25 +7,18 @@ import { } from "../../../shared/telemetry/error-actionability.ts"; /** - * Extracts a display message from a thrown `cause`. Every `Effect.try` catch in this - * command's handler/signing-key resolver wraps a function that only ever throws a real - * `Error` (never a plain string/object) — but `catch` still types `cause` as `unknown`, - * so the `instanceof` check stays. Pulled out here (rather than inlined at each call - * site) so neither `bearer-jwt.handler.ts` nor `bearer-jwt.signing-key.ts` carries this - * branch itself for coverage purposes — it's exercised directly by this file's own - * unit tests instead. + * Extracts a display message from a thrown `cause`. Every catch site here + * only ever throws a real `Error`, but `catch` still types `cause` as + * `unknown`, hence the `instanceof` check. */ export function bearerJwtErrorMessage(cause: unknown): string { return cause instanceof Error ? cause.message : String(cause); } /** - * `--role` is required, but required-flag validation runs only AFTER the - * telemetry context is set up and later flushed. Enforced in the handler - * (after the telemetry-flushing wrapper is already active) rather than at - * parse time, so this failure still flushes `telemetry.json`. Established - * message text `required flag(s) "role" not set`, with no usage block and - * no `"Error: "` prefix. + * `--role` is required, but enforced in the handler rather than at parse + * time, so a missing value still flushes `telemetry.json`. Message text is + * `required flag(s) "role" not set`, with no usage block or `"Error: "` prefix. */ export class GenBearerJwtRoleRequiredError extends Data.TaggedError( "GenBearerJwtRoleRequiredError", @@ -89,9 +82,9 @@ export class GenBearerJwtKeyNotFoundError extends Data.TaggedError("GenBearerJwt } /** - * Branch C: the TTY key picker given ZERO available keys quits immediately - * without ever letting the user select anything. Established bare, - * unwrapped message `"user aborted"`. + * Branch C: the TTY key picker given zero available keys quits immediately + * without ever letting the user select anything. Message is the bare, + * unwrapped `"user aborted"`. */ export class GenBearerJwtKeyPickerAbortedError extends Data.TaggedError( "GenBearerJwtKeyPickerAbortedError", diff --git a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.flags.ts b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.flags.ts index 211f71bbcb..a6d2125131 100644 --- a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.flags.ts +++ b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.flags.ts @@ -1,28 +1,19 @@ import { parseGoDuration } from "../../../command-internal/go-duration.ts"; import { bearerJwtErrorMessage } from "./bearer-jwt.errors.ts"; -// The fractional-seconds separator accepts EITHER `.` or `,` — `time.Parse` -// treats both as introducing a fractional second for any layout element: -// `time.Parse(time.RFC3339, "2030-01-01T00:00:00,5Z")` succeeds with the same -// nanosecond result as the `.5` spelling. +// The fractional-seconds separator accepts either `.` or `,`, matching +// `time.Parse`'s RFC3339 handling for any layout element. const RFC3339_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:[.,](\d+))?(?:(Z)|([+-])(\d{2}):(\d{2}))$/; /** - * A Unix instant split into an exact whole-second floor and a non-negative - * nanosecond remainder in `[0, 1e9)` — mirrors `time.Time` (a - * whole-seconds field plus a separate nanoseconds field) instead of - * collapsing both into a single `number`. A single float CANNOT hold both an - * epoch-scale whole-second count (~2e9, already ~31 bits) and full - * nanosecond precision (9 decimal digits) without silent rounding: - * `1_893_456_000 + 0.999999999` rounds UP to the exact integer - * `1_893_456_001` in plain JS float addition — a full second later than - * the established `jwt.NewNumericDate` truncation, which truncates the real - * (nanosecond-preserving) `time.Time` DOWN to `1_893_456_000`. Both fields - * here are plain safe integers (`wholeSeconds` is many orders of magnitude - * below `Number.MAX_SAFE_INTEGER`; `nanos` is always `< 1e9`), so every - * operation on this type — see {@link addSecondsAndFloor} — is exact - * integer arithmetic, never float rounding. + * A Unix instant split into a whole-second floor and a nanosecond remainder + * in `[0, 1e9)`, instead of one `number`. + * + * A single float can't hold an epoch-scale whole-second count together with + * full nanosecond precision without silent rounding: `1_893_456_000 + + * 0.999999999` rounds up to `1_893_456_001` in plain JS float addition, a + * full second later than the correct floored value. */ export interface BearerJwtInstant { readonly wholeSeconds: number; @@ -34,15 +25,9 @@ const NANOS_PER_SECOND = 1_000_000_000; /** * Adds a (possibly fractional, possibly negative) duration in seconds to an * exact {@link BearerJwtInstant} and returns the correctly-floored - * whole-second result — mirrors exact nanosecond-precision `time.Time` - * arithmetic followed by a truncate-to-seconds step, without ever adding an - * epoch-scale whole-second count directly to a sub-second float (see - * {@link BearerJwtInstant}'s own doc comment for why that rounds - * incorrectly). `deltaSeconds` itself (`--valid-for`, parsed by - * {@link parseBearerJwtValidFor}) stays a plain float — its own - * magnitude is never epoch-scale, so splitting it into whole/fractional parts - * here is exact enough — only the addition against an epoch-scale instant - * needs the exact-integer treatment. + * whole-second result, without ever adding an epoch-scale whole-second count + * directly to a sub-second float (see {@link BearerJwtInstant} for why that + * rounds incorrectly). */ export function addSecondsAndFloor(instant: BearerJwtInstant, deltaSeconds: number): number { const deltaWhole = Math.floor(deltaSeconds); @@ -72,16 +57,11 @@ function daysInMonth(year: number, month: number): number { } /** - * Validates the calendar/clock components a syntactically-RFC3339 string decodes to, - * matching `time.Parse(time.RFC3339, ...)` — which genuinely REJECTS an - * out-of-range month, day (including per-month/leap-year day-of-month - * bounds), hour, minute, or second, rather than normalizing them the way - * `time.Date`/JS's own `Date.parse` would (e.g. `2030-02-30T...` silently - * rolling over to March 2nd). `time.Parse` itself raises a - * component-specific error (`"day out of range"`, etc.), but pflag's - * `timeValue.Set` discards that text entirely and falls through to the SAME - * generic wrapped message used for a syntactically-malformed value — so - * this only needs a boolean, not per-field error text. + * Validates the calendar/clock components an RFC3339 string decodes to, + * rejecting an out-of-range month, day, hour, minute, or second rather than + * normalizing them the way JS's own `Date.parse` would (e.g. `2030-02-30` + * silently rolling over to March 2nd). A boolean is enough since every + * rejection reason maps to the same generic error message. */ function isValidRfc3339Calendar( year: number, @@ -109,51 +89,21 @@ function rfc3339FlagError(trimmedValue: string): Error { } /** - * `--exp` is parsed the same way pflag's RFC3339 time flag parses it, during - * flag parsing — BEFORE the handler runs. Established parsing rules: - * - The input is trimmed with `strings.TrimSpace` BEFORE ever parsing, - * so `--exp " 2030-01-01T00:00:00Z "` (surrounding whitespace) parses - * successfully — the trimmed value is used both for parsing and for the - * error message below (the already-trimmed value is re-embedded, never - * the original untrimmed argument). - * - A parse failure — whether syntactic, a rejected out-of-range calendar - * component (`isValidRfc3339Calendar` above), or an out-of-range zone offset - * (below) — raises the exact same wrapped message: `invalid argument "<val>" for - * "--exp" flag: invalid time format \`<val>\` must be one of: - * \`2006-01-02T15:04:05Z07:00\`` (a single format, since `--exp` registers only - * RFC3339). - * - The numeric zone-offset range check rejects an offset hour/minute that - * OVERFLOWS a 2-digit field's max plausible value using `>` rather than - * `>=` ("as some people do write offsets of 24 hours or 60 minutes") — - * so `+24:00` parses successfully but `+99:99` does not. `Date.parse` - * cannot be reused for the final timestamp once an offset is present: it - * rejects `+24:00`/`+60`-minute offsets that must be accepted, and — - * more importantly — silently returns `NaN` for a genuinely-invalid - * offset like `+99:99` instead of throwing, which would let a - * malformed `--exp` mint a token with `exp`/`iat` claims that JSON-serialize as - * `null` (`JSON.stringify(NaN) === "null"`) rather than failing the command. - * Reimplements `t.addSec(-zoneOffset)` directly instead: the local - * wall-clock components, interpreted as UTC, minus the signed offset in - * seconds. - * - Fractional seconds after the whole-seconds field are accepted EVEN - * THOUGH the RFC3339 layout has no fractional-seconds directive — this - * is a documented parse-only extension ("in the absence of a fractional - * second in the format, the fractional part will still be parsed if it - * is present"). Extra digits beyond nanosecond (9-digit) precision are - * TRUNCATED, not rounded: `.9999999995` (10 digits) parses to nanosecond - * `999999999`, not a rounded-up `1000000000` that would carry into the - * next second. The parsed instant carries that fraction at full - * (nanosecond) precision into the `iat = exp - validFor` arithmetic in - * `buildBearerJwtClaims`, which floors only the FINAL `exp`/`iat` - * — so the fraction must survive this function's return value rather - * than being discarded here, matching `parseBearerJwtValidFor`'s - * own no-early-flooring rule below: `--exp 2030-01-01T00:00:00.9Z - * --valid-for 1.2s` must yield `iat=1893455999`, not the `1893455998` - * that dropping the `.9` fraction during parsing would produce. - * Returns the parsed instant as an exact {@link BearerJwtInstant} — NOT a single - * float — on success. See that type's own doc comment for why a single `number` cannot - * hold both an epoch-scale whole-second count and nanosecond precision without silent - * rounding. + * `--exp`, parsed the same way pflag's RFC3339 time flag does, at flag-parse + * time. The input is trimmed before parsing and before it's re-embedded in + * the error message. Any failure (syntax, an out-of-range calendar + * component, or an out-of-range zone offset) raises the same wrapped + * `invalid argument "<val>" for "--exp" flag: ...` message. + * + * The zone-offset range check accepts `+24:00`/60-minute offsets (`>`, not + * `>=`) and rejects `+99:99` outright, unlike `Date.parse`, which returns + * `NaN` for the latter — silently minting a token whose `exp`/`iat` claims + * serialize as `null` instead of failing the command. + * + * Fractional seconds are accepted even though RFC3339 has no fractional + * directive, truncated (not rounded) to 9 digits, and returned unfloored as + * an exact {@link BearerJwtInstant} so `buildBearerJwtClaims` can floor only + * the final `exp`/`iat`. */ export function parseBearerJwtExp(value: string): BearerJwtInstant { const trimmedValue = value.trim(); @@ -199,19 +149,10 @@ export function parseBearerJwtExp(value: string): BearerJwtInstant { if (offsetSign === "-") offsetSeconds = -offsetSeconds; } - // `setUTCFullYear`/`setUTCHours` here, NOT `Date.UTC(...)`/`new Date(...)` — those - // two apply JS's legacy two-digit-year remapping (`Date.UTC(1, 0, 1, ...)` silently - // becomes 1901, not year 1) to any year in `[0, 99]`, which is a genuinely valid - // 4-digit RFC3339 year that must be accepted LITERALLY (`0001-01-01T00:00:00Z` - // parses to year 1, Unix `-62135596800`). `Date.prototype.setUTCFullYear` - // has no such special case at any year, per ECMA-262 (unlike the `Date.UTC`/`Date` - // constructor forms), so building the instant via the epoch `Date` and setter calls - // avoids the remapping entirely while still being exact (always a multiple of 1000, - // since only whole-second components are passed in) — and `offsetSeconds` above is - // always an exact integer number of seconds (a whole hour/minute offset), so - // `wholeSeconds` needs no fractional handling at all. Only the fractional-second - // digits themselves need a dedicated integer field: truncate (not round) to 9 - // digits, matching the truncation of excess fractional digits described above. + // `setUTCFullYear`/`setUTCHours`, not `Date.UTC`/`new Date(...)`: those remap + // any two-digit year in `[0, 99]` (e.g. year 1 becomes 1901), but a valid + // RFC3339 year in that range must be accepted literally. `setUTCFullYear` + // has no such remapping, so building the instant this way avoids it. const parsedDate = new Date(0); parsedDate.setUTCFullYear(Number(year), Number(month) - 1, Number(day)); parsedDate.setUTCHours(Number(hour), Number(minute), Number(second), 0); @@ -221,19 +162,12 @@ export function parseBearerJwtExp(value: string): BearerJwtInstant { } /** - * `--valid-for`: same parse-time-failure shape as `--exp` above, wrapping - * `parseGoDuration`'s own Go-format `time: invalid duration "..."` text. - * `time.Duration`'s own pflag `Value.Set` does NOT trim its input (unlike `--exp`'s - * `timeValue.Set` above), so no `.trim()` here. + * `--valid-for`, parsed via {@link parseGoDuration} (Go duration syntax); + * unlike `--exp`, this input is not trimmed. * - * Returns SECONDS WITHOUT FLOORING — `exp`/`iat` are computed via - * exact-nanosecond `time.Time` arithmetic on the parsed `time.Duration` and - * only the FINAL timestamps are floored (see `buildBearerJwtClaims`). - * Flooring the duration itself here, before that arithmetic runs, would produce an - * off-by-one-second result whenever the truncated fraction pushes the final sum/ - * difference across a second boundary: `--exp 2030-01-01T00:00:00Z - * --valid-for 1.5s` must yield `iat=1893455998`, not the `1893455999` a - * floor-first implementation would produce. + * Returns seconds unfloored: `buildBearerJwtClaims` floors only the final + * `exp`/`iat`, so flooring here first would produce an off-by-one-second + * result whenever the truncated fraction crosses a second boundary. */ export function parseBearerJwtValidFor(value: string): number { try { diff --git a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts index f3fc38000c..445e597a9d 100644 --- a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts +++ b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.flags.unit.test.ts @@ -14,9 +14,8 @@ describe("parseBearerJwtExp", () => { }); it("honors a non-zero numeric offset", () => { - // "+05:00" means local wall-clock time is 5 hours AHEAD of UTC, so the same wall - // time is an EARLIER instant than at "Z": - // 2030-01-01T00:00:00+05:00 -> exp 1893438000, ...Z -> exp 1893456000. + // A +05:00 offset means the wall-clock time is 5 hours ahead of UTC, so + // the same wall time is an earlier instant than at "Z". const withOffset = parseBearerJwtExp("2030-01-01T00:00:00+05:00"); const atZ = parseBearerJwtExp("2030-01-01T00:00:00Z"); expect(withOffset).toEqual({ wholeSeconds: atZ.wholeSeconds - 5 * 60 * 60, nanos: 0 }); @@ -35,10 +34,6 @@ describe("parseBearerJwtExp", () => { }); it("rejects an invalid calendar date instead of silently rolling it over (CLI-1961)", () => { - // `time.Parse` genuinely errors on `2030-02-30` ("day out of range"), it does - // NOT roll over to March 2nd the way `Date.parse` does — pflag's wrapper - // discards that specific error text and falls back to the same generic - // message used for a syntactically-malformed value. expect(() => parseBearerJwtExp("2030-02-30T03:04:05Z")).toThrow( 'invalid argument "2030-02-30T03:04:05Z" for "--exp" flag: invalid time format `2030-02-30T03:04:05Z` must be one of: `2006-01-02T15:04:05Z07:00`', ); @@ -79,19 +74,12 @@ describe("parseBearerJwtExp", () => { }); it("rejects an out-of-range zone offset instead of silently signing a null exp/iat (CLI-1961 Codex review finding)", () => { - // Without this check, the calendar check never looks at the offset at all, so - // `+99:99` would pass validation, `Date.parse` would return `NaN`, and the caller - // would sign a token whose `exp`/`iat` claims serialize as JSON `null` - // (`JSON.stringify(NaN) === "null"`) instead of failing the command — this - // input must be rejected during flag parsing. expect(() => parseBearerJwtExp("2030-01-01T00:00:00+99:99")).toThrow( 'invalid argument "2030-01-01T00:00:00+99:99" for "--exp" flag: invalid time format `2030-01-01T00:00:00+99:99` must be one of: `2006-01-02T15:04:05Z07:00`', ); }); it("tolerates a 24-hour/60-minute offset the same way Go's time.Parse does (`>` not `>=`)", () => { - // Established rationale: "The range test use > rather than >=, as some - // people do write offsets of 24 hours or 60 minutes." expect(parseBearerJwtExp("2030-01-01T00:00:00+24:00")).toEqual({ wholeSeconds: parseBearerJwtExp("2030-01-01T00:00:00Z").wholeSeconds - 24 * 60 * 60, nanos: 0, @@ -112,10 +100,6 @@ describe("parseBearerJwtExp", () => { }); it("preserves fractional seconds instead of dropping them during parsing (CLI-1961 Codex review finding)", () => { - // `time.Parse(time.RFC3339, ...)` accepts (and preserves at full precision) - // fractional seconds even though the RFC3339 layout has no fractional - // directive. Dropping the `.9` here (this port's previous behavior) would - // produce nanos `0` instead of `900_000_000`. expect(parseBearerJwtExp("2030-01-01T00:00:00.9Z")).toEqual({ wholeSeconds: 1_893_456_000, nanos: 900_000_000, @@ -132,11 +116,6 @@ describe("parseBearerJwtExp", () => { }); it("preserves a near-second nanosecond fraction as an exact integer instead of rounding it into the next second (CLI-1961 Codex review finding)", () => { - // A naive `wholeSeconds + Number('0.999999999')` float addition rounds UP to - // the exact integer `wholeSeconds + 1` in plain JS float arithmetic — - // `time.Time` keeps the nanoseconds in a separate integer field and the - // established truncate-down floor means the true parsed instant must - // report `nanos: 999_999_999` here, not silently become `wholeSeconds + 1, nanos: 0`. expect(parseBearerJwtExp("2030-01-01T00:00:00.999999999Z")).toEqual({ wholeSeconds: 1_893_456_000, nanos: 999_999_999, @@ -144,8 +123,6 @@ describe("parseBearerJwtExp", () => { }); it("truncates (not rounds) fractional digits beyond nanosecond precision, matching Go's time.Parse", () => { - // `.9999999995` (10 digits) parses to nanosecond `999999999`, not a - // rounded-up `1000000000` that would carry into the next second. expect(parseBearerJwtExp("2030-01-01T00:00:00.9999999995Z")).toEqual({ wholeSeconds: 1_893_456_000, nanos: 999_999_999, @@ -153,9 +130,6 @@ describe("parseBearerJwtExp", () => { }); it("accepts a comma as the fractional-seconds separator, matching Go's time.Parse (CLI-1961 Codex review finding)", () => { - // `time.Parse(time.RFC3339, "2030-01-01T00:00:00,5Z")` succeeds with the - // same nanosecond result as the `.5` spelling — either `.` or `,` is - // accepted as the fractional-seconds separator for any layout element. expect(parseBearerJwtExp("2030-01-01T00:00:00,5Z")).toEqual( parseBearerJwtExp("2030-01-01T00:00:00.5Z"), ); @@ -168,10 +142,6 @@ describe("parseBearerJwtExp", () => { }); it("parses an early (0000-0099) RFC3339 year literally instead of applying JS's two-digit-year remapping (CLI-1961 Codex review finding)", () => { - // `0001-01-01T00:00:00Z` parses to year 1, Unix `-62135596800` — - // `Date.UTC`/`new Date(...)`'s legacy two-digit-year special case (year `1` - // silently becomes `1901`) does NOT apply here, since this is a genuine - // 4-digit RFC3339 year, not a 2-digit shorthand. expect(parseBearerJwtExp("0001-01-01T00:00:00Z")).toEqual({ wholeSeconds: -62_135_596_800, nanos: 0, @@ -205,8 +175,6 @@ describe("parseBearerJwtValidFor", () => { }); it("preserves sub-second precision instead of flooring it away (CLI-1961)", () => { - // Flooring here (this port's previous behavior) would silently discard the 0.5s - // fraction before it ever reaches `buildBearerJwtClaims`'s final truncation. expect(parseBearerJwtValidFor("1.5s")).toBe(1.5); }); @@ -223,8 +191,7 @@ describe("parseBearerJwtValidFor", () => { }); it("accepts the Greek-mu microsecond spelling, matching Go's time.ParseDuration (CLI-1961 Codex review finding)", () => { - // The established `unitMap` accepts "us", "µs" (U+00B5), and "μs" (U+03BC - // Greek mu) alike. + // Accepts both U+00B5 (µ, micro sign) and U+03BC (μ, Greek mu). expect(parseBearerJwtValidFor("1μs")).toBe(0.000_001); }); }); @@ -235,9 +202,6 @@ describe("addSecondsAndFloor", () => { }); it("carries into the next second when nanos overflow 1e9", () => { - // Regression coverage (`--exp` omitted, sub-second `--valid-for`): a `now` - // of `X.900` plus a `0.2s` delta must land in the NEXT second (`X + 1`), - // not stay in the current one. expect(addSecondsAndFloor({ wholeSeconds: 100, nanos: 900_000_000 }, 0.2)).toBe(101); }); @@ -246,9 +210,6 @@ describe("addSecondsAndFloor", () => { }); it("never rounds an epoch-scale whole-second count up via float addition (CLI-1961 Codex review finding)", () => { - // The exact regression this helper exists to prevent: naive - // `wholeSeconds + fraction` float addition at epoch scale rounds a near-second - // fraction UP into the next integer. expect(addSecondsAndFloor({ wholeSeconds: 1_893_456_000, nanos: 999_999_999 }, 0)).toBe( 1_893_456_000, ); diff --git a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.handler.ts b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.handler.ts index 199f4659b9..a870a819bb 100644 --- a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.handler.ts +++ b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.handler.ts @@ -19,32 +19,15 @@ import { import { resolveBearerJwtSigningKey } from "./bearer-jwt.signing-key.ts"; /** - * `gen bearer-jwt`: fully local, no Docker, no network. Established order: + * `gen bearer-jwt` is fully local (no Docker, no network). * - * 0. Required-flag validation (ported as the `flags.role` check just - * below) — runs after the telemetry context is installed but before - * claims parsing, so a missing `--role` still flushes `telemetry.json` - * (see {@link GenBearerJwtRoleRequiredError}). - * 1. Claims parsing (ported as {@link buildBearerJwtClaims} + - * {@link mergeBearerJwtPayload}) — runs entirely BEFORE the rest of - * the command runs, so a malformed `--payload` fails before any config - * load or signing-key prompt ever happens. - * 2. Project config load — loads the project `.env` cascade (see - * SIDE_EFFECTS.md); ported via `loadProjectEnv` for the same - * failure mode, even though this command has no `.env`-sourced prompt - * of its own to gate. - * 3. Signing-key resolution (ported as - * {@link resolveBearerJwtSigningKey} in `bearer-jwt.signing-key.ts`) - * — resolves a JWK, prompting interactively when needed. - * 4. Signing (ported as `signJwtWithJwk` in `go-jwt.ts`) — - * signs the claims. - * 5. The token, then exactly one trailing newline, on stdout. Nothing - * else ever reaches stdout; every prompt and error goes to stderr. + * Required-flag validation and claims-payload parsing both run before the + * project config load and signing-key resolution, so a missing `--role` or a + * malformed `--payload` fails before any config load or signing-key prompt. * - * Unconditional on `--output-format`, matching `gen signing-key`'s own established - * precedent (`signing-key.handler.ts`): the raw token IS the payload — there is no - * separate human/machine shape to choose between, and this command has no - * `-o`/`--output-format` concept at all. + * Output is unconditional on `--output-format`: the raw token is the only + * payload, written to stdout with one trailing newline; every prompt and + * error goes to stderr. */ export const genBearerJwt = Effect.fn("gen.bearer-jwt")(function* (flags: GenBearerJwtFlags) { const cliSettings = yield* CommandSettings; @@ -63,9 +46,8 @@ export const genBearerJwt = Effect.fn("gen.bearer-jwt")(function* (flags: GenBea } const role = flags.role.value; - // Built directly from `Date.now()`'s integer milliseconds, NOT floored to whole - // seconds — see `BearerJwtClaimsInput.nowInstant`'s own doc comment for why - // pre-flooring here would shorten a sub-second `--valid-for`'s effective lifetime. + // Not floored to whole seconds — see `BearerJwtClaimsInput.nowInstant` + // for why pre-flooring would shorten a sub-second `--valid-for`. const nowMs = Date.now(); const nowInstant = { wholeSeconds: Math.floor(nowMs / 1000), diff --git a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts index aa8554bde7..9f6f79f4b5 100644 --- a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts +++ b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts @@ -103,11 +103,10 @@ async function writeSigningKeys(contents: string) { } /** - * Writes `supabase/.env.development` — a file the dotenv cascade reads - * (selected by `SUPABASE_ENV`, defaulting to `"development"`) but - * `@supabase/config`'s OWN default env resolution does NOT - * (`resolveSigningKeysConfigPaths` must resolve an accurate - * `CliProjectEnvironment` and thread it through explicitly). + * Writes `supabase/.env.development`, selected by `SUPABASE_ENV` (default + * `"development"`) but not read by `@supabase/config`'s own default env + * resolution — `resolveSigningKeysConfigPaths` must resolve and thread + * through an accurate `CliProjectEnvironment` explicitly. */ async function writeSupabaseEnvDevelopment(contents: string) { await mkdir(join(tempRoot.current, "supabase"), { recursive: true }); @@ -141,8 +140,6 @@ describe("gen bearer-jwt integration", () => { return Effect.gen(function* () { yield* genBearerJwt(baseFlags); - // Go: `fmt.Fprintln(w, token)` — the token, then exactly one trailing newline, - // nothing else on stdout. expect(out.stdoutText.endsWith("\n")).toBe(true); expect(out.stdoutText.indexOf("\n")).toBe(out.stdoutText.length - 1); @@ -227,10 +224,6 @@ describe("gen bearer-jwt integration", () => { it.live( "computes iat with a sub-second --valid-for, truncating only the final timestamp (CLI-1961)", () => { - // Verified against the real binary: `--exp 2030-01-01T00:00:00Z --valid-for 1.5s` - // (unix 1893456000) yields Go `iat=1893455998` — flooring the 1.5s duration to 1s - // BEFORE subtracting (this port's previous behavior) would wrongly yield - // 1893455999. const { layer, out } = setup(); return Effect.gen(function* () { yield* genBearerJwt({ @@ -261,10 +254,6 @@ describe("gen bearer-jwt integration", () => { }).pipe(Effect.provide(layer)); }); - // `--role` is required, but required-flag validation runs only AFTER the - // telemetry context is installed. A missing `--role` still writes - // `telemetry.json`, so the handler enforces the flag itself (after the telemetry-flushing - // wrapper is already active) instead of relying on the framework's parse-time rejection. it.live( "fails with cobra's required-flag error, and still flushes telemetry, when --role is omitted", () => { @@ -286,21 +275,12 @@ describe("gen bearer-jwt integration", () => { it.live( "ignores an ancestor project's signing_keys_path when the resolved workdir has no config.toml of its own (CLI-1961)", () => { - // Config resolution must resolve ONLY `<workdir>/supabase/config.toml` - // — no ancestor climb (once `cliSettings.workdir` is already resolved, - // matching an explicit `--workdir` pointing at a subdirectory below - // another project's root — the workdir change does not climb when - // `--workdir`/`SUPABASE_WORKDIR` is explicit either). Without - // `{ tomlOnly: true, search: false }` in `gen.signing-keys-config.ts`, - // the TS port picked up the PARENT directory's `signing_keys_path` - // and prompted for a kid instead of falling back to the - // unconfigured-default branch. const nestedWorkdir = join(tempRoot.current, "nested", "deeper"); const { layer, out } = setup({ workdir: nestedWorkdir, pipedAnswer: "" }); return Effect.gen(function* () { yield* Effect.tryPromise(() => mkdir(nestedWorkdir, { recursive: true })); - // `writeConfig`/`writeSigningKeys` target `tempRoot.current` — the ANCESTOR of - // `nestedWorkdir` — never `nestedWorkdir` itself. + // `writeConfig`/`writeSigningKeys` target `tempRoot.current`, the + // ancestor of `nestedWorkdir`, never `nestedWorkdir` itself. yield* Effect.tryPromise(() => writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), ); @@ -309,8 +289,6 @@ describe("gen bearer-jwt integration", () => { yield* genBearerJwt(baseFlags); const token = tokenFrom(out); const [header] = token.split("."); - // The unconfigured-default branch's prompt was answered blank, so this must be - // the built-in default dev key, NOT the ancestor's `ec-kid`. expect(decodeSegment(header ?? "")).toEqual({ alg: "ES256", kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", @@ -335,7 +313,6 @@ describe("gen bearer-jwt integration", () => { expect(json).toContain("GenBearerJwtPayloadError"); expect(json).toContain("failed to parse payload:"); } - // No signing-key prompt should have been reached — the payload merge runs first. expect(out.stderrText).toBe(""); }).pipe(Effect.provide(layer)); }, @@ -360,10 +337,8 @@ describe("gen bearer-jwt integration", () => { }); it.live("Branch A: on a real TTY, still prompts via stdin but does not echo the answer", () => { - // Branch A ALWAYS uses plain `PromptText` regardless of TTY-ness — only - // Branches B/C (a configured `signing_keys_path`) fork on interactivity. On a - // real TTY the terminal's own line-editing already echoes what was typed, so - // `consolePromptText` must not double-echo it itself. + // Branch A always uses plain text prompting regardless of TTY-ness; only + // Branches B/C fork on interactivity. const jwk = generateEcJwk("ec-kid"); const { layer, out } = setup({ stdinIsTty: true, pipedAnswer: JSON.stringify(jwk) }); return Effect.gen(function* () { @@ -445,10 +420,6 @@ describe("gen bearer-jwt integration", () => { it.live( "Branch A: a literal 'null' pasted at the stdin JWK prompt is rejected, NOT the default key", () => { - // `json.Unmarshal([]byte("null"), &key)` is a documented no-op for a - // non-pointer struct target — it leaves `key` at its zero value - // rather than erroring, and rather than falling back to the default - // key. That zero-value JWK (empty `kty`) then fails downstream at SIGN time. const { layer } = setup({ pipedAnswer: "null" }); return Effect.gen(function* () { const exit = yield* Effect.exit(genBearerJwt(baseFlags)); @@ -465,9 +436,8 @@ describe("gen bearer-jwt integration", () => { it.live( "Branch A: a truly blank answer at the stdin JWK prompt still falls back to the default key", () => { - // Distinct from the literal-'null' case above: an EMPTY answer never reaches - // `JSON.parse` at all (Go: `len(kid) == 0` gate), so it's the only input that - // legitimately falls back to the built-in default key. + // Distinct from the literal-`null` case above: an empty answer never + // reaches `JSON.parse` at all. const { layer, out } = setup({ pipedAnswer: "" }); return Effect.gen(function* () { yield* genBearerJwt(baseFlags); @@ -485,8 +455,6 @@ describe("gen bearer-jwt integration", () => { it.live( "Branch A: rejects a pasted JWK with an unsupported alg at decode time, not sign time", () => { - // `config.Algorithm.UnmarshalText` rejects anything other than - // RS256/ES256 DURING JSON decode, before the JWK ever reaches signing. const { layer } = setup({ pipedAnswer: JSON.stringify({ kty: "oct", alg: "HS256" }) }); return Effect.gen(function* () { const exit = yield* Effect.exit(genBearerJwt(baseFlags)); @@ -520,10 +488,6 @@ describe("gen bearer-jwt integration", () => { it.live( "Branch A: rejects a pasted JWK with a non-string key_ops element (CLI-1961 Codex review finding)", () => { - // `{"kty":"oct","alg":"ES256","key_ops":["sign",1]}` must exit 1 with - // this exact message — decoding into `config.JWK`'s `KeyOps []string` - // field fails outright on a non-string element rather than silently - // dropping the field the way this normalizer previously did. const { layer } = setup({ pipedAnswer: JSON.stringify({ kty: "oct", alg: "ES256", key_ops: ["sign", 1] }), }); @@ -544,9 +508,6 @@ describe("gen bearer-jwt integration", () => { it.live( "Branch A: rejects a pasted JWK with ext given as a string instead of a bool (CLI-1961 Codex review finding)", () => { - // `{"kty":"oct","alg":"ES256","ext":"true"}` must exit 1 with this - // exact message — decoding into `config.JWK`'s `Extractable *bool` - // field fails outright rather than silently dropping it. const { layer } = setup({ pipedAnswer: JSON.stringify({ kty: "oct", alg: "ES256", ext: "true" }), }); @@ -582,10 +543,6 @@ describe("gen bearer-jwt integration", () => { it.live( "Branch A: rejects a pasted JWK with a duplicate kid where the earlier occurrence is malformed (CLI-1961 Codex review finding)", () => { - // Verified against the real binary: `json.Unmarshal` into `config.JWK` decodes - // struct fields in the object's OWN source order and errors on the FIRST - // type-mismatch it finds — even though `JSON.parse` alone would silently keep - // only the LAST occurrence (a valid `"k1"`) and never see the earlier `1` at all. const { layer } = setup({ pipedAnswer: '{"kty":"oct","alg":"ES256","kid":1,"kid":"k1"}', }); @@ -604,11 +561,6 @@ describe("gen bearer-jwt integration", () => { it.live( "Branch A: rejects a pasted JWK with a duplicate alg where the earlier occurrence fails the allowlist, even though the later one is allowed (CLI-1961 Codex review finding)", () => { - // `config.Algorithm`'s `UnmarshalText` (the RS256/ES256 allowlist) - // returning an error for the FIRST `alg` occurrence ("HS256") stops - // the decoder from ever attempting the second ("ES256") — the - // overall decode still fails with the allowlist error, even though - // `JSON.parse` alone would keep only the later, individually-valid "ES256". const { layer } = setup({ pipedAnswer: '{"kty":"oct","alg":"HS256","alg":"ES256"}', }); @@ -627,10 +579,6 @@ describe("gen bearer-jwt integration", () => { it.live( "Branch A: accepts a pasted JWK with a duplicate alg where EVERY occurrence is individually valid and allowed", () => { - // Contrast with the previous test: the decoder only stops attempting - // later occurrences once an EARLIER one fails — when every occurrence - // independently succeeds, the LAST one wins normally, same as any - // other duplicated field. const { layer, out } = setup({ pipedAnswer: JSON.stringify({ ...generateEcJwk("dup-alg-kid"), alg: "ES256" }).replace( '"alg":"ES256"', @@ -653,12 +601,6 @@ describe("gen bearer-jwt integration", () => { it.live( "Branch A: accepts a pasted JWK with Go-decodable case-variant field names (CLI-1961 Codex review finding)", () => { - // `encoding/json`-style matching resolves `config.JWK`'s struct fields - // case-insensitively: `{"KTY":"EC","ALG":"ES256",...}` decodes - // identically to the all-lowercase spelling, including `alg` despite its extra - // `encoding.TextUnmarshaler` allowlist hook. A previous version of this - // normalizer only read exact lowercase property names, silently treating a - // case-variant field as absent and rejecting a key the established decoder accepts. const jwk = generateEcJwk("case-variant-kid"); const caseVariantJwk = { KTY: jwk.kty, @@ -686,13 +628,6 @@ describe("gen bearer-jwt integration", () => { it.live( "Branch A: rejects a pasted JWK with a case-variant duplicate kid where the earlier occurrence is malformed (CLI-1961 Codex review finding)", () => { - // Same mechanism as the exact-case duplicate-kid test above, but the earlier - // malformed occurrence spells the field "KID" while the later, valid one spells - // it "kid" — case-insensitive struct-field matching means both feed the - // SAME `config.JWK.KeyID` field, so the earlier malformed occurrence still fails - // the overall decode, exactly like a same-case duplicate does. - // `{"kty":"oct","alg":"ES256","KID":1,"kid":"k1"}` fails with this - // exact message even though `kid`'s own later, valid occurrence is "k1". const { layer } = setup({ pipedAnswer: '{"kty":"oct","alg":"ES256","KID":1,"kid":"k1"}', }); @@ -730,10 +665,6 @@ describe("gen bearer-jwt integration", () => { it.live( "Branch B: rejects a stored signing key with a non-string key_ops element (CLI-1961 Codex review finding)", () => { - // Verified against the real binary: a `signing_keys_path` file entry with a - // malformed `key_ops` fails during config load with THIS wrap (matching the - // sibling `alg`-allowlist check's own wrap for the same call site), not - // silently dropping the field. const { layer } = setup(); return Effect.gen(function* () { yield* Effect.tryPromise(() => @@ -761,10 +692,6 @@ describe("gen bearer-jwt integration", () => { it.live( "Branch B: rejects a stored signing key with a duplicate kid where the earlier occurrence is malformed (CLI-1961 Codex review finding)", () => { - // Same gap as Branch A's pasted-JWK duplicate-kid test, but for a - // `signing_keys_path` file entry: `readSigningKeysFile` must check each - // element's OWN raw source text, not the already-`JSON.parse`d (duplicate-key - // collapsed) record. const { layer } = setup(); return Effect.gen(function* () { yield* Effect.tryPromise(() => @@ -837,11 +764,6 @@ describe("gen bearer-jwt integration", () => { it.live( "Branch B: accepts a stored signing key with Go-decodable case-variant field names (CLI-1961 Codex review finding)", () => { - // Same fix as Branch A's pasted-JWK case-variant test, but for a - // `signing_keys_path` file entry — `normalizeStoredJwk`'s field lookups go - // through the SAME case-insensitive `resolveJwkFieldValue` helper, and the - // `alg` allowlist pre-check in `readSigningKeysFile` needs the identical - // fix. const jwk = generateEcJwk("case-variant-stored-kid"); const caseVariantJwk = { KTY: jwk.kty, @@ -874,14 +796,9 @@ describe("gen bearer-jwt integration", () => { it.live( "Branch B: resolves signing_keys_path = env(KEYS_PATH) from supabase/.env.development, a file @supabase/config's own default env resolution doesn't read (CLI-1961 Codex review finding)", () => { - // The dotenv cascade (which selects `.env.<SUPABASE_ENV>`, defaulting - // to "development") runs BEFORE the TOML decoder ever resolves `env(...)` - // references — so a `KEYS_PATH` set only in `supabase/.env.development` is visible - // to the `signing_keys_path = "env(KEYS_PATH)"` resolution. `@supabase/config`'s - // own default env loader (used whenever no `projectEnv` is explicitly threaded - // through) only reads plain `supabase/.env`/`.env.local` and would otherwise leave - // the literal string "env(KEYS_PATH)" unexpanded, and this call would fail trying - // to open a file with THAT literal name. + // The dotenv cascade runs before the TOML decoder resolves `env(...)` + // references, so `KEYS_PATH` from `.env.development` is visible to + // `signing_keys_path = "env(KEYS_PATH)"`. const jwk = generateEcJwk("ec-kid"); const { layer, out } = setup(); return Effect.gen(function* () { @@ -943,9 +860,6 @@ describe("gen bearer-jwt integration", () => { it.live( "Branch B: rejects a configured signing key with an unsupported alg at decode time, not sign time", () => { - // Decoding straight into `[]config.JWK` runs - // `config.Algorithm.UnmarshalText`'s RS256/ES256 allowlist DURING that - // decode. const { layer } = setup(); return Effect.gen(function* () { yield* Effect.tryPromise(() => @@ -971,14 +885,6 @@ describe("gen bearer-jwt integration", () => { it.live( "Branch B: rejects a nested array entry in signing_keys_path instead of partially accepting a later valid key (CLI-1961 Codex review finding)", () => { - // Go decodes `signing_keys_path` straight into `[]config.JWK` - // (`fetcher.ParseJSON[[]JWK]`) — an array-shaped element can never unmarshal into - // the `config.JWK` struct, so the WHOLE decode fails, verified directly against - // `encoding/json`: `json.Unmarshal([]byte('[[], {"kty":"EC","kid":"k2"}]'), - // &[]JWK{})` returns `"json: cannot unmarshal array into Go value of type - // config.JWK"`. Before this fix, `isRecord`'s `typeof value === "object"` check - // also matched arrays (arrays are `typeof "object"` in JS), so `[]` passed as a - // "record" and a later valid key (`k2`) could still be selected and signed. const { layer } = setup(); return Effect.gen(function* () { const validKey = generateEcJwk("k2"); @@ -1001,22 +907,6 @@ describe("gen bearer-jwt integration", () => { it.live( "Branch B: accepts a null entry AFTER a valid key in signing_keys_path, signing with an exact kid match (CLI-1961 Codex review finding)", () => { - // Distinct from the earlier-rejected null-BEFORE-valid-key finding on this PR: - // there, `SigningKeys[0]` is the null-decoded zero-value JWK, so - // `generateAPIKeys` fails signing before kid selection is ever reached. Here - // the valid key is FIRST, so `generateAPIKeys` succeeds — but a BLANK kid - // answer still fails, because the exact-KeyID-match loop runs BEFORE the - // blank-input fallback and the null-decoded second entry's OWN kid is `""`, - // an exact match for a blank answer (same quirk this file's "an exact kid - // match on a key with an empty kid wins ahead of the blank-input - // fallback-to-first" test already covers): a blank answer against - // `[validKey, null]` fails identically to this port with - // `"failed to convert JWK to private key: unsupported key type: "`. An - // EXPLICIT exact-kid answer for the valid key still signs successfully in - // both, which is what the finding's "a non-TTY user can still select - // validKey" actually depends on. `json.Unmarshal` accepts - // `[validKey, null]`, decoding the trailing `null` into a zero-value - // `config.JWK` rather than failing the whole array. const validKey = generateEcJwk("valid-kid"); const { layer, out } = setup({ pipedAnswer: "valid-kid" }); return Effect.gen(function* () { @@ -1040,11 +930,6 @@ describe("gen bearer-jwt integration", () => { it.live( "Branch B: ignores trailing bytes after the first JSON value in signing_keys_path, matching Go's single Decode (CLI-1961 Codex review finding)", () => { - // Decoding is a single `json.Decoder.Decode`-style call, which reads - // exactly one JSON value and never checks for trailing bytes: a - // `signing_keys_path` file containing a valid array followed by a second, - // syntactically-valid JSON value still lets signing succeed with the - // first array's key. const validKey = generateEcJwk("valid-kid"); const { layer, out } = setup(); return Effect.gen(function* () { @@ -1068,10 +953,6 @@ describe("gen bearer-jwt integration", () => { it.live( "Branch B: accepts a stored signing key with a null key_ops element, matching Go's zero-value decode (CLI-1961 Codex review finding)", () => { - // `key_ops` is never read by signing (it only inspects - // `kty`/`Algorithm`/the key-material fields), and `json.Unmarshal` decodes a - // `null` element of a `[]string` as that element's zero value (`""`), not a - // type mismatch. const jwk = { ...generateEcJwk("null-key-ops-kid"), key_ops: ["sign", null] }; const { layer, out } = setup(); return Effect.gen(function* () { @@ -1095,9 +976,6 @@ describe("gen bearer-jwt integration", () => { it.live( "Branch C: TTY with zero configured signing keys fails with Go's exact 'user aborted' text", () => { - // A zero-item list quits immediately without ever letting the user - // select anything. Previously this crashed with an unhandled - // `TypeError` instead of failing gracefully. const { layer } = setup({ stdinIsTty: true }); return Effect.gen(function* () { yield* Effect.tryPromise(() => @@ -1167,9 +1045,9 @@ describe("gen bearer-jwt integration", () => { yield* Effect.tryPromise(() => writeConfig('[auth]\nsigning_keys_path = "./signing_keys.json"\n'), ); - // `namedKey` is listed FIRST, but has a non-empty kid; `unnamedKey` (no kid - // field at all -> "") is listed SECOND. A blank answer must still resolve to - // `unnamedKey` via the exact-match loop, not to `namedKey` via "return first". + // `namedKey` has a non-empty kid; `unnamedKey` has none (-> ""). A + // blank answer must resolve to `unnamedKey` via the exact-match + // loop, not to `namedKey` via "return first". yield* Effect.tryPromise(() => writeSigningKeys(JSON.stringify([namedKey, unnamedKey]))); yield* genBearerJwt(baseFlags); @@ -1196,9 +1074,6 @@ describe("gen bearer-jwt integration", () => { const token = tokenFrom(out); const [header] = token.split("."); expect(decodeSegment(header ?? "")).toEqual({ alg: "RS256", kid: "rsa-kid", typ: "JWT" }); - // This command's own stdout is the signed-token payload even in text mode, so the - // line must land on stderr (`output.raw(..., "stderr")`), not via `output.info` - // (clack's `log.info`, which defaults to stdout). expect(out.stderrText).toContain("Selected key ID: rsa-kid"); }).pipe(Effect.provide(layer)); }, @@ -1262,8 +1137,6 @@ describe("gen bearer-jwt integration", () => { yield* genBearerJwt(baseFlags); const token = tokenFrom(out); const [header] = token.split("."); - // The default key's kid, NOT the file's key — the file is never read - // when auth.enabled is false. expect(decodeSegment(header ?? "")).toEqual({ alg: "ES256", kid: "b81269f1-21d8-4f2e-b719-c2240a840d90", diff --git a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts index adfc0f9186..a93dc60834 100644 --- a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts +++ b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts @@ -28,18 +28,14 @@ import { GenBearerJwtReadError, } from "./bearer-jwt.errors.ts"; -/** Established console read-line timeouts. */ +/** Console read-line timeouts. */ const GO_CONSOLE_TTY_TIMEOUT_MILLIS = 10 * 60 * 1000; const GO_CONSOLE_NON_TTY_TIMEOUT_MILLIS = 100; /** - * Writes `label` to stderr with NO trailing newline, reads one line bounded - * by a TTY-aware timeout, and — only on a non-TTY — echoes the (trimmed) - * input back to stderr. On a real TTY the terminal's own line-editing - * already echoes what the user types, so no explicit echo is written - * there. Used for BOTH of `getSigningKey`'s text prompts — the branch - * between them is purely which label/fallback the caller applies to the - * returned string, not how the read itself behaves. + * Writes `label` to stderr with no trailing newline, reads one line bounded + * by a TTY-aware timeout, and echoes the input back to stderr only on a + * non-TTY (a real TTY's own line-editing already echoes it). */ const consolePromptText = Effect.fnUntraced(function* (label: string) { const output = yield* Output; @@ -57,36 +53,16 @@ const consolePromptText = Effect.fnUntraced(function* (label: string) { }); /** - * Narrows an untrusted JSON record (a `signing_keys_path` file entry, or a pasted - * stdin JWK) into `Jwk`'s shape — every field `config.JWK` would - * leave at its zero value (`""`/absent) when missing from the JSON decodes - * the same way here. Every downstream consumer in this file works with the - * resulting typed `Jwk`, not the raw untrusted record, so - * key/kid/alg lookups stay type-checked instead of re-guarding - * `Record<string, unknown>` at every call site. + * Narrows an untrusted JSON record (a `signing_keys_path` entry, or a pasted + * stdin JWK) into `Jwk`'s shape, defaulting each missing field the way a Go + * zero-value struct would. * - * Throws a bare `Error` (the established unwrapped `encoding/json` text) - * the moment any field above is present with the wrong JSON type — both - * call sites below (`normalizeStoredJwk` itself is synchronous) catch that - * throw and apply THEIR OWN established wrap: Branch A's pasted-JWK path - * wraps `"failed to parse JWK: %w"`, while the `signing_keys_path` file - * path wraps `"failed to decode signing keys: failed to parse response - * body: %w"` — the same two wraps this file's sibling checks (the - * JSON-decode / `alg` allowlist checks) already use for those exact same - * two call sites. Decoding processes struct fields in the JSON document's - * own key order and stops at the FIRST mismatch; this function instead - * always checks in a fixed field order, so on a payload with MULTIPLE - * simultaneously-malformed fields the reported field may not always match - * the established decoder for that specific multi-error case — accepted - * gap, no fixture covers ordering across more than one bad field at once, - * and every field is still correctly rejected either way. - * `readOptionalString`/`readOptionalStringArray`/`readOptionalBoolean` are - * hoisted to `gen.signing-keys-config.ts` (the `gen` family root) rather - * than defined locally: `assertNoMalformedDuplicateJwkField` there needs - * the exact same per-field checks to catch a DUPLICATED field whose - * earlier occurrence `JSON.parse` alone would have silently discarded — - * see both call sites below, and that function's own doc comment for the - * mechanics. + * Throws a bare `Error` with the unwrapped `encoding/json` type-mismatch + * text the moment any field has the wrong JSON type; each call site wraps it + * with its own prefix. Checks fields in a fixed order rather than the + * document's own key order, so on a payload with multiple malformed fields + * the reported field may not always match a real decoder — accepted gap, + * every field is still rejected either way. */ function normalizeStoredJwk(record: Record<string, unknown>): Jwk { const keyOps = readOptionalStringArray(record, "key_ops"); @@ -133,15 +109,10 @@ const resolveSigningKeyFromStdinJwk = Effect.fnUntraced(function* () { }), ); } - // A JSON `null` answer decodes into a ZERO-VALUE `config.JWK{}`: - // `json.Unmarshal([]byte("null"), &key)` where `key` is a non-pointer - // struct is a documented no-op (it leaves every field at its zero value: - // `kty: ""`, `alg: ""`, ...) rather than an error, and rather than the - // built-in default key. This must reach the SAME zero-value JWK the - // empty-object shape below does, so it fails downstream at SIGN time - // with "unsupported key type: " (empty kty) — a `null` answer is - // genuinely rejected where a truly BLANK answer (handled above, before - // `JSON.parse` is ever called) falls back to the default key. + // A JSON `null` answer decodes to a zero-value JWK (same as `{}`), which + // fails downstream at sign time with "unsupported key type: " — distinct + // from a truly blank answer (handled above), which falls back to the + // default key instead. if (parsed === null) { return normalizeStoredJwk({}); } @@ -153,10 +124,8 @@ const resolveSigningKeyFromStdinJwk = Effect.fnUntraced(function* () { ); } const record = parsed as Record<string, unknown>; - // Case-insensitive lookup (`resolveJwkFieldValue`) — the `alg` allowlist - // check (`config.Algorithm.UnmarshalText`) runs at JSON-decode time - // regardless of the key's casing; see that function's doc comment in - // `gen.signing-keys-config.ts`. + // Case-insensitive lookup: the `alg` allowlist check runs regardless of + // the key's casing; see `resolveJwkFieldValue` in `gen.signing-keys-config.ts`. const alg = resolveJwkFieldValue(record, "alg"); try { assertDecodableJwkAlgorithm(typeof alg === "string" ? alg : undefined); @@ -167,23 +136,11 @@ const resolveSigningKeyFromStdinJwk = Effect.fnUntraced(function* () { }), ); } - // `normalizeStoredJwk` throws the established bare `encoding/json` - // struct-field type-mismatch text (see its own doc comment) the moment - // any OTHER field is malformed — e.g. - // `{"kty":"oct","alg":"ES256","key_ops":["sign",1]}` — wrapped here with - // the same `"failed to parse JWK: %w"` this Branch A path already uses - // above. - // - // `assertNoMalformedDuplicateJwkField` runs FIRST, against `input` — the untouched - // pasted text — rather than `record`: by the time `parsed`/`record` exist, `JSON.parse` - // has ALREADY collapsed any duplicate top-level key down to its last occurrence (plain - // JS object-literal semantics), silently discarding evidence of an earlier occurrence - // that must still be rejected — e.g. a pasted `{"kid":1,"kid":"k"}` parses to - // `record.kid === "k"` with no trace `1` was ever there, yet decoding into - // `config.JWK` still errors on the `1` and never reaches `normalizeStoredJwk`'s - // equivalent of the merged, valid `"k"` (see that function's own doc - // comment for the full mechanics, including the `alg` field's distinct - // allowlist-driven behavior). + // `assertNoMalformedDuplicateJwkField` checks the raw `input` text, not + // `record`: `JSON.parse` already collapsed any duplicate top-level key to + // its last occurrence, silently discarding evidence (e.g. `{"kid":1,"kid":"k"}` + // parses to `record.kid === "k"` with no trace `1` was ever there) that + // must still be rejected. return yield* Effect.try({ try: () => { assertNoMalformedDuplicateJwkField(input); @@ -197,46 +154,19 @@ const resolveSigningKeyFromStdinJwk = Effect.fnUntraced(function* () { }); /** - * Branches B/C (reached when `[auth].signing_keys_path` IS configured): - * non-TTY prompts for a kid by exact string match (falling back to the - * first key on a blank answer); a real TTY presents an interactive picker - * instead (`output.promptSelect`, the same `@clack/prompts`-backed pattern - * `project-ref.layer.ts` already uses for the established - * bubbletea-style `PromptChoice` — the rendered ANSI never byte-matches a - * TUI either way, so this codebase's established precedent is to match - * only the observable stderr line printed after a choice, "Selected key - * ID: <kid>"). + * Branches B/C (reached when `[auth].signing_keys_path` is configured): a + * non-TTY prompts for a kid by exact match, falling back to the first key on + * a blank answer; a real TTY presents an interactive picker instead. * - * Both the picker AND that line are routed to stderr explicitly (`{ stream: "stderr" - * }` / `output.raw(..., "stderr")` below) rather than the shared `promptSelect`/`info` - * defaults: interactive prompts should always be written to stderr — but - * clack's `select()`/`log.info()` default to `process.stdout`, and this - * command's own stdout IS the signed-token payload even in text mode (see - * `bearer-jwt.handler.ts`'s doc comment) — so the unmodified defaults would - * corrupt a piped/captured token with picker UI and the "Selected key ID: - * ..." line for any interactive user with a configured `signing_keys_path`. + * Both the picker and its "Selected key ID: ..." line are routed to stderr + * explicitly rather than the shared `promptSelect`/`info` defaults, since + * this command's stdout is the signed-token payload even in text mode (see + * `bearer-jwt.handler.ts`) and clack's own defaults write to stdout. * - * The picker tries the AMBIENT `Output` first — this is what every test in this file - * mocks, and it's what a real `text` run already uses — and only on the AMBIENT - * `output.promptSelect`/`raw` failing with `NonInteractiveError` (the json/stream-json - * `Output` layers' unconditional behavior, `output.layer.ts`) does it retry through a - * FRESH, locally-provided {@link textOutputLayer} instance. This is the same rationale - * as `commands/migration/migration.prompt.ts`'s `migrationConfirm`: - * the established `PromptChoice` has no concept of an output format at all and - * always prompts on a real TTY, and this command's stdout is the raw token - * unconditionally in EVERY format (see `bearer-jwt.handler.ts`'s doc comment) — there - * is no structured json/stream-json result here for an interactive widget to corrupt, - * unlike `project-ref.layer.ts`'s own `promptSelect` (which DOES stay gated - * behind `output.format`, because ITS caller's json/stream-json mode has a real - * machine payload an interactive prompt would otherwise interleave with). The ambient - * json/stream-json `Output` layers' `promptSelect` unconditionally raises - * `NonInteractiveError`, which would abort this command entirely on a real TTY with a - * configured `signing_keys_path` and more than one stored key. The try-then-fall-back - * shape (rather than unconditionally swapping to `textOutputLayer`) is deliberate: it - * keeps every existing mock-driven test exercising the SAME ambient `Output` path they - * already do, and only reaches the real, un-mockable `@clack/prompts` renderer in the - * one combination (`NonInteractiveError` from a genuinely non-text production layer) - * that requires it. + * The picker tries the ambient `Output` first (what every test here mocks), + * falling back to a fresh {@link textOutputLayer} only when that raises + * `NonInteractiveError` (json/stream-json layers reject prompts outright), + * so the picker still renders on a real TTY under a non-text ambient layer. */ const resolveSigningKeyFromConfigured = Effect.fnUntraced(function* ( availableKeys: ReadonlyArray<Jwk>, @@ -247,9 +177,8 @@ const resolveSigningKeyFromConfigured = Effect.fnUntraced(function* ( const kid = yield* consolePromptText( "Enter the kid of your signing key (or leave blank to use the first one): ", ); - // The exact `KeyID` match check runs BEFORE the blank-input fallback — - // so a key whose own `kid` is literally `""` still matches a blank - // answer here, ahead of "return the first key". + // The exact kid match runs before the blank-input fallback, so a key + // whose own `kid` is `""` still matches ahead of "return the first key". const found = availableKeys.find((key) => (key.kid ?? "") === kid); if (found !== undefined) { return found; @@ -263,13 +192,9 @@ const resolveSigningKeyFromConfigured = Effect.fnUntraced(function* ( } if (availableKeys.length === 0) { - // A ZERO-item list must quit immediately without ever letting the user - // select anything: `errors.New("user aborted")`, unwrapped. Guarded - // here rather than ever reaching `output.promptSelect` — - // `@clack/prompts`' own `select()` has no equivalent "immediately quit - // on an empty option list" behavior to lean on, and calling it with - // zero options would otherwise resolve to an out-of-range index and - // crash with a raw `TypeError` when `.kid` is accessed below. + // A zero-item list must quit immediately: `@clack/prompts`' own + // `select()` has no "quit on empty options" behavior, and calling it + // with zero options would crash with a raw `TypeError` instead. return yield* Effect.fail(new GenBearerJwtKeyPickerAbortedError({ message: "user aborted" })); } @@ -279,24 +204,14 @@ const resolveSigningKeyFromConfigured = Effect.fnUntraced(function* ( label: key.kid ?? "", hint: `${key.alg ?? ""} (${(key.key_ops ?? []).join(",")})`, })); - // Try the AMBIENT `Output` first (this is what every test in this file mocks, and - // what a real `text` run already uses) — only on `NonInteractiveError` (the - // json/stream-json `Output` layers' `promptSelect`/`raw`, `output.layer.ts`) fall - // back to a REAL, locally-provided `textOutputLayer` instance so the picker still - // renders on a genuine TTY. `textOutputLayer` needs only `Tty` and `Stdio`, both - // ambient from the CLI root, so this fallback is a local override; the token is written - // through the AMBIENT `Output` later, in `bearer-jwt.handler.ts`, completely - // unaffected by it. See this function's doc comment above for why this - // picker needs this at all. + // See the doc comment above for why this falls back to `textOutputLayer`. const pickSigningKey = (pickerOutput: typeof Output.Service) => Effect.gen(function* () { const chosen = yield* pickerOutput.promptSelect("Select a signing key:", options, { stream: "stderr", }); const chosenKey = availableKeys[Number(chosen)]!; - // `output.raw(..., "stderr")`, NOT `output.info` — `info` is clack's - // `log.info`, which defaults to stdout (see this function's doc - // comment above). + // `output.raw`, not `output.info` (clack's `log.info` defaults to stdout). yield* pickerOutput.raw(`Selected key ID: ${chosenKey.kid ?? ""}\n`, "stderr"); return chosenKey; }); @@ -314,23 +229,14 @@ const resolveSigningKeyFromConfigured = Effect.fnUntraced(function* ( }); /** - * Fully assembled: resolves `[auth].signing_keys_path`'s config, then - * dispatches to Branch A (unconfigured) or Branches B/C (configured, - * non-TTY/TTY). + * Resolves `[auth].signing_keys_path`'s config, then dispatches to Branch A + * (unconfigured) or Branches B/C (configured, non-TTY/TTY). * - * Reproduces the established `[auth].enabled = false` quirk: config - * validation only reads the `signing_keys_path` FILE when auth is enabled, - * but `getSigningKey` only checks whether the PATH STRING is configured — - * independent of `auth.enabled`. So when auth is disabled and a path IS - * configured, the kid-prompt branch still runs, but the actual available - * keys stay the built-in default (the file is never read) — a real user - * hitting this combination sees a misleading kid prompt they can never - * satisfy with their own file's kids. `gen signing-key`'s OWN sibling - * resolver ({@link genSigningKey}'s `loadSigningKeysConfig`) needs and - * replicates this exact same gate — it goes through the identical config - * load and validation pipeline as this command, so it is subject to the - * same auth-disabled quirk (see `gen.signing-keys-config.ts`'s - * `authEnabled` doc comment). + * When auth is disabled but a path is configured, the kid-prompt branch + * still runs, but the available keys stay the built-in default since the + * file is never read — config validation only reads the file when auth is + * enabled, independent of whether the path string is set. `gen signing-key` + * shares this same quirk; see `authEnabled` in `gen.signing-keys-config.ts`. */ export const resolveBearerJwtSigningKey = Effect.fnUntraced(function* (workdir: string) { const paths = yield* resolveSigningKeysConfigPaths( @@ -349,17 +255,10 @@ export const resolveBearerJwtSigningKey = Effect.fnUntraced(function* (workdir: (message) => new GenBearerJwtReadError({ message }), (message) => new GenBearerJwtDecodeError({ message }), ); - // `normalizeStoredJwk` throws the established bare `encoding/json` - // struct-field type-mismatch text (see its own doc comment) the moment - // any stored key entry has a malformed field — e.g. - // `[{"kty":"oct","alg":"ES256","ext":"true"}]` — wrapped here with the - // SAME `"failed to decode signing keys: failed to parse response body: %w"` this - // file's sibling `alg`-allowlist check (inside `readSigningKeysFile`) already - // uses for this exact call site. A DUPLICATE - // malformed field (e.g. `[{"kid":1,"kid":"k1",...}]`) is already rejected earlier, - // by `readSigningKeysFile` itself calling `assertNoMalformedDuplicateJwkField` - // against each element's own raw source text — `storedKeys` here is guaranteed - // free of that gap by the time this runs. + // A malformed field throws via `normalizeStoredJwk` (see its own doc + // comment), wrapped with the same message `readSigningKeysFile`'s + // `alg`-allowlist check already uses. A duplicate malformed field is + // already rejected earlier, inside `readSigningKeysFile` itself. availableKeys = yield* Effect.try({ try: () => storedKeys.map(normalizeStoredJwk), catch: (cause) => From 0fdcbc9aaade67b0a68629ce05fb23f77da1084f Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:08:20 +0100 Subject: [PATCH 41/56] chore(repo): tighten remaining long module headers (final pass 2) --- .../src/command-internal/config-validate.ts | 56 ++++++------------- .../src/command-internal/connect-errors.ts | 14 ++--- .../db-bootstrap/shadow-database.ts | 15 ++--- .../db-bootstrap/start-database.ts | 20 ++----- .../db-bootstrap/start-local-database.ts | 16 ++---- apps/cli/src/command-internal/linked-state.ts | 25 +++------ .../db/inspect-query.integration.test.ts | 2 +- tools/release/local-release.ts | 2 +- 8 files changed, 49 insertions(+), 101 deletions(-) diff --git a/apps/cli/src/command-internal/config-validate.ts b/apps/cli/src/command-internal/config-validate.ts index 658f838884..9c99dcd42d 100644 --- a/apps/cli/src/command-internal/config-validate.ts +++ b/apps/cli/src/command-internal/config-validate.ts @@ -11,27 +11,18 @@ import { BRANCH_PROJECT_REF_PATTERN } from "./ref-patterns.ts"; import { goUrlParse } from "./storage-url.ts"; /** - * Single home for config validation, consolidating two independent implementations: + * Single home for config validation, shared by the two config readers: * - * - **D** = `db-config.toml-read.ts` — raw smol-toml document + `EnvLookup`, Effect-based, - * fails with `DbConfigLoadError`. Feeds ~15 db/migration commands via `db-config.layer.ts`. - * - **L** = `local-config-values.ts` — decoded `@supabase/config` `CliConfig`, synchronous - * `node:fs`, throws plain `Error`. Feeds `status-values.ts` and `stop/stop.handler.ts`. + * - **D** = `db-config.toml-read.ts`: raw TOML document, Effect-based, fails with + * `DbConfigLoadError`. + * - **L** = `local-config-values.ts`: decoded `@supabase/config` `CliConfig`, synchronous, + * throws `Error`. * - * Per-command reimplementations of any branch {@link validateResolvedConfig} owns are - * forbidden — hoist here instead (see `apps/cli/AGENTS.md`'s "Hoist Before You Duplicate"). - * - * A few branches stay caller-only and are never routed through this module: - * `remotes[*].project_id` and `auth.sms`/`auth.external` are D-only (L instead relies on - * `@supabase/config`'s own schema, or needs the raw pre-decode document); `auth.jwt_secret` - * length checking lives in each caller's own key-generation flow. - * - * D and L each call {@link validateResolvedConfig} exactly once, positioned after their own - * per-section I/O reads (signing keys, `api.tls` cert/key, email template content) and, for D, - * before its own inline sms/external checks. This means an error from a pure branch this - * module owns always surfaces ahead of a caller-only I/O or sms/external error, even in the - * rare case where both would independently fail — an accepted, narrow ordering gap, not a bug - * to fix by splitting this function into multiple calls. + * Per-command reimplementations of any branch {@link validateResolvedConfig} owns are forbidden; + * hoist here instead. A few branches stay caller-only: `remotes[*].project_id` and + * `auth.sms`/`auth.external` need the raw pre-decode document, and `auth.jwt_secret` length lives + * in each key-generation flow. Each reader calls {@link validateResolvedConfig} once, after its + * own I/O reads, so an error from a branch here surfaces before a caller-only I/O error. */ // Re-exported under this module's established name; `ref-patterns.ts` is the canonical @@ -624,26 +615,15 @@ function isPathContainedInRoot(root: string, candidatePath: string): boolean { const MAX_SYMLINK_FOLLOW_DEPTH = 40; /** - * Canonicalizes `path` when it exists, or returns `undefined` when it genuinely doesn't — the - * signal {@link canonicalPathForContainment} needs to decide whether to keep walking up - * towards an existing ancestor. - * - * "Exists" is decided with `lstatSync` (which doesn't dereference `path`), not by whether - * `realpathSync` succeeded — a dangling symlink, an unsearchable symlink target (`EACCES`), or - * a symlink loop (`ELOOP`) all make `realpathSync` throw even though `path` exists on disk. - * Such a symlink is followed one hop by hand (`readlinkSync`, bounded by - * {@link MAX_SYMLINK_FOLLOW_DEPTH}) and its target canonicalized in turn, so containment - * always sees where the symlink actually points. A symlink chain still unresolved at the depth - * bound returns the lexical path as-is, so a genuine loop is rejected rather than laundered - * into an accept by the ancestor walk-up. + * Canonicalizes `path` when it exists (per `lstatSync`), or returns `undefined` so + * {@link canonicalPathForContainment} keeps walking up to an existing ancestor. * - * `lstatSync` can itself fail for reasons unrelated to the path's own existence (an unreadable - * ancestor directory, a too-long name, or — on some platforms — a directory needing search - * permission on itself to resolve its own name). That's treated the same as "doesn't exist - * yet": {@link canonicalPathForContainment}'s walk-up resolves the deepest ancestor it can and - * re-appends the unresolvable tail lexically, so an honest in-root path behind a restricted - * ancestor isn't falsely rejected, while an escaping symlink whose target sits behind the same - * kind of restricted component is still rejected on its own target's ancestor. + * `realpathSync` can throw for a path that exists (dangling symlink, `EACCES` target, `ELOOP`), + * so such a symlink is followed one hop by hand, bounded by {@link MAX_SYMLINK_FOLLOW_DEPTH}, + * and its target canonicalized in turn; a chain still unresolved at the bound returns the lexical + * path so a loop is rejected rather than accepted. An `lstatSync` failure unrelated to the path + * itself (unreadable ancestor, over-long name) counts as "doesn't exist yet", so an honest in-root + * path behind a restricted ancestor isn't falsely rejected. */ function canonicalizeExistingPath(path: string, depth: number): string | undefined { try { diff --git a/apps/cli/src/command-internal/connect-errors.ts b/apps/cli/src/command-internal/connect-errors.ts index 5de1f1432a..3a1c345859 100644 --- a/apps/cli/src/command-internal/connect-errors.ts +++ b/apps/cli/src/command-internal/connect-errors.ts @@ -313,16 +313,10 @@ export function connectFailureMessage(target: ConnectFailureTarget, error: unkno const IPV6_DIAL_CODES = new Set(["ENETUNREACH", "EHOSTUNREACH", "EADDRNOTAVAIL"]); /** - * Whether the error chain carries a node dial failure whose errno + `address` fields identify - * an unreachable IPv6 target. This is the structured complement to - * {@link isIPv6ConnectivityError}: node system errors carry the dialed address as a field - * (`connect EHOSTUNREACH 2600:…:5432` has `code` and `address`) rather than embedding it in - * the message text. Narrower than `isIPv6ConnectivityErrorCause` — it never treats `ENOTFOUND` - * (a plain DNS miss, e.g. a typo'd host) as IPv6. Use this one for the connect suggestion; the - * container-level pooler fallback keeps the broader `isIPv6ConnectivityErrorCause`. Like - * {@link deepestConnectCause}, an `AggregateError` descends into only its last child. - * Depth-bounded recursion (no `seen` set): a pathological cause cycle re-walks at most 8 - * levels, which is cheap and cannot loop. + * Whether the error chain holds a node dial failure whose `code` and `address` fields identify + * an unreachable IPv6 target: the structured complement to {@link isIPv6ConnectivityError}, and + * narrower than `isIPv6ConnectivityErrorCause` since a DNS miss (`ENOTFOUND`) is never IPv6. + * Descends into an `AggregateError`'s last child only, at most 8 levels deep. */ function hasIPv6DialCause(error: unknown, depth = 0): boolean { if (depth > 8 || typeof error !== "object" || error === null) return false; 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 0fe6d07083..c01cf0a5e5 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts @@ -1,15 +1,10 @@ /** - * The shadow-database provisioning primitives: create, health-wait, platform-baseline setup, and - * migrations replay. Exposed individually rather than fused, since callers compose different - * subsets — `migration squash` needs create -> health-wait -> connect -> setup only, while `db - * diff --use-pgadmin` needs create -> health-wait -> migrations replay. The composed diff/pull - * shape lives in `commands/db/shared/shadow-source.ts` instead, so this module never pulls in the - * diff engines. + * Shadow-database primitives (create, health-wait, baseline setup, migrations replay), exposed + * individually because callers compose different subsets; the fused diff/pull shape lives in + * `commands/db/shared/shadow-source.ts` so this module never imports the diff engines. * - * The shadow container is created with no name and no network alias, unlike every other - * container this codebase creates. Its PG15+ one-shot setup jobs still need a hostname to reach - * it over the shared Docker network, so `dbHost: container.slice(0, 12)` (the container id's own - * 12-char short form) is used instead. + * The shadow container has no name or network alias, so its one-shot setup jobs reach it by the + * container id's 12-char short form as hostname. */ import { 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 44f0c8ee82..78adf0bc54 100644 --- a/apps/cli/src/command-internal/db-bootstrap/start-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/start-database.ts @@ -1,19 +1,11 @@ /** - * The one function both `supabase start` and `db start` call to bring up the local Postgres - * container, so the two callers share one sequence to test and maintain instead of - * independently-typed copies that could drift. + * The single bring-up sequence for the local Postgres container, shared by `supabase start` and + * `db start`: volume probe, image resolve and network ensure, create and start, health wait + * (swallowed only for `fromBackup`), fresh-volume setup (skipped for `fromBackup`), then + * `initCurrentBranch`. `--ignore-health-check` and rollback are the caller's concern. * - * Call order: pre-create volume-existence probe (+ the `fromBackup`-on-an-existing-volume guard) - * -> image resolve + network ensure -> container create+start -> health wait (swallowed only when - * `fromBackup` is set) -> the fresh-volume setup pipeline (skipped entirely when `fromBackup` is - * set) -> `initCurrentBranch`, unconditionally, on every path that doesn't already return or fail - * above. `--ignore-health-check` and rollback are the caller's own concern, not this function's. - * - * `resolvePostgresImage` and `setup.jwks` are caller-supplied `Effect`s, not plain values, because - * their timing differs between callers: `db start` resolves both lazily, right where they're - * needed, while `supabase start` already resolved them earlier in its own prelude (for its - * pre-pull and its other services respectively) and just threads the same values through — see - * each field's own doc comment. + * `resolvePostgresImage` and `setup.jwks` are caller-supplied `Effect`s because `db start` + * resolves them lazily while `supabase start` threads values it already resolved. */ import { Data, Effect, type FileSystem, type Path, Result } from "effect"; 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 96a9ec5363..d1670d8103 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 @@ -1,15 +1,11 @@ /** - * A plain local-database start — the shared entrypoint `supabase db start` and the declarative - * `--local` paths' `ensureLocalDatabaseStarted` both call. Self-contained: resolves every service - * it needs itself, emits progress via `output.raw` only, and never flushes telemetry — that stays - * the top-level command's own concern. + * Plain local-database start shared by `supabase db start` and the declarative `--local` paths. + * Self-contained: resolves its own services, reports progress via `output.raw` only, and never + * flushes telemetry. * - * Unlike `resetLocalDatabase`, "the database is already running" is a normal, successful outcome - * here, not a failure — and the two real callers want different observable behavior on it: `db - * start` prints "Postgres database is already running.", while the declarative seam stays silent. - * So this function never prints the terminal-state line itself; it returns a - * {@link StartLocalDatabaseResult} discriminator and leaves the print (or lack of one) to the - * caller. + * "Already running" is a success here, and callers print it differently (`db start` prints a + * message, the declarative seam stays silent), so this returns a + * {@link StartLocalDatabaseResult} discriminator instead of printing the terminal line itself. */ import { Effect, FileSystem, Option, Path } from "effect"; diff --git a/apps/cli/src/command-internal/linked-state.ts b/apps/cli/src/command-internal/linked-state.ts index 81f52cde6b..0c5c8a48c1 100644 --- a/apps/cli/src/command-internal/linked-state.ts +++ b/apps/cli/src/command-internal/linked-state.ts @@ -61,24 +61,15 @@ const resolveSoftLinkedRef = Effect.fnUntraced(function* () { }); /** - * Resolves the current linked-state display (project or branch) for - * `status`, without requiring a link. Never fails — every step degrades - * rather than propagating an error: + * Resolves the linked-state display (project or branch) for `status` without requiring a link. + * Never fails; each step degrades instead: * - * - Not linked → `{ linked: false }`. - * - The cache confirms the linked ref is its own parent → a plain project - * link, zero API calls. - * - The cache names a different parent and the ref came from the - * `project-ref` file → a branch link. Attempts the best-effort - * {@link findBranchName} lookup, but degrades to "assumed branch, name - * unknown" rather than a plain line on any failure, so the user still sees - * they're on a branch when the lookup can't run. - * - Same divergence, but the ref came from `SUPABASE_PROJECT_ID` (env) — the - * cache belongs to the workdir, not necessarily to what the env var points - * at, so the branch claim additionally requires the lookup to positively - * confirm it; otherwise it degrades to the plain `{ linked: true, - * projectRef }` shape. - * - No cache at all → the plain shape, with zero API calls. + * - No link, or no cache: the plain shape, with zero API calls. + * - The cache says the linked ref is its own parent: a plain project link. + * - The cache names a different parent: a branch link. With the ref from the `project-ref` file, + * a failed {@link findBranchName} lookup still reports "branch, name unknown"; with the ref + * from `SUPABASE_PROJECT_ID` the cache may not describe it, so the branch claim needs the + * lookup to confirm it and otherwise degrades to the plain shape. */ export const resolveLinkedState = Effect.fnUntraced(function* () { const cliSettings = yield* CommandSettings; diff --git a/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts b/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts index 7a17788349..28bf8bc289 100644 --- a/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts +++ b/apps/cli/src/commands/inspect/db/inspect-query.integration.test.ts @@ -139,7 +139,7 @@ interface SetupOpts { connectFails?: boolean; queryFails?: boolean; dnsResolver?: "native" | "https"; - /** Raw CLI args slice — drives Changed-based flag detection (cobra parity). */ + /** Raw CLI args slice; drives explicitly-passed flag detection. */ cliArgs?: ReadonlyArray<string>; } diff --git a/tools/release/local-release.ts b/tools/release/local-release.ts index f3f2efc0b0..25e1267cc8 100644 --- a/tools/release/local-release.ts +++ b/tools/release/local-release.ts @@ -22,7 +22,7 @@ const REGISTRY = `http://localhost:${PORT}`; const root = path.resolve(import.meta.dir, "../.."); const tokenPath = path.join(root, "tmp", "verdaccio-token"); -// All seven platform packages that appear in optionalDependencies. +// Every platform package that appears in optionalDependencies. const PLATFORM_PACKAGES = [ "cli-darwin-arm64", "cli-darwin-x64", From 690925b69b53da210fd93d7fb5e384d50da84f34 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:08:52 +0100 Subject: [PATCH 42/56] chore(repo): trim comments, shard 38 (1499 -> 614 comment lines) --- apps/cli/src/shared/cli/agent-output.ts | 51 +-- .../src/shared/cli/agent-output.unit.test.ts | 10 - apps/cli/src/shared/cli/bin.ts | 14 +- apps/cli/src/shared/cli/cobra-flag-groups.ts | 231 +++-------- .../shared/cli/cobra-flag-groups.unit.test.ts | 50 +-- .../src/shared/cli/command-docs.unit.test.ts | 12 - apps/cli/src/shared/cli/global-flags.ts | 13 +- .../src/shared/cli/hidden-flag.unit.test.ts | 26 +- .../src/shared/cli/invalid-value-message.ts | 62 +-- .../cli/markdown-formatter.unit.test.ts | 11 - apps/cli/src/shared/cli/run.e2e.test.ts | 34 +- .../src/shared/cli/run.integration.test.ts | 120 +----- apps/cli/src/shared/cli/run.ts | 381 ++++-------------- apps/cli/src/shared/cli/run.unit.test.ts | 131 +----- .../shared/cli/subcommand-flag-suggestions.ts | 48 +-- .../subcommand-flag-suggestions.unit.test.ts | 20 +- .../cli-project-home.layer.unit.test.ts | 8 +- .../project-config-api-drift.unit.test.ts | 78 ++-- .../project-config-auth-contract.unit.test.ts | 68 +--- apps/cli/src/shared/config/supabase-home.ts | 13 +- apps/cli/src/shared/functions/delete.ts | 10 +- apps/cli/src/shared/functions/deploy.ts | 207 ++++------ .../src/shared/functions/deploy.unit.test.ts | 69 +--- .../src/shared/functions/download.errors.ts | 7 +- 24 files changed, 392 insertions(+), 1282 deletions(-) diff --git a/apps/cli/src/shared/cli/agent-output.ts b/apps/cli/src/shared/cli/agent-output.ts index 49ac6a1d11..408d43acae 100644 --- a/apps/cli/src/shared/cli/agent-output.ts +++ b/apps/cli/src/shared/cli/agent-output.ts @@ -2,10 +2,9 @@ import { Option } from "effect"; import type { OutputFormat } from "../output/types.ts"; import { GLOBAL_VALUE_FLAG_TOKENS } from "./cobra-flag-groups.ts"; -// The union of every command's `--output` values (see -// `command-internal/global-flags.ts`): resource commands use `env|pretty|json|toml|yaml`, -// `db query` adds `table|csv`. An explicit `-o` of any of these suppresses the -// coding-agent JSON auto-default below. +// Every command's `--output` value (see `command-internal/global-flags.ts`): resource commands +// accept `env|pretty|json|toml|yaml`, `db query` adds `table|csv`. An explicit `-o` value +// suppresses the coding-agent JSON auto-default below. type GoOutputFormat = "env" | "pretty" | "json" | "toml" | "yaml" | "table" | "csv"; type AgentOverride = "auto" | "yes" | "no"; @@ -90,20 +89,17 @@ function agentOverrideFromArg(value: string | undefined): AgentOverride { } } -// These predicates run pre-parse to pick the formatter a built-in ACTION -// renders through, so they must also know the CLI library's built-in flags: -// `--completions bash --version` serves the Version action (Version precedes -// Completions), and missing entries here rendered it as JSON under agent -// detection instead of the plain version line. The flag set is the shared -// derived registry, not a fourth hand-written copy (issue #6482). +// These predicates run pre-parse to pick the formatter a built-in action renders through, so +// they must mirror the CLI library's own flags: `--completions bash --version` serves the +// Version action, and missing an entry here would render agent JSON instead of the plain +// version line. function isRootValueFlag(arg: string): boolean { return GLOBAL_VALUE_FLAG_TOKENS.has(arg); } function isRootValueFlagWithInlineValue(arg: string): boolean { - // Attached `-o<value>` — kept from the pre-derivation predicate; the - // shipped parser rejects this spelling, so it only ever classifies argv - // that already fails the parse. + // The shipped parser rejects `-o<value>` (no `=`), so this only classifies argv that + // already fails to parse. if (arg.length > 2 && arg.startsWith("-o")) return true; for (const token of GLOBAL_VALUE_FLAG_TOKENS) { if (arg.startsWith(`${token}=`)) return true; @@ -125,10 +121,11 @@ function isFlagOccurrence(arg: string, name: string): boolean { } /** - * Inline values the CLI's boolean primitive ACCEPTS (lowercase only) — an - * acceptance set: any of these serves the flag's action, `=false` included. - * `run.ts`'s `PFLAG_BOOL_TRUE` answers a DIFFERENT question (ParseBool - * truthiness, for the pflag-modeled upgrade-notice scans); do not merge them. + * Inline values the CLI's boolean primitive accepts (lowercase only); any of these serves the + * flag's action, including `=false`. + * + * Distinct from `run.ts`'s `PFLAG_BOOL_TRUE`, which answers ParseBool truthiness for the + * pflag-modeled upgrade-notice scans — do not merge them. */ export const BOOLEAN_FLAG_VALUES: ReadonlySet<string> = new Set([ "true", @@ -143,27 +140,23 @@ export const BOOLEAN_FLAG_VALUES: ReadonlySet<string> = new Set([ "off", ]); -// An action flag's own inline value must be one the boolean primitive -// accepts: `--version=true` (any accepted value, `false` included) serves the -// Version action, while `--version=bogus` fails the flag's own parse — no -// action is served, and the error keeps the agent JSON envelope. +// The action flag's own inline value must be one the boolean primitive accepts: +// `--version=true` serves the Version action, but `--version=bogus` fails to parse, so no +// action fires and the error keeps the agent JSON envelope. function isBooleanActionOccurrence(arg: string, name: string): boolean { if (arg === name) return true; return arg.startsWith(`${name}=`) && BOOLEAN_FLAG_VALUES.has(arg.slice(name.length + 1)); } -// Inline spellings count for the skipped booleans with ANY value: the Version -// action is scanned on presence before `--wizard=bogus` ever parses, so even -// an invalid inline value there still renders the plain version line. +// Skipped booleans count on presence, so an invalid inline value like `--wizard=bogus` still +// lets `--version` scan through and render the plain version line. function isRootBooleanFlag(arg: string): boolean { return ROOT_BOOLEAN_FLAGS.some((name) => isFlagOccurrence(arg, name)); } -// Deliberately bails at the first token that is not a known root flag -// (subcommand names included): the renderer serves the Version action at any -// depth, but several leaves declare their own `--version` (e.g. `db reset`), -// so `<group> --version` resolving conservatively to JSON is the accepted -// trade-off, ledgered with the walk-consolidation follow-up. +// Bails at the first token that isn't a known root flag, subcommand names included: some +// leaves declare their own `--version` (e.g. `db reset`), so `<group> --version` resolving +// conservatively to JSON is the accepted trade-off. function hasRootVersionRequest(args: ReadonlyArray<string>): boolean { for (let i = 0; i < args.length; i++) { const arg = args[i]; diff --git a/apps/cli/src/shared/cli/agent-output.unit.test.ts b/apps/cli/src/shared/cli/agent-output.unit.test.ts index 681cc92b95..6482543e2f 100644 --- a/apps/cli/src/shared/cli/agent-output.unit.test.ts +++ b/apps/cli/src/shared/cli/agent-output.unit.test.ts @@ -122,8 +122,6 @@ describe("resolveAgentOutputFormat", () => { expect( resolveAgentOutputFormatFromArgs(["--log-level=error", "--version"], Option.some("codex")), ).toBe("text"); - // Version wins over Completions/Wizard (built-in action precedence), so - // these argv render the version line and must resolve to text. expect( resolveAgentOutputFormatFromArgs( ["--completions", "bash", "--version"], @@ -139,23 +137,15 @@ describe("resolveAgentOutputFormat", () => { expect( resolveAgentOutputFormatFromArgs(["--debug=false", "--version"], Option.some("codex")), ).toBe("text"); - // An invalid inline value on a SKIPPED boolean still serves the action - // (presence is scanned before `--wizard=bogus` parses) — text. On the - // action flag ITSELF it fails that flag's own parse — no action, so the - // error keeps the agent JSON envelope. expect( resolveAgentOutputFormatFromArgs(["--wizard=bogus", "--version"], Option.some("codex")), ).toBe("text"); - // The parser consumes a space-separated boolean literal too. expect( resolveAgentOutputFormatFromArgs(["--wizard", "false", "--version"], Option.some("codex")), ).toBe("text"); expect( resolveAgentOutputFormatFromArgs(["--debug", "0", "--version"], Option.some("codex")), ).toBe("text"); - // Boundary: a non-literal operand is NOT skipped — the walk bails - // conservatively even though the renderer still serves the version here - // (ledgered with the walk-consolidation follow-up). expect( resolveAgentOutputFormatFromArgs(["--wizard", "bogus", "--version"], Option.some("codex")), ).toBe("json"); diff --git a/apps/cli/src/shared/cli/bin.ts b/apps/cli/src/shared/cli/bin.ts index d62c3babdd..ddc57222e1 100644 --- a/apps/cli/src/shared/cli/bin.ts +++ b/apps/cli/src/shared/cli/bin.ts @@ -22,10 +22,8 @@ if (!candidates) throw new Error(`Unsupported architecture: ${os.arch()} on ${pr const ext = process.platform === "win32" ? ".exe" : ""; const require = createRequire(import.meta.url); -// `SUPABASE_CLI_BINARY_OVERRIDE` lets tests and local dev point the shim at a -// specific compiled binary on disk, bypassing the optional-dependency lookup. -// This is the entrypoint the e2e harness uses to exercise the real shim + -// compiled binary handoff without publishing platform packages. +// `SUPABASE_CLI_BINARY_OVERRIDE` lets tests and local dev point the shim at a specific compiled +// binary on disk, bypassing the optional-dependency lookup. let binPath = process.env["SUPABASE_CLI_BINARY_OVERRIDE"]; if (!binPath) { @@ -46,11 +44,9 @@ if (!binPath) { ); } -// The compiled binary owns signal semantics, so the shim never dies to a -// signal's default action while the child runs: a group signal (terminal -// Ctrl-C) already reaches the child directly, and a signal sent to the shim -// PID alone (a supervisor's kill) is forwarded so cancellation still lands. -// Either way the shim just waits and mirrors the child's exit. +// The compiled binary owns signal semantics: a group signal (terminal Ctrl-C) already reaches +// the child directly, and a signal sent to the shim PID alone (a supervisor's kill) is forwarded +// so cancellation still lands. Either way the shim just waits and mirrors the child's exit. const child = spawn(binPath, process.argv.slice(2), { stdio: "inherit" }); const forwardedSignals: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP"]; const forwarders = new Map<NodeJS.Signals, () => void>(); diff --git a/apps/cli/src/shared/cli/cobra-flag-groups.ts b/apps/cli/src/shared/cli/cobra-flag-groups.ts index acbaf90220..9380bf88d2 100644 --- a/apps/cli/src/shared/cli/cobra-flag-groups.ts +++ b/apps/cli/src/shared/cli/cobra-flag-groups.ts @@ -1,8 +1,7 @@ /** - * Whether `--<flagName>` (or `--<flagName>=`) appears in the raw argv after - * the command path, matching cobra's `pflag.Changed` semantics — a flag - * counts as "set" once passed explicitly, regardless of its resulting value - * (e.g. `--use-docker=false` still counts as changed). + * Whether `--<flagName>` (or `--<flagName>=`) appears in argv after the command path. A flag + * counts as set once passed explicitly, regardless of its value (e.g. `--use-docker=false` still + * counts as changed) — matching pflag's `Changed` semantics. */ export function hasExplicitLongFlag( rawArgs: ReadonlyArray<string>, @@ -38,16 +37,9 @@ const PFLAG_BOOLEAN_FALSE_VALUES: ReadonlySet<string> = new Set([ ]); /** - * Last explicit `--<flagName>`/`--<flagName>=<value>` boolean occurrence in - * argv, or `undefined` when the flag never appears — matching pflag/viper's - * shared-variable last-`Set()`-wins semantics (mirrors - * `experimentalFlagFromArgs`, `command-internal/global-flags.ts`). A bare - * `--<flagName>` records pflag's bool `NoOptDefVal` (`true`); an inline value - * is parsed through pflag's `strconv.ParseBool` false set — anything else - * (including garbage) is truthy, same as `cast.ToBool`'s permissive default. - * Unlike a bare presence scan, this distinguishes `--<flagName>=false` - * from presence alone, which matters for Go call sites gated on - * `viper.GetBool` rather than "was the flag passed at all". + * Last explicit `--<flagName>`/`--<flagName>=<value>` boolean occurrence in argv (`undefined` if + * absent), matching pflag/viper's last-`Set()`-wins semantics. A bare flag is true; an inline + * value is false only when it matches pflag's boolean false set, otherwise truthy. */ export function explicitBooleanLongFlag( rawArgs: ReadonlyArray<string>, @@ -65,11 +57,9 @@ export function explicitBooleanLongFlag( } /** - * The LAST explicit `--<flagName>` occurrence's value in raw argv, matching - * pflag's last-wins resolution (`--profile a --profile b` → `b`), or - * `undefined` when the flag never appears. Like pflag, `--<flagName> <next>` - * consumes the following token verbatim; a trailing valueless occurrence is - * ignored (pflag would have aborted the parse before any resolution). + * The last explicit `--<flagName>` occurrence's value in argv (pflag's last-wins resolution: + * `--profile a --profile b` → `b`), or `undefined` when absent. `--<flagName> <next>` consumes + * the following token verbatim; a trailing valueless occurrence is ignored. */ export function lastExplicitLongFlagValue( rawArgs: ReadonlyArray<string>, @@ -100,24 +90,11 @@ export function lastExplicitLongFlagValue( } /** - * Value-taking long flags registered persistently on the Go root command - * (`apps/cli-go/cmd/root.go:337-348`: `--workdir`, `--network-id`, - * `--profile`, `--output`, `--dns-resolver`, `--agent`), plus the TS-only - * globals the TS parser accepts on any subcommand: `--output-format` - * (`shared/cli/global-flags.ts`) and the CLI library's built-in - * `--log-level`. pflag lets any of these consume the following argv token, - * so a pflag-faithful scan must know them or it will miscount positionals - * on perfectly normal invocations like `sso update --workdir . <id>` — or - * `sso update --log-level error <id>`, which mis-reported - * `accepts 1 arg(s), received 2` before `log-level` was listed here - * (issue #6482). `--completions`, the other value-taking built-in, is - * deliberately absent: every consumer of this set runs inside a command - * handler, which a parsed `--completions` never reaches (its print-and-exit - * action runs first); only the pre-parse scanners (`globalFlagsWithValues` - * in `shared/cli/run.ts`, the predicates in `shared/cli/agent-output.ts`) - * need it. No manual sync is required: both pre-parse consumers read - * `GLOBAL_VALUE_FLAG_TOKENS` below, which is derived from this set plus - * `--completions`. + * Value-taking long flags recognized globally — the persistent root flags (`--workdir`, + * `--network-id`, `--profile`, `--output`, `--dns-resolver`, `--agent`) plus the TS-only + * `--output-format` and `--log-level` — so a pflag-faithful scan doesn't miscount positionals. + * Excludes `--completions`, which only pre-parse scanners need (see `GLOBAL_VALUE_FLAG_TOKENS` + * below); no command handler ever runs while it's set. */ export const PERSISTENT_VALUE_FLAG_NAMES: ReadonlySet<string> = new Set([ "workdir", @@ -130,22 +107,15 @@ export const PERSISTENT_VALUE_FLAG_NAMES: ReadonlySet<string> = new Set([ "log-level", ]); -/** - * Shorthands of the persistent value-taking flags above (`-o` → `--output`, - * `cmd/root.go:344`), mapped to their canonical long names. - */ +/** Shorthands of the persistent value-taking flags above (`-o` → `--output`), mapped to their canonical long names. */ export const PERSISTENT_VALUE_FLAG_SHORTHANDS: ReadonlyMap<string, string> = new Map([ ["o", "output"], ]); /** - * Token-keyed view of every value-taking global the TS parser accepts: - * `PERSISTENT_VALUE_FLAG_NAMES` as `--` tokens, its shorthands, and the - * `--completions` built-in (needed by pre-parse scanners only — see above). - * Derived rather than hand-copied so the registries cannot drift apart, which - * is how issue #6482 happened. Consumed by `run.ts`'s argv scanners and - * `agent-output.ts`'s format predicates; pinned to its exact expected - * contents in `cobra-flag-groups.unit.test.ts`. + * Token-keyed view of every value-taking global the TS parser accepts: `PERSISTENT_VALUE_FLAG_NAMES` + * as `--` tokens, its shorthands, and `--completions` (needed only by the pre-parse scanners in + * `run.ts` and `agent-output.ts`). Derived rather than hand-copied so the two lists cannot drift apart. */ export const GLOBAL_VALUE_FLAG_TOKENS: ReadonlySet<string> = new Set([ ...[...PERSISTENT_VALUE_FLAG_NAMES].map((name) => `--${name}`), @@ -161,10 +131,9 @@ export interface PflagArgvScanSpec { */ readonly valueFlagNames: ReadonlySet<string>; /** - * Value-taking shorthand characters (`t` for `-t`) mapped to their - * canonical long names. Occurrences are recorded under the long name, - * matching pflag, whose `Visit` reports the canonical flag regardless of - * which form set it. + * Value-taking shorthand characters (`t` for `-t`) mapped to their canonical long names. + * Occurrences are recorded under the long name, matching pflag's behavior of reporting the + * canonical flag regardless of which form set it. */ readonly valueFlagShorthands?: ReadonlyMap<string, string>; } @@ -173,60 +142,44 @@ export interface PflagArgvScan { /** Whether the command path was found in argv and the scan is scoped to it. */ readonly anchored: boolean; /** - * Every flag pflag would mark `Changed`, mapped to the raw value of each - * of its occurrences in argv order (shorthand occurrences under their - * canonical long name). + * Every flag pflag would mark `Changed`, mapped to the raw value of each occurrence in argv + * order (shorthand occurrences recorded under their canonical long name). */ readonly occurrences: ReadonlyMap<string, ReadonlyArray<string>>; /** - * pflag-effective positional arguments: tokens not interpreted as flags - * and not consumed as a flag's value. Cobra's `ValidateArgs` (e.g. - * `ExactArgs(1)`) counts THESE, which can differ from what the Effect - * parser saw whenever pflag consumed a flag-shaped token as a value. - * Only populated when `anchored` — an unscoped scan cannot tell command - * path segments apart from operands. + * pflag-effective positional arguments: tokens not interpreted as flags and not consumed as a + * flag's value; this can differ from what the Effect parser saw when pflag consumed a + * flag-shaped token as a value. Only populated when `anchored`. */ readonly positionals: ReadonlyArray<string>; /** - * Canonical long names of flags whose own token was consumed as another - * flag's value — pflag never parses them, so they stay unchanged even - * though the token is visibly present in argv. Covers both long tokens - * (`--type`, `--type=saml`) and mapped shorthand tokens (`-t`, `-t=saml`, - * `-tsaml`). Used to emulate cobra's `ValidateRequiredFlags` for flags the - * Effect parser believed were set. + * Canonical long names of flags whose own token was consumed as another flag's value — pflag + * never parses them, so they stay unchanged even though the token is visibly present in argv + * (covers both long and mapped shorthand spellings). Used to emulate cobra's + * `ValidateRequiredFlags` for flags the Effect parser believed were set. */ readonly consumedFlagNames: ReadonlySet<string>; /** - * Value-taking flags parsed BEFORE the command path completed — cobra's - * `Find`/`stripFlags` steps over them while routing to the subcommand, but - * pflag still parses them and marks them `Changed`, so a pre-path - * occurrence is the effective value when every post-path token of the same - * flag was consumed (e.g. `--profile A sso add --domains --profile`: - * pflag keeps A). Values in argv order. Boolean pre-path flags are not - * tracked — this exists for last-wins reconciliation of value-taking - * persistent flags (PR #5974 review round 10). + * Value-taking flags parsed before the command path completed. Cobra routes past them while + * locating the subcommand, but pflag still parses and marks them `Changed`, so a pre-path + * occurrence is the effective value once every post-path token for the same flag gets consumed + * elsewhere. Boolean pre-path flags are not tracked. */ readonly prePathOccurrences: ReadonlyMap<string, ReadonlyArray<string>>; /** - * pflag's `ValueRequiredError` message when a bare value-taking flag is - * the final argv token — byte-exact per pflag `errors.go:75,78` - * (`flag needs an argument: --domains` / `flag needs an argument: 't' in - * -t`). pflag fails `ParseFlags` (cobra `command.go:919`) before - * `ValidateArgs`, every hook, `ValidateRequiredFlags`, and - * `ValidateFlagGroups`, so handlers must reject argv carrying this before - * any other check or side effect (binary-verified: `sso update a b - * --domains` reports the missing argument, not the arity violation). + * pflag's `ValueRequiredError` message when a bare value-taking flag is the final argv token, + * byte-exact (`flag needs an argument: --domains` / `flag needs an argument: 't' in -t`). pflag + * fails parsing before any validation or side effect runs, so handlers must reject argv + * carrying this before any other check. */ readonly missingValueError: string | undefined; } /** - * Walks a shorthand cluster (`token.slice(1)`) the way pflag's - * `parseShortArg`/`parseSingleShortArg` does: characters before the first - * value-taking shorthand are boolean/unknown and consume nothing; the first - * value-taking shorthand either carries an inline value (`-o=json`, `-ojson`) - * or must consume the next argv token (`-o`). Returns `undefined` when no - * character maps to a value-taking flag. + * Walks a shorthand cluster (`token.slice(1)`) the way pflag does: characters before the first + * value-taking shorthand are boolean/unknown and consume nothing; the first value-taking + * shorthand either carries an inline value (`-o=json`, `-ojson`) or consumes the next argv token + * (`-o`). Returns `undefined` when no character maps to a value-taking flag. */ function clusterValueShorthand( cluster: string, @@ -254,54 +207,14 @@ function clusterValueShorthand( } /** - * Scans raw argv the way pflag's `parseArgs`/`parseLongArg`/ - * `parseSingleShortArg` (`flag.go:1013-1031, 1080-1094`) would, returning - * every flag pflag would mark `Changed` after the command path, the - * pflag-effective positional arguments, and the flags whose own tokens got - * consumed as values. - * - * pflag-faithful rules: - * - `--name=value` records `value` (split on the first `=`) for any flag. - * - A bare `--name` where `name` is a *value-taking* flag - * (`spec.valueFlagNames`) unconditionally consumes the very next argv - * token as its value — even a flag-shaped token or `--`. pflag never - * checks that the consumed token "looks like a value", so - * `--metadata-file --metadata-url` is pflag's `metadata-file` flag being - * handed the (oddly named, but valid) string value `"--metadata-url"` — - * `metadata-url.Changed` stays `false`. The vendored Effect parser - * deliberately differs (`internal/parser.ts` only consumes `Value`-tagged - * tokens), which is why handlers must reconcile the values they act on - * against this scan instead of trusting the parsed options alone - * (CLI-1982). - * - A bare `--name` not in `valueFlagNames` (a boolean flag) records pflag's - * bool `NoOptDefVal` `"true"` (`flag.go:1017-1019`) and consumes nothing, - * while an inline-empty `--name=` records `""` (`flag.go:1014-1016`) — the - * two must stay distinguishable because pflag hands `""` to - * `strconv.ParseBool`, which rejects it (PR #5974 review round 5: - * `--skip-url-validation=false --skip-url-validation=` aborts Go's - * ParseFlags; a bare repeat ends true). - * - A shorthand cluster is walked per pflag's `parseSingleShortArg`: a - * value-taking shorthand takes `-t=v` / `-tv` inline or consumes the next - * token for `-t v`; other characters (booleans, `-h`) consume nothing. - * - A bare `--` that was not consumed as a value terminates flag parsing; - * every remaining token is positional (pflag `parseArgs`). - * - A bare value-taking flag as the very last token records nothing and - * reports pflag's `flag needs an argument` parse error via - * `missingValueError` — pflag aborts before the flag is ever Set. - * - * Anchoring mirrors cobra's `Find`/`stripFlags`: persistent flags (and the - * values they consume) may sit before or between command path segments — - * `sso --profile foo update <id>` routes to `sso update` — so the walk steps - * over flag tokens while matching path segments in order. When the path - * cannot be completed (a stray operand, a `--`, or argv ends), the scan - * falls back to an unscoped pass over the whole argv. + * Scans raw argv the way pflag would after the command path completes: which flags pflag marks + * `Changed` and their values, the resulting positional arguments, and any flag whose own token + * was consumed as another flag's value instead of being parsed itself. * - * Remaining gap, kept deliberately: the spec is written out per command - * rather than derived from the command tree, and unknown flags are treated - * as non-consuming. That is fail-open — argv carrying flags outside the - * spec is rejected by the Effect parser before any handler runs, so the - * scan can never invent a false positional (and with it a false arity - * error) for an invocation that actually reaches a handler. + * Anchoring mirrors cobra: persistent flags may sit before or between path segments, so the walk + * steps over flag tokens while matching the path in order, falling back to an unscoped scan when + * the path can't be completed. The spec is per-command, and unknown flags are treated as + * non-consuming and fail-open, since the Effect parser rejects them before any handler runs. */ export function pflagArgvScan( rawArgs: ReadonlyArray<string>, @@ -310,9 +223,8 @@ export function pflagArgvScan( ): PflagArgvScan { const valueFlagNames = spec.valueFlagNames; const valueFlagShorthands = spec.valueFlagShorthands ?? new Map<string, string>(); - // Anchor: match the command path segments in argv order, stepping over - // flag tokens and the values they consume (cobra `stripFlags` consumes the - // next token for any bare value-taking flag while locating subcommands). + // Anchor: match command path segments in argv order, stepping over flag tokens and the values + // they consume. let segmentIndex = 0; let cursor = 0; const prePathOccurrences = new Map<string, Array<string>>(); @@ -377,9 +289,8 @@ export function pflagArgvScan( } const anchored = segmentIndex === commandPath.length; const tokens = anchored ? rawArgs.slice(cursor) : rawArgs; - // Like `positionals`, pre-path occurrences are only meaningful when the - // scan anchored — an unscoped scan re-walks the whole argv below, so - // keeping the partial walk's records would double-count them. + // Pre-path occurrences only matter when the scan anchored; an unscoped scan re-walks argv + // below, so keeping the partial walk's records would double-count them. if (!anchored) { prePathOccurrences.clear(); } @@ -396,12 +307,8 @@ export function pflagArgvScan( existing.push(value); } }; - // pflag's `--flag arg` / `-f arg` branches: the very next token is the - // value, unconditionally, so it can't be read as a flag (or positional) of - // its own. When that token is itself a flag pflag never got to parse, - // remember its canonical name. When there is no next token, pflag fails - // parsing (`errors.go:63-78`) — nothing is recorded because the flag never - // gets Set. Returns the index to resume scanning from. + // pflag consumes the next token unconditionally as the value, even if it looks like a flag — + // remember that flag's canonical name as consumed, since pflag never parses it itself. const consumeNext = (name: string, index: number, missingMessage: string): number => { const next = tokens[index + 1]; if (next === undefined) { @@ -413,9 +320,8 @@ export function pflagArgvScan( const equalsIndex = next.indexOf("="); consumedFlagNames.add(next.slice(2, equalsIndex === -1 ? undefined : equalsIndex)); } else if (next.startsWith("-") && !next.startsWith("--") && next !== "-") { - // A consumed shorthand cluster (`--domains -t saml`): pflag never - // parses `-t`, so `type` stays unchanged even though the Effect parser - // read it as a flag (CLI-1982, PR #5974 review round 3). + // A consumed shorthand cluster (`--domains -t saml`): pflag never parses `-t`, so `type` + // stays unchanged even though the Effect parser read it as a flag. const hit = clusterValueShorthand(next.slice(1), valueFlagShorthands); if (hit !== undefined) { consumedFlagNames.add(hit.longName); @@ -439,23 +345,22 @@ export function pflagArgvScan( continue; } if (!token.startsWith("-") || token === "-") { - // pflag `parseArgs`: an empty token, a token without a leading dash, - // or a lone `-` is an operand. + // An empty token, a token without a leading dash, or a lone `-` is an operand. if (anchored) { positionals.push(token); } continue; } if (!token.startsWith("--")) { - // Shorthand cluster — pflag `parseShortArg` walks it one character at - // a time; the first value-taking shorthand ends the cluster. + // Shorthand cluster: walked one character at a time; the first value-taking shorthand + // ends the cluster. const hit = clusterValueShorthand(token.slice(1), valueFlagShorthands); if (hit !== undefined) { if (hit.inlineValue !== undefined) { record(hit.longName, hit.inlineValue); // `-o=json` / `-ojson` } else { - // `-o json` — pflag `errors.go:75` quotes the shorthand character - // and the remaining cluster when the value is missing. + // `-o json` — pflag quotes the shorthand character and remaining cluster when the + // value is missing. index = consumeNext( hit.longName, index, @@ -474,9 +379,8 @@ export function pflagArgvScan( if (valueFlagNames.has(name)) { index = consumeNext(name, index, `flag needs an argument: --${name}`); } else { - // pflag's `NoOptDefVal` branch (`flag.go:1017-1019`): a bare boolean - // records the value pflag would Set — `"true"` — keeping it distinct - // from the `""` an inline-empty `--name=` records above. + // A bare boolean records the value pflag would set — `"true"` — keeping it distinct from + // the `""` an inline-empty `--name=` records above. record(name, "true"); } } @@ -491,10 +395,9 @@ export function pflagArgvScan( } /** - * Byte-matches cobra's `validateExclusiveFlagGroups` error - * (`flag_groups.go:204`): `group` is the full mutually-exclusive set in - * registration order (unsorted, no dashes); `changed` is the subset that - * were actually set, sorted alphabetically per cobra's own `sort.Strings`. + * Builds the mutually-exclusive-flag-group error message: `group` is the full set in + * registration order (unsorted, no dashes); `changed` is the subset actually set, sorted + * alphabetically. */ export function cobraMutuallyExclusiveErrorMessage( group: ReadonlyArray<string>, diff --git a/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts b/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts index 592abbbca9..d23df739c4 100644 --- a/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts +++ b/apps/cli/src/shared/cli/cobra-flag-groups.unit.test.ts @@ -120,10 +120,6 @@ describe("pflagArgvScan", () => { }; test("records value-taking persistent flags parsed BEFORE the command path", () => { - // cobra's Find/stripFlags steps over `--profile a.yml` while routing to - // `sso update`, but pflag parses it and marks it Changed — the pre-path - // occurrence is the effective value when later tokens are consumed - // (PR #5974 review round 10). const scan = pflagArgvScan( ["--profile", "a.yml", "sso", "update", "id", "--profile=b.yml"], SSO_UPDATE_PATH, @@ -173,10 +169,6 @@ describe("pflagArgvScan", () => { }); test("a consumed flag-shaped token becomes the value, not a sibling flag", () => { - // pflag's `--flag arg` branch consumes the next token unconditionally - // (`flag.go:1013-1031`), so `--metadata-file --metadata-url` gives - // `metadata-file` the literal value `"--metadata-url"` and never parses - // `--metadata-url` as its own flag. const scan = pflagArgvScan( ["sso", "update", "id", "--metadata-file", "--metadata-url"], SSO_UPDATE_PATH, @@ -199,8 +191,6 @@ describe("pflagArgvScan", () => { }); test("an inline (`=`) value never consumes the next token", () => { - // `--metadata-file=--metadata-url` is one token: metadata-file's value is - // the literal string "--metadata-url", and no token is consumed after it. const scan = pflagArgvScan( ["sso", "update", "id", "--metadata-file=--metadata-url", "--domains", "a.com"], SSO_UPDATE_PATH, @@ -209,7 +199,6 @@ describe("pflagArgvScan", () => { expect(scan.occurrences.get("metadata-file")).toEqual(["--metadata-url"]); expect(scan.occurrences.has("metadata-url")).toBe(false); expect(scan.occurrences.get("domains")).toEqual(["a.com"]); - // The inline form consumed nothing — no flag token was swallowed. expect(scan.consumedFlagNames.size).toBe(0); }); @@ -244,9 +233,6 @@ describe("pflagArgvScan", () => { }); test("an inline-empty boolean (`--flag=`) records the empty string, distinct from bare", () => { - // pflag `flag.go:1014-1019`: `--flag=` Sets `""` (which ParseBool later - // rejects) while a bare `--flag` Sets NoOptDefVal `"true"` — the scan - // must preserve that difference (PR #5974 review round 5). const scan = pflagArgvScan( ["sso", "update", "id", "--skip-url-validation=false", "--skip-url-validation="], SSO_UPDATE_PATH, @@ -280,15 +266,12 @@ describe("pflagArgvScan", () => { describe("missing value detection (pflag ValueRequiredError parity)", () => { test("a bare value flag at the end of argv reports pflag's parse error", () => { - // pflag fails `ParseFlags` before anything else runs (`errors.go:78`), - // and the flag is never Set — so no occurrence is recorded either. const scan = pflagArgvScan(["sso", "update", "id", "--metadata-file"], SSO_UPDATE_PATH, SPEC); expect(scan.missingValueError).toBe("flag needs an argument: --metadata-file"); expect(scan.occurrences.has("metadata-file")).toBe(false); }); test("a bare value shorthand at the end of argv quotes the character", () => { - // pflag `errors.go:75`: `flag needs an argument: %q in -%s`. const scan = pflagArgvScan(["sso", "update", "id", "-o"], SSO_UPDATE_PATH, SPEC); expect(scan.missingValueError).toBe("flag needs an argument: 'o' in -o"); expect(scan.occurrences.has("output")).toBe(false); @@ -337,8 +320,6 @@ describe("pflagArgvScan", () => { const scan = pflagArgvScan(["--domains", "a.com"], SSO_UPDATE_PATH, SPEC); expect(scan.anchored).toBe(false); expect(scan.occurrences.get("domains")).toEqual(["a.com"]); - // An unscoped scan collects no positionals — it cannot tell command path - // segments apart from operands. expect(scan.positionals).toEqual([]); }); @@ -359,9 +340,6 @@ describe("pflagArgvScan", () => { }); test("a consumed flag token shifts its parser-value into the positionals", () => { - // pflag hands `--metadata-url` to `--domains`; the URL the Effect - // parser read as metadata-url's value is a positional to pflag, so - // cobra's ExactArgs(1) sees 2 args (CLI-1982, PR #5974 review). const scan = pflagArgvScan( ["sso", "update", "--domains", "--metadata-url", "https://idp.example.com/m", "id"], SSO_UPDATE_PATH, @@ -373,8 +351,6 @@ describe("pflagArgvScan", () => { }); test("a persistent global value flag consumes its value token", () => { - // `--workdir .` must not count `.` as a positional — pflag consumes it - // (root persistent flags, `cmd/root.go:337-348`). const scan = pflagArgvScan( ["sso", "update", "--workdir", ".", "id", "--profile", "staging"], SSO_UPDATE_PATH, @@ -384,9 +360,6 @@ describe("pflagArgvScan", () => { }); test("the built-in --log-level global consumes its value token", () => { - // The real parser consumes `error`, so the scan must too or - // `sso update --log-level error <id>` mis-reports - // `accepts 1 arg(s), received 2` (issue #6482). const scan = pflagArgvScan( ["sso", "update", "--log-level", "error", "id"], SSO_UPDATE_PATH, @@ -397,8 +370,6 @@ describe("pflagArgvScan", () => { }); test("a pre-path --log-level keeps the scan anchored instead of falling back unscoped", () => { - // Unregistered, the anchor walk hit `error` as a stray operand and - // fell back to the unanchored scan, skipping the arity re-count. const scan = pflagArgvScan( ["--log-level", "error", "sso", "update", "id"], SSO_UPDATE_PATH, @@ -410,8 +381,6 @@ describe("pflagArgvScan", () => { }); test("a bare slice flag consumes a global flag token, orphaning its value", () => { - // Binary-verified Go behaviour: `--domains --profile staging <id>` - // arity-errors because `staging` becomes positional. const scan = pflagArgvScan( ["sso", "update", "--domains", "--profile", "staging", "id"], SSO_UPDATE_PATH, @@ -474,10 +443,6 @@ describe("pflagArgvScan", () => { describe("anchoring across interspersed flags (cobra Find/stripFlags parity)", () => { test("a persistent value flag between group and leaf still anchors", () => { - // cobra routes `sso --profile foo update <id>` to `sso update` - // (`stripFlags` steps over the flag and its value while locating - // subcommands) — binary-verified; the arity re-count must not be - // skipped for such argv (PR #5974 review round 3). const scan = pflagArgvScan( ["sso", "--profile", "foo", "update", "id", "--domains", "a.com"], SSO_UPDATE_PATH, @@ -489,8 +454,7 @@ describe("pflagArgvScan", () => { }); test("an interspersed value flag consuming a path-named token still anchors", () => { - // `--profile update` hands the literal value "update" to --profile; - // the NEXT "update" is the real leaf segment. + // The first "update" is --profile's value; the second is the real leaf segment. const scan = pflagArgvScan( ["sso", "--profile", "update", "update", "id"], SSO_UPDATE_PATH, @@ -526,8 +490,6 @@ describe("pflagArgvScan", () => { }); test("a stray operand before the path fails the anchor open", () => { - // `sso foo update` never routes to `sso update` (cobra treats `foo` - // as an unknown subcommand), so the scan falls back to unscoped. const scan = pflagArgvScan(["sso", "foo", "update", "id"], SSO_UPDATE_PATH, SPEC); expect(scan.anchored).toBe(false); expect(scan.positionals).toEqual([]); @@ -559,10 +521,6 @@ describe("pflagArgvScan", () => { }); test("a consumed `-t` shorthand is tracked under its long name", () => { - // Binary-verified: `sso add --domains -t saml` fails Go's - // required-flag check — pflag hands `-t` to `--domains` and `type` - // stays unchanged, while `saml` becomes positional (PR #5974 review - // round 3). const scan = pflagArgvScan(["sso", "add", "--domains", "-t", "saml"], ADD_PATH, ADD_SPEC); expect(scan.occurrences.has("type")).toBe(false); expect(scan.consumedFlagNames.has("type")).toBe(true); @@ -586,8 +544,6 @@ describe("pflagArgvScan", () => { }); test("a shorthand `-t` occurrence coexists with a consumed `--type` token", () => { - // pflag: `-t saml` sets type; `--domains` then swallows `--type`. The - // flag IS changed, so required-flag emulation must not fire. const scan = pflagArgvScan( ["sso", "add", "-t", "saml", "--domains", "--type", "saml"], ADD_PATH, @@ -607,10 +563,6 @@ describe("pflagArgvScan", () => { }); describe("GLOBAL_VALUE_FLAG_TOKENS", () => { - // The derived token registry feeds run.ts's argv scanners and - // agent-output.ts's format predicates. Pinning its exact contents makes an - // accidental edit to the derivation (or its inputs) fail loudly — the - // silent-drift trap behind issue #6482. test("holds exactly the value-taking global tokens the TS parser accepts", () => { expect(GLOBAL_VALUE_FLAG_TOKENS).toEqual( new Set([ diff --git a/apps/cli/src/shared/cli/command-docs.unit.test.ts b/apps/cli/src/shared/cli/command-docs.unit.test.ts index ec2afcda86..640fb45e05 100644 --- a/apps/cli/src/shared/cli/command-docs.unit.test.ts +++ b/apps/cli/src/shared/cli/command-docs.unit.test.ts @@ -2,10 +2,6 @@ import { describe, expect, it } from "vitest"; import { Command } from "effect/unstable/cli"; import { collectCommands, findCommand } from "./command-docs.ts"; -// --------------------------------------------------------------------------- -// Test fixtures -// --------------------------------------------------------------------------- - function makeTree() { const login = Command.make("login").pipe(Command.withDescription("Log in to Supabase")); const logout = Command.make("logout").pipe(Command.withDescription("Log out of Supabase")); @@ -29,10 +25,6 @@ function makeDeepTree() { return { root, mid, child }; } -// --------------------------------------------------------------------------- -// findCommand -// --------------------------------------------------------------------------- - describe("findCommand", () => { it("returns the root command when path is empty", () => { const { root } = makeTree(); @@ -67,10 +59,6 @@ describe("findCommand", () => { }); }); -// --------------------------------------------------------------------------- -// collectCommands -// --------------------------------------------------------------------------- - describe("collectCommands", () => { it("includes the root command itself", () => { const { root } = makeTree(); diff --git a/apps/cli/src/shared/cli/global-flags.ts b/apps/cli/src/shared/cli/global-flags.ts index e63b9791b7..782d878e4a 100644 --- a/apps/cli/src/shared/cli/global-flags.ts +++ b/apps/cli/src/shared/cli/global-flags.ts @@ -1,17 +1,10 @@ import { Flag, GlobalFlag } from "effect/unstable/cli"; /** - * The TS-only `--output-format` global (no Go counterpart), accepted on any - * subcommand. + * The TS-only `--output-format` global, accepted on any subcommand. * - * It takes a value, so its name is registered in - * `PERSISTENT_VALUE_FLAG_NAMES` (`shared/cli/cobra-flag-groups.ts`) — the - * pre-parse scanners derive their token set from it - * (`GLOBAL_VALUE_FLAG_TOKENS`), so that one edit covers any value-taking - * global added here. Without it, the raw-argv scanners that run for - * `--help`/`--version`/bare-group invocations will not consume its following - * token. See `GLOBAL_FLAGS` (`command-internal/global-flags.ts`) for what - * silently breaks when a flag is missing from the shared registry. + * A value-taking global flag must also be registered in `PERSISTENT_VALUE_FLAG_NAMES` + * (`shared/cli/cobra-flag-groups.ts`), or the pre-parse argv scanners won't consume its value. */ export const OutputFormatFlag = GlobalFlag.setting("output-format")({ flag: Flag.choice("output-format", ["text", "json", "stream-json"]).pipe( diff --git a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts index badaceb5f2..0d317fec81 100644 --- a/apps/cli/src/shared/cli/hidden-flag.unit.test.ts +++ b/apps/cli/src/shared/cli/hidden-flag.unit.test.ts @@ -237,9 +237,6 @@ describe("hidden subcommands", () => { }); it("still executes hidden subcommands by exact name", async () => { - // `db branch *` / `db remote *` are still Phase 0 proxy wrappers, so a - // successful proxy call is direct proof that cobra-style `Hidden` doesn't - // block exact-name dispatch through `effect/unstable/cli`. const proxy = mockGoProxy(); await Effect.runPromise( @@ -258,15 +255,8 @@ describe("hidden subcommands", () => { }); it("still executes the native `db test` hidden alias by exact name (CLI-1962)", async () => { - // `db test` was ported off the Go proxy in CLI-1962, so it no longer calls - // `GoProxy` — this test only needs to prove dispatch still reaches the - // real (now-native) handler, not that the handler fully succeeds (this file's - // minimal layer doesn't wire the docker/db/telemetry services the native - // handler needs, matching how `start`/`stop` are treated above). A genuinely - // unresolved subcommand fails BEFORE any handler runs, with a distinct typed - // `UnknownSubcommand` CliError; the native handler instead defects on a - // missing service once dispatch has already succeeded — that defect is the - // proof, mirrored against a deliberately unknown sibling subcommand below. + // This test's minimal layer doesn't wire the services the native handler needs, so dispatch + // reaching the handler (a Die on a missing service, not success) is what's being proven. const proxy = mockGoProxy(); const layer = Layer.mergeAll(proxy.layer, CliOutput.layer(textCliOutputFormatter())); @@ -281,12 +271,7 @@ describe("hidden subcommands", () => { ) as Effect.Effect<unknown, never, never>, ); expect((dbTestExit as { _tag: string })._tag).toBe("Failure"); - // The real defect is `Error: Service not found: supabase/telemetry/Analytics` - // — asserting it directly (rather than a negative `not.toContain` on the - // near-empty JSON serialization of the defect) proves dispatch reached the - // native handler and it defected on a missing ambient service, not merely - // that the failure happens not to mention `UnknownSubcommand`. - expect(causeOf(dbTestExit).reasons[0]?._tag).toBe("Die"); // handler ran, then defected on a missing service + expect(causeOf(dbTestExit).reasons[0]?._tag).toBe("Die"); expect(String(causeOf(dbTestExit).reasons[0]?.defect)).toContain( "Service not found: supabase/telemetry/Analytics", ); @@ -297,10 +282,7 @@ describe("hidden subcommands", () => { Effect.exit, ) as Effect.Effect<unknown, never, never>, ); - // Effect CLI's raw `_tag` uses the corrected "UnknownSubcommand" spelling. - // This assertion checks the raw, un-normalized tag so it stays aligned with - // the upstream parser error value. expect(JSON.stringify(unknownExit)).toContain("UnknownSubcommand"); - expect(causeOf(unknownExit).reasons[0]?._tag).toBe("Fail"); // typed CliError, pre-handler — dispatch never reached a handler + expect(causeOf(unknownExit).reasons[0]?._tag).toBe("Fail"); }); }); diff --git a/apps/cli/src/shared/cli/invalid-value-message.ts b/apps/cli/src/shared/cli/invalid-value-message.ts index d067395128..0f69072383 100644 --- a/apps/cli/src/shared/cli/invalid-value-message.ts +++ b/apps/cli/src/shared/cli/invalid-value-message.ts @@ -1,50 +1,15 @@ -// Workaround for a doubled "Expected: Expected ..." prefix in -// effect@4.0.0-beta.93's own primitive parsers. Several `Primitive`s under -// `effect/unstable/cli` (`choice` — used by `Flag.choice`/ -// `Flag.choiceWithValue` — plus the schema-backed `integer`, `float`, -// `boolean`, and `date`) fail with a raw message that already starts with -// the word "Expected" (e.g. `Expected "micro" | "small", got "nano"` or -// `Expected a valid date, got Invalid Date`), and `CliError.InvalidValue`'s -// own `message` getter independently prepends its own `"Expected: "` label -// on top of that — so any flag or argument backed by one of these -// primitives renders "Expected: Expected ...". Detect this from -// `error.expected` (the field the buggy primitives actually populate) -// rather than searching the fully composed `error.message`: `error.value` -// is user-controlled and interpolated into that same message (including a -// second time inside `expected` itself, via `choice`'s "got <value>" -// suffix), so a message-wide, first-occurrence string replace can target -// the wrong spot if the value itself happens to contain the literal text -// "Expected: Expected ". Anchoring on `error.expected` and rebuilding the -// message from the same template `CliError.InvalidValue` uses avoids ever -// scanning `error.value`. Remove once upstream `effect` fixes this (see -// CLI-1898). +// Some of effect's own CLI primitive parsers (`choice`, and the schema-backed `integer`, `float`, +// `boolean`, `date`) fail with an `expected` string that already starts with "Expected", which +// `CliError.InvalidValue`'s own message getter then prefixes again, rendering +// "Expected: Expected ...". Anchors on `error.expected` rather than the composed `message`, since +// `error.value` is user-controlled and can itself contain that literal text. // -// TODO: remove once Effect-TS/effect#6312 is fixed upstream. -// https://github.com/Effect-TS/effect/issues/6312 -// -// Shared by two call sites that each see `InvalidValue` failures at a -// different point in `effect`'s CLI runtime: -// - `subcommand-flag-suggestions.ts` formats errors that reach the -// `CliOutput.Formatter` via the `ShowHelp` envelope — i.e. ordinary -// subcommand/argument flags, validated while `Command.runWith` parses the -// command tree. -// - `normalize-error.ts` formats errors from `GlobalFlag.setting` flags -// (`--output-format`, and the `--output`/`-o`, `--dns-resolver`, -// `--agent`), which `Command.runWith` validates in a later step that runs -// *outside* the `ShowHelp` path and therefore never reaches the -// formatter — it surfaces as a raw failure through `runCli`'s catch-all -// instead. +// TODO(CLI-1898): remove once https://github.com/Effect-TS/effect/issues/6312 is fixed upstream. const EXPECTED_PREFIX = "Expected "; -// Go-parity passthrough (CLI-1983, CLI-1990): flags that byte-match Go -// pflag's parse-time diagnostics (`stringSliceFlag`'s malformed-CSV -// failure, `migration down --last`, and `storage cp --jobs` via -// `Flag.mapTryCatch`) fail with the COMPLETE Go message as `expected` — -// pflag's `invalid argument %q for %q flag: %v` (pflag v1.0.10 -// `errors.go:116`). Wrapping that in `CliError.InvalidValue`'s own -// `Invalid value for flag --X: "V". Expected: ...` template would -// double-frame it and break the CLI's stderr contract (byte-parity -// with the Go CLI), so render it verbatim instead. +// Some flags already fail with pflag's own byte-exact diagnostic (e.g. malformed CSV in a +// string-slice flag), which the CLI's stderr contract must reproduce verbatim. Wrapping it in +// `CliError.InvalidValue`'s own template would double-frame it, so render it as-is instead. const PFLAG_INVALID_ARGUMENT_PREFIX = "invalid argument "; export interface InvalidValueMessageFields { @@ -55,11 +20,10 @@ export interface InvalidValueMessageFields { } /** - * Rebuilds a `CliError.InvalidValue` message from its own template when - * `expected` carries the doubled "Expected" prefix, or passes `expected` - * through verbatim when it is a complete pflag-format diagnostic (Go - * flag-parse parity). Returns `undefined` when `expected` is unaffected, so - * callers can fall back to the error's own untouched `message`. + * Rebuilds a `CliError.InvalidValue` message from its own template when `expected` carries the + * doubled "Expected" prefix, or passes `expected` through verbatim when it's already a complete + * pflag-format diagnostic. Returns `undefined` when unaffected, so callers fall back to the + * error's own message. */ export function formatInvalidValueMessage(error: InvalidValueMessageFields): string | undefined { if (error.expected.startsWith(PFLAG_INVALID_ARGUMENT_PREFIX)) return error.expected; diff --git a/apps/cli/src/shared/cli/markdown-formatter.unit.test.ts b/apps/cli/src/shared/cli/markdown-formatter.unit.test.ts index f6e50610d5..74f4cdd29f 100644 --- a/apps/cli/src/shared/cli/markdown-formatter.unit.test.ts +++ b/apps/cli/src/shared/cli/markdown-formatter.unit.test.ts @@ -3,10 +3,6 @@ import type { HelpDoc } from "effect/unstable/cli"; import { describe, expect, it } from "vitest"; import { formatHelpDocAsMarkdown } from "./markdown-formatter.ts"; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - type RawFlagDoc = Omit<HelpDoc.FlagDoc, "description"> & { readonly description?: string }; type RawArgDoc = Omit<HelpDoc.ArgDoc, "description"> & { readonly description?: string }; type RawHelpDoc = Omit<Partial<HelpDoc.HelpDoc>, "flags" | "args"> & { @@ -40,10 +36,6 @@ function makeDoc(overrides: RawHelpDoc = {}): HelpDoc.HelpDoc { }; } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe("formatHelpDocAsMarkdown", () => { describe("usage section", () => { it("always renders a Usage section as a sh code block", () => { @@ -126,7 +118,6 @@ describe("formatHelpDocAsMarkdown", () => { const result = formatHelpDocAsMarkdown(doc); const lines = result.split("\n"); const flagLines = lines.filter((l) => l.startsWith("|")); - // All table rows should have the same length (padded) const lengths = flagLines.map((l) => l.length); expect(new Set(lengths).size).toBe(1); }); @@ -251,7 +242,6 @@ describe("formatHelpDocAsMarkdown", () => { const result = formatHelpDocAsMarkdown(doc); expect(result).toContain("## Examples"); expect(result).toContain("```sh\nmyapp deploy --env production\n```"); - // No description prefix before the block const examplesSection = result.split("## Examples\n\n")[1]!; expect(examplesSection.trimStart().startsWith("```sh")).toBe(true); }); @@ -507,7 +497,6 @@ describe("formatHelpDocAsMarkdown", () => { }); const result = formatHelpDocAsMarkdown(doc); expect(result).toContain("## Subcommands"); - // The ungrouped table should not have a ### heading before it const subcommandsSection = result.split("## Subcommands\n\n")[1]!; expect(subcommandsSection.trimStart().startsWith("|")).toBe(true); expect(result).toContain("### Database"); diff --git a/apps/cli/src/shared/cli/run.e2e.test.ts b/apps/cli/src/shared/cli/run.e2e.test.ts index 520ef339c1..6102ce0d5b 100644 --- a/apps/cli/src/shared/cli/run.e2e.test.ts +++ b/apps/cli/src/shared/cli/run.e2e.test.ts @@ -6,12 +6,8 @@ import { afterEach, describe, expect, test } from "vitest"; import { runSupabase } from "../../../tests/helpers/cli.ts"; /** - * CLI-1906: the real bug here is the actual OS process exit code — - * `ProcessControl.exit` calls real `process.exit(code)`, so only a genuine - * subprocess run proves the shipped binary's exit code changed. Everything - * else about this fix (`exitCodeForFailure`'s classification) is covered by - * `run.unit.test.ts` and `run.integration.test.ts`; this is the one minimal - * case that observes the real subprocess boundary. + * Only a genuine subprocess run proves the shipped binary's real OS exit code — the + * classification logic itself is covered by `run.unit.test.ts` and `run.integration.test.ts`. */ describe("CLI process exit codes (CLI-1906)", () => { test("bare `branches` (no subcommand, no --help) exits 0", async () => { @@ -26,18 +22,9 @@ describe("CLI process exit codes (CLI-1906)", () => { }); /** - * CLI-1901: a required-flag/choice parse failure used to dump the full help - * doc to **stdout** and print the error **twice** on stderr. The buffering - * mechanism that fixes this (`withoutParseErrorHelpDump` in `run.ts`) is - * already covered against a real command definition, in-process, by - * `run.integration.test.ts` — this is the one minimal case that observes the - * real subprocess boundary: whether the actual `stdout`/`stderr` streams of - * the shipped binary stay separated, and whether the error text is - * de-duplicated, matching the real `apps/cli-go/supabase-go` binary (Go - * still shows a usage block for an unrecognized flag — raised during - * `ParseFlags`, before `PersistentPreRunE` sets `SilenceUsage` - * (`apps/cli-go/cmd/root.go:97`) — always on stderr, never stdout; - * verified directly against the built Go binary). + * `withoutParseErrorHelpDump` (`run.ts`) is already covered against a real command definition, + * in-process, by `run.integration.test.ts` — this is the one case that observes the real + * subprocess boundary: whether stdout/stderr stay separated and the error text isn't duplicated. */ describe("CLI required-flag/choice parse errors (CLI-1901)", () => { test("an unrecognized flag: stdout stays clean, the help/usage content and the single error line land on stderr with no duplicate", async () => { @@ -47,14 +34,7 @@ describe("CLI required-flag/choice parse errors (CLI-1901)", () => { ]); expect(exitCode).toBe(1); expect(stdout).toBe(""); - // Matches Go's still-shown usage block for this error class (see the - // describe-level comment) — this library's help doc isn't byte-identical - // to cobra's shorter usage template, but it's on the right stream now. expect(stderr).toContain("USAGE"); - // The error text appears exactly once — before the fix, the library's own - // duplicate render put it on stderr a second time (on top of the stdout - // help dump this test doesn't even need to check for, since stdout is - // asserted empty above). const occurrences = stderr.split("Unrecognized flag: --this-flag-does-not-exist").length - 1; expect(occurrences).toBe(1); expect( @@ -88,8 +68,8 @@ describe("CLI upgrade notice (#5853)", () => { expect(suppressed.exitCode).toBe(0); expect(suppressed.stderr).not.toContain("A new version of Supabase CLI is available"); - // `--help` exits through the plain-success branch, bare `branches` through - // the clean-ShowHelp one — both handledProgram call sites must fire. + // `--help` exits through the plain-success branch, bare `branches` through the + // clean-ShowHelp one — both call sites must fire. const helped = await runSupabase(["branches", "--help"], { cwd: workdir, env: { SUPABASE_NO_UPDATE_NOTIFIER: "0" }, diff --git a/apps/cli/src/shared/cli/run.integration.test.ts b/apps/cli/src/shared/cli/run.integration.test.ts index 2f5ff34234..04cd71dd8d 100644 --- a/apps/cli/src/shared/cli/run.integration.test.ts +++ b/apps/cli/src/shared/cli/run.integration.test.ts @@ -14,14 +14,9 @@ const testBranchesCommand = branchesCommand.pipe( ); /** - * A `Console.Console` test double that records `log`/`error` calls into - * `calls` instead of writing anywhere, for asserting on writes through the - * `Console.Console` Effect service directly. Deliberately NOT `vi.spyOn`ing - * the global `console` object: under this repo's Bun + Vitest combination, - * spying on `console.log` and `console.error` in the SAME test reliably - * breaks call detection on both (reproduced in isolation, unrelated to this - * fix) — observing the `Console.Console` service `withoutParseErrorHelpDump` - * itself reads and overrides is both more precise and immune to that quirk. + * A `Console.Console` test double that records `log`/`error` calls into `calls` instead of + * writing anywhere. Not `vi.spyOn`-based: spying on `console.log` and `console.error` in the same + * test unreliably breaks call detection under this repo's Bun + Vitest combination. */ function fakeConsole(): { readonly console: Console.Console; readonly calls: Array<string> } { const calls: Array<string> = []; @@ -57,23 +52,10 @@ function fakeConsole(): { readonly console: Console.Console; readonly calls: Arr } /** - * CLI-1906: `supabase branches` (a "group" command — subcommands, no - * runnable handler of its own) used to exit 1 when invoked bare, even though - * the printed help was identical to `supabase branches --help`, which already - * exited 0. These tests run the real `branchesCommand` definition - * through `Command.runWith` (same technique as `version.integration.test.ts`) - * so the `ShowHelp` cause shape is the one the real CLI actually produces, not - * a hand-rolled stand-in. `branchesCommand` is exercised directly - * (rather than nested under `rootCommand`) because `rootCommand`'s - * `Command.provide` (see `Command.ts`'s `provide`/`withSubcommands`) wraps its - * *entire* handle — including the bare/`--help`/parse-error paths exercised - * here — in the production output/proxy layer graph (`Layer.unwrap` reading - * every global flag, resolving the Go proxy binary, etc). `Effect.provide` - * still *builds* that layer graph before running the wrapped handle even on - * these runs; it just never gets *consumed*, because the `ShowHelp` failure - * fires before any leaf subcommand handler body executes. Exercising - * `branchesCommand` directly avoids needing to provide or mock that - * unused graph for a test that only cares about the `ShowHelp` cause shape. + * Runs the real `branchesCommand` definition directly (not nested under `rootCommand`) through + * `Command.runWith`, so the `ShowHelp` cause shape is the one the real CLI produces. Bypassing + * `rootCommand` avoids needing to provide or mock its full production layer graph, since the + * `ShowHelp` failure these tests care about fires before any leaf handler body runs. */ describe("group command exit codes (CLI-1906)", () => { const layerFor = (args: ReadonlyArray<string>) => @@ -101,8 +83,6 @@ describe("group command exit codes (CLI-1906)", () => { test("`branches --help` succeeds outright and exits 0", async () => { const exit = await runBranches(["--help"]); - // The `--help` global flag is handled as a successful `GlobalFlag.Action`, so this - // never even reaches the ShowHelp-as-failure path bare `branches` goes through above. expect(Exit.isSuccess(exit)).toBe(true); }); @@ -116,42 +96,15 @@ describe("group command exit codes (CLI-1906)", () => { }); /** - * CLI-1901: a required-flag/choice parse failure used to dump the full help - * doc to stdout (`console.log`) AND print the error twice — once from the - * vendored `effect` CLI library's own `showHelp()` (`console.error`), once - * from this repo's own Go-parity renderer (`handledProgram` + - * `normalizeCause` in `run.ts`, exercised downstream of this test, not - * here). These tests run real commands through `Command.runWith`, wrapped in - * `withoutParseErrorHelpDump`, and assert on the calls recorded by a fake - * `Console.Console` (see `fakeConsole` above) provided in place of the real - * one — that's the exact service `withoutParseErrorHelpDump` overrides and - * later replays through, so it's what actually matters here. + * Runs commands through `Command.runWith`, wrapped in `withoutParseErrorHelpDump`, and asserts on + * the calls recorded by a fake `Console.Console` (see `fakeConsole` above) substituted for the + * real one — the exact service `withoutParseErrorHelpDump` overrides and replays through. * - * Go only suppresses its own usage block for a MISSING REQUIRED FLAG - * (`ValidateRequiredFlags`, post-`PersistentPreRunE`) — verified against the - * real `apps/cli-go/supabase-go` binary. Every other parse-error tag - * (`UnrecognizedOption`, `InvalidValue`, `MissingArgument`, - * `UnknownSubcommand`) is raised during `ParseFlags`/`ValidateArgs`, BEFORE - * `PersistentPreRunE` sets `SilenceUsage` — Go still shows a usage block for - * those, always on stderr, never stdout. `classifyParseErrorConsoleOutput` - * (see `run.ts`) mirrors that split; these tests cover both branches. - * - * `branchesCommand` (no `Command.provide` of its own — see the - * sibling CLI-1906 suite above for why that matters) covers the - * `UnrecognizedOption` shape and proves the buffering/conditional-flush - * wiring end to end. `MissingOption`/`InvalidValue` specifically need a - * genuinely required flag / `Flag.choice`, which every shipped native - * command with one (e.g. `sso add`'s `--type`, see `add.command.ts`) also - * wraps in its own `Command.provide`d management-API runtime layer — - * exercising those tags against a real shipped command would mean - * providing or mocking that whole layer graph for services this parse-error - * path never consumes (same friction the CLI-1906 suite's own comment - * describes and avoids). A minimal `Command.make` with a required - * `Flag.string`/`Flag.choice` still runs through the exact same vendored - * `Command.runWith`/`showHelp()` machinery that produces the bug — the - * command is synthetic, but the `ShowHelp` cause shape it produces is not; a - * real subprocess run against `sso add` itself (the issue's own named - * repro target) is covered end to end by `sso.e2e.test.ts`. + * `branchesCommand` covers the `UnrecognizedOption` shape end to end. `MissingOption`/ + * `InvalidValue` need a genuinely required flag or `Flag.choice`, which every shipped command + * with one also wraps in its own management-API runtime layer — a minimal synthetic + * `Command.make` runs through the same `Command.runWith`/`showHelp()` machinery without that + * overhead. A real subprocess run against `sso add` is covered end to end by `sso.e2e.test.ts`. */ describe("withoutParseErrorHelpDump (CLI-1901)", () => { const layerFor = (args: ReadonlyArray<string>, console: Console.Console) => @@ -169,12 +122,8 @@ describe("withoutParseErrorHelpDump (CLI-1901)", () => { { rootCommand: testBranchesCommand, args }, ).pipe(Effect.provide(layerFor(args, console))); - // `type`'s `-t` alias mirrors the real `sso add --type`/`-t` flag - // (`add.command.ts`) so the short-alias "present but missing its value" - // case below (Codex review finding, CLI-1901 follow-up) exercises the same - // shape end to end, without pulling in `sso add`'s own management-API - // runtime layer graph (see the suite-level comment above for why that's - // avoided here). + // `type`'s `-t` alias mirrors the real `sso add --type`/`-t` flag, so the short-alias case + // below exercises the same shape without pulling in a management-API runtime layer. const requiredFlagCommand = Command.make("test-required-flag", { type: Flag.choice("type", ["saml"] as const).pipe(Flag.withAlias("t")), }); @@ -189,18 +138,9 @@ describe("withoutParseErrorHelpDump (CLI-1901)", () => { const { console, calls } = fakeConsole(); const exit = await Effect.runPromiseExit(runBranches(["--this-flag-does-not-exist"], console)); - // The library's own duplicate `Console.error` write is gone. Its help - // doc survives, but redirected to stderr (`error:`), never stdout - // (`log:`) — matching Go, which still shows usage for an unrecognized - // flag (raised during `ParseFlags`, before `SilenceUsage` is set), just - // on stderr. expect(calls.length).toBeGreaterThan(0); expect(calls.every((call) => call.startsWith("error:"))).toBe(true); - // The original ShowHelp/UnrecognizedOption failure still propagates — - // the fix only suppresses the library's own console writes, it must not - // swallow or reshape the failure this repo's own `handledProgram` + - // `normalizeCause` still needs to render the single Go-parity line. expect(Exit.isFailure(exit)).toBe(true); if (!Exit.isFailure(exit)) return; expect(exitCodeForFailure(exit.cause)).toBe(1); @@ -221,21 +161,12 @@ describe("withoutParseErrorHelpDump (CLI-1901)", () => { const { console, calls } = fakeConsole(); const exit = await Effect.runPromiseExit(runRequiredFlagCommand([], console)); - // Go's `SilenceUsage` is already active for a missing required flag - // (post-`PersistentPreRunE`) — nothing survives, not even on stderr. expect(calls).toEqual([]); expect(Exit.isFailure(exit)).toBe(true); if (!Exit.isFailure(exit)) return; expect(exitCodeForFailure(exit.cause)).toBe(1); }); - // CLI-1901 (Codex review finding): the vendored library can't tell "flag - // never given" apart from "flag given with no value following it" — both - // raise `MissingOption`. Go's pflag DOES distinguish these (a present-but- - // valueless flag is a `ParseFlags`-time error, before `SilenceUsage` is - // set) — verified against the real `apps/cli-go/supabase-go` binary, which - // still prints its usage block for this input. `--type` as the LAST token - // (no value token follows it) reproduces that "present but valueless" case. test("a required flag present on argv but missing its value: replays the help dump to stderr instead of dropping it", async () => { const { console, calls } = fakeConsole(); const exit = await Effect.runPromiseExit(runRequiredFlagCommand(["--type"], console)); @@ -247,16 +178,6 @@ describe("withoutParseErrorHelpDump (CLI-1901)", () => { expect(exitCodeForFailure(exit.cause)).toBe(1); }); - // Codex review finding (CLI-1901 follow-up): the same "present but missing - // its value" case, but supplied via the flag's SHORT ALIAS (`-t`) instead of - // its canonical long form. Go's pflag treats a present-but-valueless - // shorthand exactly like the long form (`parseSingleShortArg` raises the - // same `ValueRequiredError` as `parseLongArg`, same pre-`SilenceUsage` - // timing) — verified against the real `apps/cli-go/supabase-go` binary - // (`sso add -t`: full usage block on stderr, byte-parallel to `sso add - // --type`). Before this fix, `isMissingFlagTokenPresent` only recognized - // the canonical `--type` token and misclassified `-t` as absent, silently - // dropping the help dump instead. test("a required flag present on argv by its short alias but missing its value: replays the help dump to stderr instead of dropping it", async () => { const { console, calls } = fakeConsole(); const exit = await Effect.runPromiseExit(runRequiredFlagCommand(["-t"], console)); @@ -288,13 +209,6 @@ describe("withoutParseErrorHelpDump (CLI-1901)", () => { expect(Exit.isSuccess(exit)).toBe(true); }); - // Effect's default logger (`Effect.log*`) resolves through this same - // `Console.Console` reference (`Logger.withConsoleLog`/`withConsoleError` - // in the vendored library) — see the "buffering scope" note on - // `withoutParseErrorHelpDump` in `run.ts`. No command handler in this - // codebase uses `Effect.log*` today, but this pins the invariant the doc - // comment describes: on a successful run, buffered logger output still - // reaches the user (deferred to end-of-run, not dropped). test("Effect.log* output during a successful run is still flushed, not lost", async () => { const { console, calls } = fakeConsole(); const program = Effect.gen(function* () { diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index c2844cf37f..86733ecf61 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -68,10 +68,9 @@ import { } from "./subcommand-flag-suggestions.ts"; /** - * Services the two CLI shells provide before evaluating a root command. Keep - * this list explicit: preserving the root command's requirement channel here - * makes an accidentally unprovided service fail at the shell boundary instead - * of becoming a runtime missing-service defect. + * Services available before evaluating the root command. Keep this list explicit: preserving the + * root command's requirement channel here makes an accidentally unprovided service fail at the + * shell boundary instead of becoming a runtime missing-service defect. */ export type AllowedRunCliServices = | Analytics @@ -97,60 +96,24 @@ export type AllowedRunCliServices = export type CliRootCommand = Command.Command<"supabase", {}, {}, unknown, AllowedRunCliServices>; -// Global flags that consume the following argv token as their value — a value -// flag missing here would make `extractCommandPath` mistake its value for a -// command-path segment, and would leave the flag's following token unconsumed -// for every scanner below — silently mis-resolving `--workdir` for the bare -// space-separated spelling, or missing the root `--version` behind -// `--completions bash`. Derived from `PERSISTENT_VALUE_FLAG_NAMES` (see -// `GLOBAL_VALUE_FLAG_TOKENS`) so the registries cannot drift apart again -// (issue #6482). +// Global flags that consume their following argv token as a value; missing one here would make +// `extractCommandPath` mistake its value for a command-path segment, and leave scanners below +// unable to skip past it. Derived from `PERSISTENT_VALUE_FLAG_NAMES` so the two registries can't +// drift apart. // -// DELIBERATE MODEL SPLIT: `extractCommandPath` consumes a recognized -// space-separated boolean literal (`--debug false`) because it selects the -// command tree and signal-wrapper behavior. `rootFlagTokens` and -// `firstPositionalIndex` retain pflag-style semantics for their version and -// flag-walk checks: a bare boolean never consumes a following token there. -// The residual divergence predates the issue #6482 fixes and is deliberately -// left with the walk-consolidation follow-up rather than widened further. +// `extractCommandPath` treats a recognized bare boolean literal (`--debug false`) as consuming a +// token too, but `rootFlagTokens`/`firstPositionalIndex` keep pflag's stricter rule that a bare +// boolean never consumes the next token — don't unify these; they answer different questions. const globalFlagsWithValues: ReadonlySet<string> = GLOBAL_VALUE_FLAG_TOKENS; -// Commands that run their own foreground signal loop (serve/start daemons) and must -// NOT be wrapped in the global signal-interrupt handler, which would otherwise race -// their graceful shutdown. Matched by leading command-path segments. +// Commands that run their own foreground signal loop (serve/start daemons) and must not be +// wrapped by the global signal-interrupt handler, which would otherwise race their graceful +// shutdown. Matched by leading command-path segments. // -// Top-level `start` (["start"]) is deliberately NOT listed here: it used to proxy to the -// Go binary, which managed SIGINT/SIGTERM itself, but the native TypeScript `start` -// installs no signal handling of its own — excluding it left Ctrl-C mid-bring-up as a raw, -// unhandled OS signal that hard-kills the process, skipping every Effect finalizer -// including `rollbackStart`. Go's own `start` DID roll back on SIGINT -// (`cmd/root.go:99,155` wraps every command's context with `signal.NotifyContext`; -// formerly `internal/start/start.go:73-82`, which rolled back on any non-nil `run()` -// error, including the `context.Canceled` a SIGINT produces — internal/start was -// deleted as unreachable in CLI-1966, last present at commit a253ccba2), so native -// `start` must participate in the global -// wrapper to match. This list is matched purely against argv command-path segments. -// -// `["db", "start"]` (top-level `db start`) is ALSO deliberately not listed here, for the exact -// same reason as `start` above: it used to proxy container bootstrap to the hidden Go -// `db __db-bootstrap --mode start` seam, which held SIGINT/SIGTERM itself, but CLI-1954's -// native port (`commands/db/start/start.handler.ts` -> `startDatabase`) installs -// no signal handling of its own — it relies on the SAME `Effect.onError(() => -// rollbackStart(...))` wrapper `supabase start` uses, which only ever fires when this -// process's own fiber is interrupted (by `Fiber.interrupt` below, or by an ordinary typed -// failure) — a raw, unhandled OS signal skips it entirely, exactly like the `start` case above. -// -// `["db", "reset"]` was ALSO listed here once, for the same reason `db start` used to be: -// its local path drove the hidden `db __db-bootstrap --mode recreate`/`--mode await-storage` -// seam via a bespoke DIRECT `ChildProcess.make` spawn (not through `GoProxy`), which -// held SIGINT/SIGTERM/SIGHUP itself while the Go child recreated the container — the global -// handler's own `Fiber.interrupt` would otherwise race that child's Docker cleanup and lose -// its real exit status. CLI-1955 removed that seam entirely: `db reset --local` is now fully -// native TS (`command-internal/db-bootstrap/recreate-local-database.ts`), installing no signal -// handling of its own. Its only remaining Go child is the niche `--experimental` remote -// delegate, via the SAME `GoProxy.exec`/`execCapture` every other unlisted -// command already uses safely alongside this global handler — so `db reset` was removed from -// this list too, matching `db start`'s own precedent exactly. +// `start` and `db start` are deliberately NOT listed here even though they sound similar: their +// native implementations install no signal handling of their own, and instead rely on the global +// handler's interruption to trigger their own rollback-on-error cleanup. Listing them would let a +// raw Ctrl-C skip that cleanup entirely. const selfManagedSignalCommands: ReadonlyArray<ReadonlyArray<string>> = [["functions", "serve"]]; /** Positional command-path tokens from argv, skipping global flags and their values. */ @@ -229,17 +192,9 @@ function isFlagOccurrence(token: string, name: string): boolean { } /** - * `strconv.ParseBool`'s true spellings — a TRUTHINESS set for the - * pflag-modeled `--version=<value>` resolution below. Answers a different - * question than `BOOLEAN_FLAG_VALUES` (`agent-output.ts`), which asks whether - * the shipped parser ACCEPTS the value at all — that parser serves the - * Version action for any accepted value, `--version=no` included, so the two - * sets must not be merged. The residual divergence runs both ways — an - * accepted-but-not-ParseBool-true spelling (`--version=no`) resolves - * `version=false` here while the renderer still serves the version, and a - * ParseBool-true spelling the parser rejects (`--version=t`) never serves - * anything — but it only steers the upgrade-notice scans and predates the - * issue #6482 fixes. + * pflag's `ParseBool` true spellings, used to resolve `--version=<value>` here. This answers a + * different question than `BOOLEAN_FLAG_VALUES` (`agent-output.ts`), which asks whether the + * shipped parser accepts the value at all — the two sets must not be merged. */ const PFLAG_BOOL_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); @@ -300,14 +255,10 @@ function firstPositionalIndex( } /** - * Whether argv sets the ROOT `--version` flag — in any spelling pflag marks as - * changed, which is what Go's `shouldFetchRelease` keys on: bare, valued - * (`--version=false` included), or followed by a - * space-form operand (`--version true` — cobra serves the version built-in - * before it ever validates the stray operand). A subcommand's own flag of the - * same name (`db reset --version x`) does not count: a positional precedes - * it. Operands after `--` and tokens consumed as another flag's value never - * count. + * Whether argv sets the root `--version` flag, in any spelling pflag marks as changed: bare, + * valued (`--version=false` included), or followed by a space-form operand (`--version true`, + * since the version built-in is served before the stray operand is validated). A subcommand's + * own `--version` (`db reset --version x`) does not count, since a positional precedes it. */ export function hasRootVersionFlag( args: ReadonlyArray<string>, @@ -322,20 +273,11 @@ export function hasRootVersionFlag( } /** - * Whether cobra serves this argv as a built-in — help at any depth, or root - * version — without ever running `PersistentPreRunE`. Help counts on - * presence: a false value on the root or a group still lands on cobra's - * non-`Runnable()` help (execute() serves the built-ins and the Runnable - * check before `preRun`), and the one input Go would instead run (a runnable - * leaf under `--help=false`) is a spelling the vendored effect CLI serves - * help for anyway. The version flag resolves pflag-style, last value wins: a - * true value counts as the version built-in, `--version=false <leaf>` counts - * as running the leaf — `ChangeWorkDir` included — and only a bare - * invocation falls back to the non-runnable root's help. That is this - * function's MODEL, not the shipped renderer's behavior: the parser serves - * the Version action for any accepted value, `--version=no` included (see - * `PFLAG_BOOL_TRUE`'s doc above for the deliberate split — only the - * upgrade-notice checks ride on this resolution). + * Whether this argv resolves to a built-in action — help at any depth, or the root version — + * without ever running a command handler. Help counts on presence, regardless of value. The + * version flag resolves pflag-style, last value wins: a true value counts as the version + * built-in, `--version=false <leaf>` counts as running the leaf, and only a bare invocation with + * no positional falls back to the root's help. Used only by the upgrade-notice checks below. */ export function hasRootHelpOrVersionFlag( args: ReadonlyArray<string>, @@ -392,21 +334,11 @@ function formatterLayerFor( } /** - * Process exit code for a failed CLI run, matching Go cobra's exit-code - * mapping. Delegates to Effect's own `Runtime` exit-code protocol (the same - * one `Runtime.defaultTeardown` uses) rather than hand-rolling `ShowHelp` - * classification: `CliError.ShowHelp` declares - * `[Runtime.errorExitCode] = this.errors.length ? 1 : 0`, so a bare group - * command's default handler failing with `ShowHelp({ errors: [] })` (no - * subcommand given, e.g. `supabase branches`) reads as exit `0` here — matching - * Go cobra's non-`Runnable()` handling, which internally returns - * `flag.ErrHelp` and `ExecuteC()` maps that to "print help, return nil error". - * A `ShowHelp` with a non-empty `errors` array (a genuine parse/validation - * failure) reads as exit `1`, and any other failure (including a `Cause.die` - * defect with no typed `ShowHelp` marker at all) falls back to - * `Runtime.getErrorExitCode`'s default of `1`. An explicit `--help` invocation - * never reaches this function — it's handled earlier as a successful - * `GlobalFlag.Action` and exits 0 via the success path. + * Process exit code for a failed CLI run. Delegates to Effect's own `Runtime` exit-code protocol + * rather than hand-rolling classification: a bare group command's default handler failing with + * `ShowHelp({ errors: [] })` (no subcommand given) reads as exit `0`; a `ShowHelp` with a + * non-empty `errors` array, or any other failure, falls back to exit `1`. An explicit `--help` + * invocation never reaches this function — it exits 0 via the success path. */ export function exitCodeForFailure(cause: Cause.Cause<unknown>): number { if (Cause.hasInterruptsOnly(cause)) return 130; @@ -414,20 +346,12 @@ export function exitCodeForFailure(cause: Cause.Cause<unknown>): number { } /** - * Whether `handledProgram` should render its generic `output.fail` stderr line - * for a failed run, given the run's cause and the exit code `exitCodeForFailure` - * already computed for it. False for a clean exit (`0`), an interrupt (`130`), - * and a `GoChildExitError` (CLI-1879) — a delegated Go child already wrote - * its own detailed failure to the inherited stderr, so a second generic line - * here would be a line Go itself never prints. - * - * Checked by concrete type, NOT Effect's shared `[Runtime.errorReported]` - * marker: `CliError.ShowHelp` also sets that marker to `false`, for an - * unrelated reason (the CLI framework already rendered help/usage text) — - * gating on the marker would ALSO suppress `normalizeCause`'s Go-parity - * rendering for a `MissingOption` wrapped in `ShowHelp` (e.g. `Error: required - * flag(s) "type" not set`), a real parity regression. See the test suite for - * the regression this guards. + * Whether `handledProgram` should render its generic `output.fail` stderr line for a failed run. + * False for a clean exit (`0`), an interrupt (`130`), and a `GoChildExitError` — a delegated Go + * child already wrote its own failure to the inherited stderr, so a second line here would be + * redundant. Checked by concrete type rather than Effect's shared `[Runtime.errorReported]` + * marker, since `CliError.ShowHelp` also sets that marker `false` for an unrelated reason and + * would otherwise suppress real error rendering too. */ export function shouldReportFailure(cause: Cause.Cause<unknown>, exitCode: number): boolean { if (exitCode === 0 || exitCode === 130) return false; @@ -435,9 +359,8 @@ export function shouldReportFailure(cause: Cause.Cause<unknown>, exitCode: numbe } /** - * A single `Console.log`/`Console.error` call captured while - * `withoutParseErrorHelpDump` runs, so it can be replayed once the run's - * outcome is known instead of being written immediately. + * A single `Console.log`/`Console.error` call captured while `withoutParseErrorHelpDump` runs, so + * it can be replayed once the run's outcome is known instead of being written immediately. */ interface BufferedConsoleWrite { readonly method: "log" | "error"; @@ -445,12 +368,10 @@ interface BufferedConsoleWrite { } /** - * A `Console.Console` that captures `log`/`error` calls into `sink` instead - * of writing them, and forwards every other method straight through to the - * real console. The vendored `effect` CLI library's parser only ever calls - * `log`/`error` (`showHelp()` and the `Help`/`Version`/`Completions` - * `GlobalFlag.Action`s in `Command.ts`) — the rest are implemented so this - * stays a faithful `Console.Console` rather than a partial stand-in. + * A `Console.Console` that captures `log`/`error` calls into `sink` instead of writing them, and + * forwards every other method straight through to the real console. The vendored CLI library only + * ever calls `log`/`error`, but every method is implemented so this stays a faithful + * `Console.Console` rather than a partial stand-in. */ function bufferingConsole(sink: Array<BufferedConsoleWrite>): Console.Console { const real = globalThis.console; @@ -482,104 +403,21 @@ function bufferingConsole(sink: Array<BufferedConsoleWrite>): Console.Console { } /** - * How `withoutParseErrorHelpDump` should dispose of its buffered - * `Console.log`/`Console.error` writes, given how the wrapped effect failed: - * - * - `"flush-unchanged"` — success, or a "clean" `ShowHelp` (`errors: []` — - * an explicit `--help` or a bare group command with no subcommand, both - * of which map to exit `0` per `exitCodeForFailure` above), or any other - * failure. Those buffered writes (if any, there normally are none outside - * the two `ShowHelp` cases) are the actual intended output. - * - `"drop"` — a genuine parse/validation failure Go cobra's - * `PersistentPreRunE` already suppresses usage for: `ValidateRequiredFlags` - * (cobra `command.go:1007`), which sets `cmd.SilenceUsage = true` - * (`apps/cli-go/cmd/root.go:97`) BEFORE it runs. This library's - * `MissingOption` is the one tag that maps to that stage — see CLI-1901 — - * but ONLY when the flag was never given at all. A required flag that IS - * present on argv but missing its value (e.g. `sso add --type` with - * nothing after it) also raises `MissingOption` in this library (it has no - * distinct "value required" tag), yet Go's own pflag raises a DIFFERENT, - * earlier `ParseFlags`-time error for that input (`flag needs an - * argument: --type`) which does NOT get `SilenceUsage` treatment — verified - * against the real binary (`apps/cli-go/supabase-go sso add --type`: full - * usage block on stderr, vs `sso add --project-ref x` with `--type` never - * mentioned at all: bare `required flag(s) "type" not set`, no usage). See - * `isMissingFlagTokenPresent` below for how this case is distinguished from - * a genuinely-absent flag. - * - `"flush-help-doc-to-stderr"` — every other genuine parse/validation - * failure (`UnrecognizedOption`, `InvalidValue`, `MissingArgument`, - * `UnknownSubcommand`; multiple simultaneous errors also lands here). - * These map to cobra's `ParseFlags`/`ValidateArgs` (`command.go:919,968`), - * which run BEFORE `PersistentPreRunE` — Go still shows a usage block for - * these, just on stderr, never stdout (verified against the real - * `apps/cli-go/supabase-go` binary, e.g. `branches --bogus-flag` and - * `sso add --type bogus`). The help doc this library renders isn't - * byte-identical to cobra's shorter usage template (that would need a - * second formatter, out of scope for CLI-1901), but showing SOME usage - * content on the RIGHT stream is closer to Go than showing none at all. - * - * In every "genuine failure" case, the buffered `Console.error` write (the - * library's own duplicate render of the errors) is always dropped — this - * repo's own `handledProgram` + `normalizeCause` already render the single - * Go-parity line for it (see `withoutParseErrorHelpDump` below). + * How `withoutParseErrorHelpDump` disposes of its buffered `Console` writes for a failed run. + * `"drop"` applies only when a required flag was never given at all (see + * `isMissingFlagTokenPresent`) — a required flag that's present but missing its value is a + * different, earlier parse error and must render normally. Every other genuine parse/validation + * failure gets its help doc redirected to stderr instead of stdout; the library's own duplicate + * error line is always dropped, since `handledProgram`/`normalizeCause` render that separately. */ export type ParseErrorConsoleDisposition = "flush-unchanged" | "drop" | "flush-help-doc-to-stderr"; /** - * Whether `option`'s canonical long-form flag token (`--option` or - * `--option=...`), or one of its short/long `aliases` (e.g. `-t`), appears - * anywhere in the raw argv this run was invoked with BEFORE the `--` - * operand terminator (if any) — used to tell a genuinely-absent required - * flag (Go: `SilenceUsage`-suppressed) apart from one that's present but - * missing its value (Go: a `ParseFlags`-time error, usage still shown). See - * the `"drop"` case on `ParseErrorConsoleDisposition` above for the full - * rationale. `aliases` come from `flagAliasesFor` (see - * `classifyParseErrorConsoleOutput` below), already formatted with their - * leading dash(es). - * - * Tokens after a literal `--` are always positional operands, never a flag - * occurrence for ANY option — this mirrors the vendored `effect` CLI - * library's own lexer, which treats `--` the same way (`internal/lexer.ts`, - * `argv.indexOf("--")`). Without this cutoff, a command like - * `migration repair -- 20230101000000 --status` (a required `Flag.choice`, - * `commands/migration/repair/repair.command.ts`) would have its - * trailing `--status` positional argument misread as evidence the `--status` - * flag was given, flipping a genuinely-absent-flag failure (Go: no usage - * shown) into a "present but missing its value" one (Go: usage shown) — see - * CLI-1901. - * - * That `--` cutoff is only genuine when the scan reaches `--` as a LIVE - * token, not when it was itself consumed as the VALUE of an immediately - * preceding value-taking flag. Go/pflag's `parseArgs` (`flag.go`) only - * recognizes `--` as the terminator when it's at the FRONT of the remaining - * args on a fresh iteration; `parseLongArg`'s value branch pops the very - * next raw token with no shape check at all, so a literal `--` right after a - * value-taking flag gets swallowed as that flag's value and never reaches - * the terminator check — e.g. `sso add --project-ref -- --type` hands the - * literal string `--` to `--project-ref`, and parsing resumes normally on - * `--type` (which then fails with pflag's OWN `ValueRequiredError` — a - * `ParseFlags`-time error, usage still shown — since nothing follows it). - * The scan below therefore folds the terminator check into the very same - * loop that already skips consumed-value tokens, rather than precomputing - * `args.indexOf("--")` up front — a Codex review finding on CLI-1901. - * - * `isValueTakingToken` (from `isValueTakingFlagTokenFor`, OR'd with - * `globalFlagsWithValues` at the `classifyParseErrorConsoleOutput` call site - * below) lets the scan skip a token immediately consumed as the VALUE of a - * preceding value-taking flag — local OR global — instead of mistaking that - * consumed token (or a consumed literal `--`, per above) for `option`'s own - * occurrence. Go/pflag's `parseLongArg` (`flag.go`) unconditionally consumes - * the very next argv entry as a value-taking flag's value, even when that - * entry itself looks like another flag — e.g. `sso add --project-ref --type` - * hands the literal string `--type` to `--project-ref`, so `--type` is never - * seen as its own occurrence in Go, and its `MissingOption` failure keeps - * Go's `SilenceUsage` treatment (no usage shown). The vendored `effect` - * parser does NOT replicate that eager consumption (it only treats a - * following token as a value when the lexer tags it `Value`, never a - * flag-shaped token — `internal/parser.ts`'s `consumeFlagValueWithTokens`), - * so without this skip the raw scan would find the literal `--type` token - * and wrongly flush the help doc for an input Go shows no usage for — a - * Codex review finding on CLI-1901. + * Whether `option`'s flag token, or one of its `aliases`, appears anywhere in argv before a live + * `--` terminator — used to tell a genuinely-absent required flag (usage suppressed) apart from + * one that's present but missing its value (a separate, earlier parse error that still shows + * usage). `isValueTakingToken` skips tokens consumed as another flag's value (including a literal + * `--`), so a consumed token is never mistaken for `option`'s own occurrence. */ function isMissingFlagTokenPresent( option: string, @@ -591,18 +429,15 @@ function isMissingFlagTokenPresent( for (let index = 0; index < args.length; index++) { const arg = args[index]; if (arg === undefined) continue; - // A literal "--" only terminates flag parsing when reached as a LIVE - // token here — if the previous iteration already skipped it as a - // value-taking flag's consumed value (below), this line never runs for - // it, matching pflag's own ordering (see the doc comment above). + // A literal "--" only terminates parsing when reached live — if it was already skipped as a + // value-taking flag's consumed value below, this check never runs for it. if (arg === "--") break; if (tokens.some((token) => arg === token || arg.startsWith(`${token}=`))) return true; const equalIndex = arg.indexOf("="); const bareToken = equalIndex === -1 ? arg : arg.slice(0, equalIndex); if (equalIndex === -1 && isValueTakingToken(bareToken)) { - // Go/pflag consumes the following argv entry as `bareToken`'s value - // unconditionally — even a literal "--" — so skip it here too, before - // the next iteration's terminator check ever sees it. + // pflag consumes the following argv entry as `bareToken`'s value unconditionally — even a + // literal "--" — so skip it here too, before the terminator check ever sees it. index++; } } @@ -617,15 +452,10 @@ export function classifyParseErrorConsoleOutput( if (!CliError.isCliError(error) || error._tag !== "ShowHelp" || error.errors.length === 0) { return "flush-unchanged"; } - // `isValueTakingFlagTokenFor` only inspects the resolved LEAF command's own - // flags, so it has no visibility into value-taking GLOBAL flags (`--network-id`, - // `--profile`, etc. — see `globalFlagsWithValues` above). Without OR-ing those - // in, a global value flag consuming the very next token (e.g. - // `migration repair --network-id --status --local <version>` handing the - // literal `--status` to `--network-id`, per pflag's `parseLongArg`) would - // leave the required `status` flag looking "present" to the scan below, even - // though Go/pflag never sees it as its own occurrence and suppresses usage - // for it (`SilenceUsage`) — a Codex review finding on CLI-1901. + // `isValueTakingFlagTokenFor` only inspects the resolved leaf command's own flags, so + // value-taking global flags (`--network-id`, `--profile`, etc.) must be OR'd in — otherwise a + // global flag consuming the very next token could leave a genuinely-required flag looking + // "present" to the scan below. const isLeafValueTakingToken = isValueTakingFlagTokenFor(context.rootCommand, error.commandPath); const isValueTakingToken = (token: string) => globalFlagsWithValues.has(token) || isLeafValueTakingToken(token); @@ -641,50 +471,14 @@ export function classifyParseErrorConsoleOutput( } /** - * Wraps `Command.runWith(rootCommand, ...)(args)` so the vendored `effect` - * CLI library's OWN `Console.log`/`Console.error` writes are captured - * instead of reaching the real console, then disposed of per - * `classifyParseErrorConsoleOutput` once the run's outcome is known: - * dropped entirely for a missing-required-flag failure, replayed to stderr - * (never stdout) for every other genuine parse/validation failure, and - * replayed unchanged for everything else. Either way, the library's own - * duplicate error render never survives — this repo's own `handledProgram` - * + `normalizeCause` already render the single Go-parity line for it. That - * fixes both halves of CLI-1901 (the stdout help dump and the duplicate - * error line) from this one call site, without patching the vendored - * library itself. - * - * TODO: remove this whole buffering/classification dance once upstream - * Effect-TS/effect#6313 is fixed — https://github.com/Effect-TS/effect/issues/6313. - * `runWith` has no supported way to opt out of, or redirect, its own - * `showHelp` console writes; everything below exists only to work around - * that gap from the outside. - * - * The "flush unchanged" outcome covers success, `--help`, `--version`, - * `--completions`, and the bare-group-command help dump, all of which stay - * untouched. + * Wraps `Command.runWith(rootCommand, ...)(args)` so the vendored CLI library's own + * `Console.log`/`Console.error` writes are captured instead of reaching the real console, then + * disposed of per `classifyParseErrorConsoleOutput`: dropped for a missing-required-flag failure, + * replayed to stderr for other parse/validation failures, and replayed unchanged otherwise. Safe + * to wrap the whole call since no handler here writes through `Console` directly except + * `@supabase/config`'s `loadCliConfigFile`, which pins itself to the real console. * - * Safe to wrap the entire `runWith` call — parsing AND the eventual command - * handler, not just the parse phase that can actually raise `ShowHelp`: no - * command handler in this codebase writes through `effect`'s `Console` - * service directly (they go through the `Output` service instead). One - * indirect exception is known — `@supabase/config`'s `loadCliConfigFile` - * emits its deprecated-config-section warnings via `Console.error`, and is - * reachable from handlers through `CliConfigStore`/`loadCliConfig` — - * so it pins itself to the real console (`Effect.provideService(Console.Console, - * globalThis.console)`) rather than relying on whatever `Console.Console` is - * ambient here; see CLI-1901 and that package's `io.ts` for why (a - * long-running command like `functions serve` would otherwise have the - * warning buffered for its entire session instead of shown at startup). - * Any other handler writing through `Console` directly would need the same - * treatment — buffering here never delays or drops real command output - * ONLY as long as that invariant holds. Note that Effect's OWN default - * logger (`Effect.log*`) DOES resolve through this same `Console` reference - * (`Logger.withConsoleLog`/`withConsoleError`) — this codebase has no - * `Effect.log*` call sites today, but if one is ever added to a handler, its - * output would be buffered too (deferred to end-of-run on the "flush - * unchanged" path, or dropped on a genuine parse failure — which never runs - * a handler in the first place, so that half is moot). + * TODO: remove once https://github.com/Effect-TS/effect/issues/6313 is fixed upstream. */ export function withoutParseErrorHelpDump<A, E, R>( effect: Effect.Effect<A, E, R>, @@ -703,9 +497,8 @@ export function withoutParseErrorHelpDump<A, E, R>( return yield* exit; } for (const write of sink) { - // The library's own duplicate error render never survives a genuine - // parse failure — only its help-doc `log` write gets a second look, - // redirected to stderr instead of its original stdout-bound method. + // The library's own duplicate error render never survives a genuine parse failure — only + // its help-doc `log` write gets a second look, redirected to stderr. if (disposition === "flush-help-doc-to-stderr" && write.method === "error") continue; const method = disposition === "flush-help-doc-to-stderr" ? "error" : write.method; yield* Console.consoleWith((console) => @@ -745,11 +538,9 @@ export interface RunCliOptions { readonly beforeParse?: Effect.Effect<void, unknown, FileSystem.FileSystem | Path.Path>; readonly analyticsLayer: AnyAnalyticsLayer; /** - * Runs just before the process exits on any invocation that exits 0 — the - * seam for the CLI's upgrade notice. `cleanShowHelp` marks the - * exit-0 ShowHelp failure branch (a bare group command), which cobra serves - * without `PersistentPreRunE`. Must never fail, and cannot change the exit - * code. + * Runs just before the process exits on any invocation that exits 0 — the seam for the CLI's + * upgrade notice. `cleanShowHelp` marks the exit-0 `ShowHelp` failure branch (a bare group + * command). Must never fail, and cannot change the exit code. */ readonly afterSuccess?: ( args: ReadonlyArray<string>, @@ -777,7 +568,8 @@ function cliProgramFor< ) { const runtimeLayer = Layer.mergeAll(processControlLayer, runtimeInfoLayer, ttyLayer); const fallbackCommandLayer = Layer.mergeAll( - // Root command env inference currently leaks some subcommand-provided services. + // Root command env inference leaks some subcommand-provided services; these stand-ins die + // if a root-level invocation ever touches them. Layer.succeed(Credentials, { getAccessToken: Effect.die("unexpected root credentials access"), saveAccessToken: () => Effect.die("unexpected root credentials write"), @@ -838,11 +630,8 @@ export async function runCli< }).pipe(Effect.provide(BunServices.layer)), ); - // Same `{ rootCommand, args }` shape `formatterLayerFor` builds below, so - // `normalizeCause`'s single-render fallback path (CLI-1901) can reuse - // `formatCliErrorsForDisplay` and surface the same subcommand-flag hint the - // text/json formatters would have shown before the vendored library's own - // duplicate render was suppressed. + // Same shape `formatterLayerFor` builds below, so `normalizeCause`'s fallback path can reuse + // `formatCliErrorsForDisplay` and surface the same subcommand-flag hint the formatters would. const suggestionContext = { rootCommand, args }; const useGlobalSignalInterrupt = shouldUseGlobalSignalInterrupt(args); const outputFormat = await Effect.runPromise( @@ -937,12 +726,8 @@ export async function runCli< : Effect.void; if (Exit.isFailure(exit)) { const exitCode = exitCodeForFailure(exit.cause); - // See `shouldReportFailure` for the reporting rules (and why they're - // NOT keyed on Effect's shared `[Runtime.errorReported]` marker). - // Literal `--help` never reaches this branch — it's handled as a - // successful `GlobalFlag.Action` and exits 0 via the success path - // below. See `exitCodeForFailure` for why a "clean" ShowHelp failure - // (e.g. a bare group command with no subcommand) also maps to exit 0. + // See `shouldReportFailure` and `exitCodeForFailure` for the exit-code/reporting rules; a + // literal `--help` never reaches this branch — it exits 0 via the success path below. if (shouldReportFailure(exit.cause, exitCode)) { yield* output.fail(normalizeCause(exit.cause, suggestionContext)); } diff --git a/apps/cli/src/shared/cli/run.unit.test.ts b/apps/cli/src/shared/cli/run.unit.test.ts index 4f2755db53..810fbc426c 100644 --- a/apps/cli/src/shared/cli/run.unit.test.ts +++ b/apps/cli/src/shared/cli/run.unit.test.ts @@ -17,9 +17,8 @@ import { hasRootVersionFlag, } from "./run.ts"; -// Real command tree (not a hand-rolled stand-in) so `classifyParseErrorConsoleOutput`'s -// alias resolution (`flagAliasesFor`, via `context.rootCommand`) has real `Flag.withAlias` -// declarations to walk — e.g. `sso add`'s `type` flag aliases to `-t` (`add.command.ts`). +// Real command tree, not a stand-in, so alias resolution (`flagAliasesFor`) has real +// `Flag.withAlias` declarations to walk. const testRoot = Command.make("supabase").pipe( Command.withSubcommands([branchesCommand, migrationCommand, ssoCommand]), ); @@ -99,21 +98,12 @@ describe("shouldUseGlobalSignalInterrupt", () => { expect(shouldUseGlobalSignalInterrupt(["projects", "list"])).toBe(true); expect(shouldUseGlobalSignalInterrupt(["start"])).toBe(true); expect(shouldUseGlobalSignalInterrupt(["db", "start"])).toBe(true); - // `db reset` (CLI-1955): the hidden `db __db-bootstrap` seam this used to drive is - // gone — the local path is fully native TS, installing no signal handling of its - // own, so it participates in the global handler like `db start` (CLI-1954) before it. expect(shouldUseGlobalSignalInterrupt(["db", "reset"])).toBe(true); expect(shouldUseGlobalSignalInterrupt([])).toBe(true); }); }); describe("exitCodeForFailure", () => { - // CLI-1906: a group command's default handler (e.g. bare `supabase branches`, which - // has subcommands but no runnable handler of its own) fails with exactly this shape: - // ShowHelp with an empty `errors` array. `CliError.ShowHelp` declares - // `[Runtime.errorExitCode] = this.errors.length ? 1 : 0`, so this reads as exit 0 — - // matching Go cobra's `flag.ErrHelp` handling for non-Runnable commands. Before - // CLI-1906, this case always returned 1. it("exits 0 for a clean ShowHelp failure (bare group command)", () => { const cause = Cause.fail(new CliError.ShowHelp({ commandPath: ["branches"], errors: [] })); expect(exitCodeForFailure(cause)).toBe(0); @@ -134,10 +124,6 @@ describe("exitCodeForFailure", () => { expect(exitCodeForFailure(cause)).toBe(1); }); - // `Cause.squash` on a `Die` cause returns the raw defect (a plain `Error`, with no - // `Runtime.errorExitCode` marker at all). This must still fall back to the default - // failure exit code (1), not silently pass through as a "clean" exit — this is the real - // unexpected-crash path through `runCli` that must keep exiting 1. it("exits 1 for a defect with no typed failure", () => { const cause = Cause.die(new Error("unexpected crash")); expect(exitCodeForFailure(cause)).toBe(1); @@ -147,9 +133,6 @@ describe("exitCodeForFailure", () => { expect(exitCodeForFailure(Cause.interrupt())).toBe(130); }); - // CLI-1879: a delegated Go child's exact exit code (not just a generic 1) - // must reach the user, via the `GoChildExitError`'s - // `[Runtime.errorExitCode]` marker. it("exits with a GoChildExitError's exact exit code", () => { const cause = Cause.fail( new GoChildExitError({ exitCode: 130, message: "supabase-go exited with code 130" }), @@ -167,9 +150,6 @@ describe("shouldReportFailure", () => { expect(shouldReportFailure(Cause.interrupt(), 130)).toBe(false); }); - // CLI-1879: the child already wrote its own detailed failure to the - // inherited stderr, so `runCli`'s generic line would be a duplicate Go - // itself never prints. it("does not report a GoChildExitError", () => { const cause = Cause.fail( new GoChildExitError({ exitCode: 1, message: "supabase-go exited with code 1" }), @@ -181,13 +161,6 @@ describe("shouldReportFailure", () => { expect(shouldReportFailure(Cause.fail(new Error("boom")), 1)).toBe(true); }); - // Regression guard: `CliError.ShowHelp` ALSO sets Effect's shared - // `[Runtime.errorReported]` marker to `false` (for an unrelated reason — the - // CLI framework already rendered help/usage text). `shouldReportFailure` - // must NOT key on that shared marker, or it would also suppress - // `normalizeCause`'s Go-parity rendering for a `MissingOption` wrapped in - // `ShowHelp` (e.g. `Error: required flag(s) "type" not set`) — silently - // dropping that message for every command with a required flag. it("still reports a ShowHelp failure carrying a genuine validation error (e.g. a missing required flag)", () => { const cause = Cause.fail( new CliError.ShowHelp({ @@ -199,21 +172,10 @@ describe("shouldReportFailure", () => { }); }); -// CLI-1901: a required-flag/choice parse failure used to dump the full help -// doc to stdout AND print the error twice (once from the vendored `effect` -// CLI library's own `showHelp()`, once from this repo's own Go-parity -// renderer). `withoutParseErrorHelpDump` fixes this by buffering the -// library's own `Console.log`/`Console.error` writes and disposing of them -// per this classifier's verdict — this suite covers the classifier; -// `run.integration.test.ts` covers the end-to-end buffering/flush behavior -// against real command definitions, and `run.e2e.test.ts` / +// This suite covers the classifier; `run.integration.test.ts` covers the end-to-end +// buffering/flush behavior against real command definitions, and `run.e2e.test.ts` / // `sso.e2e.test.ts` cover the real subprocess stdout/stderr streams. describe("classifyParseErrorConsoleOutput", () => { - // Go cobra's `PersistentPreRunE` sets `SilenceUsage = true` - // (`apps/cli-go/cmd/root.go:97`) BEFORE `ValidateRequiredFlags` - // (`command.go:1007`) runs — verified against the real `supabase-go` - // binary (`sso add` without `--type`): a single clean stderr line, no - // usage block. `MissingOption` is the one tag that maps to that stage. it("drops the help dump for a missing required flag", () => { const cause = Cause.fail( new CliError.ShowHelp({ @@ -221,7 +183,6 @@ describe("classifyParseErrorConsoleOutput", () => { errors: [new CliError.MissingOption({ option: "type" })], }), ); - // `--type` (nor its `-t` alias) never appears on argv at all — genuinely absent. expect( classifyParseErrorConsoleOutput(cause, { rootCommand: testRoot, @@ -230,8 +191,6 @@ describe("classifyParseErrorConsoleOutput", () => { ).toBe("drop"); }); - // Multiple simultaneously-missing required flags: still `ValidateRequiredFlags`, - // still post-`PersistentPreRunE` in Go — must still drop, not just for a lone error. it("drops the help dump when every error is a missing required flag", () => { const cause = Cause.fail( new CliError.ShowHelp({ @@ -247,17 +206,6 @@ describe("classifyParseErrorConsoleOutput", () => { ).toBe("drop"); }); - // CLI-1901 (Codex review finding): the vendored library raises the SAME - // `MissingOption` tag whether a required flag was never given at all, or - // given with no value following it (e.g. `sso add --type` as the last - // token) — it has no distinct "value required" error. Go's own pflag does - // distinguish these: a present-but-valueless flag is a `ParseFlags`-time - // error (`flag needs an argument: --type`), raised BEFORE - // `PersistentPreRunE` sets `SilenceUsage` — verified against the real - // `apps/cli-go/supabase-go` binary, which still prints its full usage block - // to stderr for this input. `isMissingFlagTokenPresent` recovers this - // distinction from raw argv so this case flushes the help doc instead of - // silently dropping it like a genuinely-absent flag. it("flushes the help dump to stderr for a required flag present on argv but missing its value", () => { const cause = Cause.fail( new CliError.ShowHelp({ @@ -273,18 +221,6 @@ describe("classifyParseErrorConsoleOutput", () => { ).toBe("flush-help-doc-to-stderr"); }); - // Codex review finding (CLI-1901 follow-up): `--` is the standard operand - // terminator — everything after it is positional, never a flag occurrence, - // for ANY option (mirrors the vendored `effect` lexer's own - // `argv.indexOf("--")` cutoff, `internal/lexer.ts`). Verified against the - // real `apps/cli-go/supabase-go` binary: - // `migration repair --local -- 20230101000000 --status` prints a bare - // `required flag(s) "status" not set`, no usage block — Go correctly parses - // the literal `--status` string as a second positional `version`, leaving - // the real `--status` flag genuinely unset. Without the `--` cutoff, - // `isMissingFlagTokenPresent` would find that trailing `--status` string - // anywhere in argv and wrongly conclude the flag was given but missing its - // value. it("drops the help dump for a missing required flag whose token only appears after the -- terminator", () => { const cause = Cause.fail( new CliError.ShowHelp({ @@ -300,16 +236,6 @@ describe("classifyParseErrorConsoleOutput", () => { ).toBe("drop"); }); - // Codex review finding (CLI-1901 follow-up): `sso add`'s `type` flag also has - // a short alias, `-t` (`add.command.ts`). Go's pflag treats a present-but- - // valueless SHORT flag exactly like the long form — `parseSingleShortArg` - // raises the same `ValueRequiredError` as `parseLongArg`, same timing, same - // "usage still shown" outcome — verified against the real - // `apps/cli-go/supabase-go` binary (`sso add -t`: full usage block on - // stderr, byte-parallel to `sso add --type`). `flagAliasesFor` resolves - // `type`'s aliases from the real command tree (via `context.rootCommand`) - // so `isMissingFlagTokenPresent` recognizes `-t` here too, instead of - // misclassifying it as a genuinely-absent flag. it("flushes the help dump to stderr for a required flag present on argv by its short alias but missing its value", () => { const cause = Cause.fail( new CliError.ShowHelp({ @@ -322,21 +248,6 @@ describe("classifyParseErrorConsoleOutput", () => { ).toBe("flush-help-doc-to-stderr"); }); - // Codex review finding (CLI-1901 follow-up): `sso add --project-ref --type` - // omits `--type`'s value, but `--type` immediately follows `--project-ref` - // (a value-taking `Flag.string`, `add.command.ts`). Verified against the - // real `apps/cli-go/supabase-go` binary: pflag's `parseLongArg` - // (`flag.go`) unconditionally consumes the very next argv entry as - // `--project-ref`'s value, even though it looks like another flag — so - // `--type` is never seen as its own occurrence, and Go shows no usage at - // all (only its own "type" required-flag error, `SilenceUsage`-suppressed). - // The vendored `effect` parser does NOT eagerly consume a flag-shaped - // token as a value (`internal/parser.ts`'s `consumeFlagValueWithTokens` - // only consumes a following `Value`-tagged token), so `--type` remains its - // own token and raises its own `MissingOption` here too — but the raw scan - // must still recognize that `--type` was effectively consumed as - // `--project-ref`'s value, matching Go, instead of concluding `--type` is - // present but missing its value (which would wrongly flush the help doc). it("drops the help dump when a required flag's own token is consumed as a preceding value-taking flag's value", () => { const cause = Cause.fail( new CliError.ShowHelp({ @@ -352,17 +263,6 @@ describe("classifyParseErrorConsoleOutput", () => { ).toBe("drop"); }); - // Codex review finding (CLI-1901 follow-up): `isValueTakingFlagTokenFor` - // only inspects the resolved LEAF command's own flags, so it doesn't know - // `--network-id` (a value-taking GLOBAL flag, `globalFlagsWithValues` in - // `run.ts`) consumes the very next argv entry. Verified against pflag's - // `parseLongArg` (`flag.go`): `--network-id --status` hands the literal - // string `--status` to `--network-id` as its value, so the required - // `status` flag (`migration repair`, `Flag.choice`) is never seen as its - // own occurrence and keeps Go's `SilenceUsage` treatment (no usage shown). - // Without OR-ing `globalFlagsWithValues` into the scan's value-taking - // predicate, the raw `--status` token would be found anyway and wrongly - // flush the help doc. it("drops the help dump when a required flag's own token is consumed as a global value-taking flag's value", () => { const cause = Cause.fail( new CliError.ShowHelp({ @@ -378,17 +278,6 @@ describe("classifyParseErrorConsoleOutput", () => { ).toBe("drop"); }); - // Codex review finding (CLI-1901 follow-up): a literal `--` immediately - // after a value-taking flag is NOT a genuine operand terminator in Go — - // pflag's `parseLongArg` (`flag.go`) pops the very next raw token as the - // flag's value with no shape check at all, so `--project-ref --` hands the - // literal string `--` to `--project-ref`. Parsing then resumes normally on - // `--type`, which (nothing follows it) raises pflag's own - // `ValueRequiredError` — a `ParseFlags`-time error, usage still shown, NOT - // `SilenceUsage`-suppressed. Precomputing `args.indexOf("--")` before the - // value-consumption scan would wrongly treat that consumed `--` as the - // terminator and drop `--type` from the scan entirely, misclassifying - // `type` as genuinely absent. it("flushes the help dump to stderr for a required flag whose own token follows a -- consumed as a preceding value-taking flag's value", () => { const cause = Cause.fail( new CliError.ShowHelp({ @@ -411,7 +300,6 @@ describe("classifyParseErrorConsoleOutput", () => { errors: [new CliError.MissingOption({ option: "type" })], }), ); - // `--type-hint` is a different flag token — must not false-positive-match `--type`. expect( classifyParseErrorConsoleOutput(cause, { rootCommand: testRoot, @@ -420,10 +308,6 @@ describe("classifyParseErrorConsoleOutput", () => { ).toBe("drop"); }); - // Go's `ParseFlags` (`command.go:919`) validates `Flag.choice` values BEFORE - // `PersistentPreRunE` runs — verified against the real binary (`sso add --type - // bogus`): Go still shows its usage block, on stderr. Same for an unrecognized - // flag and a missing positional argument (both also pre-`PersistentPreRunE`). it("flushes the help dump to stderr for an invalid Flag.choice value", () => { const cause = Cause.fail( new CliError.ShowHelp({ @@ -473,10 +357,6 @@ describe("classifyParseErrorConsoleOutput", () => { ).toBe("flush-help-doc-to-stderr"); }); - // A mix (e.g. a missing required flag alongside an unrecognized flag) can't - // actually occur in practice — the library fails fast on the earlier - // `ParseFlags`-class error before `MissingOption` is ever checked — but the - // classifier must still not mistake a mix for the all-`MissingOption` case. it("flushes the help dump to stderr for a mix of error tags", () => { const cause = Cause.fail( new CliError.ShowHelp({ @@ -545,8 +425,7 @@ describe("hasRootVersionFlag", () => { [["--profile", "-v"], false], [["--profile=x", "-v"], false], [["-o", "-v"], false], - // `--completions` consumes its shell value, so the root `--version` - // behind it stays a version request (issue #6482 built-in registration). + // `--completions` consumes its shell value, so the root `--version` behind it still counts. [["--completions", "bash", "--version"], true], [["--completions", "--version"], false], [["db", "reset", "--version", "20240101000000"], false], diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts index 50eb8f0557..85b7e20774 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.ts @@ -63,10 +63,9 @@ function findCommand( } /** - * The longest command-path prefix argv's positionals resolve to in the tree, - * cobra-`Find`-style: segments descend while they match a subcommand and stop - * at the first that doesn't (an operand). Includes the root's own name, the - * shape `isValueTakingFlagTokenFor` expects. + * The longest command-path prefix argv's positionals resolve to in the tree: segments descend + * while they match a subcommand and stop at the first that doesn't (an operand). Includes the + * root's own name, the shape `isValueTakingFlagTokenFor` expects. */ export function resolvedCommandPathForArgv( rootCommand: Command.Command.Any, @@ -134,17 +133,10 @@ function flagMatchesOption(flag: HelpDoc.FlagDoc, option: string): boolean { } /** - * Every argv token (e.g. `-t`, alongside the canonical `--type`) that also - * resolves to `option` for the command at `commandPath`, by walking the - * command tree the same way `buildSubcommandFlagHint` does. Returns `[]` if - * `commandPath` doesn't resolve to a real command or that command has no - * flag named `option` — callers should treat that as "no aliases", not an - * error, since a synthetic/test command path is a legitimate input. - * - * Used by `run.ts`'s `isMissingFlagTokenPresent` to recognize a required - * flag supplied by its short alias but missing its value (Go/pflag still - * shows usage for that case — see CLI-1901) instead of misclassifying it as - * genuinely absent (Go: `SilenceUsage`-suppressed, no usage shown). + * Every argv token (e.g. `-t`, alongside the canonical `--type`) that also resolves to `option` + * for the command at `commandPath`. Returns `[]` if `commandPath` doesn't resolve to a real + * command or that command has no flag named `option` — callers should treat that as "no + * aliases", not an error, since a synthetic/test command path is a legitimate input. */ export function flagAliasesFor( rootCommand: Command.Command.Any, @@ -160,27 +152,11 @@ export function flagAliasesFor( } /** - * Builds a lookup for whether an argv token (a canonical `--name` or one of - * its aliases, e.g. `-t`) at `commandPath` belongs to a *value-taking* flag - * (any flag whose `HelpDoc.FlagDoc.type !== "boolean"`) on the command - * resolved the same way `flagAliasesFor` does. Returns a function that - * answers `false` for every token when `commandPath` doesn't resolve to a - * real command, mirroring `flagAliasesFor`'s "no aliases" fallback. - * - * Used by `run.ts`'s `isMissingFlagTokenPresent` to recognize when a - * DIFFERENT flag's value-consumption ate the very token being scanned for. - * Go/pflag's `parseLongArg` (`flag.go`) unconditionally consumes the next - * argv entry as a value-taking flag's value, even when that entry itself - * looks like another flag — e.g. `sso add --project-ref --type` hands the - * literal string `--type` to `--project-ref`, so `--type` is never seen as - * its own occurrence and its `MissingOption` failure gets Go's - * `SilenceUsage` treatment (no usage shown). The vendored `effect` CLI - * library's own parser does NOT replicate that (`internal/parser.ts`'s - * `consumeFlagValueWithTokens` only consumes a following token when it's - * lexed as a plain `Value`, never a flag-shaped token), so without this - * lookup the raw argv scan in `isMissingFlagTokenPresent` would find the - * literal `--type` token and wrongly conclude the flag is present but - * missing its value — reintroducing the usage dump CLI-1901 suppresses. + * Builds a lookup for whether an argv token (a canonical `--name` or an alias) at `commandPath` + * belongs to a value-taking flag (any flag whose type isn't `"boolean"`), answering `false` for + * every token when `commandPath` doesn't resolve to a real command. Used by `run.ts`'s + * `isMissingFlagTokenPresent` to detect when pflag would have consumed the token as a preceding + * flag's value instead of treating it as its own occurrence. */ export function isValueTakingFlagTokenFor( rootCommand: Command.Command.Any, diff --git a/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts b/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts index a47f988f53..761f2c1177 100644 --- a/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts +++ b/apps/cli/src/shared/cli/subcommand-flag-suggestions.unit.test.ts @@ -159,9 +159,8 @@ describe("subcommand flag placement suggestions", () => { }); it("also collapses the doubled prefix for a non-choice primitive whose failure text starts with 'Expected' (e.g. an invalid integer flag value)", () => { - // Real failure text from effect@4.0.0-beta.93's schema-backed `Primitive.integer` - // (also affects `float`, `boolean`, and `date` — every primitive whose parse - // failure happens to start with the word "Expected" hits the same doubling). + // Real failure text from effect's schema-backed `Primitive.integer` (also affects `float`, + // `boolean`, and `date`). const errors = formatCliErrorsForDisplay([ new CliError.InvalidValue({ option: "port", @@ -179,9 +178,8 @@ describe("subcommand flag placement suggestions", () => { }); it("leaves invalid-value errors whose expected text does not start with 'Expected' unchanged", () => { - // Real failure text from effect@4.0.0-beta.93's `Primitive.keyValuePair` — - // it never starts with the word "Expected", so it isn't doubled by - // `CliError.InvalidValue`'s own "Expected: " prefix and needs no rewriting. + // Real failure text from effect's `Primitive.keyValuePair`, which never starts with + // "Expected", so it needs no rewriting. const errors = formatCliErrorsForDisplay([ new CliError.InvalidValue({ option: "define", @@ -199,10 +197,6 @@ describe("subcommand flag placement suggestions", () => { }); it("passes a complete pflag-format diagnostic through verbatim (Go stderr parity, CLI-1983)", () => { - // Flags that byte-match Go pflag's parse-time diagnostics - // (`stringSliceFlag`'s malformed-CSV failure) emit the COMPLETE Go - // message as `expected`. Wrapping it in the `Invalid value for flag ...` - // template would double-frame it — pflag prints the bare line. const pflagMessage = 'invalid argument "\\"1.2.3.4" for "--db-unban-ip" flag: parse error on line 1, column 9: extraneous or missing " in quoted-field'; const errors = formatCliErrorsForDisplay([ @@ -219,12 +213,6 @@ describe("subcommand flag placement suggestions", () => { }); it("does not corrupt a value that itself contains the literal 'Expected: Expected' text", () => { - // Regression test: the fix must anchor on `error.expected` (the field the - // buggy primitive actually populates) rather than searching the fully - // composed `error.message`, since `error.value` is user-controlled and is - // interpolated into that same message twice (once directly, once again - // inside `expected`'s "got <value>" suffix). A value that happens to - // contain the literal doubled-prefix text must be left untouched. const errors = formatCliErrorsForDisplay([ new CliError.InvalidValue({ option: "env", diff --git a/apps/cli/src/shared/config/cli-project-home.layer.unit.test.ts b/apps/cli/src/shared/config/cli-project-home.layer.unit.test.ts index 88d10856ea..b4ae50e292 100644 --- a/apps/cli/src/shared/config/cli-project-home.layer.unit.test.ts +++ b/apps/cli/src/shared/config/cli-project-home.layer.unit.test.ts @@ -195,11 +195,9 @@ describe("cliProjectHomeLayer", () => { it.live( "dies with CliProjectHomeNotDirectoryError (BadResource) when a FILE occupies an ancestor of the project home path", () => { - // Distinct from the AlreadyExists case above: here `.supabase` itself - // doesn't exist, but a FILE sits on one of ITS OWN parent directories - // (`<tempDir>/proj`), so `mkdir(..., { recursive: true })` fails with - // ENOTDIR (-> PlatformError reason "BadResource") while trying to - // traverse through it, rather than EEXIST on the leaf itself. + // Distinct from the AlreadyExists case above: here `.supabase` doesn't exist, but a file + // sits on one of its own parent directories, so `mkdir` fails with ENOTDIR while + // traversing, not EEXIST on the leaf itself. const tempDir = makeTempDir(); const fileAsDir = join(tempDir, "proj"); const cwd = join(fileAsDir, "child"); diff --git a/apps/cli/src/shared/config/project-config-api-drift.unit.test.ts b/apps/cli/src/shared/config/project-config-api-drift.unit.test.ts index 8c3cdc6f77..e147255748 100644 --- a/apps/cli/src/shared/config/project-config-api-drift.unit.test.ts +++ b/apps/cli/src/shared/config/project-config-api-drift.unit.test.ts @@ -4,41 +4,18 @@ import { toProjectConfig } from "@supabase/config"; import type { ProjectConfigApiAttributes } from "@supabase/config/internal"; /** - * Compile-time drift guards (CLI-2230 design requirement): `@supabase/config` - * deliberately hand-mirrors the Management API v2 project-config response - * shape in `ProjectConfigApiAttributes` rather than importing - * `packages/api`'s generated client (the config package must stay decoupled - * from `@supabase/api` so it can publish to npm independently). That mirror - * can only ever drift silently from the real, generated OpenAPI contract — - * unless something pins the two together. Two independent checks do that, - * because neither alone covers every kind of drift: + * Compile-time drift guards: `@supabase/config` hand-mirrors the Management API v2 + * project-config shape in `ProjectConfigApiAttributes` rather than importing the generated + * client, so it stays decoupled and can publish independently. Two checks are needed because + * neither alone covers every kind of drift: `_typeDriftGuard` fails to compile if the real + * contract widens a field's type, but TypeScript's structural assignability lets it add, remove, + * or rename fields undetected; the key-set assertions further down (`AssertNever<Exclude<keyof + * A, keyof B>>`) catch exactly that gap instead, one pair per mirrored nesting level (`auth` is + * exempt, since both sides model it as an open record). * - * 1. `_typeDriftGuard` below fails to compile if the real contract *widens* a - * field's type out from under the lenient mirror (e.g. a field changing - * from a primitive to an object). It does NOT catch a field the real - * contract adds, removes, or renames: TypeScript's structural - * assignability allows the source type (the real contract's attributes) - * to carry extra or differently-named properties the target type (the - * mirror) never sees, so the assignment still compiles. - * 2. The type-level key-set assertions further down this file - * (`AssertNever<Exclude<keyof A, keyof B>>`, one added/removed pair per - * mirrored nesting level) are the guard for exactly that gap: an added, - * removed, or renamed key at any mirrored level fails one of those - * assertions to compile. `auth` is exempt — both sides model it as an - * open `Record<string, Json>`, so there is no fixed key set to diff. - * - * `_typeDriftGuard` is also intentionally vacuous for every field - * `ProjectConfigApiAttributes` mirrors as `Schema.Unknown` (every unmapped - * field, per `api-attributes.ts`'s own docstring): the mirror's field type is - * `unknown`, and every type is assignable to `unknown`, so a real-contract - * type change on one of those fields can never fail this assignability check - * — only the key-set assertions in point 2 still catch that field being - * added, removed, or renamed outright. This is by design (ADR 0019 rule 2: - * an unmapped field's *type* is deliberately not load-bearing at decode - * time), not a gap this file needs to close. - * - * No `as` cast anywhere in this file: a cast would defeat either guard's - * entire purpose by silencing exactly the failure it exists to surface. + * `_typeDriftGuard` is intentionally vacuous for fields mirrored as `Schema.Unknown` (by design, + * ADR 0019 rule 2) — only the key-set assertions still catch those being added or removed. No + * `as` cast anywhere in this file: it would silence the failure each guard exists to surface. */ const _typeDriftGuard: ( value: (typeof V2GetProjectConfigOutput.Type)["data"]["attributes"], @@ -81,11 +58,9 @@ type _RemovedNetworkRestrictionsKeys = AssertNever< Exclude<keyof MirrorNetworkRestrictions, keyof GeneratedNetworkRestrictions> >; -// `allowed_cidrs` is mapped (`filterCidrAddresses`, `@supabase/config`'s -// `registry.ts`), so its element shape stays concretely typed on the mirror -// side (unlike the sibling `entitlement`/`status`/`updated_at`/`applied_at` -// fields above, which the mirror widens to `Schema.Unknown` since no row -// maps them) — worth its own key-set pair. +// `allowed_cidrs` is mapped (`filterCidrAddresses`), so it stays concretely typed on the mirror +// side (unlike the sibling fields above, which widen to `Schema.Unknown` since no row maps +// them) — worth its own key-set pair. type GeneratedAllowedCidrsElement = NonNullable< GeneratedNetworkRestrictions["allowed_cidrs"] >[number]; @@ -132,10 +107,9 @@ type _RemovedStorageFeaturesKeys = AssertNever< Exclude<keyof MirrorStorageFeatures, keyof GeneratedStorageFeatures> >; -// `image_transformation`/`s3_protocol` are mapped (`@supabase/config`'s -// `registry.ts`), so — unlike sibling `purge_cache`, which the mirror widens -// to `Schema.Unknown` since no row maps it — they stay concretely typed -// `{enabled}` structs on the mirror side, each worth its own key-set pair. +// `image_transformation`/`s3_protocol` are mapped, so — unlike sibling `purge_cache`, which +// widens to `Schema.Unknown` — they stay concretely typed `{enabled}` structs, each worth its +// own key-set pair. type GeneratedImageTransformation = GeneratedStorageFeatures["image_transformation"]; type MirrorImageTransformation = NonNullable<MirrorStorageFeatures["image_transformation"]>; @@ -174,21 +148,15 @@ type _RemovedVectorBucketsKeys = AssertNever< Exclude<keyof MirrorVectorBuckets, keyof GeneratedVectorBuckets> >; -// `storage.capabilities` is unmapped in full (no row reads `list_v2` or -// `iceberg_catalog`), so the mirror widens the whole substruct to -// `Schema.Unknown` (`@supabase/config`'s `api-attributes.ts`) rather than -// keeping a `{list_v2, iceberg_catalog}` shape — there is no longer an inner -// key set to diff here. `_AddedStorageKeys`/`_RemovedStorageKeys` above still -// cover `capabilities`'s own presence as a key of `storage`; only its -// interior stopped being type-checked, which is the point of widening an -// unmapped field. +// `storage.capabilities` is unmapped in full, so the mirror widens the whole substruct to +// `Schema.Unknown` rather than keeping a `{list_v2, iceberg_catalog}` shape — no inner key set +// to diff. `_AddedStorageKeys`/`_RemovedStorageKeys` above still cover `capabilities`'s own +// presence as a key of `storage`. describe("project-config API type drift guard", () => { it("keeps the generated v2 attributes type assignable to the package's lenient input type", () => { - // The type-level assignment above (and the type-level key-set assertions - // further up this file) are the real guards; this only asserts the guard - // function itself is a callable identity so the module isn't pure dead - // code under `noUnusedLocals`-style lint passes. + // The type-level assertions above are the real guards; this only keeps the guard function + // from being pure dead code under `noUnusedLocals`-style lint passes. expect(typeof _typeDriftGuard).toBe("function"); }); diff --git a/apps/cli/src/shared/config/project-config-auth-contract.unit.test.ts b/apps/cli/src/shared/config/project-config-auth-contract.unit.test.ts index a92b1a091a..3e4782a191 100644 --- a/apps/cli/src/shared/config/project-config-auth-contract.unit.test.ts +++ b/apps/cli/src/shared/config/project-config-auth-contract.unit.test.ts @@ -7,37 +7,18 @@ import { } from "@supabase/config/internal"; /** - * Contract-derived auth guard (CLI-2230's residual review): closes two gaps - * `@supabase/config`'s own `registry-integrity.unit.test.ts` cannot close by - * itself. + * Contract-derived auth guard: closes two gaps `@supabase/config`'s own + * `registry-integrity.unit.test.ts` can't close by itself. That file's `apiPath` check resolves + * every auth row against an open `Record<string, Json>` field, so it's structurally vacuous and + * can't catch a renamed or retired GoTrue key. And nothing in `@supabase/config` walks the + * generated auth-config contract's full key set looking for a secret-shaped key with no registry + * row at all — the gap that let several HMAC-digest fields leak through `unmappedApiFields`. * - * 1. That file's `apiPath` check resolves every auth row against - * `ProjectConfigApiAttributesSchema`'s `auth: Schema.Record(Schema.String, - * Schema.Json)` field — an OPEN record, so ANY second path segment - * resolves through its index signature whether or not GoTrue actually has - * a key by that name. That check is structurally vacuous for all 189 auth - * rows; it cannot catch a renamed, retired, or invented GoTrue key. - * 2. Nothing in `@supabase/config` walks the generated Management API v1 - * auth-config contract's full key set looking for a secret-shaped key with - * no registry row at all — the exact gap that let `external_slack_secret`, - * `hook_after_user_created_secrets`, and `nimbus_oauth_client_secret` leak - * HMAC digests through `unmappedApiFields` until this pass. - * - * `packages/config` cannot import `packages/api`'s generated client (the - * package must stay decoupled so it can publish to npm independently), so - * this guard lives in `apps/cli`, which can import both. It needs `@supabase/ - * config`'s row data and orphan-secret list at runtime, which is why - * `projectConfigMappingRows`/`unmappedSecretApiPaths` are exported from - * `@supabase/config/internal` (`packages/config/src/internal.ts`) — - * otherwise-internal registry data, exposed solely so this cross-package - * guard (and `apps/cli`'s own contract tests) can walk it. - * - * `V1GetAuthServiceConfigOutput` (not the v2 project-config resource) is the - * authority here: it is the generated schema whose field names are the real, - * flat GoTrue key set every auth row's `apiPath` targets — the same contract - * `registry-auth.ts`'s `unmappedSecretApiPaths` docstring now cites in place - * of the legacy hand-mined `auth.sync.ts` interface, which never carried - * `external_slack_*`/`nimbus_oauth_*` at all. + * This guard lives in `apps/cli`, which can import both `@supabase/config` and the generated API + * client, unlike `packages/config` itself (which must stay decoupled to publish independently). + * It reads `projectConfigMappingRows`/`unmappedSecretApiPaths` from `@supabase/config/internal` + * for that reason. `V1GetAuthServiceConfigOutput` (not the v2 project-config resource) is the + * authority: it's the generated schema with the real, flat GoTrue key set every auth row targets. */ const generatedAuthKeys: ReadonlySet<string> = new Set( @@ -50,8 +31,7 @@ const authRows: ReadonlyArray<ProjectConfigMappingRow> = projectConfigMappingRow describe("registry auth rows resolve against the generated v1 auth-config contract", () => { it("has a non-trivial generated key set and a non-trivial set of auth rows to check", () => { - // Guards both loops below against passing vacuously if either import is - // ever broken. + // Guards both loops below against passing vacuously if either import is ever broken. expect(generatedAuthKeys.size).toBeGreaterThan(100); expect(authRows.length).toBeGreaterThan(100); }); @@ -65,10 +45,9 @@ describe("registry auth rows resolve against the generated v1 auth-config contra expect(generatedAuthKeys.has(apiKey as string)).toBe(true); }); - // A transform reads every path it declares in `alsoConsumes` (e.g. the - // Apple/Google `external_*_additional_client_ids` fold) — a renamed or - // removed generated key there would leave the transform silently reading - // a stale key while the primary-apiPath check above stays green. + // A transform reads every path it declares in `alsoConsumes` — a renamed or removed + // generated key there would leave it silently reading a stale key while the primary check + // above stays green. for (const alsoPath of row.alsoConsumes ?? []) { if (alsoPath[0] !== "auth") continue; const alsoKey = alsoPath[1]; @@ -81,10 +60,8 @@ describe("registry auth rows resolve against the generated v1 auth-config contra }); /** - * Key names shaped like a secret per CLI-2230's finding: any of these - * suffixes on an otherwise-plain GoTrue key name. Kept in sync with - * `registry-auth.ts`'s `unmappedSecretApiPaths` docstring, which names the - * same six suffixes. + * Key-name suffixes that mark a GoTrue key as secret-shaped. Kept in sync with + * `registry-auth.ts`'s `unmappedSecretApiPaths` docstring, which names the same six suffixes. */ const SECRET_SHAPE_SUFFIXES = [ "_secret", @@ -100,13 +77,10 @@ function isSecretShaped(key: string): boolean { } /** - * `sms_vonage_api_key` is `_api_key`-shaped but genuinely not `x-secret` on - * the config side: `packages/config/src/auth/sms.ts`'s `vonage.api_key` - * field is a plain `Schema.String.annotate(...)`, with no `secret()` wrapper - * — unlike its sibling `vonage.api_secret`, which has one. It already has an - * ordinary `stringRow` (`registry-auth.ts`'s `smsCredentialRows`), so it is - * deliberately excluded from `unmappedSecretApiPaths` and allowlisted here - * instead of being treated as an orphan. + * `sms_vonage_api_key` is `_api_key`-shaped but genuinely not a secret: `packages/config/src/ + * auth/sms.ts`'s `vonage.api_key` field has no `secret()` wrapper, unlike its sibling + * `vonage.api_secret`. It already has an ordinary `stringRow`, so it's excluded from + * `unmappedSecretApiPaths` and allowlisted here instead of being treated as an orphan. */ const NON_SECRET_ALLOWLIST: ReadonlySet<string> = new Set(["sms_vonage_api_key"]); diff --git a/apps/cli/src/shared/config/supabase-home.ts b/apps/cli/src/shared/config/supabase-home.ts index e8de86e989..5ba5b327a1 100644 --- a/apps/cli/src/shared/config/supabase-home.ts +++ b/apps/cli/src/shared/config/supabase-home.ts @@ -3,15 +3,10 @@ import { join } from "node:path"; /** * Resolves the global Supabase CLI state root. * - * `SUPABASE_HOME` overrides the location when set to a non-empty value after - * trimming surrounding whitespace (an absolute path is expected; the value is - * used verbatim). Otherwise it defaults to `<homeDir>/.supabase`. - * - * This is the single source of truth for the `SUPABASE_HOME` contract in the - * TypeScript CLI. It is a pure function: callers pass their own environment and - * home directory so it stays trivially testable and free of global state. The - * All CLI callers resolve through this function, keeping the `SUPABASE_HOME` - * contract in one place. + * `SUPABASE_HOME` overrides the location when set to a non-empty value after trimming + * surrounding whitespace (an absolute path is expected; the value is used verbatim). Otherwise it + * defaults to `<homeDir>/.supabase`. A pure function, so every caller resolves through it with + * its own environment and home directory, keeping the contract in one place. */ export const resolveSupabaseHome = ( env: Readonly<Record<string, string | undefined>>, diff --git a/apps/cli/src/shared/functions/delete.ts b/apps/cli/src/shared/functions/delete.ts index 62a77a2e15..b1f6c8525d 100644 --- a/apps/cli/src/shared/functions/delete.ts +++ b/apps/cli/src/shared/functions/delete.ts @@ -26,10 +26,9 @@ export interface DeleteFunctionDependencies<ResolveError, ResolveRequirements> { projectRef: Option.Option<string>, ) => Effect.Effect<string, ResolveError, ResolveRequirements>; /** - * Optional shell-specific styling for the slug/ref in the success line. - * Defaults to identity (plain text). The CLI injects Go's aqua - * here; keeping the hook injected keeps this shared module free of - * CLI-specific rendering. + * Optional shell-specific styling for the slug/ref in the success line. Defaults to identity + * (plain text); keeping the hook injected keeps this shared module free of CLI-specific + * rendering. */ readonly styleIdentifier?: (text: string) => string; } @@ -104,9 +103,6 @@ export function deleteFunction<ResolveError, ResolveRequirements>( return; } - // Go: `fmt.Printf("Deleted Function %s from project %s.\n", utils.Aqua(slug), - // utils.Aqua(projectRef))` (`internal/functions/delete/delete.go:20`) — the - // handler injects the aqua styling via `styleIdentifier`; next stays plain. const style = dependencies.styleIdentifier ?? ((text: string) => text); yield* output.raw(`Deleted Function ${style(flags.slug)} from project ${style(projectRef)}.\n`); }).pipe(Effect.withSpan("functions.delete")); diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index e3680be572..458afc9d73 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -100,14 +100,12 @@ interface DeployFunctionsDependencies<ResolveError, ResolveRequirements> { projectRef: Option.Option<string>, ) => Effect.Effect<string, ResolveError, ResolveRequirements>; /** - * Optional shell-specific styling hooks. Both default to identity (plain - * text); the CLI injects Go's aqua/bold here so this shared module stays - * free of CLI-specific rendering. + * Optional shell-specific styling hooks. All default to identity (plain text); keeping them + * injected keeps this shared module free of CLI-specific rendering. * - `styleIdentifier`: the project ref in the stdout success line. - * - `styleEmphasis`: the slug in the stderr `Bundling Function:` line and - * the functions dir in the no-functions error. - * - `styleWarning`: the `WARNING:` token on the "Docker is not running" - * fallback line. Go: `utils.Yellow("WARNING:")` (`deploy.go:60`). + * - `styleEmphasis`: the slug in the stderr `Bundling Function:` line and the functions dir in + * the no-functions error. + * - `styleWarning`: the `WARNING:` token on the "Docker is not running" fallback line. */ readonly styleIdentifier?: (text: string) => string; readonly styleEmphasis?: (text: string) => string; @@ -198,11 +196,9 @@ function decodeFunctionListResponse(value: unknown): ReadonlyArray<RemoteFunctio return decodeFunctionListResponseSchema(normalized); } -// Format a raw response body for an unexpected-status error message. When the -// body is JSON, re-stringify it so the message stays byte-identical to the -// previous `JSON.stringify(parsedBody)` form; otherwise fall back to the raw -// text (a non-JSON body was previously impossible because the response was -// eagerly JSON-decoded before the status check). +// Formats a raw response body for an unexpected-status error message: re-stringifies JSON so the +// message stays byte-identical to a parsed-then-stringified body, falling back to raw text +// otherwise. function formatUnexpectedStatusBody(text: string): string { try { return JSON.stringify(JSON.parse(text)); @@ -298,12 +294,8 @@ function explicitBooleanFlag( */ export const dockerWorkdirLabel = "com.supabase.cli.workdir"; /** - * Go parity (`apps/cli-go/internal/functions/deploy/bundle.go:68-70`, deleted - * in CLI-1970; last present at commit 7b469f5b3): the eszip - * bundler container receives only `NPM_CONFIG_REGISTRY` from the host - * environment. `NPM_AUTH_TOKEN` is deliberately NOT forwarded — the Go-side PR - * proposing it (supabase/cli#4933) was closed unmerged, and CLI-1985 ruled - * strict parity over the TS-only forwarding that #5645 had added. + * The eszip bundler container receives only `NPM_CONFIG_REGISTRY` from the host environment. + * `NPM_AUTH_TOKEN` is intentionally not forwarded, even though a caller might expect it to be. */ const dockerNpmEnvNames = ["NPM_CONFIG_REGISTRY"] as const; @@ -328,10 +320,9 @@ export function formatDockerBind(bind: DockerBind) { * Drops every bind another bind already supplies verbatim: same mode, host * path strictly beneath the other's, container path at the same relative * offset. The import walker and import-map target enumeration routinely emit - * such pairs, and Docker rejects `docker cp` into a created container whose - * config nests a file bind inside a read-only parent bind - * (supabase/supabase#50088). A bind that overrides its parent's source, mode, - * or container mapping is never collapsed. + * such pairs, and Docker rejects `docker cp` into a created container whose config nests a file + * bind inside a read-only parent bind. A bind that overrides its parent's source, mode, or + * container mapping is never collapsed. */ export function pruneRedundantDockerBinds( binds: ReadonlyArray<DockerBind>, @@ -385,13 +376,9 @@ function isContainedInAnyPath(roots: ReadonlyArray<string>, candidate: string) { } /** - * Go parity (`apps/cli-go/pkg/function/deploy.go:251-284`, via - * `afero.IOFS.Open` → `fs.ValidPath`): `writeForm`'s `addFile` opens every - * uploaded path through an `fs.FS`, which rejects any path containing a `..` - * element before the read (and thus the upload) happens. A workdir≠git-root - * layout can otherwise produce a multipart `File` name like - * `../packages/shared/src/index.ts` that escapes the anchor dir — reject it - * the same way Go does, before any upload is attempted. + * Rejects any path containing a `..` segment before it's uploaded. A workdir that differs from + * the git root can otherwise produce a multipart `File` name like + * `../packages/shared/src/index.ts` that escapes the anchor directory. */ function hasParentPathSegment(relativePath: string) { return toSlash(relativePath) @@ -674,11 +661,8 @@ function substituteImportMapValue( if (prefix.length === 0) { continue; } - // Import-maps spec (implemented by Deno): a key matches exactly, or as a - // prefix only when it ends with "/". Go's walker prefix-matches every key - // (pkg/function/deno.go:150-155) — intentional divergence, see - // go-cli-divergences.md: the lax match fabricates paths the runtime - // can never resolve (the ENOTDIR family this PR fixes). + // Import-maps spec (implemented by Deno): a key matches exactly, or as a prefix only when it + // ends with "/" — see go-cli-divergences.md for why this differs from a naive prefix match. if (prefix.endsWith("/")) { // Spec normalization: a `/`-suffixed key whose address lacks a trailing // `/` is an invalid mapping — dropped, not concatenated. @@ -772,10 +756,8 @@ async function walkImportPaths( await onWarning(`WARN: ${message}\n`); continue; } - // Go aborts on any other read error (pkg/function/deno.go:131-136); an - // ENOTDIR (import path routed through a file) gets Go's message instead - // of an unhandled raw Node error, via a classified error so telemetry - // books it as user-fixable config instead of a panic. + // An ENOTDIR (import path routed through a file) gets a classified, user-facing message + // instead of an unhandled raw Node error, so telemetry books it as user-fixable config. if (error.code === "ENOTDIR") { throw new FunctionImportNotDirectoryError({ message: `failed to read file: open ${toApiRelativePath(displayRoot, current)}: not a directory`, @@ -802,10 +784,9 @@ async function walkImportPaths( ); modulePath = toSlash(modulePath); - // A module file needs a dot in the FINAL path segment (Go's path.Ext - // semantics): a dot earlier in the path (`dist/index.mjs/core`) is a - // directory-shaped path, not a module file. Not basename(): a - // trailing-slash directory import must yield an empty final segment here. + // A module file needs a dot in the final path segment: a dot earlier in the path + // (`dist/index.mjs/core`) is a directory-shaped path, not a module file. Not basename(): + // a trailing-slash directory import must yield an empty final segment here. const finalSegment = modulePath.slice(modulePath.lastIndexOf("/") + 1); if (!finalSegment.includes(".")) { continue; @@ -1030,9 +1011,8 @@ async function writeSourceDeployForm( return; } uploadedAssets.add(realPathname); - // Uploaded file names are anchored at the workdir like Go's `toRelPath` - // (`apps/cli-go/pkg/function/deploy.go:94-103`, relative to `os.Getwd()`), - // NOT at `sourceRoot` — see the CLI-1985 note in `deployViaApi`. + // Uploaded file names are anchored at the workdir, not at `sourceRoot` — see the note in + // `deployViaApi`. const relativePath = toApiRelativePath(workdir, pathname); if (hasParentPathSegment(relativePath)) { throw new Error(`failed to read file: open ${relativePath}: invalid argument`); @@ -1161,10 +1141,8 @@ async function writeSourceDeployForm( } /** - * Server-recorded metadata paths are anchored at the workdir, matching Go's - * `toRelPath` (`apps/cli-go/pkg/function/deploy.go:42-57,94-103`): relative to - * `os.Getwd()` (the Go CLI chdirs to the workdir), forward slashes via - * `filepath.ToSlash` — see the CLI-1985 note in `deployViaApi`. + * Server-recorded metadata paths are anchored at the workdir, with forward slashes regardless of + * platform — see the note in `deployViaApi`. */ function createSourceMetadata( workdir: string, @@ -1439,9 +1417,6 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( projectEnvValues, } = options; const output = yield* Output; - // Go: `fmt.Fprintln(os.Stderr, "Bundling Function:", utils.Bold(slug))` - // (`internal/functions/deploy/bundle.go:30`) — the handler injects - // the bold styling via `styleEmphasis`; next stays plain. yield* output.raw(`Bundling Function: ${styleEmphasis(config.slug)}\n`, "stderr"); const outputRoot = resolve(functionsDir, "..", ".temp"); @@ -1459,22 +1434,17 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( }); } const outputPath = join(outputDir, "output.eszip"); - // `edgeRuntimeImage` applies the tag VERBATIM (Go's `replaceImageTag`) - // — a `.temp/edge-runtime-version` pin flows through unmodified, `v` - // prefix or not (see the helper's doc in `functions.shared.ts`). + // `edgeRuntimeImage` applies the tag verbatim — a `.temp/edge-runtime-version` pin flows + // through unmodified, `v` prefix or not (see the helper's doc in `functions.shared.ts`). const rawImage = edgeRuntimeImage(edgeRuntimeVersion); const binds = yield* Effect.promise(() => buildDockerBinds(projectId, functionsDir, outputDir, config, { onWarning: (message) => Effect.runPromise(output.raw(message, "stderr")), }), ); - // Go: `DockerStart` -> `DockerResolveImageIfNotCached` (`internal/utils/docker.go:326-386`) - // — resolves ECR->GHCR->Docker-Hub candidates and pulls with retry, per - // container, before ever touching the network/volume. Deliberately NOT - // hoisted out of the per-function loop the way `download.ts`'s - // `PulledEdgeRuntimeImage` is: per-slug matches Go's per-container - // `DockerStart` exactly, and the first resolve failure aborts the loop, - // so the only cost is one cached `docker image inspect` per function. + // Resolved per function rather than hoisted out of the loop (unlike `download.ts`'s + // `PulledEdgeRuntimeImage`): the first resolve failure aborts the loop, and the only added + // cost is one cached `docker image inspect` per function. const image = yield* resolveFunctionsDockerImage(rawImage, projectEnvValues); yield* ensureDockerNetwork(networkMode, projectId); yield* ensureDockerNamedVolume(edgeRuntimeCacheVolume(projectId).name, projectId); @@ -1515,17 +1485,14 @@ const bundleFunctionWithDocker = Effect.fnUntraced(function* ( networkMode, binds: binds.map(formatDockerBind), env, - // Go: `WorkingDir: utils.ToDockerPath(cwd)` (`bundle.go:79`), where - // `cwd` is the post-`ChangeWorkDir` workdir — `functionsDir` is - // `<workdir>/supabase/functions`, same derivation as `deployViaApi`'s + // `functionsDir` is `<workdir>/supabase/functions`, same derivation as `deployViaApi`'s // own `projectRoot`. workingDir: toDockerPath(resolve(functionsDir, "..", "..")), containerArgs, }); - // Live-tees each chunk to `output.raw` as it arrives (Go's - // `DockerRunOnceWithConfig` copies the container's log stream live) - // rather than buffering the whole run until exit. + // Live-tees each chunk to `output.raw` as it arrives, rather than buffering the whole run + // until exit. const result = yield* runChildProcess("docker", command, { stdout: "pipe", stderr: "pipe", @@ -2101,18 +2068,10 @@ const deployViaApi = Effect.fnUntraced(function* ( jobs: number, ) { const output = yield* Output; - // CLI-1985: uploaded file names and the server-recorded metadata paths - // (`entrypoint_path`, `import_map_path`, `static_patterns`) are anchored at the - // workdir (`projectRoot`), matching the pinned Go CLI's `toRelPath`, which is - // relative to `os.Getwd()` after the CLI chdirs to the workdir - // (`apps/cli-go/pkg/function/deploy.go:94-103`, `internal/utils/misc.go:238`). - // Upstream Go never anchored deploy paths at the git root — that was a TS-only - // divergence introduced by #5755. The import-walk *boundary* (which files may - // be uploaded at all) intentionally stays at the nearest git root: the boundary - // itself is a TS-only safeguard with no Go equivalent (Go's `WalkImportPaths` - // uploads any reachable import unbounded; #5755 widened the TS boundary from - // the workdir to the git root so monorepo imports outside the workdir deploy). - // Such files upload with Go-`toRelPath`-style `../`-relative names. + // Uploaded file names and the server-recorded metadata paths are anchored at the workdir + // (`projectRoot`), not at `sourceRoot`. The import-walk boundary (which files may be uploaded + // at all) is intentionally wider, extending to the nearest git root, so files outside the + // workdir but inside a monorepo can still deploy — those upload with `../`-relative names. const sourceRoot = yield* Effect.tryPromise({ try: () => resolveFunctionsSourceRoot(projectRoot), catch: (error) => (error instanceof Error ? error : new Error(String(error))), @@ -2146,10 +2105,10 @@ const deployViaApi = Effect.fnUntraced(function* ( return; } - // INC-699: each bundleOnly upload writes the bundle and bumps the remote version without - // persisting metadata, which only the final bulk update does. Failing fast on the first - // upload error strands that metadata remotely and makes every later deploy conflict, so - // run every upload to completion, always persist what succeeded, then report the errors. + // Each bundleOnly upload writes the bundle and bumps the remote version without persisting + // metadata, which only the final bulk update does. Failing fast on the first upload error + // strands that metadata remotely and makes every later deploy conflict, so run every upload to + // completion, always persist what succeeded, then report the errors. const results = yield* Effect.forEach( enabled, (config) => @@ -2294,11 +2253,9 @@ const pruneFunctions = Effect.fnUntraced(function* ( return; } - // Go's `confirmPruneAll` + `fmt.Sprintln` (`deploy.go:189,206-212`): header, one - // ` • <bold slug>` line per function, and a trailing blank line before the - // `[y/N]` choices. Routed through `promptYesNo` (Go `PromptYesNo(msg, - // false)`, `console.go:64-82`) so `--yes`/`SUPABASE_YES` auto-confirms with the - // stderr echo and a non-TTY stdin honors a piped `y`/`n` answer (CLI-1974). + // Header, one ` • <bold slug>` line per function, and a trailing blank line before the [y/N] + // choices. Routed through `promptYesNo` so `--yes`/`SUPABASE_YES` auto-confirms with the + // stderr echo, and a non-TTY stdin still honors a piped `y`/`n` answer. const prompt = `${[ "Do you want to delete the following Functions from your project?", ...toDelete.map((slug) => ` • ${bold(slug)}`), @@ -2325,10 +2282,9 @@ export function deployFunctions<ResolveError, ResolveRequirements>( const styleIdentifier = dependencies.styleIdentifier ?? ((text: string) => text); const styleEmphasis = dependencies.styleEmphasis ?? ((text: string) => text); const commandPath = ["functions", "deploy"] as const; - // Presence-based (true for `--use-api=false`, not just bare `--use-api`) — mirrors - // cobra's `Changed()`-driven `MarkFlagsMutuallyExclusive`, so it's only used for the - // mutual-exclusivity check below. Behavior branches (bundler routing, --jobs guard) - // key off the resolved `flags.useApi` value instead, matching Go's own `if useApi`. + // Presence-based (true for `--use-api=false`, not just bare `--use-api`) — used only for + // the mutual-exclusivity check below. Behavior branches (bundler routing, --jobs guard) key + // off the resolved `flags.useApi` value instead. const explicitUseApi = hasExplicitLongFlag(dependencies.rawArgs, commandPath, "use-api"); const explicitUseDocker = hasExplicitLongFlag(dependencies.rawArgs, commandPath, "use-docker"); const explicitLegacyBundle = hasExplicitLongFlag( @@ -2351,30 +2307,23 @@ export function deployFunctions<ResolveError, ResolveRequirements>( ); } - // Go parity (`cmd/functions.go:79-80`): `if useApi { useDocker = false }` mutates the - // resolved boolean, not a presence flag — `--use-api=false` alone must NOT force the - // API path, it should fall through to whatever `--use-docker`/`--legacy-bundle` - // already resolved to. + // `--use-api=false` alone must not force the API path — it should fall through to whatever + // `--use-docker`/`--legacy-bundle` already resolved to. const useLocalBundler = !flags.useApi && (flags.useDocker || flags.legacyBundle); const configuredJobs = Option.getOrElse(flags.jobs, () => 1); const jobs = configuredJobs === 0 ? 1 : configuredJobs; - // Go parity (`cmd/functions.go:79-82`): the guard is `if useApi { ... } else if - // maxJobs > 1 { error }` — keyed on the resolved `--use-api` value alone, not on - // whether local bundling (Docker/legacy-bundle) is in play. + // Keyed on the resolved `--use-api` value alone, not on whether local bundling + // (Docker/legacy-bundle) is in play. if (!flags.useApi && jobs > 1) { return yield* Effect.fail(new Error("--jobs must be used together with --use-api")); } const projectRef = yield* dependencies.resolveProjectRef(flags.projectRef); - // `@supabase/config` merges the matching `[remotes.*]` block over the base - // config (Go's `loadFromFile` with `Config.ProjectId` set), so the resolved - // config already reflects any remote function/edge_runtime overrides. - // In the CLI this also runs the same `Config.Validate`/dotenv/ - // env-override pipeline `start`/`stop`/`status` already go through — see - // `functions-config.ts`. Go: `flags.LoadConfig` runs before validating any - // slug (`deploy.go:22-28`), so this must precede the loop below too — an - // invalid `config.toml` is reported ahead of a malformed slug when both - // are wrong (review round on CLI-1963). + // `@supabase/config` merges the matching `[remotes.*]` block over the base config, so this + // already reflects any remote function/edge_runtime overrides, through the same + // `Config.Validate`/dotenv/env-override pipeline `start`/`stop`/`status` use (see + // `functions-config.ts`). Must precede the slug-validation loop below, so an invalid + // `config.toml` is reported ahead of a malformed slug when both are wrong. const context = yield* loadFunctionsCliConfig({ projectRoot: dependencies.projectRoot, projectRef, @@ -2393,11 +2342,8 @@ export function deployFunctions<ResolveError, ResolveRequirements>( "no-verify-jwt", flags.noVerifyJwt, ); - // Go gates the bundler's `--verbose` on `viper.GetBool("DEBUG")` - // (`bundle.go:59`), so `--debug=false` must resolve to `false` — a plain - // presence check would get that backwards (same rule as `download.ts`'s - // own `--debug` read; the `SUPABASE_DEBUG` env fallback is deferred - // there too). + // `--debug=false` must resolve to `false` — a plain presence check would get that backwards + // (same rule as `download.ts`'s own `--debug` read). const debugEnabled = explicitBooleanLongFlag(dependencies.rawArgs, "debug") ?? false; const deployConfig = context.loaded?.config; const edgeRuntimeVersion = yield* resolveEdgeRuntimeVersion( @@ -2407,11 +2353,9 @@ export function deployFunctions<ResolveError, ResolveRequirements>( const configFunctions = yield* inferFunctionsManifest({ cwd: dependencies.projectRoot, config: deployConfig, - // Matches `loadFunctionsCliConfig`'s own options above (`search: false, - // tomlOnly: true` for the CLI): no ancestor directory is - // searched past `dependencies.projectRoot` for EITHER load, so they can - // never resolve two different projects (same rationale as - // `start.handler.ts`'s equivalent call). + // Matches `loadFunctionsCliConfig`'s own options above: no ancestor directory is searched + // past `dependencies.projectRoot` for either load, so they can never resolve two + // different projects. search: dependencies.goConfigCompat === undefined, }); const configDeclaredFunctions = deployConfig?.functions ?? {}; @@ -2425,11 +2369,8 @@ export function deployFunctions<ResolveError, ResolveRequirements>( if (slugs.length === 0) { return yield* Effect.fail( new NoFunctionsToDeployError({ - // Go: `errors.Errorf("No Functions specified or found in %s", - // utils.Bold(utils.FunctionsDir))` (`internal/functions/deploy/deploy.go:35`) — - // the handler injects the bold styling via `styleEmphasis`. Styling is - // text-mode only: in `--output-format json`/`stream-json` this message lands in - // the structured error payload, which must stay free of ANSI escapes. + // Styling is text-mode only: in `--output-format json`/`stream-json` this message + // lands in the structured error payload, which must stay free of ANSI escapes. message: `No Functions specified or found in ${ output.format === "text" ? styleEmphasis(SUPABASE_FUNCTIONS_DIR) @@ -2483,12 +2424,10 @@ export function deployFunctions<ResolveError, ResolveRequirements>( return yield* deployWithApi; } - // `lastExplicitLongFlagValue` preserves the "explicitly cleared" vs - // "never touched" distinction `resolveDockerNetworkMode` needs to - // decide whether `SUPABASE_NETWORK_ID` applies — see that - // function's own doc comment. `SUPABASE_NETWORK_ID` (env or - // project dotenv) is CLI-only — same Go-viper-parity gate - // as `context.projectEnvValues` itself (`undefined` for library callers). + // `lastExplicitLongFlagValue` preserves the "explicitly cleared" vs "never touched" + // distinction `resolveDockerNetworkMode` needs — see that function's own doc comment. + // `SUPABASE_NETWORK_ID` (env or project dotenv) is CLI-only, `undefined` for library + // callers. const networkMode = resolveDockerNetworkMode({ explicit: lastExplicitLongFlagValue(dependencies.rawArgs, [], "network-id"), envOverride: @@ -2521,12 +2460,8 @@ export function deployFunctions<ResolveError, ResolveRequirements>( } if (output.format === "text") { - // Go: `fmt.Printf("Deployed Functions on project %s: %s\n", - // utils.Aqua(flags.ProjectRef), strings.Join(slugs, ", "))` - // (`internal/functions/deploy/deploy.go:70`) — the handler injects - // the aqua styling via `styleIdentifier` (stdout-bound, so its TTY gate - // must check stdout); next stays plain. Go joins the raw `slugs` list, not - // the deduped set, so `functions deploy foo foo` prints "foo, foo". + // Joins the raw `slugs` list, not the deduped set, so `functions deploy foo foo` prints + // "foo, foo". yield* output.raw( `Deployed Functions on project ${styleIdentifier(projectRef)}: ${slugs.join(", ")}\n`, ); diff --git a/apps/cli/src/shared/functions/deploy.unit.test.ts b/apps/cli/src/shared/functions/deploy.unit.test.ts index 67a9c09215..ec46dc9965 100644 --- a/apps/cli/src/shared/functions/deploy.unit.test.ts +++ b/apps/cli/src/shared/functions/deploy.unit.test.ts @@ -13,11 +13,10 @@ import { import { FunctionImportNotDirectoryError } from "./deploy.errors.ts"; /** - * `../../` from `<root>/supabase/functions/hello/deno.json`'s directory - * lands at `<root>/supabase/_vendor/package/dist/index.mjs` — deliberately - * OUTSIDE `functionsDir` (`<root>/supabase/functions`) so a bind for it - * survives `sanitizeDockerBinds`, which strips every bind under - * `functionsDir`/`outputDir`. That makes bind-list assertions observable + * `../../` from `<root>/supabase/functions/hello/deno.json`'s directory lands at + * `<root>/supabase/_vendor/package/dist/index.mjs`, outside `functionsDir` + * (`<root>/supabase/functions`), so a bind for it survives `sanitizeDockerBinds`, which strips + * every bind under `functionsDir`/`outputDir`. That makes bind-list assertions observable * instead of vacuously true. */ const VENDOR_TARGET_RELATIVE = "../../_vendor/package/dist/index.mjs"; @@ -29,12 +28,10 @@ async function createFunctionProjectWithDenoJson( indexTsContents: string, options: { readonly nestedProject?: boolean } = {}, ) { - // realpath the temp dir up front: on macOS `TMPDIR` resolves through a - // `/var` -> `/private/var` symlink, and `buildDockerBinds` compares - // realpath'd module roots against a non-realpath'd fallback path for a - // dotted-but-nonexistent specifier — an unresolved symlink prefix would - // make every path below "outside the source root" and mask the real - // assertions this file is testing. + // realpath the temp dir up front: on macOS `TMPDIR` resolves through a `/var` -> + // `/private/var` symlink, and `buildDockerBinds` compares realpath'd module roots against a + // non-realpath'd fallback path — an unresolved prefix would make every path look "outside the + // source root" and mask the real assertions here. const root = await realpath(await mkdtemp(join(tmpdir(), "deploy-import-scanner-"))); const projectRoot = options.nestedProject ? join(root, "infra", "my-project") : root; const functionsDir = join(projectRoot, "supabase", "functions"); @@ -100,12 +97,9 @@ async function createSlashVendoredFunctionProject(indexTsContents: string) { describe("buildDockerBinds — import-map key matching (spec-strict) and the file-mapped-key guard", () => { it("drops a specifier reachable only through a JSDoc comment, now via a no-match on the unqualified bare key (not the extension guard)", async () => { - // Import-maps spec: a bare key ("@supabase/server", no trailing slash) - // matches only exactly, so "@supabase/server/core" no longer substitutes - // at all here — it is dropped as an unresolvable bare specifier before - // the final-segment guard ever runs. Kept as its own test because it - // pins the exact field-reported shape; see the "final-segment guard" - // test below for the guard itself under a spec-valid `/`-suffixed key. + // A bare key ("@supabase/server", no trailing slash) matches only exactly, so + // "@supabase/server/core" is dropped as an unresolvable bare specifier before the + // final-segment guard ever runs — see "final-segment guard" below for the guard itself. const { root, functionsDir, outputDir, config, vendorIndexPath } = await createVendoredFunctionProject( [ @@ -126,8 +120,6 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil }, }); - // The vendor file is still bound via the import-map target walk - // (independent of whether the entrypoint's own specifier matched). expect(binds.some((bind) => bind.hostPath === vendorIndexPath)).toBe(true); expect(binds.some((bind) => formatDockerBind(bind).includes("index.mjs/core"))).toBe(false); expect(warnings).toEqual([]); @@ -271,14 +263,10 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil }); it("does not crash when an unreferenced `/`-suffixed import-map target resolves through a file, with no options passed", async () => { - // Regression for a bug found while writing the test above: - // `forEachLocalImportMapTarget` enumerates every import-map VALUE - // unconditionally (regardless of whether the entrypoint references it), - // and Bun's `realpath` — unlike Node's — throws ENOTDIR on a - // trailing-slash path through a file. A spec-valid `/`-suffixed value - // (which SHOULD end in "/") pointing at a real file used to crash - // `buildDockerBinds` with a raw ENOTDIR here, with no options passed — - // exactly how the real `functions deploy` bundling call site invokes it. + // `forEachLocalImportMapTarget` enumerates every import-map value unconditionally, and + // Bun's `realpath` (unlike Node's) throws ENOTDIR on a trailing-slash path through a file — + // this reproduces that with no options passed, matching how the real bundling call site + // invokes it. const { root, functionsDir, outputDir, config } = await createHelloFunctionProject( { "@x/": VENDOR_TARGET_RELATIVE_SLASH }, 'Deno.serve(() => new Response("ok"));\n', @@ -293,10 +281,9 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil }); it("skips an unreferenced import-map target that resolves through a file, regardless of skipMissingImportMapTargets", async () => { - // ENOTDIR (a target routed through a file) is now always skippable, with - // its own wording distinct from the ENOENT "missing" case below — see - // "skips a genuinely missing import-map target" for the option's actual - // gate. + // ENOTDIR (a target routed through a file) is always skippable, with its own wording + // distinct from the ENOENT "missing" case below — see "skips a genuinely missing import-map + // target" for that option's actual gate. const { root, functionsDir, outputDir, config } = await createHelloFunctionProject( { "@x": `${VENDOR_TARGET_RELATIVE}/sub.ts` }, 'Deno.serve(() => new Response("ok"));\n', @@ -508,9 +495,6 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil }, }); - // Pre-fix, "pkg/core.ts" fabricated "<vendor>/index.mjscore.ts" (no - // separator) and "pkg//core.ts" fabricated "<vendor>/index.mjs/core.ts" - // (a genuine through-a-file crash shape) — both warned or threw. expect(warnings).toEqual([]); } finally { await rm(root, { recursive: true, force: true }); @@ -567,10 +551,6 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil }, }); - // Proves the LONGER key ("@v/deep/") won: the walker followed - // "@v/deep/mod.ts" through dirB and bound the resolved FILE. Had the - // shorter key incorrectly won, the walker would have tried - // "<dirA>/deep/mod.ts" instead (which does not exist). expect(binds.some((bind) => bind.hostPath === modPath)).toBe(true); expect(binds.some((bind) => formatDockerBind(bind).includes(join("dirA", "deep")))).toBe( false, @@ -603,16 +583,9 @@ describe("buildDockerBinds — import-map key matching (spec-strict) and the fil skipMissingImportMapTargets: true, }); - // Scope name "../hell" resolves to ".../functions/hell" — the OLD bare - // `startsWith` rule let that match the entrypoint's OWN directory - // (".../functions/hello") purely as a string prefix ("hello" starts - // with "hell" as characters, not as a path segment). If that scope - // incorrectly applied, "@lib" would resolve to the scoped (nonexistent) - // target and the walker itself would emit a "failed to read file" - // warning for it — distinct from the constant "Skipping missing import - // map target" warning that the independent, unconditional - // target-enumeration walk always emits for that same value regardless - // of whether its scope matches anything. + // If the scope incorrectly matched, "@lib" would emit a "failed to read file" warning + // instead of the constant "Skipping missing import map target" warning every target + // enumeration walk emits regardless of scope matching. expect( warnings.some( (warning) => warning.includes("failed to read file") && warning.includes("not-real"), diff --git a/apps/cli/src/shared/functions/download.errors.ts b/apps/cli/src/shared/functions/download.errors.ts index 2883d0b782..c1e4122bca 100644 --- a/apps/cli/src/shared/functions/download.errors.ts +++ b/apps/cli/src/shared/functions/download.errors.ts @@ -50,10 +50,9 @@ export class UnsafeFunctionDownloadPathError extends Data.TaggedError( )<{ readonly message: string; /** - * True when a 200 response's multipart filename/metadata entrypoint - * resolved outside `supabase/functions` — an API response problem, not a - * local write/rename failure (the other construction sites, which keep the - * default `permission` classification). + * True when a 200 response's multipart filename/metadata entrypoint resolved outside + * `supabase/functions` — an API response problem, not a local write/rename failure (the other + * construction sites keep the default `permission` classification). */ readonly unsafeResponsePath?: boolean; }> { From b31578c3b1f740f10f776084b9333f79929abf75 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:09:03 +0100 Subject: [PATCH 43/56] chore(repo): trim comments, shard 39 (1529 -> 865 comment lines) --- apps/cli/src/shared/functions/download.ts | 437 +++++------------ .../shared/functions/functions-api.errors.ts | 26 +- .../src/shared/functions/functions-config.ts | 49 +- .../src/shared/functions/functions-docker.ts | 116 ++--- .../functions/functions-docker.unit.test.ts | 34 +- .../src/shared/functions/functions.shared.ts | 57 +-- .../shared/functions/serve-main-bundler.ts | 16 +- .../src/shared/functions/serve-main-deps.ts | 8 +- .../functions/serve-main-offline.e2e.test.ts | 30 +- apps/cli/src/shared/functions/serve.main.ts | 26 +- apps/cli/src/shared/functions/serve.ts | 463 ++++++------------ apps/cli/src/shared/git/git-branch.ts | 20 +- .../project-init.modes.integration.test.ts | 9 +- .../init/project-init.templates.unit.test.ts | 48 +- apps/cli/src/shared/init/project-init.ts | 70 +-- apps/cli/src/shared/output/errors.ts | 29 +- .../src/shared/output/json-error-handling.ts | 9 +- .../output/json-error-handling.unit.test.ts | 16 - .../output/machine-error-context.service.ts | 15 +- apps/cli/src/shared/output/normalize-error.ts | 79 +-- .../output/normalize-error.unit.test.ts | 81 +-- apps/cli/src/shared/output/output.layer.ts | 29 +- .../shared/output/output.layer.unit.test.ts | 32 +- apps/cli/src/shared/output/output.service.ts | 18 +- .../shared/runtime/process-control.layer.ts | 10 +- .../process-control.layer.unit.test.ts | 10 +- apps/cli/src/shared/runtime/random.service.ts | 6 +- .../shared/runtime/stdin.integration.test.ts | 74 ++- 28 files changed, 575 insertions(+), 1242 deletions(-) diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index e0b0d12d22..76f87a177c 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -44,8 +44,7 @@ import { import { FunctionsApiStatusError, FunctionsApiTransportError } from "./functions-api.errors.ts"; const legacyEntrypointPath = "file:///src/index.ts"; -// Go: `utils.DockerDenoDir`/`utils.DockerEszipDir` (`internal/utils/deno.go:34-35`) -// — fixed container-side paths for the docker-unbundle path, unrelated to +// Fixed container-side paths for the docker-unbundle path, unrelated to // deploy's `toDockerPath` host-mirroring scheme. const DOCKER_DENO_DIR = "/home/deno"; const DOCKER_ESZIP_DIR = "/root/eszips"; @@ -76,47 +75,36 @@ interface DownloadDockerRuntimeDependencies extends DownloadRuntimeDependencies readonly rawArgs: ReadonlyArray<string>; /** * Optional shell-specific styling hook for the `Downloading function:` - * progress line — mirrors `deploy.ts`'s `DeployFunctionsDependencies.styleEmphasis`. - * Defaults to identity (plain text); the CLI injects Go's bold - * styling here so this shared module stays free of CLI-specific - * rendering. Go: `utils.Bold(slug)` (`downloadOne`, `download.go:219`). + * progress line. Defaults to identity (plain text); the CLI injects bold + * styling here so this shared module stays free of CLI-specific rendering. */ readonly styleEmphasis?: (text: string) => string; /** * Optional shell-specific styling hook for the `--legacy-bundle` command * suggested inside {@link suggestLegacyBundle} — same isolation rationale - * as {@link styleEmphasis}, just a different Go colour. Go: - * `utils.Aqua("supabase functions download --legacy-bundle "+slug)` - * (`suggestLegacyBundle`, `download.go:315`). + * as {@link styleEmphasis}. */ readonly styleAqua?: (text: string) => string; /** * Optional shell-specific styling hook for the `WARNING:` token on the * "Docker is not running" fallback line — same isolation rationale as - * {@link styleEmphasis}. Go: `utils.Yellow("WARNING:")` (`download.go:146`). + * {@link styleEmphasis}. */ readonly styleWarning?: (text: string) => string; } -/** - * What {@link resolveEdgeRuntimeImage} needs to resolve the Docker - * edge-runtime image tag — split out so it's declared once instead of - * duplicated across `DownloadFunctionsDependencies`'s fields. - */ +/** What {@link resolveEdgeRuntimeImage} needs to resolve the Docker edge-runtime image tag. */ interface EdgeRuntimeImageDependencies { readonly projectRoot: string; /** - * `undefined` for library callers; the CLI injects - * `functionsGoConfigCompat` so this file never imports the command tree - * directly — see {@link FunctionsGoConfigCompat}. + * `undefined` for library callers; the CLI injects this so this file + * never imports the command tree directly — see {@link FunctionsGoConfigCompat}. */ readonly goConfigCompat: FunctionsGoConfigCompat | undefined; /** - * Fallback edge-runtime image tag used when the project config doesn't pin - * `edge_runtime.deno_version` to `1` (which forces the older - * `DENO1_EDGE_RUNTIME_VERSION`) — mirrors `deploy.ts`'s own - * `edgeRuntimeVersion` dependency, read via - * `resolveEdgeRuntimeVersionPin` by the shell-specific handler. + * Fallback edge-runtime image tag used when the project config doesn't + * pin `edge_runtime.deno_version` to `1`. Mirrors `deploy.ts`'s own + * `edgeRuntimeVersion` dependency. */ readonly edgeRuntimeVersion: string; } @@ -132,12 +120,9 @@ export interface DownloadFunctionsDependencies< projectRef: Option.Option<string>, ) => Effect.Effect<string, ResolveError, ResolveRequirements>; /** - * `captureOutput` is `true` whenever `output.format !== "text"`: the Go - * child's raw stdout must not reach the terminal (it would corrupt the - * JSON/NDJSON envelope, CLI-1546's "stdout is payload-only in machine - * mode" invariant), so the dependency must capture/discard it (e.g. via - * `GoProxy.execCapture`) instead of inheriting stdio. Only invoked - * for `--legacy-bundle` today — `--use-docker` now runs natively (CLI-1963). + * `true` whenever `output.format !== "text"`: the child's raw stdout must + * not reach the terminal (it would corrupt the JSON/NDJSON envelope), so + * the dependency must capture/discard it instead of inheriting stdio. */ readonly proxyDownload: ( flags: DownloadFunctionsOptions, @@ -146,9 +131,8 @@ export interface DownloadFunctionsDependencies< ) => Effect.Effect<void, ProxyError, ProxyRequirements>; } -// `--legacy-bundle` is the only case `downloadFunctions()` still delegates to -// the Go binary for (CLI-1963) — `functionName` is the one remaining piece of -// user input the delegating branch needs to forward. +// `--legacy-bundle` is the only case `downloadFunctions()` still delegates +// to the Go binary; `functionName` is the one remaining input to forward. export function makeGoProxyLegacyBundleArgs( functionName: Option.Option<string>, projectRef: string, @@ -203,17 +187,11 @@ function validateSlug(slug: string): Effect.Effect<void, InvalidFunctionSlugErro } /** - * Go parity (`downloadAll`, `apps/cli-go/internal/functions/download/download.go:172-188`, - * CLI-1891): the Management API's function list is untrusted — a malicious or - * compromised response (or a MITM) could return a slug containing `..` or `/` - * segments, which every downloader below joins into a filesystem path - * (the docker-unbundle temp eszip path, and the server-side path's functions - * directory) before any validation of its own. This is the single point of - * entry that must reject it, rather than relying on each downstream - * path-construction site to defend itself — same rule Go's own comment states. - * Distinct from {@link validateSlug} (used for the user-supplied `<Function - * name>` argument), which fails with a plain `InvalidFunctionSlugError` and no - * "failed to download function" prefix or suggestion. + * The Management API's function list is untrusted: a malicious or + * compromised response could return a slug containing `..`/`/` segments, + * which every downloader below joins into a filesystem path unvalidated. + * This is the single point of entry that must reject it — distinct from + * {@link validateSlug} (the user-supplied `<Function name>` argument). */ function validateRemoteSlug( slug: string, @@ -225,7 +203,6 @@ function validateRemoteSlug( return Effect.fail( Object.assign(new Error(`failed to download function ${slug}: ${invalidFunctionSlugDetail}`), { - // Go: `utils.Aqua(f.Slug)` (`download.go:185`). suggestion: `The Supabase API returned an unexpected function slug (${styleAqua(slug)}). Retry the command, and if this keeps happening, verify your network connection is not being intercepted before contacting Supabase support.`, }), ); @@ -717,33 +694,13 @@ const listRemoteFunctionSlugs = Effect.fnUntraced(function* (api: ApiClient, pro if (!Array.isArray(parsed)) { throw new Error("expected functions list response to be an array"); } - // Go: `FunctionResponse.Slug` (`apps/cli-go/pkg/api/types.gen.go:6465`) - // is a required, non-pointer `string` — a list entry with a missing or - // `null` "slug" decodes to the zero value `""` rather than erroring - // (`encoding/json`'s documented null-into-non-pointer no-op), and that - // empty slug then fails loudly downstream (`validateRemoteSlug`, - // matching Go's own per-item `ValidateFunctionSlug` in `downloadAll`, - // `download.go:182-188`) instead of silently vanishing from the list. - // Coercing here (rather than filtering the entry out, as before) - // preserves that "always surface an unexpected API response, never - // silently download fewer functions than requested" invariant — the - // exact CLI-1891 threat model `validateRemoteSlug` exists for (review - // round on CLI-1963's `functions download` port). + // A missing/null "slug" coerces to "" here (rather than being filtered + // out) so it fails loudly downstream via `validateRemoteSlug`, instead + // of silently vanishing from the list. // - // A "slug" present but typed as something other than string/null is a - // different case: Go's generated client decodes the *entire* array in - // one `json.Unmarshal` call (`ParseV1ListAllFunctionsResponse`, - // `apps/cli-go/pkg/api/client.gen.go:22186-22208`), and a type mismatch - // on any single element fails that whole call — confirmed empirically - // (`json.Unmarshal([]byte(`+"`"+`[{"slug":"ok"},{"slug":123}]`+"`"+`), &dest)` - // returns a `*json.UnmarshalTypeError`; `dest` is partially populated in - // memory, but `ParseV1ListAllFunctionsResponse` returns before ever - // assigning `response.JSON200`, discarding it), so `V1ListAllFunctionsWithResponse` - // returns an error and `downloadAll` fails with "failed to list - // functions: ..." before downloading anything — not after downloading - // the earlier, well-formed entries. Throwing here (rather than - // coercing to `""` like the missing/null case above) preserves that - // same fail-before-any-download ordering. + // A "slug" typed as something other than string/null throws here, + // failing the whole list call before any function is downloaded — + // never after some entries have already been fetched. return parsed.map((value) => { const slug = getObjectProperty(value, "slug"); if (slug === null || slug === undefined) { @@ -838,28 +795,11 @@ const downloadBody = Effect.fnUntraced(function* ( ); }); -// Go: `downloadOne` (`apps/cli-go/internal/functions/download/download.go:218-245`) -// sends this request with no `Accept` header set at all (contrast -// `downloadBody` above, which requests `multipart/form-data` for the -// server-side path). This operation's generated contract marks its response -// `kind: "json"` (`packages/api/src/generated/contracts.ts`), so -// `executeRaw` would otherwise default to `Accept: application/json` here -// (`buildRequest`'s unconditional `acceptJson` for json-kind operations, -// `packages/api/src/internal/client.ts`) and risk a negotiated JSON response -// instead of the raw eszip body — overriding to `*/*` (no preference) is the -// closest equivalent this API surface has to Go sending no header at all. -// Go explicitly decodes a brotli `Content-Encoding` itself because Go's -// `http.Transport` only auto-decodes `gzip`; this TS CLI's transport -// (`effect/unstable/http`'s `FetchHttpClient`, backed by the platform -// `fetch`) already transparently decodes `br` per the Fetch spec — while -// still reporting `Content-Encoding: br` on the exposed `Response.headers` -// (confirmed empirically: a `fetch()` against a real `Content-Encoding: br` -// response returns already-decompressed bytes from `arrayBuffer()`). -// Re-running `brotliDecompressSync` here would therefore throw on -// already-decoded bytes, so this reads the body as-is and does not -// re-implement Go's manual decode step. Error prefix ("failed to get -// function body") is deliberately distinct from `downloadBody`'s ("failed to -// download function") — the two Go call sites use different wording. +// Overrides `Accept: */*` so `executeRaw` doesn't default to +// `Accept: application/json` for this json-kind operation and risk a +// negotiated JSON response instead of the raw eszip body. The HTTP transport +// already transparently decodes `Content-Encoding: br`, so this reads the +// body as-is with no manual decompression step. const downloadEszipBody = Effect.fnUntraced(function* ( api: ApiClient, projectRef: string, @@ -897,31 +837,24 @@ function suggestLegacyBundle( slug: string, styleAqua: (text: string) => string = (text) => text, ): string { - // Go: `suggestLegacyBundle` (`download.go:314-316`) — verbatim, including - // the source's own "trying running" wording and its leading newline. Go - // wraps only the suggested command itself in `utils.Aqua`, not the whole - // sentence — `styleAqua` mirrors that scope exactly. + // Preserves the established "trying running" wording (not a typo) and + // leading newline; `styleAqua` wraps only the suggested command, not the + // whole sentence. return `\nIf your function is deployed using CLI < 1.120.0, trying running ${styleAqua(`supabase functions download --legacy-bundle ${slug}`)} instead.`; } function suggestDenoV2(styleEmphasis: (text: string) => string = (text) => text): string { - // Go: `suggestDenoV2` (`download.go:306-312`), verbatim including its - // trailing newline. Go bolds `utils.ConfigPath` via `utils.Bold` — the - // same hook `styleEmphasis` already covers for the slug above - // (`downloadOne`, `download.go:219`). + // Preserves the established trailing newline; `styleEmphasis` covers the + // config path the same way it covers the slug above. return `Please use deno v2 in ${styleEmphasis("supabase/config.toml")} to download this Function:\n\n[edge_runtime]\ndeno_version = 2\n`; } /** - * Attaches Go's `suggestLegacyBundle` hint to any Docker-extraction failure — - * matches `downloadWithDockerUnbundle`'s `CmdSuggestion += - * suggestLegacyBundle(slug)` (`download.go:211-214`), which runs whenever - * `extractOne` fails for *any* reason (network/volume creation, container - * create/start, log streaming, container inspect), not just a non-zero exit - * code. `ensureDockerNetwork`/`ensureDockerNamedVolume` already prefix their - * own "failed to create docker network/volume: ..." context on the failures - * they raise themselves (`functions-docker.ts`), so this only normalizes - * (never re-prefixes) whatever `describeContainerCliFailure` reports. + * Attaches the legacy-bundle hint to any Docker-extraction failure — + * network/volume creation, container create/start, log streaming, or a + * non-zero exit code alike. Only normalizes (never re-prefixes) whatever + * {@link describeContainerCliFailure} reports, since + * `ensureDockerNetwork`/`ensureDockerNamedVolume` prefix their own context. */ function withLegacyBundleSuggestion(slug: string, styleAqua?: (text: string) => string) { return (cause: unknown): Error => @@ -931,12 +864,10 @@ function withLegacyBundleSuggestion(slug: string, styleAqua?: (text: string) => } /** - * Same as {@link withLegacyBundleSuggestion}, plus a `step` prefix — for - * `runChildProcess` itself, whose own failure (a spawn error, or the - * `PlatformError` `functions-docker.ts`'s hoisted `collectByteStream` erases - * to `unknown`) carries no context of its own about which command was - * running, unlike `ensureDockerNetwork`/`ensureDockerNamedVolume`'s - * self-describing errors. + * Same as {@link withLegacyBundleSuggestion}, plus a `step` prefix: unlike + * `ensureDockerNetwork`/`ensureDockerNamedVolume`'s self-describing errors, + * `runChildProcess`'s own failure carries no context about which command + * was running. */ function withDockerStepFailure(step: string, slug: string, styleAqua?: (text: string) => string) { return (cause: unknown): Error => @@ -945,15 +876,9 @@ function withDockerStepFailure(step: string, slug: string, styleAqua?: (text: st }); } -// Go: `Config.EdgeRuntime.Image` (`extractOne`, `download.go:271`) resolves -// from `edge_runtime.deno_version` — `1` pins the older -// `DENO1_EDGE_RUNTIME_VERSION`, anything else (including unset) uses the -// project's configured/default tag (`resolveEdgeRuntimeVersion`, shared with -// `deploy.ts`). Resolved once per invocation by the caller -// (`downloadFunctions`), not once per slug — Go's `Config` is likewise loaded -// once, before any per-function work. `loadFunctionsCliConfig` (CLI -// path only) runs the same `Config.Validate`/dotenv/env-override pipeline -// `start`/`stop`/`status` already go through — see `functions-config.ts`. +// `deno_version = 1` pins the older `DENO1_EDGE_RUNTIME_VERSION`; anything +// else (including unset) uses the project's configured/default tag. +// Resolved once per invocation by the caller, not once per slug. const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( dependencies: EdgeRuntimeImageDependencies, projectRef: string, @@ -970,11 +895,9 @@ const resolveEdgeRuntimeImage = Effect.fnUntraced(function* ( return { projectId: context.projectId, denoVersion: context.denoVersion, - // `edgeRuntimeImage` applies the tag VERBATIM (Go's `replaceImageTag`) — - // a `.temp/edge-runtime-version` pin flows through unmodified, `v` prefix - // or not (see the helper's doc in `functions.shared.ts`). Registry - // mapping + pull-with-retry happens per-container, right before - // `ensureDockerNetwork`, matching Go's `DockerStart` (see the caller). + // `edgeRuntimeImage` applies the tag verbatim; a `.temp/edge-runtime-version` + // pin flows through unmodified. Registry mapping + pull-with-retry happens + // per-container, right before `ensureDockerNetwork` (see the caller). rawImage: edgeRuntimeImage(edgeRuntimeVersion), projectEnvValues: context.projectEnvValues, }; @@ -989,26 +912,19 @@ interface EdgeRuntimeImage { } /** - * `EdgeRuntimeImage` plus the pull-resolved reference, once per invocation - * (not once per slug — see {@link downloadFunctions}'s own resolve site): - * the image is identical for every function being downloaded, so resolving - * it inside the per-slug loop would multiply both the cache-check subprocess - * count and, on a registry outage, the retry-backoff sleep (up to ~36s) by - * the function count. Go's own `DockerStart` DOES run per-container (once - * per `extractOne`), but its image-cache check is an in-process Engine API - * call, not a fork+exec — the per-slug cost that justifies hoisting here has - * no Go equivalent to stay faithful to. + * `EdgeRuntimeImage` plus the pull-resolved reference, resolved once per + * invocation (not once per slug — see {@link downloadFunctions}'s own + * resolve site): the image is identical for every function, so resolving it + * per-slug would multiply both the cache-check subprocess count and, on a + * registry outage, the retry-backoff sleep (up to ~36s) by the function count. */ interface PulledEdgeRuntimeImage extends EdgeRuntimeImage { readonly image: string; } -// Go: `downloadWithDockerUnbundle`/`extractOne` -// (`download.go:198-282`) — downloads the function body as an eszip, writes -// it to a temp file, then runs the edge-runtime image's `unbundle` -// subcommand against it, mounting the *shared* `supabase/functions` -// directory (not the slug's own subdirectory — `download_test.go:267-271` -// asserts this explicitly). +// Downloads the function body as an eszip, writes it to a temp file, then +// runs the edge-runtime image's `unbundle` subcommand against it, mounting +// the shared `supabase/functions` directory (not the slug's own subdirectory). const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( dependencies: DownloadDockerRuntimeDependencies, edgeRuntimeImage: PulledEdgeRuntimeImage, @@ -1019,12 +935,8 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( const styleEmphasis = dependencies.styleEmphasis ?? ((text: string) => text); const styleAqua = dependencies.styleAqua ?? ((text: string) => text); - // Go: `downloadOne` (`download.go:219`) — lowercase "function", distinct - // from the server-side path's "Downloading Function:" (capital F, - // `downloadWithServerSideUnbundle`, `download.go:329`). Both Go call sites - // bold the slug (`utils.Bold`); this path is new in CLI-1963, so it picks - // up the styling hook now. `downloadSingle`'s server-side path below has - // the identical gap, but predates this PR (#5527) — left as-is here. + // Lowercase "function", distinct from the server-side path's "Downloading + // Function:" (capital F) below — an established text difference, not a typo. yield* output.raw(`Downloading function: ${styleEmphasis(slug)}\n`, "stderr"); const eszip = yield* downloadEszipBody(dependencies.api, projectRef, slug); @@ -1045,23 +957,13 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( ), }); - // Go: the `defer fsys.Remove(eszipPath)` cleanup is registered right after - // the write and covers the whole of `extractOne`, including the container - // run — it fires on every return path, success or failure - // (`download.go:203-209`). `Effect.ensuring` below is the equivalent: it - // wraps every step from here on so a failure resolving the network/volume, - // spawning Docker, or a non-zero container exit all still clean up the - // temp eszip, matching Go instead of only doing so on the happy path. + // `Effect.ensuring` below wraps every step from here on so a failure + // resolving the network/volume, spawning Docker, or a non-zero container + // exit all still clean up the temp eszip file, not just the happy path. // - // Go gates this on `viper.GetBool("DEBUG")` (`download.go:203`), which - // resolves an explicit `--debug=false` to `false` (cleanup runs) — a plain - // presence check would get that backwards, so this reads the last explicit - // occurrence's boolean value instead (`explicitBooleanLongFlag`), falling - // back to `false` (cleanup runs) when `--debug` never appears. `SUPABASE_DEBUG` - // env-var fallback is a separate, pre-existing gap shared with every other - // presence-only `--debug` read this file family used to have - // (e.g. `deploy.ts`) and the CLI's debug logger itself, none of which - // currently honor it either — left open rather than fixed piecemeal here. + // An explicit `--debug=false` must still run cleanup, so this reads the + // flag's last explicit boolean value rather than a plain presence check, + // falling back to `false` (cleanup runs) when `--debug` never appears. const debugEnabled = explicitBooleanLongFlag(dependencies.rawArgs, "debug") ?? false; const cleanupEszip = debugEnabled ? Effect.void @@ -1077,14 +979,11 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( const dockerEszipPath = posix.join(DOCKER_ESZIP_DIR, eszipFileName); const dockerOutputPath = posix.join(DOCKER_DENO_DIR, slug); - // Go: `viper.GetString("network-id")` else `NetId` (`docker.go:379-383`) — - // `--network-id` is a persistent root flag (`cmd/root.go:328`), not - // registered on `functions download` itself. `lastExplicitLongFlagValue` - // preserves the "explicitly cleared" vs "never touched" distinction - // `resolveDockerNetworkMode` needs to decide whether `SUPABASE_NETWORK_ID` - // applies — see that function's own doc comment. `SUPABASE_NETWORK_ID` - // (env or project dotenv) is CLI-only — same Go-viper-parity gate - // as `projectEnvValues` itself (`undefined` for library callers). + // `--network-id` is a persistent root flag, not registered on `functions + // download` itself. `lastExplicitLongFlagValue` preserves the "explicitly + // cleared" vs "never touched" distinction `resolveDockerNetworkMode` needs + // — see that function's own doc comment. `SUPABASE_NETWORK_ID` is CLI-only, + // like `projectEnvValues` (`undefined` for library callers). const networkMode = resolveDockerNetworkMode({ explicit: lastExplicitLongFlagValue(dependencies.rawArgs, [], "network-id"), envOverride: @@ -1104,12 +1003,10 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( Effect.mapError(withLegacyBundleSuggestion(slug, styleAqua)), ); - // Bind order matches `extractOne` (`download.go:260-266`) exactly. Go's - // `DockerStart` drops the named-volume bind entirely on Bitbucket - // (`internal/utils/docker.go:400-405`) rather than just skipping its - // explicit creation — `docker run -v <name>:...` would otherwise still - // implicitly create the named volume, which Bitbucket's restricted Docker - // environment doesn't allow, same carve-out as `deploy.ts`'s + // On Bitbucket, the named-volume bind is dropped entirely (not just its + // explicit creation skipped): `docker run -v <name>:...` would otherwise + // still implicitly create the volume, which Bitbucket's restricted + // Docker environment doesn't allow — same carve-out as `deploy.ts`'s // `buildDockerBinds`. const binds = [ ...(process.env["BITBUCKET_CLONE_DIR"] === undefined ? [cacheVolume.bind] : []), @@ -1124,14 +1021,10 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( containerArgs: ["unbundle", "--eszip", dockerEszipPath, "--output", dockerOutputPath], }; - // Go pipes the container's stdout/stderr straight to `os.Stdout`/`getErrorLogger()` - // while the container runs (`DockerRunOnceWithConfig`, copied live via the - // log stream) — `runChildProcess`'s `onStdout`/`onStderr` tee each chunk - // to `output.raw` as it arrives instead of buffering the whole run. - // Go pipes the container's stdout straight to `os.Stdout` - // (`download.go:279`); machine-output modes must keep stdout - // payload-only (CLI-1546), so this mirrors `deploy.ts`'s own - // `bundleFunctionWithDocker` routing. + // Each chunk tees to `output.raw` live instead of buffering the whole + // run. Container stdout routes to real stdout only in text mode — + // machine-output modes must keep stdout payload-only — mirroring + // `deploy.ts`'s own Docker routing. const result = yield* runChildProcess("docker", buildFunctionsDockerRunArgs(spec), { stdout: "pipe", stderr: "pipe", @@ -1144,15 +1037,9 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( ); if (result.exitCode !== 0) { - // Go's `getErrorLogger` (deno-v1 only) sets `CmdSuggestion = - // suggestDenoV2()` (assignment) as soon as a full stderr line reads - // "invalid eszip v2" (case-insensitive), then `downloadWithDockerUnbundle` - // appends `suggestLegacyBundle` (`+=`) once extraction has failed - // (`download.go:213,284-304`). Go's own implementation races these two - // goroutines (the pipe writer is never closed) — this resolves that - // race deterministically to the common (non-race) ordering instead of - // reproducing the nondeterminism. The line match is exact (not a - // substring) to match Go's `strings.EqualFold(line, "invalid eszip v2")`. + // Detects a full stderr line reading "invalid eszip v2" + // (case-insensitive, exact match not substring) to append the deno-v2 + // suggestion ahead of the legacy-bundle one — deno-v1 containers only. const invalidEszipV2 = denoVersion === 1 && result.stderr @@ -1166,10 +1053,8 @@ const downloadWithDockerUnbundle = Effect.fnUntraced(function* ( }), ); } - // Go: `downloadWithDockerUnbundle` has no final "Downloaded Function ..." - // print, unlike `RunLegacy`/`downloadWithServerSideUnbundle` — its only - // stdout/stderr text is "Downloading function: ..." above plus whatever - // the `unbundle` container itself wrote. + // No final "Downloaded Function ..." print here, unlike the server-side + // path below — only "Downloading function: ..." plus the container's own output. return slug; }); @@ -1245,29 +1130,14 @@ const downloadSingle = Effect.fnUntraced(function* ( }); /** - * Attaches the on-disk directory of every function this loop had already - * fully downloaded before `error` — mutating `error` in place via - * `Object.assign` and returning that SAME object, so every existing caller's - * `_tag`/`instanceof` check on the heterogeneous error classes this loop can - * fail with (`InvalidFunctionSlugError`, `FunctionDownloadNotFoundError`, - * `InvalidFunctionDownloadResponseError`, `UnsafeFunctionDownloadPathError`, - * `FunctionsApiStatusError`/`FunctionsApiTransportError`, plus the ad-hoc - * `Object.assign(new Error(...), {suggestion})` values from the Docker path) - * stays unchanged — deliberately generic rather than adding a field to each - * of those ~8 classes individually. Field name/shape matches the established - * `MigrationFetchWriteError.writtenSoFar` precedent - * (`commands/migration/fetch/fetch.errors.ts`), read by `pull.aggregate.ts`'s - * `hasWrittenSoFar` duck-type so `supabase pull`'s `functions` step can report - * partial progress instead of always claiming `written: []` on a failed - * download. Each entry is an absolute path to the same "representative - * function directory" `pull.aggregate.ts`'s own `pullFunctionsStepResult` - * already uses for a SUCCESSFUL download (`supabase/functions/<slug>`) — the - * loop below never tracks individual downloaded file paths, and - * `DownloadFunctionsResult` doesn't enumerate them either. Omitted entirely - * (not an empty array) when nothing had downloaded yet, matching what - * `hasWrittenSoFar`'s duck-type actually needs: presence, not non-emptiness — - * an empty array would report the exact same `written: []` the caller already - * falls back to, so attaching one would add no information. + * Mutates `error` in place via `Object.assign` and returns the same object, + * so every caller's `_tag`/`instanceof` check on this loop's heterogeneous + * error classes stays unchanged. Field name matches the established + * `MigrationFetchWriteError.writtenSoFar` precedent, read by + * `pull.aggregate.ts`'s `hasWrittenSoFar` duck-type so `supabase pull` can + * report partial progress. Omitted entirely (not an empty array) when + * nothing had downloaded yet, since the duck-type checks presence, not + * non-emptiness. */ function attachDownloadWrittenSoFar<E extends object>( error: E, @@ -1296,49 +1166,35 @@ export function downloadFunctions<ResolveError, ResolveRequirements, ProxyError, yield* validateSlug(flags.functionName.value); } - // `--legacy-bundle` is the only case still delegated to the Go binary - // after CLI-1963: it requires installing/upgrading a real Deno binary on - // the host (`InstallOrUpgradeDeno`) and shelling out to an embedded Deno - // script — a Deno-host-binary-management subsystem with no precedent - // anywhere else in this codebase or the Go CLI itself. See CLI-1963 for - // the parity-audit rationale on why porting it is tracked separately. - // `--use-docker` (default `true`) now runs natively below, matching - // Go's own dispatcher, which falls through to the *same* native - // server-side downloader when Docker isn't running rather than - // delegating anywhere (`download.go:138-148`). + // `--legacy-bundle` still delegates to the Go binary: it requires + // installing/upgrading a Deno binary on the host and shelling out to an + // embedded Deno script, with no other precedent in this codebase. + // `--use-docker` (default `true`) runs natively below and falls through + // to the same server-side downloader when Docker isn't running. if (flags.legacyBundle) { const projectRef = yield* dependencies.resolveProjectRef(flags.projectRef); if (output.format === "text") { yield* dependencies.proxyDownload(flags, projectRef, false); - // The `--legacy-bundle` path is left untouched by the emission-moving - // refactor below (its own `output.success` calls stay put) — the - // slug list is never resolved in text mode here, so this result is - // not meaningful and is unused by callers (the orchestrator never + // The slug list is never resolved in text mode here, so this result + // is not meaningful — callers never read it (the orchestrator never // sets `legacyBundle: true`). return { projectRef, slugs: [], empty: false }; } - // Resolve the slug list *before* delegating, mirroring Go's own - // `downloadAll` (which also lists before looping through downloads, - // `apps/cli-go/internal/functions/download/download.go`). The - // delegated Go child never emits the TS `Output` envelope itself - // (CLI-1546: stdout is payload-only in machine mode, so its raw text - // is captured/discarded below, not inherited) — this list is purely - // for the JSON payload. Resolving it first means a transient listing - // failure is reported before any download side effect, instead of - // masking an already-successful delegated download with a later, - // unrelated listing failure. + // Resolved before delegating: this list is purely for the JSON + // payload (the delegated child's own stdout is captured/discarded, not + // inherited, since it never emits the `Output` envelope). Resolving it + // first means a transient listing failure is reported before any + // download side effect, rather than masking an already-successful + // delegated download with an unrelated listing failure after the fact. const slugs = Option.isSome(flags.functionName) ? [flags.functionName.value] : yield* listRemoteFunctionSlugs(dependencies.api, projectRef); - // Mirrors the native path's empty-project short-circuit just below: - // an empty project has nothing to delegate, so report it the same way - // ("No functions found.") instead of still invoking the Go child (an - // unnecessary Docker/subprocess round-trip) and reporting a - // misleading "Downloaded Edge Function source." success with an - // empty slug list. + // Mirrors the native path's empty-project short-circuit below: an + // empty project has nothing to delegate, so this reports "No functions + // found." instead of invoking the Go child unnecessarily. if (slugs.length === 0) { yield* output.success("No functions found.", { function_slugs: [], @@ -1358,32 +1214,17 @@ export function downloadFunctions<ResolveError, ResolveRequirements, ProxyError, const projectRef = yield* dependencies.resolveProjectRef(flags.projectRef); - // Go: `flags.LoadConfig(fsys)` runs unconditionally at the very top of - // `Run`, before checking `useDocker`, before checking whether Docker - // itself is running, and before any API/filesystem side effect - // (`download.go:135-138`) — an invalid `supabase/config.toml` (e.g. a bad + // Resolved unconditionally here, before checking `useDocker` or whether + // Docker is running: an invalid `supabase/config.toml` (e.g. a bad // `edge_runtime.deno_version`) must fail up front regardless of - // `--use-api`/`--use-docker`/Docker's running state, not only on the - // Docker happy path. Resolving unconditionally here (rather than nested - // inside the `isDockerRunning()` branch below) keeps that ordering: a - // config error now surfaces even when `--use-api` was passed or Docker is - // down, matching Go instead of silently skipping validation and - // proceeding straight to `listRemoteFunctionSlugs`/`downloadSingle`. + // `--use-api`/`--use-docker`/Docker's state, not only on the Docker path. const resolvedEdgeRuntimeImage = yield* resolveEdgeRuntimeImage(dependencies, projectRef); - // Go: `Run` resolves ONE downloader for the entire invocation, before any - // per-function work — including before listing when no slug is given - // (`download.go:138-153`), so this warning can print even when the - // project turns out to have zero functions. Mirrors Go's - // `if useApi { useDocker = false }` (`cmd/functions.go:51-53`), which - // reads the resolved flag value, not `pflag.Changed`. - // Single source of truth for "use the Docker downloader": `undefined` - // means the native server-side path runs instead, whether because - // `--use-api` (or `--use-docker=false`) resolved that way or because - // Docker isn't running. Deliberately not a separate `useDocker: boolean` - // alongside this — a boolean that could disagree with whether an image - // was actually resolved would let the loop below silently downgrade to - // the server-side path while claiming to honor `--use-docker`. + // Resolved once for the entire invocation, before any per-function work, + // so the "Docker is not running" warning can print even for a project + // with zero functions. `edgeRuntimeImage === undefined` is the single + // source of truth for "use the server-side path" instead of a separate + // boolean that could silently disagree with whether an image resolved. const styleWarning = dependencies.styleWarning ?? ((text: string) => text); const edgeRuntimeImage: EdgeRuntimeImage | undefined = !flags.useApi && flags.useDocker @@ -1398,9 +1239,8 @@ export function downloadFunctions<ResolveError, ResolveRequirements, ProxyError, ? [flags.functionName.value] : yield* listRemoteFunctionSlugs(dependencies.api, projectRef); - // Final-summary emission for the empty-project case moved to the - // standalone handler (`functionsDownload`) — this only computes and - // returns the result now. + // The standalone `functionsDownload` handler emits the final summary; + // this only computes and returns the result. if (slugs.length === 0) { return { projectRef, slugs: [], empty: true }; } @@ -1409,17 +1249,10 @@ export function downloadFunctions<ResolveError, ResolveRequirements, ProxyError, yield* output.raw(`Found ${slugs.length} function(s) to download\n`, "stderr"); } - // Go: `DockerStart` -> `DockerResolveImageIfNotCached` (`internal/utils/docker.go:326-386`) - // — resolved ONCE here, for the whole invocation, not once per slug - // inside the loop below: the image is identical for every function, so - // per-slug resolution would multiply both the cache-check subprocess - // count and, on a registry outage, the retry-backoff sleep (up to ~36s) - // by the function count — see `PulledEdgeRuntimeImage`'s own doc comment - // for why this diverges from Go's per-container `DockerStart` without - // losing parity (Go's cache check is in-process, not a fork+exec). The - // `--legacy-bundle` suggestion on a resolve failure uses the first slug - // as a representative example, since no single slug is "the" one being - // processed yet at this point. + // Resolved once for the whole invocation, not once per slug — see + // `PulledEdgeRuntimeImage`'s own doc comment. The `--legacy-bundle` + // suggestion on a resolve failure uses the first slug as a + // representative example, since none is "the" one being processed yet. const styleAqua = dependencies.styleAqua ?? ((text: string) => text); const pulledEdgeRuntimeImage: PulledEdgeRuntimeImage | undefined = edgeRuntimeImage === undefined @@ -1433,20 +1266,15 @@ export function downloadFunctions<ResolveError, ResolveRequirements, ProxyError, }; const downloaded: string[] = []; - // Absolute directory path per fully-downloaded slug so far, for - // `attachDownloadWrittenSoFar` below — separate from `downloaded` (which - // feeds the returned `DownloadFunctionsResult.slugs`) since a caller - // three steps up (`pull.aggregate.ts`'s `hasWrittenSoFar`) needs an - // on-disk path, not a bare slug. + // Absolute directory path per fully-downloaded slug, separate from + // `downloaded` (bare slugs): a caller upstream (`pull.aggregate.ts`'s + // `hasWrittenSoFar`) needs an on-disk path. const downloadedPaths: string[] = []; for (const slug of slugs) { yield* Effect.gen(function* () { - // Go: CLI-1891, `downloadAll`'s per-item validation runs before any - // per-slug network/filesystem work (`download.go:182-188`). A - // user-supplied slug is already validated above (`validateSlug`); - // this covers slugs sourced from the Management API's function - // list, which this threat model treats as untrusted (a - // malicious/compromised response, or a MITM). + // A user-supplied slug is already validated above; this covers + // slugs sourced from the Management API's function list, which is + // untrusted (a malicious/compromised response, or a MITM). if (Option.isNone(flags.functionName)) { yield* validateRemoteSlug(slug, styleAqua); } @@ -1466,9 +1294,8 @@ export function downloadFunctions<ResolveError, ResolveRequirements, ProxyError, }).pipe(Effect.mapError((error) => attachDownloadWrittenSoFar(error, downloadedPaths))); } - // Final-summary emission for the completed download loop moved to the - // standalone handler (`functionsDownload`) — this only computes and - // returns the result now. + // The standalone `functionsDownload` handler emits the final summary; + // this only computes and returns the result. return { projectRef, slugs: downloaded, empty: false }; }); } diff --git a/apps/cli/src/shared/functions/functions-api.errors.ts b/apps/cli/src/shared/functions/functions-api.errors.ts index 25d1357bd1..2525d0a513 100644 --- a/apps/cli/src/shared/functions/functions-api.errors.ts +++ b/apps/cli/src/shared/functions/functions-api.errors.ts @@ -7,12 +7,9 @@ import { } from "../telemetry/error-actionability.ts"; /** - * Shared error for a non-OK Management API response where an HTTP status - * code is available, used by both `deploy.ts` and `download.ts`. Keeping one - * class here (rather than duplicating it per file) lets both call sites - * classify identically via {@link statusCodeActionability} instead of - * falling back to a plain `Error` (which reports as `unknown` in the error - * actionability KPI). + * Non-OK Management API response with an HTTP status code, shared by + * `deploy.ts` and `download.ts` so both classify it identically via + * {@link statusCodeActionability} instead of an `unknown` plain `Error`. */ export class FunctionsApiStatusError extends Data.TaggedError("FunctionsApiStatusError")<{ readonly status: number; @@ -20,11 +17,8 @@ export class FunctionsApiStatusError extends Data.TaggedError("FunctionsApiStatu /** The request path names a user-selected function slug. */ readonly notFoundIsInvalidInput?: boolean; /** - * Set when the failure is a successful-status response whose body could not - * be decoded (status is 200/201 but the JSON is malformed / unexpected). - * That is an API-response problem, not a raw status failure, so it classifies - * as `api_status` with the `api_response` fingerprint ahead of the - * status-code policy. + * Set when a successful-status response's body failed to decode. Classifies + * as `api_status` with the `api_response` fingerprint instead of the status-code policy. */ readonly decode?: boolean; }> { @@ -39,12 +33,10 @@ export class FunctionsApiStatusError extends Data.TaggedError("FunctionsApiStatu } /** - * Shared error for a Management API request that failed before a response - * was received (DNS, connection reset, timeout, ...), used by both - * `deploy.ts` and `download.ts`'s `mapTransportError`. Keeping one class here - * (rather than duplicating it per file) lets both call sites classify - * identically as a network failure instead of falling back to a plain - * `Error` (which reports as `unknown` in the error actionability KPI). + * Management API request that failed before a response arrived (DNS, + * connection reset, timeout, ...), shared by `deploy.ts` and + * `download.ts`'s `mapTransportError` so both classify it as a network + * failure instead of an `unknown` plain `Error`. */ export class FunctionsApiTransportError extends Data.TaggedError("FunctionsApiTransportError")<{ readonly message: string; diff --git a/apps/cli/src/shared/functions/functions-config.ts b/apps/cli/src/shared/functions/functions-config.ts index 7812702398..5b095852e9 100644 --- a/apps/cli/src/shared/functions/functions-config.ts +++ b/apps/cli/src/shared/functions/functions-config.ts @@ -5,30 +5,25 @@ import { loadCliConfig } from "@supabase/config/effect"; import { normalizeProjectId } from "./functions-docker.ts"; /** - * Everything the native `functions` Docker paths (`deploy`/`download`/`serve`) - * need from project config resolution. In the - * CLI this also runs the same `Config.Validate`/dotenv pipeline - * `start`/`stop`/`status` already go through — see {@link FunctionsGoConfigCompat}. - * Callers that omit the hook keep the plain `loadCliConfig` behavior. + * Config resolution context shared by the `functions` Docker paths + * (`deploy`, `download`, `serve`). Callers that inject + * {@link FunctionsGoConfigCompat} additionally run the config/dotenv + * validation pipeline `start`/`stop`/`status` already share; callers that + * omit it keep the plain `loadCliConfig` behavior. */ interface FunctionsCliConfigContext { readonly loaded: LoadedCliConfig | null; - /** Go's post-`loadNestedEnv` merged env (ambient-wins). `undefined` when the hook is not injected. */ + /** Merged env with ambient values winning; `undefined` when the hook is not injected. */ readonly projectEnvValues: Readonly<Record<string, string>> | undefined; - /** Go's `Config.ProjectId`, sanitized, after `Config.Validate` in the CLI. */ + /** Sanitized project id, resolved after config validation. */ readonly projectId: string; readonly denoVersion: number | undefined; } /** - * Go-parity hook, injected so this shared module never imports the command - * tree's validation/dotenv machinery directly — same rationale as - * `download.ts`'s `styleEmphasis`/`styleAqua`. `undefined` disables the hook. - * - * A single method (not one hook per step) so the command-tree implementation can - * delegate its dotenv/config-load work to `local-project-context.ts`'s - * `loadLocalProjectContext` end to end — the same pipeline `start`/ - * `stop`/`status` already share — rather than re-implementing it here. + * Hook that lets a caller run its own config/dotenv validation pipeline + * without this shared module importing the command tree's validation + * machinery directly. `undefined` disables the hook. */ export interface FunctionsGoConfigCompat { readonly load: (input: { @@ -47,12 +42,9 @@ export interface FunctionsGoConfigCompat { } /** - * Go: `flags.LoadConfig` (`internal/utils/flags/config_path.go:10-14` -> - * `pkg/config/config.go:579-611,878`) — loads dotenv, decodes config.toml - * (merging template defaults + env even when the file is absent), and ends in - * `Config.Validate`, unconditionally, before any Docker/API work. Only the - * CLI (`goConfigCompat` set) runs that Go-parity dotenv/validate - * pipeline; callers without the hook keep the plain `loadCliConfig` behavior. + * Loads project config for a `functions` command. Callers that provide + * `goConfigCompat` run its dotenv/config-validate pipeline before any + * Docker/API work; callers that don't fall back to `loadCliConfig`. */ export const loadFunctionsCliConfig = Effect.fnUntraced(function* (input: { readonly projectRoot: string; @@ -67,18 +59,9 @@ export const loadFunctionsCliConfig = Effect.fnUntraced(function* (input: { return { loaded, projectEnvValues: undefined, - // `input.projectRef` is a definite string for every current caller - // (`deploy`/`download` always resolve one first); the `basename` - // fallback only matters if this ever runs with `projectRef` - // `undefined` and no `project_id` in the file — matching Go's `Eject` - // basename default (`pkg/config/config.go:561-570`) and the compat - // branch's own `resolveLocalProjectId` fallback below. - // Sanitized like the compat branch's (`sanitizeProjectId`, run - // inside its validate pipeline): this id feeds `dockerProjectLabels`' - // raw label values as well as `localDockerId`'s (self-sanitizing) - // resource names, and an unsanitized `project_id = "My Project"` would - // label the container `My Project` while its network/volume are named - // `..._My_Project` — breaking label-based cleanup filters. + // Falls back to `basename` only when `projectRef` is undefined and the + // config lacks `project_id`. Sanitized because it also feeds Docker + // label/resource names, where an unsanitized value breaks cleanup filters. projectId: normalizeProjectId( loaded?.config.project_id ?? input.projectRef ?? basename(input.projectRoot), ), diff --git a/apps/cli/src/shared/functions/functions-docker.ts b/apps/cli/src/shared/functions/functions-docker.ts index 8cb3c26d14..2407ef5a31 100644 --- a/apps/cli/src/shared/functions/functions-docker.ts +++ b/apps/cli/src/shared/functions/functions-docker.ts @@ -1,9 +1,7 @@ -// Docker orchestration primitives shared by `deploy.ts` and `download.ts` -// (the `functions` command family root, `src/shared/functions/`) — plus -// `serve.ts` (same family) and `command-internal/db-bootstrap/container-lifecycle.ts` -// (a different family, reaching in for the generic `isUserDefinedDockerNetwork` -// predicate), both of which already imported these primitives from `deploy.ts` -// before this file existed. +// Docker orchestration primitives shared by `deploy.ts`, `download.ts`, and +// `serve.ts` (the `functions` family), plus +// `command-internal/db-bootstrap/container-lifecycle.ts` (a different +// family, using the generic `isUserDefinedDockerNetwork` predicate). import { resolve } from "node:path"; import { Effect, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; @@ -45,25 +43,11 @@ export function edgeRuntimeCacheVolume(projectId: string) { } /** - * Go: `DockerStart`'s network selection (`internal/utils/docker.go:379-383`) - * combined with root's `viper.BindPFlags`/`AutomaticEnv` for the persistent - * `--network-id` flag (`cmd/root.go:316-334`). viper's `find()` resolves a - * `Changed` pflag *before* it ever consults a bound env var (`viper.go`'s - * flag-override branch precedes its env-override branch) — so an explicit - * `--network-id=` (empty, but still marks the flag `Changed`) makes - * `viper.GetString("network-id")` return `""` and stop there, WITHOUT - * falling through to `SUPABASE_NETWORK_ID`; only THEN does the consuming - * `len(networkId) > 0` check fall through, straight to the generated - * default. An explicit-but-empty *env* value, by contrast, genuinely means - * unset (viper never enables `AllowEmptyEnv`) and falls through to the - * default the normal way. Net effect: `explicit === undefined` (flag never - * touched) is the ONLY case that consults `envOverride` — `explicit === ""` - * (flag explicitly cleared) skips straight to the generated default, same - * as a non-empty `explicit` skips it by using the flag's own value. Callers - * MUST pass a flag reader that preserves this 3-way distinction — see + * Resolves the Docker network mode. `explicit` is tri-state: `undefined` + * (never set) falls through to `envOverride`; `""` (explicitly cleared) and + * any non-empty value both skip `envOverride` and resolve immediately. + * Callers must pass a flag reader that preserves this distinction — see * `lastExplicitLongFlagValue` (`shared/cli/cobra-flag-groups.ts`). - * `envOverride` is `undefined` for callers without the Go-viper env-binding - * hook — see `resolveDockerNetworkMode`'s callers. */ export function resolveDockerNetworkMode(input: { readonly explicit: string | undefined; @@ -117,17 +101,13 @@ export function containerArchiveBytes( export interface FunctionsDockerRunSpec { /** Already registry/pull-resolved image reference. */ readonly image: string; - /** Go's `Config.ProjectId` — the label value (`docker.go:374-376`). */ + /** The label value applied to the container. */ readonly projectId: string; readonly networkMode: string; readonly binds: ReadonlyArray<string>; /** `KEY=VALUE` entries, each emitted as `-e KEY=VALUE`. */ readonly env?: ReadonlyArray<string>; - /** - * Emitted as `-w <dir>` — Go's bundler sets `WorkingDir: - * utils.ToDockerPath(cwd)` (`bundle.go:79`); the unbundler sets none - * (`download.go:268-281`), so this is optional. - */ + /** Emitted as `-w <dir>`; optional because only the bundler container sets a working directory. */ readonly workingDir?: string; /** argv after the image, e.g. `["bundle", "--entrypoint", …]`. */ readonly containerArgs: ReadonlyArray<string>; @@ -137,13 +117,10 @@ export interface FunctionsDockerRunSpec { /** * Assembles the one-shot `docker run` invocation shared by `deploy.ts`'s * bundler and `download.ts`'s unbundler containers: binds, network, the - * linux `host.docker.internal` workaround, env, and Go's unconditional - * `com.supabase.cli.project`/`com.docker.compose.project` labels - * (`DockerStart`, `internal/utils/docker.go:349-386`) — previously applied - * only to the network/volume these containers depend on - * (`ensureDockerNetwork`/`ensureDockerNamedVolume` above), never to the - * one-shot containers themselves, so label-based cleanup/inspection couldn't - * associate an orphaned container with the project. + * linux `host.docker.internal` workaround, env, and the unconditional + * `com.supabase.cli.project`/`com.docker.compose.project` labels — applied + * to the container itself (not just the network/volume it depends on) so + * label-based cleanup/inspection can find an orphaned container. */ export function buildFunctionsDockerRunArgs(spec: FunctionsDockerRunSpec): Array<string> { const command = ["run", "--rm", ...spec.binds.flatMap((bind) => ["-v", bind])]; @@ -168,12 +145,9 @@ export function buildFunctionsDockerRunArgs(spec: FunctionsDockerRunSpec): Array return command; } -// Decodes a byte stream to text, both accumulating the full text (returned, -// for callers that need to post-process it, e.g. scanning stderr for -// "invalid eszip v2") AND tee-ing each decoded chunk to `onChunk` as it -// arrives — Go's `DockerStreamLogs`/`DockerRunOnceWithConfig` copy a -// container's log stream live while it runs, rather than buffering the whole -// thing until exit. +// Decodes a byte stream to text, accumulating the full text (returned, for +// callers that post-process it, e.g. scanning stderr for "invalid eszip +// v2") while also tee-ing each decoded chunk to `onChunk` as it arrives. function collectByteStream( stream: Stream.Stream<Uint8Array, unknown>, onChunk?: (chunk: string) => Effect.Effect<void>, @@ -194,15 +168,11 @@ function collectByteStream( }); } -// Runs a container CLI command and collects its output. Every caller runs -// `docker`, so the spawn goes through `spawnContainerCli` to fall back to -// `podman` on Docker-less hosts. `command` is retained for the extendEnv -// default and the `functions serve` dependency-injection seam. -// `Effect.scoped` closes the spawn's own acquireRelease scope as soon as the -// process has exited and both streams are drained — without it, every call -// parks a release finalizer in the CALLER's scope, and `functions serve`'s -// session-long restart loop (one `Effect.scoped` around an infinite loop) -// would accumulate one per docker invocation per file-change restart. +// Falls back to `podman` on Docker-less hosts via `spawnContainerCli`. +// `Effect.scoped` closes the spawn's own scope as soon as the process exits +// and both streams drain — without it, a release finalizer would leak into +// the caller's scope, and `functions serve`'s session-long restart loop +// would accumulate one per docker invocation. export const runChildProcess = Effect.fnUntraced(function* ( command: string, args: ReadonlyArray<string>, @@ -247,21 +217,19 @@ export const runChildProcess = Effect.fnUntraced(function* ( ); }); -// Go: `container.NetworkMode.IsContainer()` (`docker/api/types/container/hostconfig.go:152-155`, -// via the unexported `containerID` helper, same file:493-499) — `--network container:<name|id>` -// (Docker's syntax for attaching to another container's network stack) is recognized by a bare -// `"container:"` prefix before the first `:`, regardless of what (if anything) follows it. +// Docker's `--network container:<name|id>` syntax (attaching to another +// container's network stack) is recognized by a bare "container:" prefix +// before the first `:`, regardless of what follows. function isContainerDockerNetworkMode(networkMode: string) { const separatorIndex = networkMode.indexOf(":"); return separatorIndex !== -1 && networkMode.slice(0, separatorIndex) === "container"; } -// Go: `container.NetworkMode.IsUserDefined()` (`docker/api/types/container/hostconfig_unix.go:23-25`) -// — `!IsDefault() && !IsBridge() && !IsHost() && !IsNone() && !IsContainer()`. Omitting the -// `IsContainer()` exclusion would make `DockerNetworkCreateIfNotExists` -// (`internal/utils/docker.go:63`) run `docker network inspect`/`create` against a -// `container:<name|id>` mode, which isn't a network name at all — Go passes that mode straight -// through to the container's `NetworkMode` without ever touching the network subsystem. +// A "user-defined" network is any mode besides default/bridge/host/none/ +// container:<id>. Omitting the container exclusion would make network +// creation try to inspect/create a network named "container:<id>", which +// isn't a real network — that mode passes straight through to the +// container's own NetworkMode instead. export function isUserDefinedDockerNetwork(networkMode: string) { return ( networkMode.length > 0 && @@ -350,13 +318,10 @@ export const isDockerRunning = Effect.fnUntraced(function* () { }); /** - * Resolves the edge-runtime image TAG (fed verbatim into - * `edgeRuntimeImage`, `functions.shared.ts` — Go's `replaceImageTag` - * semantics, no `v` synthesis). `defaultVersion` is the - * `supabase/.temp/edge-runtime-version` pin when present, else the - * Dockerfile default tag (`resolveEdgeRuntimeVersionPin`); `deno_version = 1` - * overrides EITHER with Go's `deno1` image tag, matching `Config.Validate` - * running after the pin was applied (`pkg/config/config.go:847-849,1164-1169`). + * Resolves the edge-runtime image tag (fed verbatim into `edgeRuntimeImage`). + * `defaultVersion` is the `.temp/edge-runtime-version` pin when present, else + * the Dockerfile default tag; `deno_version = 1` overrides either with the + * deno-1 image tag. */ export function resolveEdgeRuntimeVersion( denoVersion: number | undefined, @@ -374,14 +339,11 @@ export function resolveEdgeRuntimeVersion( } /** - * Go: `DockerStart` -> `DockerResolveImageIfNotCached`/`DockerImagePullWithRetry` - * (`internal/utils/docker.go:304-348,366-370`) — checks every registry - * candidate (ECR/GHCR/Docker Hub) for a local cache hit first, then pulls - * with 2 retries per candidate (4s/8s backoff), returning whichever - * candidate answered. Shared by every `functions` Docker path - * (`deploy`/`download`/`serve`) — `getRegistryImageUrl`'s single-URL - * mapping is already called unconditionally by both today, so the retry is - * strictly-better resilience, not a Go-only quirk. + * Resolves a functions Docker image, checking every registry candidate + * (ECR/GHCR/Docker Hub) for a local cache hit first, then pulling with 2 + * retries per candidate (4s/8s backoff) and returning whichever candidate + * answered. Shared by every `functions` Docker path (`deploy`, `download`, + * `serve`). */ export const resolveFunctionsDockerImage = Effect.fnUntraced(function* ( image: string, diff --git a/apps/cli/src/shared/functions/functions-docker.unit.test.ts b/apps/cli/src/shared/functions/functions-docker.unit.test.ts index 486f9f0eb8..e1fce53741 100644 --- a/apps/cli/src/shared/functions/functions-docker.unit.test.ts +++ b/apps/cli/src/shared/functions/functions-docker.unit.test.ts @@ -16,11 +16,10 @@ import { /** * A `ChildProcessSpawner` layer whose handle emits exactly the given raw - * `Uint8Array` chunks on stdout/stderr — unlike the shared - * `mockChildProcessSpawner` (`apps/cli/tests/helpers/child-process-spawner.ts`), - * which encodes one full line per chunk, this lets a test place an arbitrary - * byte boundary mid-codepoint to exercise `collectByteStream`'s per-stream - * `TextDecoder` buffering. + * `Uint8Array` chunks on stdout/stderr. Unlike the shared + * `mockChildProcessSpawner`, which encodes one full line per chunk, this lets + * a test place an arbitrary byte boundary mid-codepoint to exercise + * `collectByteStream`'s per-stream `TextDecoder` buffering. */ function mockStreamingChildProcessLayer( opts: { @@ -58,12 +57,10 @@ describe("toDockerPath", () => { it.runIf(process.platform === "win32")( "strips the drive letter and flips separators for a Windows path", () => { - // The container path is constructed here and never re-parsed, so this is - // the single guard for supabase/cli#6035's Windows `--workdir` behavior: - // a drive-letter colon surviving into the container path would corrupt - // every `host:container:mode` bind built from it. `resolve()` only - // treats a drive-letter path as absolute on Windows, so the guard is - // exercisable only there. + // A drive-letter colon surviving into the container path would corrupt + // every `host:container:mode` bind built from it (supabase/cli#6035). + // `resolve()` only treats a drive-letter path as absolute on Windows, + // so this guard only runs there. const containerPath = toDockerPath("C:\\Users\\u\\p\\supabase\\functions"); expect(containerPath).toBe("/Users/u/p/supabase/functions"); expect(containerPath).not.toContain(":"); @@ -250,8 +247,6 @@ describe("containerArchiveBytes", () => { it("strips leading slashes into root-relative tar entries with the contractual 0644 mode", async () => { const archive = await containerArchiveBytes({ "/root/index.ts": "export const x = 1;\n" }); - // The 0644 mode is contractual — a Bun default change must fail here, not as a - // runtime permission error inside the container. expect(tarRegularFileEntries(archive)).toEqual([["root/index.ts", 0o644]]); const files = await new Bun.Archive(archive).files(); expect(await files.get("root/index.ts")?.text()).toBe("export const x = 1;\n"); @@ -280,9 +275,6 @@ describe("resolveDockerNetworkMode", () => { }); it("treats an explicit empty flag (--network-id=) as skipping straight to the generated default, not the env override", () => { - // Go parity: viper's Changed pflag wins over AutomaticEnv outright — an - // explicit `--network-id=` never falls back to SUPABASE_NETWORK_ID, only - // an OMITTED flag does. expect( resolveDockerNetworkMode({ explicit: "", @@ -319,11 +311,9 @@ describe("runChildProcess", () => { "tees a multi-byte UTF-8 character split across a chunk boundary, decoding it correctly in both the live tee and the accumulated stdout, and never tees an empty string", () => Effect.gen(function* () { - // "café"'s bytes are [c, a, f, 0xC3, 0xA9] — "é" is the 2-byte sequence - // 0xC3 0xA9. Chunk 1 ends right after the leading byte (incomplete on - // its own); chunk 2 is a genuinely empty chunk (decodes to "", must - // never be teed); chunk 3 carries only the trailing byte, completing - // "é" once joined with the decoder's buffered leading byte. + // "café" bytes: [c, a, f, 0xC3, 0xA9] ("é" = 0xC3 0xA9). Chunk 1 ends + // mid-codepoint; chunk 2 is empty (must never be teed); chunk 3 + // completes "é" once joined with the decoder's buffered byte. const full = new TextEncoder().encode("café"); const chunk1 = full.slice(0, 4); const chunk2 = new Uint8Array(0); @@ -337,7 +327,6 @@ describe("runChildProcess", () => { ); expect(result.stdout).toBe("café"); - // The teed chunks, concatenated, must equal the returned stdout exactly. expect(stdoutTee.join("")).toBe(result.stdout); expect(stdoutTee).not.toContain(""); expect(stdoutTee.every((chunk) => chunk.length > 0)).toBe(true); @@ -365,7 +354,6 @@ describe("runChildProcess", () => { expect(result.stderr).toBe("stderr-chunk"); expect(stdoutTee.join("")).toBe(result.stdout); expect(stderrTee.join("")).toBe(result.stderr); - // Neither stream's tee ever observes so much as a fragment of the other. expect(stdoutTee.some((chunk) => chunk.includes("stderr"))).toBe(false); expect(stderrTee.some((chunk) => chunk.includes("stdout"))).toBe(false); }), diff --git a/apps/cli/src/shared/functions/functions.shared.ts b/apps/cli/src/shared/functions/functions.shared.ts index 61e2dd6673..0c6ebabb3c 100644 --- a/apps/cli/src/shared/functions/functions.shared.ts +++ b/apps/cli/src/shared/functions/functions.shared.ts @@ -13,50 +13,24 @@ export function validateFunctionSlugMessage(slug: string): string | undefined { return functionSlugPattern.test(slug) ? undefined : invalidFunctionSlugDetail; } -// Go marks `--project-ref` telemetry-safe on `functionsListCmd`, `functionsDeleteCmd`, -// `functionsDeployCmd`, and `functionsDownloadCmd` -// (`cmd/functions.go:151,153,165,178`). export const FUNCTIONS_PROJECT_REF_SAFE_FLAGS = ["project-ref"] as const; -// Registration order matches Go's `functionsDeployCmd`/`functionsDownloadCmd` -// `MarkFlagsMutuallyExclusive("use-api", "use-docker", "legacy-bundle")` -// (`cmd/functions.go:158,182`). +// Order is rendered verbatim in the mutually-exclusive-flags error message. export const FUNCTIONS_BUNDLER_MUTEX_GROUP = ["use-api", "use-docker", "legacy-bundle"] as const; -// Go: `Images.EdgeRuntime` is baked into the binary via the embedded -// Dockerfile (`pkg/config/constants.go:40-58`; `edge-runtime-image.ts` -// reads the same source) — sourced from there rather than `@supabase/stack`'s -// independently-maintained catalog, so a Dockerfile pin bump can never drift -// from what the `functions` Docker paths resolve. -// Go's `deno1` image tag (`pkg/config/constants.go:15`, -// `supabase/edge-runtime:v1.68.4`) — a full tag, since tags flow verbatim -// into `edgeRuntimeImage` with no `v` synthesis. Shared with -// `functions-docker.ts`'s `resolveEdgeRuntimeVersion`, which selects it. +/** + * Full deno-1 edge-runtime image tag, resolved from the embedded Dockerfile + * pin (not `@supabase/stack`'s catalog) so it can't drift from what the + * `functions` Docker paths pull. Shared with `functions-docker.ts`'s + * `resolveEdgeRuntimeVersion`. + */ export const DENO1_EDGE_RUNTIME_VERSION = "v1.68.4"; /** - * Go: `replaceImageTag(Images.EdgeRuntime, tag)` (`pkg/config/utils.go:81-84`) - * — everything after the image's first `:` is replaced with `tag` VERBATIM, - * no `v` synthesis. A bare pin like `latest` or `9.9.9` therefore produces - * `supabase/edge-runtime:latest`/`:9.9.9`, exactly as Go does (an earlier - * revision `v`-prefixed bare pins here, which broke pins that work in Go and - * made this path disagree with `edge-runtime-image.ts`'s faithful - * `replaceImageTag` port reading the SAME pin file — review round on - * CLI-1963). Both non-pin sources are already full tags: the Dockerfile - * default above and `resolveEdgeRuntimeVersion`'s deno-1 constant. - * Single home for the repository too — only the tag half is parameterized, - * so a `supabase/edge-runtime` rename in the Dockerfile propagates whole. - * - * `deno_version = 1` is a locked docker.io-only exception (no slim build): - * the "tag" it selects is really a whole different image squeezed through - * this tag-shaped API, so it bypasses the (possibly slim-rewritten) default - * base entirely and returns the full docker.io ref. Flag-off this is - * byte-identical to the general path, since the default base is already - * docker.io then. The tag check deliberately also catches an explicit - * `.temp/edge-runtime-version` pin of this exact tag under the slim flag: - * no slim build of it exists either, so docker.io is the only resolvable - * image for that tag regardless of WHY it was selected — a separate - * deno_version signal would change nothing observable. + * Resolves the edge-runtime image for `tag`. The deno-1 pin always resolves + * to the full docker.io image (no slim build exists for it); every other tag + * is substituted verbatim into the (possibly slim-rewritten) default image, + * with no `v` prefix synthesized. */ export function edgeRuntimeImage(tag: string): string { if (tag === DENO1_EDGE_RUNTIME_VERSION) { @@ -66,12 +40,9 @@ export function edgeRuntimeImage(tag: string): string { } /** - * Go: `Config.EdgeRuntime.Image` reflects `supabase/.temp/edge-runtime-version` - * when present (`pkg/config/config.go:847-849`) — shared by every `functions` - * command that resolves a Docker edge-runtime image: `deploy`, `download`, - * and `serve`. - * Single home for the file-read rather than several copies of the same - * `readFile` -> `trim` -> fallback pipeline. + * Reads the `.temp/edge-runtime-version` pin file if present, falling back to + * the Dockerfile-embedded tag. Shared by every `functions` command that + * resolves a Docker edge-runtime image. */ export const resolveEdgeRuntimeVersionPin = Effect.fnUntraced(function* (supabaseDir: string) { return yield* Effect.tryPromise(() => diff --git a/apps/cli/src/shared/functions/serve-main-bundler.ts b/apps/cli/src/shared/functions/serve-main-bundler.ts index e6960ff28c..4572e7b206 100644 --- a/apps/cli/src/shared/functions/serve-main-bundler.ts +++ b/apps/cli/src/shared/functions/serve-main-bundler.ts @@ -9,16 +9,14 @@ import { build } from "esbuild"; const serveMainEntrypoint = fileURLToPath(new URL("./serve.main.ts", import.meta.url)); /** - * Bundle `serve.main.ts` into a single self-contained ES module string with all of - * its dependencies inlined. + * Bundles `serve.main.ts` into a single self-contained ES module with all + * dependencies inlined, so the runtime entrypoint needs no network access + * (the template previously resolved `deno.land/std`/`jsr:` imports over the + * network on every container start, breaking offline `functions serve` — + * supabase/supabase#45570). * - * The template used to import `deno.land/std` and `jsr:` modules that Deno resolved - * over the network on every container start, breaking `functions serve` offline - * (supabase/supabase#45570). Bundling inlines `jose` and the local `serve-main-deps` - * helpers so the runtime entrypoint needs no network access. - * - * `platform: "browser"` selects `jose`'s Web Crypto build, which runs under the - * edge-runtime's Deno. `Deno` and `EdgeRuntime` are left as free globals. + * `platform: "browser"` selects `jose`'s Web Crypto build for the + * edge-runtime's Deno; `Deno` and `EdgeRuntime` are left as free globals. */ export async function bundleServeMainTemplate(): Promise<string> { const result = await build({ diff --git a/apps/cli/src/shared/functions/serve-main-deps.ts b/apps/cli/src/shared/functions/serve-main-deps.ts index 6660ec1d42..0007e2c20e 100644 --- a/apps/cli/src/shared/functions/serve-main-deps.ts +++ b/apps/cli/src/shared/functions/serve-main-deps.ts @@ -1,11 +1,7 @@ /** * Runtime dependencies inlined into the edge-runtime bootstrap template - * (`serve.main.ts`). These replace the remote `deno.land/std` imports the template - * used to resolve over the network on every container start, which broke - * `functions serve` offline (supabase/supabase#45570). - * - * Kept as a normal, type-checked module so the path logic can be unit-tested; the - * template imports it relatively and the build inlines it via the bundler. + * (`serve.main.ts`), replacing `deno.land/std` imports that used to resolve + * over the network on every container start (supabase/supabase#45570). */ /** HTTP status codes used by the runtime template (subset of `deno.land/std/http/status.ts`). */ diff --git a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts index fd38945399..8868d39a90 100644 --- a/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts +++ b/apps/cli/src/shared/functions/serve-main-offline.e2e.test.ts @@ -12,16 +12,15 @@ import { dockerfileServiceImage } from "../services/dockerfile-images.ts"; import { bundleServeMainTemplate } from "./serve-main-bundler.ts"; /** - * Regression guard for supabase/supabase#45570: the edge-runtime worker bootstrap - * template must boot with **no network access**. Before bundling, the template - * imported `deno.land/std` and `jsr:` modules that Deno resolved over the network on - * every start, so `functions serve` failed offline. + * Regression guard for supabase/supabase#45570: the edge-runtime worker + * bootstrap template must boot with no network access. Before bundling, the + * template imported `deno.land/std` and `jsr:` modules resolved over the + * network on every start, so `functions serve` failed offline. * - * This boots the real bundled template as an edge-runtime main service with - * `--network none` and asserts it reaches the template's own "Serving functions" - * log line without any remote fetch. The service is mounted at `/app` (read-only) so - * `/root` stays writable for Deno's module cache — isolating the network as the only - * variable (a control run of the unbundled template fails here with a DNS error). + * Boots the real bundled template with `--network none` and asserts it + * reaches the "Serving functions" log line without any remote fetch. Mounted + * at `/app` (read-only) so `/root` stays writable for Deno's module cache — + * isolating the network as the only variable. */ function hasDocker(): boolean { @@ -35,9 +34,8 @@ function hasDocker(): boolean { const dockerAvailable = hasDocker(); const SERVE_OFFLINE_STARTUP_TIMEOUT_MS = 60_000; -// Cold-cache image resolution (up to one shared 90s resolveDeadline budget) -// runs inside the test body, ahead of the 60s startup wait — the test budget -// must cover both stacked, or a healthy near-cap pull trips vitest first. +// Cold-cache image resolution (up to a shared 90s budget) runs ahead of the +// 60s startup wait; the test timeout must cover both stacked. const SERVE_OFFLINE_TEST_TIMEOUT_MS = 180_000; const AUTH_FUNCTIONS_CONFIG = JSON.stringify({ test: { @@ -187,10 +185,8 @@ async function fetchColdFunction( } async function writeKongConfig(dir: string, edgeRuntimeContainer: string) { - // Was: read straight from apps/cli-go/internal/start/templates/kong.yml. That - // package was deleted outright (CLI-1966; unreachable from the TS CLI, directly - // or indirectly), so this now uses the TS transcription of the same template - // that `start`'s Kong service already ports byte-for-byte. + // Uses the TS transcription of the Kong template that `start`'s Kong + // service already ports byte-for-byte. const config = START_KONG_YML_TEMPLATE.replaceAll("{{ .EdgeRuntimeId }}", edgeRuntimeContainer) .replaceAll("{{ .BearerToken }}", "$((headers.authorization or headers.apikey))") .replaceAll("{{ .QueryToken }}", "$((query_params.apikey))") @@ -251,9 +247,7 @@ describe("functions serve runtime template (offline)", () => { await new Promise((resolve) => setTimeout(resolve, 250)); } - // The template's own onListen message — proves the bundled worker booted. expect(logs).toMatch(/Serving functions on/); - // No remote module resolution occurred (the #45570 failure mode). expect(logs).not.toMatch(/deno\.land|jsr\.io/); expect(logs).not.toMatch(/dns error|name resolution|worker boot error/i); } finally { diff --git a/apps/cli/src/shared/functions/serve.main.ts b/apps/cli/src/shared/functions/serve.main.ts index 09245bd470..2b70f91caa 100644 --- a/apps/cli/src/shared/functions/serve.main.ts +++ b/apps/cli/src/shared/functions/serve.main.ts @@ -122,11 +122,11 @@ const functionsConfig: Record<string, FunctionConfig> = (() => { } })(); -// Edge Runtime pools user workers by servicePath. Keep the source directory for the -// common case, but give each function a process-owned temporary path when multiple -// configured functions share that directory. Deno creates each path outside the set of -// existing source directories, so a real function directory cannot use the same pool key. -// maybeEntrypoint still points at the real source file, so module resolution is unchanged. +// Edge Runtime pools user workers by servicePath. Keep the source directory +// for the common case, but give each function a process-owned temporary +// path when multiple configured functions share that directory, since a +// real function directory can't be reused as a pool key. `maybeEntrypoint` +// still points at the real source file, so module resolution is unchanged. const workerServicePaths = (() => { const sourcePathCounts = new Map<string, number>(); for (const config of Object.values(functionsConfig)) { @@ -146,7 +146,6 @@ const workerServicePaths = (() => { ); })(); -/* --- JWT verification --- */ export function extractBearerToken(rawToken: string) { const tokenParts = rawToken.split(" "); const [bearer, token] = tokenParts; @@ -161,7 +160,7 @@ function getAuthToken(req: Request): string | AuthFailure { const authHeader = req.headers.get("authorization"); const sbApiKeyCompatibilityToken = req.headers.get("sb-api-key"); - // NOTE:(kallebysantos) Kong on CLI stack pass it down as 'Bearer Token' format + // Kong on the CLI stack passes this down as "Bearer Token" format. const cleanSbApiKeyCompatibilityToken = sbApiKeyCompatibilityToken?.replace("Bearer", "")?.trim(); if (!authHeader && !cleanSbApiKeyCompatibilityToken) { @@ -171,9 +170,8 @@ function getAuthToken(req: Request): string | AuthFailure { }; } - // NOTE:(kallebysantos) Compatibility mode is triggered when all conditions match: - // - API proxy mints a temp token - // - Original bearer is not present or is ApiKey + // Compatibility mode triggers when the API proxy mints a temp token and + // the original bearer is absent or an API key. const bearerToken = extractBearerToken(authHeader ?? ""); const token = !bearerToken || bearerToken.startsWith("sb_") ? cleanSbApiKeyCompatibilityToken : bearerToken; @@ -292,7 +290,6 @@ export function prepareUserRequest(req: Request): Request { clonedURL.hostname = forwardedHost ?? clonedURL.hostname; const clonedReq = new Request(clonedURL, req.clone()); - // remove custom api headers clonedReq.headers.delete("sb-api-key"); EdgeRuntime.applySupabaseTag(req, clonedReq); @@ -304,12 +301,10 @@ Deno.serve({ const url = new URL(req.url); const { pathname } = url; - // handle health checks if (pathname === "/_internal/health") { return getResponse({ message: "ok" }, STATUS_CODE.OK); } - // handle metrics if (pathname === "/_internal/metric") { const metric = await EdgeRuntime.getRuntimeMetrics(); return Response.json(metric); @@ -378,10 +373,7 @@ Deno.serve({ const cpuTimeSoftLimitMs = 1000; const cpuTimeHardLimitMs = 2000; - // NOTE(Nyannyacha): Decorator type has been set to tc39 by Lakshan's request, - // but in my opinion, we should probably expose this to customers at some - // point, as their migration process will not be easy. - // This need to be kept for Deno 1 compatibility. + // Kept as "tc39" for Deno 1 compatibility. const decoratorType = "tc39"; const absEntrypoint = join(Deno.cwd(), functionsConfig[functionName].entrypointPath); diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index dc29ef96aa..fd56755eaf 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -96,8 +96,8 @@ const defaultCliConfig = decodeCliConfig({}); const dockerRuntimeServerPort = 8081; const dockerRuntimeInspectorPort = 8083; -// Unix timestamp (~2032-11-30) used as the `exp` claim of the local-dev default -// JWTs, matching the Go CLI's hardcoded expiry for anon/service_role tokens. +// Unix timestamp (~2032-11-30) used as the `exp` claim of the local-dev +// default JWTs (anon/service_role tokens). const defaultJwtExpiry = 1983812996; const defaultSigningKey = { kty: "EC", @@ -165,10 +165,9 @@ export interface FunctionsServeDependencies { readonly projectIdOverride: Option.Option<string>; readonly goViperCompat: boolean; /** - * `undefined` for library callers; the CLI injects - * `functionsGoConfigCompat` so this file never imports the command tree - * directly — see {@link FunctionsGoConfigCompat}. Distinct from - * `goViperCompat` above, which only gates `env(...)` interpolation. + * `undefined` for library callers; the CLI injects this so this file + * never imports the command tree directly — see {@link FunctionsGoConfigCompat}. + * Distinct from `goViperCompat` above, which only gates `env(...)` interpolation. */ readonly goConfigCompat: FunctionsGoConfigCompat | undefined; } @@ -200,7 +199,7 @@ interface ServeResolvedConfig { readonly configFunctions: Readonly<Record<string, ManifestFunctionConfig>>; readonly rawConfigFunctions: Readonly<Record<string, Readonly<Record<string, unknown>>>>; readonly configPath?: string; - /** Go's post-`loadNestedEnv` merged env (ambient-wins). `undefined` for library callers. */ + /** Merged env with ambient values winning; `undefined` for library callers. */ readonly projectEnvValues: Readonly<Record<string, string>> | undefined; } @@ -225,15 +224,11 @@ export interface StartedRuntime { } /** - * Every already-resolved secret/key {@link startEdgeRuntimeContainer} needs, - * matching {@link finalizeAuthArtifacts}'s return shape. Named and exported so - * a caller outside this module (`start`'s own edge-runtime bring-up, - * `commands/start/services/edge-runtime.service.ts`) can build the - * exact same shape from values it has already resolved itself, instead of - * calling {@link resolveLocalAuthArtifacts} (which re-reads `config.toml`/signing - * keys independently — correct for the standalone `functions serve` command, - * but would risk resolving different secrets than the rest of a `start` - * stack for a caller that already has these values). + * Every already-resolved secret/key {@link startEdgeRuntimeContainer} needs. + * Exported so a caller outside this module (`start`'s own edge-runtime + * bring-up) can build this from values it already resolved, instead of + * {@link resolveLocalAuthArtifacts} re-reading `config.toml`/signing keys + * independently and risking different secrets than the rest of that stack. */ export interface ServeAuthArtifacts { readonly publishableKey: string; @@ -246,14 +241,10 @@ export interface ServeAuthArtifacts { /** * Everything {@link startEdgeRuntimeContainer} needs from `config.toml` - * beyond auth (see {@link ServeAuthArtifacts}) — a narrowed, exported view of - * {@link ServeResolvedConfig} (which also carries `auth`/`configPath`, used - * only to resolve {@link ServeAuthArtifacts} and therefore irrelevant once - * those are already resolved). `start`'s own bring-up builds this directly - * from its own already-loaded `CliConfig` via {@link toPlainEdgeRuntimeConfig}/ - * {@link toPlainFunctionRecord}/`inferFunctionsManifest` (`@supabase/config`) - * rather than going through {@link resolveServeConfig}'s independent - * config-loading pipeline. + * beyond auth (see {@link ServeAuthArtifacts}) — a narrowed view of + * {@link ServeResolvedConfig}. `start`'s own bring-up builds this directly + * from its own already-loaded `CliConfig` rather than going through + * {@link resolveServeConfig}'s independent config-loading pipeline. */ export interface ServeEdgeRuntimeContainerConfig { readonly projectId: string; @@ -269,25 +260,20 @@ export interface ServeEdgeRuntimeContainerConfig { /** * Input to {@link startEdgeRuntimeContainer} — the reusable "bring up one * Edge Runtime container" core extracted from `serveFunctions`'s interactive - * loop, kept independent of BOTH `functions serve`'s own config-loading - * (`resolveServeConfig`/`resolveLocalAuthArtifacts`) and its file-watch/log-stream - * loop, so `start`'s bring-up can call it directly with values it has already - * resolved through its own pipeline (see `internal-db-connection.ts` for why - * {@link dbUrl} specifically must be caller-supplied rather than hardcoded). + * loop, kept independent of both `functions serve`'s own config-loading and + * its file-watch/log-stream loop, so `start`'s bring-up can call it directly + * with values already resolved through its own pipeline. */ export interface StartEdgeRuntimeContainerInput { readonly onContainerCreated?: () => void; readonly config: ServeEdgeRuntimeContainerConfig; readonly authArtifacts: ServeAuthArtifacts; /** - * `SUPABASE_DB_URL`. Deliberately NOT hardcoded in the shared core: Go's own - * two callers resolve this differently — standalone `functions serve` - * (`restartEdgeRuntime`, `serve.go:122`) always uses the `db` network alias - * (`postgresql://postgres:postgres@db:5432/postgres`, matching - * {@link defaultServeDbUrl} below), while `start`'s direct call - * (`start.go:66-72,1103`) uses the real `dbConfig` — the `db` container's - * own sanitized name and `config.db.password` — NOT the alias. Every caller - * must supply its own Go-accurate value; this module does not choose one. + * `SUPABASE_DB_URL`. Not hardcoded in the shared core: standalone + * `functions serve` always uses the `db` network alias (matching + * {@link defaultServeDbUrl} below), while `start`'s direct call uses the + * `db` container's own sanitized name and `config.db.password` instead. + * Every caller must supply its own value; this module does not choose one. */ readonly dbUrl: string; /** Already-resolved edge-runtime image reference (registry-mapped, tag/deno-version already applied). */ @@ -335,11 +321,9 @@ export const serveFileWatcherLayer = Layer.sync(FileWatcher, () => filename === null || filename === undefined || filename.length === 0 ? root : resolve(root, filename.toString()); - // Node's `fs.watch` only distinguishes "rename" (create/delete/ - // rename) from "change" (write); Go prints the real fsnotify op - // (`internal/functions/serve/watcher.go:100`). The closest - // recoverable equivalent is an existence check on "rename" - // events: present → create, gone → delete; "change" → update. + // `fs.watch` only distinguishes "rename" (create/delete/rename) + // from "change" (write); an existence check on "rename" + // disambiguates create vs delete, "change" always means update. const type: FileWatchEvent["type"] = eventType === "rename" ? (existsSync(pathname) ? "create" : "delete") : "update"; Queue.offerUnsafe(queue, [{ path: pathname, type }]); @@ -360,13 +344,13 @@ export const serveFileWatcherLayer = Layer.sync(FileWatcher, () => /** * `serve.main.ts` runs verbatim as a Deno entrypoint inside the edge-runtime - * container (written to `/root/index.ts`). It is bundled into a single - * self-contained module so its `jose` and local helper dependencies are inlined and - * the runtime needs no network access on start (supabase/supabase#45570). + * container (written to `/root/index.ts`), bundled into a single + * self-contained module so its `jose` and local helper dependencies are + * inlined and the runtime needs no network access on start. * * Compiled builds embed the pre-bundled template via the - * `SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE` define (see `scripts/build.ts`), so the - * shipped binary never bundles at runtime. Running from source (`bun src/supabase.ts`) + * `SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE` define (see `scripts/build.ts`), + * so the shipped binary never bundles at runtime. Running from source * bundles on demand. */ function getFunctionsServeMainTemplate(): Promise<string> { @@ -377,9 +361,9 @@ function getFunctionsServeMainTemplate(): Promise<string> { cachedFunctionsServeMainTemplate = SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE; return Promise.resolve(cachedFunctionsServeMainTemplate); } - // Running from source: the build-time define is absent, so bundle on demand. The - // bundler (and its esbuild dependency) is imported lazily and only here, so it is - // never loaded by shipped binaries — which always take the define branch above. + // Bundler (and its esbuild dependency) is imported lazily and only here, + // so it's never loaded by shipped binaries, which always take the define + // branch above. return import("./serve-main-bundler.ts") .then(({ bundleServeMainTemplate }) => bundleServeMainTemplate()) .then((bundled) => { @@ -434,11 +418,9 @@ function toPlainAuthConfig( } /** - * Exported so `start`'s own edge-runtime bring-up - * (`commands/start/services/edge-runtime.service.ts`) can reuse this - * exact `Redacted`-unwrapping/zero-hash-filtering logic against its own, - * already-loaded `CliConfig` instead of duplicating it — see - * {@link ServeEdgeRuntimeContainerConfig}'s doc comment. + * Exported so `start`'s own edge-runtime bring-up can reuse this exact + * `Redacted`-unwrapping/zero-hash-filtering logic against its own, + * already-loaded `CliConfig` instead of duplicating it. */ export function toPlainEdgeRuntimeConfig( edgeRuntime: CliConfig["edge_runtime"] | ResolvedCliConfigValue<CliConfig["edge_runtime"]>, @@ -447,19 +429,11 @@ export function toPlainEdgeRuntimeConfig( policy: reveal(edgeRuntime.policy) ?? "", inspector_port: edgeRuntime.inspector_port, deno_version: edgeRuntime.deno_version, - // Go's config loader rewrites every `[edge_runtime.secrets]` key with - // `strings.ToUpper` (`pkg/config/config.go:766-771`, the viper #1014 - // workaround) before `set.ListSecrets` - // (`internal/secrets/set/set.go:48-52`) reads the map, so secret names - // always reach the container env UPPERCASED regardless of authored - // casing. ListSecrets then keeps only entries with a non-empty SHA256: - // `DecryptSecretHookFunc` (`pkg/config/secret.go:94-107`) leaves the - // SHA256 empty exactly when the value is empty or a still-unresolved - // `env(VAR)` literal. In the TS pipeline `resolveCliConfigSubtree` wraps - // resolved secret leaves in `Redacted` and leaves unresolved `env()` - // literals as plain strings, so `Redacted.isRedacted` + non-empty mirrors - // both zero-hash cases — the same guard `secrets set` uses - // (`commands/secrets/set/set.handler.ts`). + // Secret names always reach the container env uppercased regardless of + // authored casing. Only resolved (non-empty) values are kept — an + // unresolved `env(VAR)` literal stays a plain string, not `Redacted`, so + // `Redacted.isRedacted` + non-empty filters it out, the same guard + // `secrets set` uses. secrets: Object.fromEntries( Object.entries(edgeRuntime.secrets ?? {}).flatMap(([name, value]) => Redacted.isRedacted(value) && Redacted.value(value).length > 0 @@ -570,20 +544,16 @@ interface ServeLocalAuthArtifacts { readonly serviceRoleKey: string; /** Third-party issuer to fetch remote JWKS from, if one is configured. */ readonly issuerUrl: string | undefined; - /** Local JWKS entries (signing keys / oct fallback), appended AFTER any remote keys (`config.go:1776-1786`). */ + /** Local JWKS entries (signing keys / oct fallback), appended after any remote keys. */ readonly localKeys: ReadonlyArray<unknown>; } /** - * Config-load-time auth resolution — exactly the work Go performs during - * `flags.LoadConfig`/`Config.Validate`, BEFORE `AssertSupabaseDbIsRunning`: - * the signing-keys read (`pkg/config/config.go:1110-1115`), the - * `auth.jwt_secret` ≥16-chars check (`pkg/config/apikeys.go:43-47` via - * `config.go:1156`), and anon/service-role key generation. Deliberately does - * NOT fetch remote JWKS: Go only does that inside `ServeFunctions` - * (`serve.go:141` → `ResolveJWKS`, `config.go:1727-1776`), after the DB - * assertion — that half lives in {@link finalizeAuthArtifacts} so a config - * error here still beats a docker-down error, matching Go's precedence. + * Config-load-time auth resolution: signing-keys read, the `auth.jwt_secret` + * ≥16-chars check, and anon/service-role key generation. Does not fetch + * remote JWKS — that half lives in {@link finalizeAuthArtifacts}, run after + * the DB assertion, so a config error here still surfaces before a + * docker-down error. */ const resolveLocalAuthArtifacts = Effect.fnUntraced(function* ( auth: PlainServeAuthConfig, @@ -635,12 +605,9 @@ const resolveLocalAuthArtifacts = Effect.fnUntraced(function* ( : auth.service_role_key; const shouldUseJwtSecretFallback = signingKeysPath.length === 0; - // Go's `Auth.ThirdParty.validate()` (the "at most one enabled" + required-field checks - // `resolveThirdPartyIssuerUrl` performs) only runs inside `Config.Validate`'s `if - // c.Auth.Enabled` block (`config.go:1087-1153`), but `functions serve`'s own JWKS resolution - // (`serve.go:141`) discards `ResolveJWKS`'s error unconditionally, regardless of `auth.enabled`. - // So a malformed/multi-enabled third-party config must not throw here when auth is disabled — - // use the unchecked, no-throw `IssuerURL()`-only builder instead, matching Go exactly. + // A malformed/multi-enabled third-party config must not throw when auth is + // disabled, so this uses the unchecked, no-throw issuer-URL-only builder + // instead of the validating one in that case. const issuerUrl = auth.enabled ? resolveThirdPartyIssuerUrl(auth.third_party) : thirdPartyIssuerUrlUnchecked(auth.third_party); @@ -677,16 +644,11 @@ const resolveLocalAuthArtifacts = Effect.fnUntraced(function* ( }); /** - * The post-assertion half of `functions serve`'s auth resolution — Go's - * `ResolveJWKS` call inside `ServeFunctions` (`serve.go:141`, - * `pkg/config/config.go:1727-1786`): fetch the third-party provider's remote - * JWKS (two sequential OIDC/JWKS requests with 10s-timeout clients, - * `config.go:1727-1776`) with the fetch error discarded (`jwks, _ :=`), then - * assemble the final key set with remote keys FIRST and local keys after - * (`config.go:1776-1786`). Kept separate from + * The post-assertion half of auth resolution: fetches the third-party + * provider's remote JWKS (error discarded on failure) and assembles the + * final key set with remote keys first, then local keys. Kept separate from * {@link resolveLocalAuthArtifacts} so `startEdgeRuntime` can run it strictly - * after `assertLocalDbRunning`, matching Go's ordering — with Docker down, no - * external JWKS request is ever made. + * after the DB assertion — with Docker down, no external JWKS request is made. */ const finalizeAuthArtifacts = Effect.fnUntraced(function* (local: ServeLocalAuthArtifacts) { const keys: unknown[] = []; @@ -716,12 +678,9 @@ const resolveServeConfig = Effect.fnUntraced(function* ( goViperCompat: boolean, goConfigCompat: FunctionsGoConfigCompat | undefined, ) { - // This single value is what keeps the `.env` discovery, the config load, - // and the functions-manifest inference from ever resolving three - // different roots: `goConfigCompat === undefined` (library path) - // keeps the package-default ancestor search; the CLI's - // `search: false` below must match `loadFunctionsCliConfig`'s own options - // exactly (see the config-load comment further down). + // Keeps `.env` discovery, config load, and functions-manifest inference + // from resolving three different roots: the CLI's `search: false` must + // match `loadFunctionsCliConfig`'s own options exactly (see below). const searchAncestors = goConfigCompat === undefined; const projectEnv = yield* loadServeCliProjectEnvironment(projectRoot, { search: searchAncestors, @@ -733,21 +692,15 @@ const resolveServeConfig = Effect.fnUntraced(function* ( return normalized.length > 0 ? normalized : undefined; }, }); - // `loadCliConfig` interpolates `env()` references against the project - // environment. We resolve that environment ourselves (Go-accurate, layering - // `.env.<SUPABASE_ENV>`/`.env.local`/`.env` over the ambient env) and pass it - // in, so loading neither re-reads those files nor mutates `process.env`. + // We resolve the project environment ourselves (layering + // `.env.<SUPABASE_ENV>`/`.env.local`/`.env` over the ambient env) and pass + // it in, so `loadCliConfig`'s `env()` interpolation neither re-reads those + // files nor mutates `process.env`. // - // `search: searchAncestors` (`false`)/`tomlOnly: true` when `goConfigCompat` - // is set (CLI): this MUST match `loadFunctionsCliConfig`'s own - // options below exactly, or the two loads can resolve two different files - // (an ancestor's config.toml vs this dir's; a stray config.json vs - // config.toml) — one supplying `auth`/`edgeRuntime`/`apiPort` here, the - // other supplying `denoVersion`/`Config.Validate` below, silently mixing - // fields from two different projects. Library callers (`goConfigCompat === undefined`) - // keep the package defaults (ancestor search, JSON preferred), unchanged — - // `search: searchAncestors` is `search: true` there, identical to the - // previously-absent default. + // `search`/`tomlOnly` here must match `loadFunctionsCliConfig`'s own + // options below exactly, or the two loads can resolve two different files, + // silently mixing fields from two different projects. Library callers + // (`goConfigCompat === undefined`) keep the package defaults unchanged. const loadedConfig = yield* loadCliConfig(projectRoot, { ...(projectRef === undefined ? {} : { projectRef }), ...(projectEnv === null ? {} : { cliProjectEnv: projectEnv }), @@ -803,37 +756,12 @@ const resolveServeConfig = Effect.fnUntraced(function* ( const rawProjectId = Option.getOrElse(projectIdOverride, () => configProjectId).trim(); const fallbackProjectId = basename(resolve(projectRoot)); - // Go: `flags.LoadConfig` -> `Config.Validate` (`pkg/config/config.go:878,989-1192`) - // — `restartEdgeRuntime` runs this FIRST, before `AssertSupabaseDbIsRunning` - // (see this function's own caller for that ordering) — so an invalid - // config must fail here too, before any Docker check. CLI only; - // library callers keep the package-default config resolution above unchanged. - // A second, independent config/dotenv load (rather than reusing this - // function's own `loadedConfig`/`projectEnv` above) — that pipeline's - // `env(...)`-interpolation purpose is unrelated to Go's `SUPABASE_*` - // `AutomaticEnv` override system this one provides, and the two shouldn't - // be entangled for a shipped, long-running command's config path. - // `search`/`tomlOnly` are aligned with this file's own `loadedConfig` call - // above (see its comment) so the two loads can never disagree about which - // file is "the" project config. `projectEnvValues` (for registry/network-id - // env lookups, this file's own caller) and the env-overridden - // `deno_version` are consumed from it; `auth`/`apiPort`/functions above - // keep their existing derivation. `projectId` also keeps its existing - // derivation — a known gap, narrow to trigger but NOT cosmetic when hit: - // unlike `deploy`/`download` (which use `context.projectId` outright), - // `rawProjectId` below only ever sees `SUPABASE_PROJECT_ID` from the - // *ambient* shell (`projectIdOverride`, from `CommandSettings`), not from - // project dotenv. A project that sets it only in `supabase/.env` therefore - // gets a different `supabase_edge_runtime_<id>`/`supabase_network_<id>` - // here than `deploy`/`download`/`start` resolve for the SAME project — so - // `serve` creates a second network and a container `reloadKong(projectId)`'s - // Kong (named off the other id) can't route to: a silently non-functional - // `serve`, where Go reads one `Config.ProjectId` for everything. Folding - // `goContext.projectEnvValues` in here would also require reconciling this - // function's `projectIdOverride`-wins-unconditionally precedence with - // `resolveLocalProjectId`'s config-file-wins-over-`projectRef` - // precedence (they're not the same order) — left open rather than risking - // that regression under time pressure (review round on CLI-1963). + // A second, independent config/dotenv load, run before any Docker check so + // an invalid config fails here too; its `search`/`tomlOnly` must match the + // `loadedConfig` call above or the two loads can pick different files. + // Known gap: `projectId` only sees ambient-shell `SUPABASE_PROJECT_ID`, not + // project dotenv, so a project setting it only in `.env` gets a different + // Docker network than `deploy`/`download`/`start` — a silently broken `serve`. const goContext = goConfigCompat === undefined ? undefined @@ -965,9 +893,8 @@ function toFunctionContainerConfig( }; return { - // The Go serve path defaults verifyJWT to true when verify_jwt is not set in - // config.toml (serve.go: `verifyJWT := true; if fc.VerifyJWT != nil { ... }`), - // unlike deploy which omits it. Mirror that default here. + // Defaults to `true` when `verify_jwt` is unset, unlike `deploy` which + // omits it. verifyJWT: config.verifyJwt ?? true, entrypointPath: toContainerPath(config.entrypoint), ...(config.importMap.length === 0 ? {} : { importMapPath: toContainerPath(config.importMap) }), @@ -1019,10 +946,9 @@ async function writeDockerMultilineEnvScript( containerDir: string, dir: string, ) { - // Self-healing — see the matching comment in `writeDockerEnvFile` above. - // Runs unconditionally, before the `env.length === 0` check, so a stale - // directory left by an earlier invocation that DID need multiline secrets - // is still reclaimed even when the current invocation doesn't. + // Self-healing — see the matching comment in `writeDockerEnvFile`. Runs + // unconditionally, before the length check, so a stale directory from an + // earlier invocation that needed multiline secrets is still reclaimed. await rm(dir, { recursive: true, force: true }); if (env.length === 0) { @@ -1258,10 +1184,9 @@ function eventMatchesSpec(spec: WatchSpec, event: FileWatchEvent) { } /** - * fsnotify op tokens as Go prints them in the file-change line - * (`event.Op.String()`, `internal/functions/serve/watcher.go:100`). RENAME and - * CHMOD are unreachable here: Node's `fs.watch` folds renames into - * create/delete pairs and does not report metadata-only changes. + * File-change op tokens for the established `File change detected: <path> + * (<OP>)` line. RENAME and CHMOD are unreachable here: `fs.watch` folds + * renames into create/delete pairs and doesn't report metadata-only changes. */ const goFileEventOp = { create: "CREATE", update: "WRITE", delete: "REMOVE" } as const; @@ -1413,11 +1338,8 @@ const streamContainerLogs = Effect.fnUntraced(function* (containerId: string) { const assertLocalDbRunning = Effect.fnUntraced(function* (projectId: string) { const dbId = localDockerId("db", projectId); // A spawn failure (neither `docker` nor `podman` on PATH) must keep its - // cause: it is the shell-out equivalent of Go's missing daemon socket, which - // `client.IsErrConnectionFailed` classifies as a connection failure and so - // gets the Docker Desktop install hint (`internal/utils/misc.go:155-166`). - // Blanking stderr here would demote it to a bare "failed to inspect - // service" with no guidance. + // cause: blanking stderr here would demote it to a bare "failed to inspect + // service" with no install guidance. const result = yield* runChildProcess("docker", ["container", "inspect", dbId], { stdout: "ignore", stderr: "pipe", @@ -1439,11 +1361,9 @@ const assertLocalDbRunning = Effect.fnUntraced(function* (projectId: string) { result.stderr.trim().length > 0 ? `failed to inspect service: ${result.stderr.trim()}` : "failed to inspect service"; - // Go's `AssertServiceIsRunning` sets `CmdSuggestion = suggestDockerInstall` - // on a daemon-connection failure (`internal/utils/misc.go:155-166`), which - // `recoverAndExit` prints on its own stderr line after the red error - // (`cmd/root.go:300-303`) — mirrored here by the `suggestion` property that - // `normalizeCliError`/`Output.fail` render the same way. + // Mirrored here by the `suggestion` property, which + // `normalizeCliError`/`Output.fail` render on their own stderr line after + // the red error. return yield* Effect.fail( isDockerDaemonUnreachable(result.stderr) ? Object.assign(new Error(message), { suggestion: SUGGEST_DOCKER_INSTALL }) @@ -1458,9 +1378,8 @@ const bestEffortRemoveContainer = Effect.fnUntraced(function* (containerId: stri }).pipe(Effect.ignore); }); -// One step of Edge Runtime's create → cp → start bring-up. Only the cp step passes a -// `messagePrefix` — its raw stderr is uninterpretable alone — while create/start keep the -// `docker run -d` era stderr surface byte-identical. +// One step of Edge Runtime's create → cp → start bring-up. Only the cp step +// passes a `messagePrefix`, since its raw stderr is uninterpretable alone. const runEdgeRuntimeDockerStep = Effect.fnUntraced(function* ( args: ReadonlyArray<string>, opts: { readonly messagePrefix?: string; readonly stdin?: Stream.Stream<Uint8Array> } = {}, @@ -1485,14 +1404,9 @@ const runEdgeRuntimeDockerStep = Effect.fnUntraced(function* ( const reloadKong = Effect.fnUntraced(function* (projectId: string) { const output = yield* Output; const kongId = localDockerId("kong", projectId); - // Reload re-renders nginx.conf from Kong's default template, so it needs the - // template bring-up wrote (`kong.service.ts`; formerly Go's - // `start.go:589-592`, deleted as unreachable in CLI-1966, last present at - // commit a253ccba2) handed back — otherwise it drops that template's - // `email_templates` server (#6059). Go's own `restartEdgeRuntime` - // (`internal/functions/serve/serve.go:129`) passes the same flag for the - // same reason — an earlier revision of this file dropped it believing it was - // start-only (#5976), which #6065 proved wrong. + // Needs the `--nginx-conf` flag pointing at the custom template + // `kong.service.ts` wrote, or reload re-renders from Kong's default + // template and drops the `email_templates` server (supabase/cli#6059). const result = yield* runChildProcess( "docker", ["exec", kongId, "kong", "reload", "--nginx-conf", "/home/kong/custom_nginx.template"], @@ -1546,24 +1460,17 @@ const resolveServeFunctionConfigs = Effect.fnUntraced(function* ( /** * Docker bind mounts (function source, import map, static assets) for every - * enabled function under `supabase/functions/**` — Go's - * `serve.PopulatePerFunctionConfigs` (`internal/functions/serve/serve.go: - * 277-318`), called both from Edge Runtime bring-up below (as part of its - * own loop) and, standalone, from `start`'s Studio container spec - * (formerly `internal/start/start.go:1149-1159`, deleted as unreachable in - * CLI-1966; last present at commit a253ccba2), which needs only the bind mounts, - * unconditionally of whether Edge Runtime itself is enabled or excluded. - * `PopulatePerFunctionConfigs` logs `Skipped serving Function: <slug>` - * unconditionally for every disabled function, regardless of which of its - * two callers invoked it — so this shared helper reproduces that logging - * too. Note this means Go (and this port) genuinely double-prints the - * message when both Edge Runtime and Studio are enabled, since both call - * sites fire; don't dedupe it, that would itself diverge from Go. + * enabled function under `supabase/functions/**`, called both from Edge + * Runtime bring-up below and, standalone, from `start`'s Studio container + * spec, which needs only the bind mounts. * - * The returned set is deliberately NOT run through - * `pruneRedundantDockerBinds`: Studio's bring-up never `docker cp`s into its - * created container, and overlapping binds are harmless to plain - * create+start — pruning is intentionally limited to Edge Runtime's cp path. + * Logs `Skipped serving Function: <slug>` unconditionally for every disabled + * function, so the message double-prints when both Edge Runtime and Studio + * are enabled — established behavior, not a bug to dedupe. + * + * The returned set is not run through `pruneRedundantDockerBinds`: Studio's + * bring-up never `docker cp`s into its container, and pruning is limited to + * Edge Runtime's cp path. */ export const resolveFunctionBindMounts = Effect.fn("functions.resolveFunctionBindMounts")( function* ( @@ -1624,25 +1531,16 @@ export const resolveFunctionBindMounts = Effect.fn("functions.resolveFunctionBin ); /** - * The reusable "bring up one Edge Runtime container" core — Go's - * `ServeFunctions` (`internal/functions/serve/serve.go:135-252`), called both - * by standalone `functions serve` (indirectly, via `startEdgeRuntime` below, - * mirroring Go's `restartEdgeRuntime` wrapper) and directly by `start`'s own - * bring-up (formerly `internal/start/start.go:1101-1108`, no wrapper step in - * between; `internal/start` was deleted as unreachable in CLI-1966, last - * present at commit a253ccba2). - * Deliberately excludes everything `ServeFunctions` itself excludes too: no - * config-loading (caller resolves {@link StartEdgeRuntimeContainerInput.config}/ - * {@link StartEdgeRuntimeContainerInput.authArtifacts} itself, matching how - * Go's two callers each resolve `config.toml`/secrets once, independently, and - * pass already-resolved values/strings into this shared core — see - * `serve.go:141-151` vs. `start.go:66-72`), no file-watching, and no log - * streaming (`serveFunctions`'s own loop, below, still owns both of those for - * the standalone command). Also excludes the Kong reload: `ServeFunctions` - * itself never reloads Kong — that only happens in `restartEdgeRuntime` - * (`startEdgeRuntime` below), after this core succeeds, so `start`'s own - * bring-up (which calls this core directly) correctly never reloads Kong - * either. + * The reusable "bring up one Edge Runtime container" core, called both by + * standalone `functions serve` (via `startEdgeRuntime` below) and directly + * by `start`'s own bring-up. + * + * Deliberately excludes config-loading (the caller resolves + * {@link StartEdgeRuntimeContainerInput.config}/`authArtifacts` itself and + * passes in already-resolved values), file-watching, and log streaming + * (`serveFunctions`'s own loop still owns those for the standalone command). + * Also excludes the Kong reload, which only happens in `startEdgeRuntime` + * below, after this core succeeds. */ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeContainer")( function* (input: StartEdgeRuntimeContainerInput) { @@ -1650,20 +1548,14 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo const projectId = input.config.projectId; const containerId = localDockerId("edge_runtime", projectId); const networkMode = input.networkId; - // Deterministic, persistent host path (the same `<workdir>/supabase/.temp/start-secrets/` - // convention `start`'s own container-lifecycle bring-up used to stage Kong/Postgres/ - // Supavisor's `secretFiles` on host disk before they moved to `docker cp` delivery — - // see `copyStartSecretFilesIntoContainer`'s doc comment, `container-lifecycle.ts`) - // rather than `os.tmpdir()`: `cleanupStartSecrets` (wired into both `stop` and a - // failed-`start` rollback) reclaims this same `<workdir>/supabase/.temp/start-secrets/ - // <containerId>` tree keyed by container name, so these JWT/service-role-key/secret env - // artifacts no longer leak on host disk indefinitely after the container is torn down. + // Deterministic, persistent host path (not `os.tmpdir()`): `cleanupStartSecrets` + // (wired into both `stop` and a failed-`start` rollback) reclaims this + // same tree keyed by container name, so these secret env artifacts don't + // leak on host disk indefinitely after the container is torn down. const stagingDir = join(input.projectRoot, "supabase", ".temp", "start-secrets", containerId); - // A single directory-wide `rm` rather than per-file `.cleanup()` closures (the JWT - // secrets/env file and the multiline-env script both live under `stagingDir`): this is - // what lets the cleanup cover the whole staging-write window below, including a mid-write - // failure between the first and second `writeDocker*` call, not just the final docker - // create/cp/start steps. + // A single directory-wide `rm` (not per-file cleanup closures) covers the + // whole staging-write window below, including a mid-write failure + // between two `writeDocker*` calls, not just the final docker steps. const removeRuntimeArtifacts = Effect.tryPromise({ try: () => rm(stagingDir, { recursive: true, force: true }), catch: (cause) => (cause instanceof Error ? cause : new Error(String(cause))), @@ -1740,11 +1632,10 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo } const aggregatedBinds = [...functionBinds.values()]; - // Pruned so the `docker cp` bootstrap below never sees a file bind nested - // inside a read-only parent bind. The workdir gate below reads the - // UNPRUNED aggregate on purpose — a pruned bind's container path still - // exists through its covering parent, so do not collapse the gate onto - // `binds`. + // Pruned so the `docker cp` bootstrap below never sees a file bind + // nested inside a read-only parent bind. The workdir gate below reads + // the unpruned aggregate on purpose — a pruned bind's container path + // still exists through its covering parent. const binds = pruneRedundantDockerBinds(aggregatedBinds); yield* ensureDockerNamedVolume(edgeRuntimeCacheVolume(projectId).name, projectId); @@ -1775,10 +1666,9 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo const dockerEnv = Object.fromEntries(env.map(splitEnvEntry)); const { singleLine: singleLineDockerEnv, multiline: multilineDockerEnv } = partitionDockerEnvEntries(dockerEnv); - // Everything from here on writes into `stagingDir` (or starts the container that reads from - // it), so the whole window — including a mid-write failure between two `writeDocker*` calls, - // not just the final docker create/cp/start steps — is wrapped in `Effect.onError` below. - // Container removal on failure stays with the callers, matching `docker run -d` behavior. + // Wrapped in `Effect.onError` below so the whole staging-write window is + // covered, not just the final docker steps. Container removal on failure + // stays with the callers, matching `docker run -d` behavior. return yield* Effect.gen(function* () { yield* Effect.try({ try: () => validateDockerMultilineEnvNames(multilineDockerEnv), @@ -1879,27 +1769,19 @@ export const startEdgeRuntimeContainer = Effect.fn("functions.startEdgeRuntimeCo ); /** - * `SUPABASE_DB_URL`'s value for standalone `functions serve` — Go's - * `restartEdgeRuntime` (`internal/functions/serve/serve.go:121-122`): "Use - * network alias because Deno cannot resolve `_` in hostname", always - * `postgresql://postgres:postgres@db:5432/postgres` regardless of - * project/config (`db`, `utils.DbAliases[0]`, is a fixed network alias, and - * `db.Password` is `toml:"-"` — never configurable, always the `"postgres"` - * literal default, `pkg/config/config.go:459`). This is genuinely NOT the - * same value `start`'s own bring-up uses — see - * {@link StartEdgeRuntimeContainerInput.dbUrl}'s doc comment. + * `SUPABASE_DB_URL` for standalone `functions serve`: always the `db` + * network alias with the fixed default password, since Deno can't resolve + * `_` in a container name. Not the same value `start`'s own bring-up uses — + * see {@link StartEdgeRuntimeContainerInput.dbUrl}'s doc comment. */ const defaultServeDbUrl = "postgresql://postgres:postgres@db:5432/postgres"; /** - * Go's `restartEdgeRuntime` (`internal/functions/serve/serve.go:108-133`): - * resolves `functions serve`'s own config/secrets/image independently on + * Resolves `functions serve`'s own config/secrets/image independently on * every (re)start, then delegates the actual bring-up to - * {@link startEdgeRuntimeContainer} (Go's `ServeFunctions`) exactly like - * `start`'s own bring-up will. Once that bring-up succeeds, this wrapper — and - * only this wrapper, matching Go's `restartEdgeRuntime` — reloads Kong - * (`serve.go:126-131`) so Kong's routing table picks up the freshly - * (re)started container. + * {@link startEdgeRuntimeContainer}. Once that succeeds, this wrapper — and + * only this wrapper — reloads Kong so its routing table picks up the + * freshly (re)started container. */ const startEdgeRuntime = Effect.fnUntraced(function* (input: { readonly flags: FunctionsServeFlags; @@ -1909,16 +1791,12 @@ const startEdgeRuntime = Effect.fnUntraced(function* (input: { readonly inspectMode: FunctionsServeInspectMode | undefined; }) { const output = yield* Output; - // Deliberately NO docker precheck here — Go's `restartEdgeRuntime` - // (`internal/functions/serve/serve.go:107-113`) runs its sanity checks in - // order: `flags.LoadConfig` first, then `utils.AssertSupabaseDbIsRunning()`. - // A down Docker daemon therefore surfaces from the DB inspect below - // (`assertLocalDbRunning`) as `failed to inspect service: …` with the - // Docker Desktop install hint as a suggestion (`misc.go:155-166`), never as - // an upfront `failed to run docker.` failure. The remote-JWKS fetch is - // likewise held until AFTER that assertion (`finalizeAuthArtifacts` below — - // Go only fetches inside `ServeFunctions`, `serve.go:141`), so a down - // daemon never waits on external OIDC/JWKS requests first. + // No docker precheck here: config resolution runs first, then + // `assertLocalDbRunning` below surfaces a down daemon as "failed to + // inspect service: ..." with the install hint as a suggestion. The + // remote-JWKS fetch is likewise held until after that assertion + // (`finalizeAuthArtifacts` below), so a down daemon never waits on + // external OIDC/JWKS requests first. const resolved = yield* resolveServeConfig( input.dependencies.projectRoot, input.dependencies.projectIdOverride, @@ -1930,8 +1808,7 @@ const startEdgeRuntime = Effect.fnUntraced(function* (input: { let ownsRuntime = false; let startedRuntime: StartedRuntime | undefined; return yield* Effect.gen(function* () { - // `SUPABASE_NETWORK_ID` (env or project dotenv) is CLI-only — - // same Go-viper-parity gate as `resolved.projectEnvValues` itself + // `SUPABASE_NETWORK_ID` is CLI-only, like `resolved.projectEnvValues` // (`undefined` for library callers). const networkMode = resolveDockerNetworkMode({ explicit: Option.getOrUndefined(input.networkId), @@ -1953,42 +1830,22 @@ const startEdgeRuntime = Effect.fnUntraced(function* (input: { yield* assertLocalDbRunning(projectId); yield* bestEffortRemoveContainer(containerId); - // Go's `restartEdgeRuntime` prints this right before calling `ServeFunctions` - // (`serve.go:124-125`) — `ServeFunctions` itself (this file's `startEdgeRuntimeContainer`, - // also called directly by `start.go:1104`) never prints it, so it belongs in this - // `functions serve`-only wrapper, not the shared core. + // Printed here, not in the shared `startEdgeRuntimeContainer` core, + // since `start`'s own bring-up (which calls that core directly) doesn't + // print it. yield* output.raw("Setting up Edge Functions runtime...\n", "stderr"); - // Go's remote-JWKS fetch happens inside `ServeFunctions` (`serve.go:141`) - // — i.e. after `AssertSupabaseDbIsRunning`, the container removal, and the - // "Setting up…" print above — never before. Finalizing here (rather than - // inside `startEdgeRuntimeContainer`) keeps the shared core's - // caller-supplies-artifacts contract intact for `start`'s bring-up - // (`edge-runtime.service.ts`), which resolves its own JWKS. + // Finalized here, not inside `startEdgeRuntimeContainer`, to keep the + // shared core's caller-supplies-artifacts contract intact for `start`'s + // bring-up, which resolves its own JWKS. const authArtifacts = yield* finalizeAuthArtifacts(localAuthArtifacts); - // Go: `DockerStart` -> `DockerResolveImageIfNotCached` (`internal/utils/docker.go:326-386`) - // — resolved here, not earlier: `hasLocalImage` fails fast on an - // unreachable daemon, which would otherwise hijack the down-daemon - // message `assertLocalDbRunning` above is responsible for producing. - // - // Known ordering divergence (not fixed here — see below): Go's own - // `ServeFunctions` (`serve.go:134-167`) parses `--env-file` and every - // per-function config BEFORE ever calling `DockerStart` - // (`serve.go:218`), so a broken env file or function config fails fast, - // before any pull. This port's `startEdgeRuntimeContainer` (below) does - // that same parsing internally, but AFTER receiving an already-resolved - // `image` — so on a cold image cache, a broken `--env-file` now surfaces - // after a potentially slow `docker pull` instead of immediately. Fixing - // this properly means splitting `startEdgeRuntimeContainer` into a - // "build container config" phase and a "run it" phase so this resolve - // can move between them — but that function is also `start`'s bring-up - // core (`edge-runtime.service.ts`), which already passes in a - // pre-resolved image via `ensureImagesCached`, so restructuring it - // risks that shipped, more critical path. Left as a documented - // UX-only regression (the command still fails with the right error, - // just later) rather than a hasty change to shared, `start`-critical - // code (review round on CLI-1963). + // Resolved here, not earlier: an unreachable-daemon check on the image + // resolver would hijack the down-daemon message `assertLocalDbRunning` + // is responsible for. Known gap: parsing env-file/function config after + // this resolve means a broken `--env-file` now surfaces after a slow + // `docker pull` on cold cache instead of immediately — left open since + // fixing it risks `start`'s shared, more critical bring-up path. const image = yield* resolveFunctionsDockerImage( edgeRuntimeImage(edgeRuntimeVersion), resolved.projectEnvValues, @@ -2029,12 +1886,10 @@ const startEdgeRuntime = Effect.fnUntraced(function* (input: { return startedRuntime; }).pipe( - // `startEdgeRuntimeContainer`'s own `Effect.onError` only reaches while it's still running — - // once it returns successfully, a failure or interrupt here (e.g. mid-`reloadKong`) escapes - // that scope entirely, so this wrapper must also run the returned runtime's own staging-file - // cleanup, not just remove the container. Removal stays with this caller for bring-up - // failures too (`docker cp`/`docker start`), matching `docker run -d` behavior — the shared - // core never removes the container it created. + // A failure after `startEdgeRuntimeContainer` returns (e.g. mid-`reloadKong`) + // escapes its own `Effect.onError`, so this wrapper also runs the + // returned runtime's staging-file cleanup, not just container removal — + // the shared core never removes the container it created. Effect.onExit((exit) => Exit.isFailure(exit) ? Effect.all([ @@ -2080,10 +1935,10 @@ export const serveFunctions = Effect.fn("functions.serve")(function* ( const started = startOutcome.started; - // `streamContainerLogs` never succeeds: it streams logs until the container - // exits, then fails. A container crash therefore propagates out of this race - // and terminates `serve` — the Go CLI never auto-restarts a crashed container. - // The race only ever resolves to "shutdown" (signal) or "restart" (file change). + // `streamContainerLogs` never succeeds: it streams logs until the + // container exits, then fails, so a crash propagates out of this race + // and terminates `serve` rather than auto-restarting. The race + // otherwise only ever resolves to "shutdown" or "restart". const outcome = yield* Effect.raceFirst( Effect.raceFirst( processControl.awaitSignal().pipe(Effect.as("shutdown" as const)), diff --git a/apps/cli/src/shared/git/git-branch.ts b/apps/cli/src/shared/git/git-branch.ts index 190917f514..114f210797 100644 --- a/apps/cli/src/shared/git/git-branch.ts +++ b/apps/cli/src/shared/git/git-branch.ts @@ -3,20 +3,14 @@ import { Effect, FileSystem, Option, Path } from "effect"; import { RuntimeInfo } from "../runtime/runtime-info.service.ts"; /** - * Reproduces `apps/cli-go/internal/utils/git.go:GetGitBranchOrDefault`: + * Detects the current git branch: `$GITHUB_HEAD_REF` when set (CI + * pull-request workflows), otherwise the nearest `.git/HEAD` walking up from + * `startDir` (default: the runtime CWD), parsed as `ref: refs/heads/<name>`. + * Returns `Option.none()` when no git repository is found; callers substitute + * their own default. * - * 1. `$GITHUB_HEAD_REF` wins when set (CI pull-request workflows). - * 2. Otherwise walk from CWD up to the filesystem root reading `.git/HEAD` - * and parsing `ref: refs/heads/<name>`. - * - * Returns `Option.none()` when no git repository is detected. Callers may - * substitute their own default (e.g. Go's `GetGitBranch` defaults to "main"; - * `branches create` defaults to the empty string so the prompt is skipped). - * - * `startDir` is the directory to begin the walk from; it defaults to the - * runtime CWD. Commands that resolve a `--workdir` should pass it, because Go - * chdirs into the workdir in `PersistentPreRunE` before calling `GetGitBranch` - * (`cmd/root.go`), so the branch must reflect the project dir, not the caller's. + * Pass `startDir` explicitly for a resolved `--workdir` so the branch + * reflects the project directory, not the process's CWD. */ export const detectGitBranch = ( startDir?: string, diff --git a/apps/cli/src/shared/init/project-init.modes.integration.test.ts b/apps/cli/src/shared/init/project-init.modes.integration.test.ts index 27d019e8ec..58ac00eb09 100644 --- a/apps/cli/src/shared/init/project-init.modes.integration.test.ts +++ b/apps/cli/src/shared/init/project-init.modes.integration.test.ts @@ -29,12 +29,9 @@ function runInit(cwd: string) { }).pipe(Effect.provide(layer)); } -// Go pins every init-scaffolded directory to 0755 and file to 0644 -// (`internal/init/init.go:89,121,138,151,166` via `utils.WriteFile`/ -// `MkdirIfNotExistFS`, `internal/utils/misc.go:273,281-284`). Node's own -// umask-masked defaults happen to coincide under the common `022`, so pin the -// process umask to 0 here to prove the modes are pinned explicitly, not -// incidental to the ambient umask. +// Pin the process umask to 0 to prove the modes below are pinned explicitly, +// not incidental to Node's own umask-masked defaults (which coincide under +// the common 022). describe("initProject file modes (Go parity: 0755 dirs, 0644 files)", () => { it.live("pins the supabase dir and config.toml to Go's exact modes", () => { const cwd = makeTempProjectDir(); diff --git a/apps/cli/src/shared/init/project-init.templates.unit.test.ts b/apps/cli/src/shared/init/project-init.templates.unit.test.ts index 611379fc2f..aa8c1b88c4 100644 --- a/apps/cli/src/shared/init/project-init.templates.unit.test.ts +++ b/apps/cli/src/shared/init/project-init.templates.unit.test.ts @@ -13,9 +13,8 @@ import { const here = dirname(fileURLToPath(import.meta.url)); const goCliRoot = join(here, "../../../../cli-go"); -// Vendored copies of Go's `internal/init/templates/` scaffold files (deleted in -// CLI-1970; last present at commit 7b469f5b3). The dotted file names are -// de-dotted so git/tooling don't interpret the fixtures themselves. +// Vendored copies of the Go CLI's init-template scaffold files. Dotted file +// names are de-dotted so git/tooling don't interpret the fixtures themselves. const goTemplatesFixtureDir = join(here, "testdata/go-templates"); function normalizeNewlines(text: string): string { @@ -30,12 +29,11 @@ function readVendoredTemplate(name: string): string { return normalizeNewlines(readFileSync(join(goTemplatesFixtureDir, name), "utf8")); } -// Go renders its config.toml scaffold through text/template (config.Eject), so an action -// containing a backtick raw string — {{ (backtick){{ .Code }}(backtick) }} in the template -// source — is rendered to the literal string it quotes: {{ .Code }} in the ejected file. -// This is the only text/template construct emulated here beyond the substituted fields; -// the "models every template action" test below fails loudly if the Go scaffold ever -// gains a construct this suite does not resolve. +// Go's config.toml scaffold renders through text/template (`config.Eject`), so +// an action wrapping a backtick raw string — {{ `{{ .Code }}` }} in the +// source — is rendered to the literal string it quotes: `{{ .Code }}` in the +// ejected file. This is the only text/template construct emulated here; the +// "models every template action" test below fails loudly if that ever changes. function resolveGoTemplateEscapes(template: string): string { return template.replace(/\{\{\s*`([^`]*)`\s*\}\}/g, "$1"); } @@ -46,15 +44,15 @@ function renderExpectedGoEject(): string { resolveGoTemplateEscapes(readGoTemplate("pkg", "config", "templates", "config.toml")) .replace("{{ .ProjectId }}", "demo-project") .replace("{{ .Experimental.OrioleDBVersion }}", "15.1.0.150") - // supabase init always opts new projects into pg-delta; the Go template renders - // this from a flag set only on the init path (false when deriving defaults). + // supabase init always opts new projects into pg-delta; the Go template + // renders this from a flag only set on the init path. .replace("{{ .Experimental.PgDeltaInitEnabled }}", "true") ); } -// The residual Go scaffold still describes `auto_expose_new_tables` as unset-means-revoked and -// deprecated. Platform projects never stopped auto-exposing new entities, so the native template -// documents unset-means-exposed with `false` as the opt-out instead. +// The Go scaffold still describes `auto_expose_new_tables` as unset-means- +// revoked and deprecated; the native template documents unset-means-exposed +// instead, since platform projects never stopped auto-exposing new entities. const GO_AUTO_EXPOSE_COMMENT = `# without explicit GRANTs. When unset, new entities are NOT auto-exposed, matching the new cloud # default. Set to \`true\` to keep the legacy behaviour of auto-exposing new entities; this is # deprecated and the field is removed on 2026-10-30 once the always-revoked behaviour is permanent. @@ -81,10 +79,9 @@ describe("project init templates", () => { }); it("models every template action in the Go scaffold, so parity cannot silently drift", () => { - // After escape resolution and field substitution, the only {{ ... }} occurrences left - // must be the GoTrue OTP placeholders quoted by the backtick escapes. Anything else - // means the Go template gained a construct this suite does not emulate yet — update - // resolveGoTemplateEscapes/renderExpectedGoEject to match config.Eject before shipping. + // Anything beyond the GoTrue OTP placeholder means the Go template gained + // a construct this suite doesn't emulate; update `resolveGoTemplateEscapes` + // to match before shipping. const unresolvedActions = renderExpectedGoEject().match(/\{\{[^}]*\}\}/g) ?? []; expect(new Set(unresolvedActions)).toEqual(new Set(["{{ .Code }}"])); }); @@ -120,12 +117,9 @@ describe("project init templates", () => { }); }); -// --------------------------------------------------------------------------- -// `config pull` (CLI-2064) surgical-editor round trip: `applyConfigEdits` (`@supabase/config`) -// must edit the scaffold this module renders exactly as intended, and nothing else — line-level -// diffing is what actually proves that, rather than trusting the editor's own report of what it -// touched. -// --------------------------------------------------------------------------- +// `applyConfigEdits` must edit the scaffold exactly as intended and nothing +// else; line-level diffing proves that, rather than trusting the editor's +// own report of what it touched. interface DiffOp { readonly kind: "equal" | "removed" | "added"; @@ -211,9 +205,9 @@ describe("config pull surgical editor round trip over the rendered scaffold", () } const ops = diffLines(source.split("\n"), outcome.text.split("\n")); - // The trailing blank line the block insertion adds can be aligned by the LCS against the - // file's pre-existing final blank, so blank lines are excluded here; their exact placement - // is pinned byte-for-byte by the tail assertion below instead. + // Blank lines are excluded: the block insertion's trailing blank can align + // with the file's pre-existing final blank via LCS. The tail assertions + // below pin blank-line placement byte-for-byte instead. const changed = ops.filter((op) => op.kind !== "equal" && op.line !== ""); expect(changed).toEqual([ diff --git a/apps/cli/src/shared/init/project-init.ts b/apps/cli/src/shared/init/project-init.ts index ff2d9b5106..83a6afa168 100644 --- a/apps/cli/src/shared/init/project-init.ts +++ b/apps/cli/src/shared/init/project-init.ts @@ -23,9 +23,8 @@ function sanitizeProjectId(src: string): string { return truncateText(sanitized, maxProjectIdLength); } -// Mirrors Go's `jsonc.ToJSONInPlace` (github.com/tidwall/jsonc): strips line and -// block comments and trailing commas while preserving string contents, so an -// existing JSONC settings file parses exactly as it does in the Go CLI. +// Strips line and block comments and trailing commas while preserving +// string contents, so an existing JSONC settings file parses correctly. function stripJsonComments(contents: string): string { const src = contents.replace(/^\uFEFF/, ""); const out: Array<string> = []; @@ -34,7 +33,6 @@ function stripJsonComments(contents: string): string { while (i < src.length) { const char = src.charAt(i); - // String literal \u2014 copy verbatim, honoring escape sequences. if (char === '"') { pendingCommaIndex = -1; out.push(char); @@ -55,7 +53,6 @@ function stripJsonComments(contents: string): string { continue; } - // Line comment. if (char === "/" && src.charAt(i + 1) === "/") { i += 2; while (i < src.length && src.charAt(i) !== "\n") { @@ -64,7 +61,6 @@ function stripJsonComments(contents: string): string { continue; } - // Block comment. if (char === "/" && src.charAt(i + 1) === "*") { i += 2; while (i < src.length && !(src.charAt(i) === "*" && src.charAt(i + 1) === "/")) { @@ -110,9 +106,9 @@ const decodeJsonObject = Schema.decodeUnknownEffect( Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), ); -// Parses a settings file through a Schema boundary so malformed JSON surfaces as -// a typed `InitParseSettingsError` (recoverable, never a fiber defect) and a -// non-object document is rejected \u2014 matching Go's `json.Decoder` into a map. +// Parses a settings file through a Schema boundary so malformed JSON surfaces +// as a typed `InitParseSettingsError` (never a fiber defect) and a +// non-object document is rejected. function parseJsonObject(pathname: string, contents: string) { return decodeJsonObject(stripJsonComments(contents)).pipe( Effect.mapError( @@ -131,22 +127,18 @@ export interface ProjectInitOptions { readonly useOrioledb: boolean; readonly interactive: boolean; /** - * Auto-confirms the interactive IDE-settings prompts, mirroring Go's - * `viper.GetBool("YES")` branch inside `PromptYesNo` (`console.go:70-72`): - * with `--yes`/`SUPABASE_YES`, `init -i` echoes the accepted VS Code prompt - * to stderr and writes the settings instead of blocking on a TTY (CLI-1974). - * Callers without a `--yes` flag pass `false`. + * Auto-confirms the interactive IDE-settings prompts: with `--yes`/ + * `SUPABASE_YES`, `init -i` echoes the accepted VS Code prompt to stderr + * and writes the settings instead of blocking on a TTY. Callers without a + * `--yes` flag pass `false`. */ readonly yes: boolean; readonly withVscodeSettings: boolean; readonly withIntellijSettings: boolean; } -// Go pins every init-scaffolded file to 0644 and every directory to 0755 -// (`internal/init/init.go:89,121,138,151,166` via `utils.WriteFile`/ -// `MkdirIfNotExistFS`, `internal/utils/misc.go:273,281-284`; config.toml at -// `internal/utils/config.go:234,243`). Node's umask-masked defaults coincide -// under the common `022`, but pin explicitly to match Go under any umask. +// Files/directories are pinned to 0644/0755 explicitly rather than relying +// on Node's umask-masked defaults, which only coincide under the common 022. const INIT_FILE_MODE = 0o644; const INIT_DIR_MODE = 0o755; @@ -228,11 +220,7 @@ export const writeIntelliJConfig = Effect.fnUntraced(function* ( } }); -// Mirrors Go's `PromptForIDESettings` (`apps/cli-go/internal/init/init.go:61-75`, -// deleted in CLI-1970; last present at commit 7b469f5b3): -// both questions go through `PromptYesNo`, so `--yes`/`SUPABASE_YES` auto-accepts -// the VS Code prompt with the `[Y/n] y` stderr echo and never reaches the -// IntelliJ one — Go returns after writing the VS Code settings (CLI-1974). +// `--yes`/`SUPABASE_YES` auto-accepts the VS Code prompt without reaching IntelliJ. const promptForIdeSettings = Effect.fnUntraced(function* (cwd: string, yes: boolean) { const output = yield* Output; @@ -276,27 +264,24 @@ const ensureSupabaseGitignore = Effect.fnUntraced(function* (cwd: string) { if (existing.includes(INIT_GITIGNORE_TEMPLATE)) { return; } - // Go always prepends a line break when appending to an existing file, even - // an empty one (`apps/cli-go/internal/init/init.go:80-96`, deleted in - // CLI-1970; last present at commit 7b469f5b3: the `err == nil` - // branch of `FileContainsBytes` covers empty files too). + // Always prepends a line break before appending, even to an empty file, + // producing a leading blank line in that case. yield* fs.writeFileString(gitignorePath, `${existing}\n${INIT_GITIGNORE_TEMPLATE}`); return; } - // The append branch above deliberately passes no mode: the file already - // exists there, and `writeFile`'s mode only applies at creation (as does - // Go's `OpenFile(..., os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)`). + // No mode here: the file already exists, and `writeFileString`'s mode only + // applies at creation. yield* fs.writeFileString(gitignorePath, INIT_GITIGNORE_TEMPLATE, { mode: INIT_FILE_MODE }); }); /** * Scaffolds the local project files (config.toml, .gitignore, optional IDE - * settings). This owns the mechanical filesystem work only — it does not decide - * how an already-initialized project is reported. When `config.toml` already - * exists and `force` is not set it short-circuits with `created: false` and - * writes nothing, leaving the caller free to treat that as a hard error (Go - * parity) or a graceful no-op. + * settings). This owns the mechanical filesystem work only — it does not + * decide how an already-initialized project is reported. When + * `config.toml` already exists and `force` is not set it short-circuits + * with `created: false` and writes nothing, leaving the caller free to + * treat that as a hard error or a graceful no-op. */ export const initProject = Effect.fnUntraced(function* (options: ProjectInitOptions) { const fs = yield* FileSystem.FileSystem; @@ -322,14 +307,11 @@ export const initProject = Effect.fnUntraced(function* (options: ProjectInitOpti ); yield* ensureSupabaseGitignore(options.cwd); - // Go gates the IDE prompts on `-i` plus a TTY stdin only (`cmd/init.go:40`). - // TS additionally requires text mode (json/stream-json runs stay payload-only - // and never scaffold IDE settings as an undisclosed side effect) and — because - // clack renders its prompt UI on stdout, unlike Go's stderr prompts — an - // interactive stdout. `yes` lifts the stdout requirement: no clack prompt is - // rendered when the answer is auto-confirmed (the `[Y/n] y` echo goes to - // stderr), so `init -i --yes` with a piped stdout writes the VS Code settings - // exactly like Go (CLI-1974). + // Requires text mode (json/stream-json stay payload-only and never scaffold + // IDE settings as an undisclosed side effect) and an interactive stdout, + // since clack renders its prompt UI there. `yes` lifts the stdout + // requirement: no prompt renders when the answer is auto-confirmed, so + // `init -i --yes` with a piped stdout still writes the VS Code settings. const effectiveInteractive = options.interactive && tty.stdinIsTty && diff --git a/apps/cli/src/shared/output/errors.ts b/apps/cli/src/shared/output/errors.ts index 96e4237087..46b705a0de 100644 --- a/apps/cli/src/shared/output/errors.ts +++ b/apps/cli/src/shared/output/errors.ts @@ -6,29 +6,14 @@ import { } from "../telemetry/error-actionability.ts"; /** - * Byte-for-byte render of Go's `context.Canceled` sentinel. + * Message used for declined-confirmation cancellation errors. The text + * `Output.fail` renderer matches this exact string (after + * `normalizeCliError`'s trimming) to suppress the `--debug` hint, since + * declining a prompt is a user decision, not something worth troubleshooting. * - * Every declined confirmation prompt in the Go CLI surfaces as a bare - * `context.Canceled` (e.g. `errors.New(context.Canceled)` in - * `apps/cli-go/internal/logout/logout.go:19`, deleted in CLI-1970; last - * present at commit 7b469f5b3), and `recoverAndExit` - * (`apps/cli-go/cmd/root.go:287-303`) deliberately skips the - * `SuggestDebugFlag` hint for it — declining a prompt is a user decision, - * not an error worth troubleshooting. Handlers that port those decline - * paths construct their cancellation errors with this exact message, and - * the text `Output.fail` renderer keys on it to suppress the `--debug` - * hint, mirroring Go's `!errors.Is(err, context.Canceled)` guard. - * - * Two invariants of that renderer check: - * - The value must stay trim-invariant: it round-trips through - * `normalizeCliError`'s trimming `readString` before reaching the - * renderer's equality check (`shared/output/normalize-error.ts`). - * - The check is exact-match, narrower than Go's chain-walking - * `errors.Is`: a future producer surfacing a WRAPPED cancellation - * (`"...: context canceled"`) through `Output.fail` would keep the hint - * where Go suppresses it — no such producer exists today (mid-flight - * Ctrl-C takes the interrupt/exit-130 path and never reaches - * `Output.fail`), but widen the check if one ever appears. + * The match is exact, not prefix/substring — widen it if a wrapped + * cancellation message (e.g. `"...: context canceled"`) ever needs the same + * treatment. */ export const CONTEXT_CANCELED_MESSAGE = "context canceled"; diff --git a/apps/cli/src/shared/output/json-error-handling.ts b/apps/cli/src/shared/output/json-error-handling.ts index e7c9968d86..e9f83f60e8 100644 --- a/apps/cli/src/shared/output/json-error-handling.ts +++ b/apps/cli/src/shared/output/json-error-handling.ts @@ -13,12 +13,9 @@ export const withJsonErrorHandling = <A, E, R>( const processControl = yield* ProcessControl; if (output.format === "text") return yield* Effect.fail(error); yield* output.fail(normalizeCliError(error)); - // `Runtime.getErrorExitCode` defaults to 1 for any error without a - // `[Runtime.errorExitCode]` marker, so this is a no-op for every existing - // error type. It only changes behavior for an error that opts in — e.g. - // `GoChildExitError` (CLI-1879), so a delegated Go child's exact exit - // code (not just a generic 1) still reaches the user under json/stream-json, - // matching the exit code `runCli`'s text-mode path already propagates. + // `Runtime.getErrorExitCode` defaults to 1 unless the error opts in via + // `[Runtime.errorExitCode]`, so a delegated child's real exit code still + // reaches the user under json/stream-json, matching the text-mode path. yield* processControl.setExitCode(Runtime.getErrorExitCode(error)); }), ), diff --git a/apps/cli/src/shared/output/json-error-handling.unit.test.ts b/apps/cli/src/shared/output/json-error-handling.unit.test.ts index a6057bd227..bd050ca0e6 100644 --- a/apps/cli/src/shared/output/json-error-handling.unit.test.ts +++ b/apps/cli/src/shared/output/json-error-handling.unit.test.ts @@ -5,10 +5,6 @@ import { GoChildExitError } from "../../command-internal/go-child-exit.error.ts" import { Output } from "./output.service.ts"; import { withJsonErrorHandling } from "./json-error-handling.ts"; -// --------------------------------------------------------------------------- -// Test error types -// --------------------------------------------------------------------------- - class TaggedErrorWithDetail extends Data.TaggedError("TaggedErrorWithDetail")<{ readonly message: string; readonly detail: string; @@ -26,10 +22,6 @@ class PlainError { } } -// --------------------------------------------------------------------------- -// Mock output factory -// --------------------------------------------------------------------------- - type FailCall = { code: string; message: string; @@ -86,10 +78,6 @@ function mockOutput(format: "text" | "json" | "stream-json" = "text") { }; } -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe("withJsonErrorHandling", () => { describe("text format", () => { it.live("re-raises the original error in text format", () => { @@ -184,10 +172,6 @@ describe("withJsonErrorHandling", () => { }).pipe(Effect.provide(out.layer), Effect.provide(processControl.layer)); }); - // CLI-1879: a delegated Go child's exact exit code must reach the user under - // json/stream-json too, not just a generic 1 — matching the exit code - // `runCli`'s text-mode path already propagates via the same - // `[Runtime.errorExitCode]` marker. it.live("sets the exact exit code for a GoChildExitError, not a generic 1", () => { const out = mockOutput("json"); const processControl = mockProcessControl(); diff --git a/apps/cli/src/shared/output/machine-error-context.service.ts b/apps/cli/src/shared/output/machine-error-context.service.ts index 94ce816086..91c7482ca3 100644 --- a/apps/cli/src/shared/output/machine-error-context.service.ts +++ b/apps/cli/src/shared/output/machine-error-context.service.ts @@ -3,16 +3,13 @@ import { Context } from "effect"; /** * Command-scoped cell for additive top-level fields on the JSON/stream-json - * error envelope. A command that has already resolved some fact worth - * surfacing to an agent — even when the command itself is about to fail — - * calls `set` before its handler can fail; `jsonOutputLayer`/`streamJsonOutputLayer`'s - * `fail` read it optionally via `Effect.serviceOption`, so a command that never - * provides this layer renders the exact same envelope as today. + * error envelope. A command calls `set` before its handler can fail; + * `jsonOutputLayer`/`streamJsonOutputLayer`'s `fail` reads it optionally via + * `Effect.serviceOption`, so a command that never provides this layer is unaffected. * - * Fields are spread onto the TOP LEVEL of the envelope, next to `_tag`/`error` - * (json) or `type`/`error`/`timestamp` (stream-json) — never inside `error` - * itself, since `error` stays the normalized failure shape callers already - * depend on. + * Fields merge into the envelope root, next to `_tag`/`error` (json) or + * `type`/`error`/`timestamp` (stream-json) — never inside `error`, which + * stays the normalized failure shape. */ interface MachineErrorContextShape { /** Merges `fields` into the envelope additions recorded so far. */ diff --git a/apps/cli/src/shared/output/normalize-error.ts b/apps/cli/src/shared/output/normalize-error.ts index b829e5b7e7..593aabfddd 100644 --- a/apps/cli/src/shared/output/normalize-error.ts +++ b/apps/cli/src/shared/output/normalize-error.ts @@ -21,11 +21,10 @@ const readString = (value: ErrorRecord, key: string): string | undefined => { return typeof field === "string" && field.trim().length > 0 ? field.trim() : undefined; }; -// Unlike `readString`, does not trim or reject empty strings. Use this for -// fields that carry raw user input (e.g. `CliError.InvalidValue#value`), -// where an empty string or meaningful surrounding whitespace is a legitimate -// value the user typed (`supabase --output-format ''`) and must be preserved -// and reported verbatim rather than normalized away. +// Unlike `readString`, does not trim or reject empty strings: some fields +// carry raw user input (e.g. `CliError.InvalidValue#value`) where an empty +// string or meaningful whitespace is a legitimate value that must be +// reported verbatim. const readRawString = (value: ErrorRecord, key: string): string | undefined => { const field = value[key]; return typeof field === "string" ? field : undefined; @@ -38,11 +37,10 @@ const mappedError = ( const tag = readString(error, "_tag"); switch (tag) { case "MissingOption": { - // Mirror Go Cobra's `required flag(s) "X" not set` wording. Effect CLI's - // default `Missing required flag: --X` differs and would break scripts - // that parse the Go CLI's stderr. We still cannot suppress Effect CLI's - // pre-error help dump (Cobra doesn't show it on parse error) — that - // would require a forked CLI parser. Match what we can. + // Matches the CLI's established `required flag(s) "X" not set` wording + // (not Effect CLI's default `Missing required flag: --X`) so scripts + // parsing stderr keep working. The pre-error help dump above it can't + // be suppressed without forking the parser. const option = readString(error, "option"); return { code: tag, @@ -52,20 +50,13 @@ const mappedError = ( }; } case "InvalidValue": { - // `CliError.InvalidValue` for a `GlobalFlag.setting` flag (e.g. - // `--output-format`, or the `--output`/`-o`, `--dns-resolver`, - // `--agent`) never reaches `CliOutput.Formatter` — `Command.runWith` - // validates those flags in a step that runs outside the `ShowHelp` - // path, so the failure lands here instead. Apply the same - // doubled-"Expected"-prefix workaround `subcommand-flag-suggestions.ts` - // applies for the `ShowHelp`-formatted case (see CLI-1898), so every - // `InvalidValue` failure — whichever path it takes — renders the same - // way. + // A global-flag `InvalidValue` (`--output-format`, `--output`/`-o`, + // `--dns-resolver`, `--agent`) bypasses `CliOutput.Formatter` and lands + // here; apply the same doubled-"Expected"-prefix fix as the `ShowHelp` path. const option = readString(error, "option"); - // Raw read: `value` is the exact argv token the user typed and can - // legitimately be `""` or carry surrounding whitespace — `readString` - // would trim it or drop it entirely, either masking the bug this case - // exists to fix or misreporting what the user actually typed. + // Raw read: `value` is the exact argv token typed by the user and may + // legitimately be `""` or carry whitespace; `readString` would trim or + // drop it, masking the bug this case exists to fix. const value = readRawString(error, "value"); const expected = readString(error, "expected"); const kind = readString(error, "kind"); @@ -86,12 +77,9 @@ const mappedError = ( message: readString(error, "message") ?? "Unknown subcommand", }; case "ShowHelp": { - // Effect CLI wraps parse errors in a ShowHelp envelope (`CliError.ts`) - // whose `errors` array holds the underlying causes. If exactly one of - // those is a known recoverable type with a Go-parity mapping, unwrap - // and surface that instead of the generic "Help requested" envelope - // message — otherwise the user sees a useless top-line above the real - // problem. + // `ShowHelp` wraps parse errors; if exactly one inner error has a known + // mapping here, surface that instead of the generic "Help requested" + // envelope message. const errors = error["errors"]; if (!Array.isArray(errors) || errors.length === 0) return undefined; @@ -103,21 +91,9 @@ const mappedError = ( } } - // No Go-parity-specific single-error mapping applies (either more than - // one simultaneous error, e.g. a child flag placed before its - // subcommand — `UnrecognizedOption` plus the `UnknownSubcommand` its - // misplaced value gets parsed as — or a lone error with no known - // mapping: UnrecognizedOption, DuplicateOption, MissingArgument, - // UnknownSubcommand, UserError, or an InvalidValue that doesn't hit - // CLI-1898's doubled-"Expected"-prefix bug). Surface every inner - // error's own message — reusing the same `formatCliErrorsForDisplay` - // the text/json formatters use, so a subcommand-flag hint (e.g. "Hint: - // --foo is available on `branches create`. Pass it after the - // subcommand") survives — rather than falling through to ShowHelp's - // useless "Help requested" envelope message: since CLI-1901 (`run.ts`'s - // `withoutParseErrorHelpDump`) stopped the vendored library from also - // `Console.error`-ing this same text, this is now the ONLY place any - // of it reaches the user, for one error or many. + // No known single-error mapping applies. Reuse + // `formatCliErrorsForDisplay` so subcommand-flag hints survive, rather + // than falling through to the generic "Help requested" envelope message. if (errors.every(CliError.isCliError)) { const formatted = formatCliErrorsForDisplay(errors, context); if (formatted.errors.length > 0) { @@ -129,9 +105,9 @@ const mappedError = ( } } - // Defensive fallback for a single inner value that carries a usable - // `_tag`/`message` pair but isn't a real `CliError` instance (e.g. a - // hand-rolled test double) — real `ShowHelp.errors` entries always are. + // Defensive fallback for an inner value with a usable `_tag`/`message` + // pair but not a real `CliError` instance (e.g. a hand-rolled test + // double); real `ShowHelp.errors` entries always are. if (errors.length === 1) { const inner = errors[0]; if (isErrorRecord(inner)) { @@ -158,12 +134,9 @@ export function normalizeCliError( const code = readString(error, "_tag") ?? "UnknownError"; const message = readString(error, "message") ?? readString(error, "detail") ?? code; const detail = readString(error, "detail"); - // Raw read: some producers' suggestion text is meaningful leading/trailing - // whitespace, not incidental — e.g. `suggestLegacyBundle`'s Go-parity - // string (`shared/functions/download.ts`) starts with `\n` to reproduce - // Go's blank separator line before the hint (`cmd/root.go:301-302`, - // `Fprintln(os.Stderr, CmdSuggestion)`). `readString` would trim exactly - // that away. + // Raw read: some producers' suggestion text carries meaningful leading + // whitespace (e.g. `suggestLegacyBundle`'s leading `\n` for a blank + // separator line); `readString` would trim exactly that away. const suggestion = readRawString(error, "suggestion"); return { code, diff --git a/apps/cli/src/shared/output/normalize-error.unit.test.ts b/apps/cli/src/shared/output/normalize-error.unit.test.ts index 41cf9ea06f..840dd4076f 100644 --- a/apps/cli/src/shared/output/normalize-error.unit.test.ts +++ b/apps/cli/src/shared/output/normalize-error.unit.test.ts @@ -26,14 +26,6 @@ describe("normalizeCliError", () => { }); }); - // Pinning tests for CLI-2235: `@supabase/config`'s tagged errors carry no - // `message`/`detail` field, so `normalizeCliError`'s generic fallback - // surfaces the bare `_tag` verbatim as both the JSON `code` and the - // displayed `message` — this is the exact string a script matching on - // `--output-format json` error output, or a user reading stderr, sees. A - // future rename of these tags (as happened with the - // ProjectConfigParseError -> CliConfigParseError rename) must fail a test, - // not sail through silently. test("CliConfigParseError falls back to its bare tag as both code and message", () => { const error = new CliConfigParseError({ path: "supabase/config.toml", @@ -87,11 +79,6 @@ describe("normalizeCliError", () => { }); test("InvalidValue collapses the doubled 'Expected: Expected' prefix (e.g. a bad GlobalFlag.setting value)", () => { - // Regression test for CLI-1898: `--output-format`/`--dns-resolver`/`--agent`/ - // `--output` are `GlobalFlag.setting` flags backed by `Flag.choice`. - // `Command.runWith` validates their values in a step that runs outside the - // `ShowHelp` path, so a bad value never reaches `CliOutput.Formatter` (and - // `subcommand-flag-suggestions.ts`'s fix) — it surfaces here instead. const error = new CliError.InvalidValue({ option: "output-format", value: "bogus", @@ -107,11 +94,6 @@ describe("normalizeCliError", () => { }); test("InvalidValue preserves an empty invalid value (e.g. `--output-format ''`)", () => { - // Regression test for a Codex review finding on CLI-1898: `value` is raw - // user input read straight off argv, so `''` is a legitimate way to - // trigger this failure. Reading it through the trim-and-reject-empty - // `readString` helper would fail the guard and leak the original - // doubled "Expected: Expected" message instead of fixing it. const error = new CliError.InvalidValue({ option: "output-format", value: "", @@ -127,8 +109,6 @@ describe("normalizeCliError", () => { }); test("InvalidValue preserves surrounding whitespace in the invalid value (e.g. `--output-format ' json'`)", () => { - // Regression test for the same Codex finding: trimming `value` would - // report a different string than what the user actually typed. const error = new CliError.InvalidValue({ option: "output-format", value: " json", @@ -144,11 +124,8 @@ describe("normalizeCliError", () => { }); test("InvalidValue passes a complete pflag-format diagnostic through verbatim (Go stderr parity, CLI-1983)", () => { - // Flags that byte-match Go pflag's parse-time diagnostics - // (`stringSliceFlag`'s malformed-CSV failure, `migration down - // --last`) emit the COMPLETE Go message as `expected`. Wrapping it in - // Effect's `Invalid value for flag ...: Expected: ...` template would - // double-frame it — Go prints the bare pflag line. + // This flag's diagnostic is emitted as a complete message in `expected`; + // wrapping it in the generic "Invalid value..." template would double-frame it. const pflagMessage = 'invalid argument "\\"1.2.3.4" for "--db-unban-ip" flag: parse error on line 1, column 9: extraneous or missing " in quoted-field'; const error = new CliError.InvalidValue({ @@ -202,11 +179,8 @@ describe("normalizeCliError", () => { }); test("InvalidValue surfaces a complete pflag-style 'expected' message verbatim (Go flag-parse parity)", () => { - // Flags that reproduce Go's flag-parse rejections (e.g. - // `storage cp --jobs=-1` via `Flag.mapTryCatch`) put pflag's entire - // `invalid argument %q for %q flag: %v` string in `expected`. Wrapping it - // in Effect's `Invalid value for flag --jobs: …` template would break - // byte-parity with the Go CLI's stderr. + // This flag's diagnostic already includes its own "invalid argument ..." + // message in `expected`; wrapping it in the generic template would double it. const error = new CliError.InvalidValue({ option: "jobs", value: "-1", @@ -243,8 +217,6 @@ describe("normalizeCliError", () => { }); test("ShowHelp envelope unwraps a single MissingOption to Cobra wording", () => { - // Effect CLI raises `ShowHelp` containing the parse error in its `errors` - // array. We unwrap to surface the actionable message instead of "Help requested". const error = { _tag: "ShowHelp", commandPath: ["sso", "add"], @@ -256,14 +228,6 @@ describe("normalizeCliError", () => { }); }); - // CLI-1901: before this fix, the vendored `effect` CLI library's own - // `showHelp()` also printed this same message (via `Console.error`) as a - // duplicate of whatever `normalizeCause` rendered here — so a tag with no - // Go-parity-specific mapping (e.g. UnrecognizedOption) still had SOME - // informative text visible, just twice. Now that CLI-1901's `run.ts` fix - // suppresses the library's own duplicate print entirely, this generic - // fallback is the ONLY place the message reaches the user — it must not - // regress to the useless "Help requested" envelope message. test("ShowHelp envelope unwraps a single UnrecognizedOption to its own message (no Go-parity mapping exists yet)", () => { const error = { _tag: "ShowHelp", @@ -282,14 +246,6 @@ describe("normalizeCliError", () => { }); }); - // Regression test for a Codex review finding on CLI-1901: `run.ts`'s - // `withoutParseErrorHelpDump` suppresses the vendored library's own - // `Console.error` render, which used to be the only place a subcommand-flag - // placement hint (`buildSubcommandFlagHint` / - // `subcommand-flag-suggestions.ts`) reached the user. When a - // `CliErrorSuggestionContext` is supplied, this fallback must reuse - // `formatCliErrorsForDisplay` — the same helper the text/json formatters - // use — so that hint still survives through the single-render path. test("ShowHelp envelope unwraps a single UnrecognizedOption and preserves its subcommand-flag hint when a suggestion context is supplied", () => { const error = { _tag: "ShowHelp", @@ -317,16 +273,9 @@ describe("normalizeCliError", () => { ); }); - // Regression test for a second Codex review finding on CLI-1901: a child - // flag placed before its subcommand, WITH a value (e.g. `network-restrictions - // --project-ref <ref> get`), makes Effect raise TWO simultaneous errors — - // `UnrecognizedOption` for the flag, plus `UnknownSubcommand` for the - // flag's own value being misread as the subcommand name. Before this fix, - // `mappedError`'s ShowHelp unwrap only ever looked at `errors.length === 1`, - // so a real two-error ShowHelp fell straight through to the generic - // "Help requested" envelope message — losing the hint (and the specific - // error) entirely now that CLI-1901 also suppresses the vendored library's - // own duplicate render for this case. + // A child flag placed before its subcommand with a value produces two + // simultaneous errors: `UnrecognizedOption` for the flag, and + // `UnknownSubcommand` for the flag's value being misread as the subcommand. test("ShowHelp envelope unwraps multiple simultaneous errors and preserves the subcommand-flag hint (child flag with a value, before its subcommand)", () => { const error = { _tag: "ShowHelp", @@ -360,9 +309,6 @@ describe("normalizeCliError", () => { expect(result.message).not.toContain("Help requested"); }); - // A genuine, unrelated multi-error case (no shared hint to collapse them - // into one) must still surface every error's own message, not just the - // first — and must not crash trying to pick a single `code`. test("ShowHelp envelope unwraps multiple unrelated simultaneous errors into a joined message", () => { const error = { _tag: "ShowHelp", @@ -388,12 +334,6 @@ describe("normalizeCliError", () => { expect(result.message).toContain("Unrecognized flag: --bogus-two in command supabase branches"); }); - // The same fallback also covers an InvalidValue that does NOT have the - // CLI-1898 doubled-"Expected"-prefix bug (e.g. a custom `Flag.mapTryCatch` - // validator like `sso add --domains`, whose `expected` text is already - // clean) — `mappedError`'s own InvalidValue case returns `undefined` for - // those (nothing to fix), so this generic fallback is what surfaces the - // message, not CLI-1898's specific rebuild. test("ShowHelp envelope unwraps a single InvalidValue with an already-clean expected message (not the CLI-1898 doubled-prefix bug)", () => { const error = { _tag: "ShowHelp", @@ -414,10 +354,6 @@ describe("normalizeCliError", () => { }); }); - // If the single inner error has a `_tag` but no usable `message` (neither a - // string via its own getter nor otherwise), the fallback must not surface a - // blank/garbage message — it should fall through to ShowHelp's own generic - // handling, same as the pre-existing multiple-errors case below. test("ShowHelp envelope with a single inner error carrying no message falls back to generic", () => { const error = { _tag: "ShowHelp", @@ -437,8 +373,7 @@ describe("normalizeCliError", () => { { _tag: "MissingOption", option: "project-ref" }, ], }; - // Should fall through to generic — message comes from ShowHelp itself, - // which doesn't include one in our test fixture. + // Fixture omits ShowHelp's own message, so only `code` is asserted here. const result = normalizeCliError(error); expect(result.code).toBe("ShowHelp"); }); diff --git a/apps/cli/src/shared/output/output.layer.ts b/apps/cli/src/shared/output/output.layer.ts index bf29bfdd3f..6054c9a3a7 100644 --- a/apps/cli/src/shared/output/output.layer.ts +++ b/apps/cli/src/shared/output/output.layer.ts @@ -145,10 +145,8 @@ export const textOutputLayer = Layer.effect( ? "autocomplete" : "select" : mode; - // clack itself defaults every one of these to `process.stdout` (verified against - // the installed `@clack/prompts` source) — only override when a caller explicitly - // asks for stderr (e.g. a command whose own stdout is a machine-readable payload - // even in text mode). + // clack defaults these to `process.stdout`; only override when a + // caller explicitly asks for stderr. const clackOutput = behavior.stream === "stderr" ? process.stderr : undefined; const value = yield* Effect.promise(() => effectiveMode === "autocomplete" @@ -370,10 +368,8 @@ export const textOutputLayer = Layer.effect( success: (message: string) => Effect.sync(() => log.success(message)), fail: (err: { code: string; message: string; detail?: string; suggestion?: string }) => Effect.sync(() => { - // Matches Go's `recoverAndExit` (apps/cli-go/cmd/root.go:300-303): a + // Bypasses clack's `log.error` framing (`│` guide + `■` icon): a // red-styled message on stderr, optionally followed by a suggestion. - // Bypasses clack's `log.error` framing (`│` guide + `■` icon) so the - // output byte-matches the Go CLI for parity tests. process.stderr.write(styleText("red", err.message) + "\n"); if (err.detail !== undefined && err.detail !== err.message) { process.stderr.write(styleText("gray", err.detail) + "\n"); @@ -384,11 +380,8 @@ export const textOutputLayer = Layer.effect( err.message !== CONTEXT_CANCELED_MESSAGE && !process.argv.includes("--debug") ) { - // Go's `utils.SuggestDebugFlag` (apps/cli-go/internal/utils/misc.go:41), - // withheld for the canceled sentinel exactly like `recoverAndExit`'s - // `!errors.Is(err, context.Canceled)` guard (apps/cli-go/cmd/root.go:287-292): - // a declined confirmation prompt is a user decision, so Go prints only - // the red `context canceled` line with no troubleshooting hint (CLI-1973). + // Withheld for the canceled sentinel: declining a prompt is a + // user decision, not something to troubleshoot. process.stderr.write( "Try rerunning the command with --debug to troubleshoot the error.\n", ); @@ -462,10 +455,8 @@ export const jsonOutputLayer = Layer.effect( fail: (err: { code: string; message: string; detail?: string; suggestion?: string }) => Effect.gen(function* () { const extra = yield* readMachineErrorContext(); - // `extra` spreads FIRST so the envelope's own `_tag`/`error` can - // never be clobbered by a context field of the same name (PR #6168 - // review) — this is opt-in, command-contributed data; the envelope - // shape it's decorating always wins. + // `extra` spreads first so the envelope's own `_tag`/`error` can't + // be clobbered by a same-named context field. yield* writeStdout(JSON.stringify({ ...extra, _tag: "Error", error: err }) + "\n"); }), raw: (text: string, stream: "stdout" | "stderr" = "stdout") => write(text, stream), @@ -564,9 +555,8 @@ export const streamJsonOutputLayer = Layer.effect( error: err, timestamp: new Date().toISOString(), }; - // `extra` spreads FIRST — same reasoning as the json layer's `fail` - // above: the event's own `type`/`error`/`timestamp` must always win - // over an opt-in context field of the same name (PR #6168 review). + // `extra` spreads first so the event's own `type`/`error`/`timestamp` + // can't be clobbered by a same-named context field. yield* writeStdout(JSON.stringify({ ...extra, ...event }) + "\n"); }), raw: (text: string, stream: "stdout" | "stderr" = "stdout") => write(text, stream), @@ -575,7 +565,6 @@ export const streamJsonOutputLayer = Layer.effect( }), ); -// Select the concrete output policy from the parsed global flag. export function outputLayerFor( format: OutputFormat, ): Layer.Layer<Output, never, Stdio.Stdio | Tty> { diff --git a/apps/cli/src/shared/output/output.layer.unit.test.ts b/apps/cli/src/shared/output/output.layer.unit.test.ts index 925b4a300a..cb75d76811 100644 --- a/apps/cli/src/shared/output/output.layer.unit.test.ts +++ b/apps/cli/src/shared/output/output.layer.unit.test.ts @@ -266,10 +266,6 @@ describe("Output", () => { process.argv = originalArgv.filter((arg) => arg !== "--debug"); return Effect.gen(function* () { const out = yield* Output; - // Same shape `normalizeCause` produces for any declined confirmation prompt - // (logout, migration fetch/repair/down, db push/reset, functions deploy - // --prune, ...): Go's `recoverAndExit` prints only the red `context canceled` - // line for `context.Canceled` (apps/cli-go/cmd/root.go:287-303) — CLI-1973. yield* out.fail({ code: "LogoutCancelledError", message: CONTEXT_CANCELED_MESSAGE }); expect(writes).toEqual(["\x1B[31mcontext canceled\x1B[39m\n"]); }).pipe( @@ -292,8 +288,6 @@ describe("Output", () => { }) as typeof process.stderr.write; return Effect.gen(function* () { const out = yield* Output; - // Go prints a pre-set `utils.CmdSuggestion` even for `context.Canceled` — - // only the `SuggestDebugFlag` fallback is withheld (cmd/root.go:287-292). yield* out.fail({ code: "E_TEST", message: CONTEXT_CANCELED_MESSAGE, @@ -313,10 +307,8 @@ describe("Output", () => { it.effect("promptText passes validate callback to clack", () => { mockClack.text.mockImplementation( (opts: { validate?: (v: string | undefined) => string | undefined }) => { - // Call with a non-empty value (exercises the non-nullish branch of v ?? "") const validationResult = opts.validate?.("bad"); expect(validationResult).toBe("invalid input"); - // Call with undefined (exercises the nullish branch of v ?? "") const validationResultUndefined = opts.validate?.(undefined); expect(validationResultUndefined).toBe("invalid input"); return Promise.resolve("good input"); @@ -434,12 +426,6 @@ describe("Output", () => { }).pipe(Effect.provide(layer)); }); - // Go's own interactive picker always writes to stderr (`internal/utils/prompt.go`'s - // `PromptChoice`: `tea.WithOutput(os.Stderr)`, "Interactive prompts should always be - // written to stderr") — but clack's `select()`/`autocomplete()` default to stdout, which - // would corrupt a command whose own stdout is a machine-readable payload even in text - // mode (e.g. `gen bearer-jwt`'s signed token — Codex review finding, CLI-1961). `{ stream: - // "stderr" }` is the opt-in escape hatch such a command passes. it.effect('promptSelect routes the picker to stderr when stream: "stderr" is requested', () => { mockClack.select.mockResolvedValue("pro"); return Effect.gen(function* () { @@ -735,11 +721,8 @@ describe("Output", () => { }).pipe(Effect.provide(layer)); }); - // CLI-2167 follow-up: `MachineErrorContext` is a command-scoped, opt-in - // cell — merged ALONGSIDE the output layer (not nested inside its own - // `Layer.provide`), matching how a real command runtime composes it, so - // the same live cell is visible both to this test's own `set` call and to - // `fail`'s read. + // Merged alongside the output layer (not nested inside its own + // `Layer.provide`) so the same live cell is visible to both `set` and `fail`. it.effect( "fail merges MachineErrorContext fields at the envelope top level when provided", () => { @@ -779,9 +762,7 @@ describe("Output", () => { }).pipe(Effect.provide(layer)); }); - // PR #6168 review: `extra` spreads FIRST in `fail`, so a context field - // sharing a name with the envelope's own keys (`_tag`/`error`) can never - // clobber them — a non-colliding field alongside it still merges fine. + // `safe_field` proves a non-colliding context field still merges normally. it.effect( "fail: a MachineErrorContext field named _tag or error cannot clobber the envelope", () => { @@ -1040,9 +1021,6 @@ describe("Output", () => { }).pipe(Effect.provide(layer)); }); - // CLI-2167 follow-up: same mechanism as the json layer's equivalent pair - // above — merged alongside `streamJsonOutputLayer`, not nested inside its - // `Layer.provide`. it.effect("fail merges MachineErrorContext fields at the event top level when provided", () => { const mock = mockStdio(); const layer = Layer.mergeAll( @@ -1078,9 +1056,7 @@ describe("Output", () => { }).pipe(Effect.provide(layer)); }); - // PR #6168 review: same reasoning as the json layer's equivalent test — - // `extra` spreads FIRST, so a context field named `type`/`error`/`timestamp` - // can never clobber the event's own keys; a non-colliding field still merges. + // `safe_field` proves a non-colliding context field still merges normally. it.effect( "fail: a MachineErrorContext field named type, error, or timestamp cannot clobber the event", () => { diff --git a/apps/cli/src/shared/output/output.service.ts b/apps/cli/src/shared/output/output.service.ts index 89d231120e..41d2022c83 100644 --- a/apps/cli/src/shared/output/output.service.ts +++ b/apps/cli/src/shared/output/output.service.ts @@ -25,13 +25,10 @@ interface OutputSelectBehavior { readonly placeholder?: string; readonly maxItems?: number; /** - * Which stream the interactive picker itself renders to. Defaults to `"stdout"` - * (clack's own default, matching every existing caller). Pass `"stderr"` for a - * command whose own stdout is a machine-readable payload even in text mode (e.g. - * `gen bearer-jwt`'s signed token) — matching Go's own convention of always - * rendering interactive prompts to stderr (`internal/utils/prompt.go`'s - * `PromptChoice`: `tea.WithOutput(os.Stderr)`, "Interactive prompts should always - * be written to stderr"). + * Which stream the interactive picker renders to. Defaults to `"stdout"` + * (clack's default). Pass `"stderr"` when the command's own stdout is a + * machine-readable payload even in text mode (e.g. `gen bearer-jwt`'s + * signed token). */ readonly stream?: "stdout" | "stderr"; } @@ -97,9 +94,10 @@ interface OutputShape { /** * Writes a raw chunk to stdout or stderr without framing. * - * Reserved for byte-exact parity output (Go-format encoders, Glamour-styled tables) - * where structured framing would change the bytes on the wire. Routes through the active - * output layer so tests can capture it without monkey-patching `process.stdout` / `process.stderr`. + * Reserved for byte-exact output (machine-format encoders, Glamour-styled + * tables) where structured framing would change the bytes on the wire. + * Routes through the active output layer so tests can capture it without + * monkey-patching `process.stdout`/`process.stderr`. */ readonly raw: (text: string, stream?: "stdout" | "stderr") => Effect.Effect<void>; /** diff --git a/apps/cli/src/shared/runtime/process-control.layer.ts b/apps/cli/src/shared/runtime/process-control.layer.ts index 8a0fbd7f7f..631882ea0c 100644 --- a/apps/cli/src/shared/runtime/process-control.layer.ts +++ b/apps/cli/src/shared/runtime/process-control.layer.ts @@ -14,7 +14,6 @@ const defaultSignals: ReadonlyArray<CliProcessSignal> = ["SIGINT", "SIGTERM"]; */ export const processControlLayer = Layer.sync(ProcessControl, () => ProcessControl.of({ - // `awaitSignal` is used for long-lived command interruption such as `start`. awaitSignal: (signals = defaultSignals) => Effect.callback<CliProcessSignal>((resume) => { const cleanup = () => { @@ -63,13 +62,8 @@ export const processControlLayer = Layer.sync(ProcessControl, () => return Effect.sync(cleanup); }), - // `holdSignals` is the no-resume dual of `awaitSignal`. It installs a - // no-op listener per signal for the lifetime of the caller's scope. The - // only purpose is to suppress the runtime's default terminate-on-signal - // behavior so a child process spawned with `detached:false` can receive - // the signal via the shared process group and handle it itself, while - // the parent waits for the child's real exit code instead of being - // killed with 130 by Bun/Node's default action. + // No-op listener per signal for the scope's lifetime; see + // `ProcessControlShape.holdSignals` for why. holdSignals: (signals) => Effect.acquireRelease( Effect.sync(() => { diff --git a/apps/cli/src/shared/runtime/process-control.layer.unit.test.ts b/apps/cli/src/shared/runtime/process-control.layer.unit.test.ts index a2290f839a..452ba363ad 100644 --- a/apps/cli/src/shared/runtime/process-control.layer.unit.test.ts +++ b/apps/cli/src/shared/runtime/process-control.layer.unit.test.ts @@ -76,7 +76,6 @@ describe("ProcessControl", () => { }), ); - // Listeners removed on scope close. expect(process.listenerCount("SIGINT")).toBe(before.SIGINT); expect(process.listenerCount("SIGTERM")).toBe(before.SIGTERM); expect(process.listenerCount("SIGHUP")).toBe(before.SIGHUP); @@ -106,7 +105,6 @@ describe("ProcessControl", () => { yield* Fiber.interrupt(fiber); - // acquireRelease's finalizer must run on interruption. expect(process.listenerCount("SIGINT")).toBe(before.SIGINT); expect(process.listenerCount("SIGTERM")).toBe(before.SIGTERM); }).pipe(Effect.provide(processControlLayer)), @@ -119,16 +117,12 @@ describe("ProcessControl", () => { yield* processControl.holdSignals(["SIGINT"]).pipe(Scope.provide(scope)); - // Emit the signal — no listener from holdSignals should do anything - // observable (no resume, no exception). If Node had no userland - // listener the default SIGINT action would kill the process, so the - // fact that we get past this line is itself proof the noop is live. + // Reaching the next line without the process dying is itself the + // assertion: with no listener, SIGINT's default action would exit. yield* Effect.sync(() => { process.emit("SIGINT"); }); - // Sanity: we can still close the scope cleanly and the listener is - // removed. No "resume" happened. yield* Scope.close(scope, Exit.void); }).pipe(Effect.provide(processControlLayer)), ); diff --git a/apps/cli/src/shared/runtime/random.service.ts b/apps/cli/src/shared/runtime/random.service.ts index 4a58681661..f16ca114eb 100644 --- a/apps/cli/src/shared/runtime/random.service.ts +++ b/apps/cli/src/shared/runtime/random.service.ts @@ -2,10 +2,8 @@ import { Context, type Effect } from "effect"; interface RandomShape { /** - * Return `bytes` cryptographically-random bytes, hex-encoded (lowercase). Used - * by `db query`'s agent-mode envelope boundary (Go's `crypto/rand` + - * `hex.EncodeToString`, `internal/db/query/query.go`). Injectable so tests can - * pin a deterministic boundary. + * Returns `bytes` cryptographically-random bytes, hex-encoded (lowercase). + * Injectable so tests can pin a deterministic value. */ readonly randomHex: (bytes: number) => Effect.Effect<string>; } diff --git a/apps/cli/src/shared/runtime/stdin.integration.test.ts b/apps/cli/src/shared/runtime/stdin.integration.test.ts index 806eefb013..d72e072247 100644 --- a/apps/cli/src/shared/runtime/stdin.integration.test.ts +++ b/apps/cli/src/shared/runtime/stdin.integration.test.ts @@ -10,17 +10,14 @@ import { stdinLayerFrom } from "./stdin.layer.ts"; const enc = (s: string) => new TextEncoder().encode(s); -// Exercises the real `stdinLayer` (its persistent, lazily-opened line reader) over a -// controllable byte stream, instead of the array-indexing `mockStdin` double, so stdin -// can be driven with deliberate chunking / delays; `Tty` is satisfied by `mockTty`. +// Exercises the real `stdinLayer` over a controllable byte stream (not the +// array-indexing `mockStdin` double) so stdin can be driven with deliberate chunking/delays. const withStdin = (stdin: Stream.Stream<Uint8Array, PlatformError>, stdinIsTty = false) => stdinLayerFrom(stdin).pipe(Layer.provide(mockTty({ stdinIsTty, stdoutIsTty: false }))); describe("stdinLayer", () => { it.live("dispenses successive lines across calls, buffering multi-line chunks", () => { - // Two chunks, the second carrying two lines: one persistent reader returns a, b, c - // across successive calls, holding the rest of a chunk for the next call instead of - // starting over. A final call on the exhausted stream yields None (the prompt default). + // Two chunks; the second carries two lines to prove buffering across calls. const layer = withStdin(Stream.fromIterable([enc("a\n"), enc("b\nc\n")])); return Effect.gen(function* () { const stdin = yield* Stdin; @@ -45,8 +42,7 @@ describe("stdinLayer", () => { }); it.live("preserves interior blank lines so answers stay aligned", () => { - // splitLines keeps blank interior lines: a caller that pipes "\ny\n" sees the - // blank line first (→ prompt default) and the y second, not y first. + // A caller piping "\ny\n" must see the blank line first, then "y" — not "y" first. const layer = withStdin(Stream.fromIterable([enc("\ny\n")])); return Effect.gen(function* () { const stdin = yield* Stdin; @@ -56,8 +52,6 @@ describe("stdinLayer", () => { }); it.live("times out to None when no line arrives within the window", () => { - // A pipe that stays open without sending a line: readLine must give up with None so - // the prompt takes its default instead of waiting for EOF. const layer = withStdin(Stream.never); return Effect.gen(function* () { const stdin = yield* Stdin; @@ -67,10 +61,8 @@ describe("stdinLayer", () => { it.live("waits for a non-blocking pipe that has nothing to read yet", () => Effect.gen(function* () { - // A non-blocking fd 0 with nothing to read yet fails the read with `WouldBlock` (how the - // layer reports `EAGAIN`, pinned over a real fd 0 below). That is "nothing yet", not EOF: - // the reader keeps asking until the answer lands, instead of taking the default for this - // prompt and every one after it. + // A non-blocking fd 0 with nothing to read fails with `WouldBlock` (how + // the layer reports `EAGAIN`) — "nothing yet", not EOF. let attempts = 0; const layer = withStdin( Stream.suspend(() => { @@ -90,9 +82,8 @@ describe("stdinLayer", () => { it.effect("keeps waiting on a non-blocking pipe across a prompt that gave up", () => Effect.gen(function* () { - // The first prompt times out between two looks, interrupting the wait. The next prompt - // must take the wait back up and see the answer once it lands, instead of inheriting the - // failed read as the reader's last word. + // A timed-out prompt must not leave the reader stuck on the failed read; + // the next prompt resumes waiting and sees the answer once it lands. const ready = yield* Ref.make(false); const layer = withStdin( Stream.unwrap( @@ -120,9 +111,8 @@ describe("stdinLayer", () => { it.effect("collects a pipe across a non-blocking read that had nothing yet", () => Effect.gen(function* () { - // The whole-pipe collects wait a non-blocking fd 0 out the same way, and a fresh reader - // over the still-open descriptor carries on where the last one stopped, so what came - // before the empty read and what comes after it read as one stream. + // `readPipedText` waits out a non-blocking fd 0 the same way readLine does, + // and a fresh reader continues where the last one stopped. let attempts = 0; const layer = withStdin( Stream.suspend(() => { @@ -147,8 +137,8 @@ describe("stdinLayer", () => { it.effect("keeps reading after a prompt times out, finishing the line it was waiting on", () => Effect.gen(function* () { - // A slow producer: the first prompt times out holding a partial line, and the bytes - // that complete it must still reach the next prompt. + // A slow producer: the first prompt times out mid-line; the bytes that + // complete it must still reach the next prompt. const queue = yield* Queue.unbounded<Uint8Array>(); const layer = withStdin(Stream.fromQueue(queue)); yield* Effect.gen(function* () { @@ -166,9 +156,8 @@ describe("stdinLayer", () => { it.live("lets one prompt at a time pull from the pipe", () => Effect.gen(function* () { - // Two prompts wait at once. The second must get `2`, held back from the chunk the first - // one pulled, instead of pulling a chunk of its own and skipping it. Each pull yields - // once, so a second pull could slip in while the first is in flight. + // Two prompts wait at once; the second must get the value held back from + // the first prompt's chunk, not pull (and skip) a chunk of its own. let pulls = 0; const layer = withStdin( Stream.fromEffectRepeat( @@ -191,8 +180,8 @@ describe("stdinLayer", () => { it.live("reads a pipe only while a prompt is waiting", () => Effect.gen(function* () { - // An endless producer, counted per chunk: nothing is pulled before the first prompt - // or between prompts, so whatever the prompts do not ask for stays in the pipe. + // Nothing is pulled before the first prompt or between prompts; the + // producer is counted per chunk to prove that. const pulled = yield* Ref.make(0); const layer = withStdin( Stream.fromEffectRepeat( @@ -211,9 +200,8 @@ describe("stdinLayer", () => { ); it.live("answers every prompt in order when a producer floods the pipe", () => { - // The 10,000th prompt still gets the 10,000th line, and an unbounded producer is only - // read as far as the prompts ask. The lines total well over 64 KiB, so the pending-line - // bound must reset at each line break. + // 10,000 lines total well over the 64 KiB pending-line bound, so that + // bound must reset at each line break, not accumulate across lines. const flood = Array.from({ length: 10_000 }, (_, index) => enc(`line-${index}\n`)); const layer = withStdin(Stream.fromIterable(flood).pipe(Stream.concat(Stream.never))); return Effect.gen(function* () { @@ -226,9 +214,9 @@ describe("stdinLayer", () => { it.live("gives up on a line that never ends instead of buffering it", () => Effect.gen(function* () { - // A producer that never sends a newline (`yes | tr -d '\n'`), counted per 16 KiB - // chunk: the reader stops pulling once the pending line outgrows its 64 KiB bound - // (the fifth chunk), and every prompt from then on takes its default. + // A newline-less producer (`yes | tr -d '\n'`), counted per 16 KiB chunk: + // the reader stops pulling once the pending line exceeds its 64 KiB + // bound (at the fifth chunk). const pulled = yield* Ref.make(0); const chunk = enc("y".repeat(16 * 1024)); const layer = withStdin( @@ -245,9 +233,9 @@ describe("stdinLayer", () => { ); it.live("answers the lines ahead of a runaway tail that shares their chunk", () => { - // `{ printf 'y\n'; cat blob-without-newline; } | …` can land the answer and the start - // of the blob in one pull. The answer is still delivered; the tail then trips the bound, - // so the `n` behind it is never read and every later prompt takes its default. + // The answer and the start of an unterminated blob can land in one pull; + // the answer still delivers, then the blob trips the bound so the "n" + // behind it is never read. const layer = withStdin( Stream.fromArray([enc("y\n"), enc("z".repeat(64 * 1024 + 1))]).pipe( Stream.concat(Stream.make(enc("n\n"))), @@ -284,10 +272,10 @@ describe("stdinLayer", () => { describe("stdinLayer over fd 0", () => { it("waits out a non-blocking fd 0 until the answer lands", async () => { - // A parent that hands fd 0 down in non-blocking mode: perl flips `O_NONBLOCK` on the pipe - // (Bun cannot), confirms the mode on stderr and execs the reader. The first prompt finds - // the pipe empty and must run out its window to None instead of taking the empty read as a - // dead descriptor; the second must read the answer written once that window has closed. + // perl flips `O_NONBLOCK` on stdin (Bun cannot) and execs into the reader + // so fd 0 stays non-blocking. The first prompt must run its window out to + // None rather than treat the empty read as a dead descriptor; the second + // reads the answer once that window closes. const bun = Bun.which("bun"); const perl = Bun.which("perl"); if (!bun || !perl) throw new Error("bun and perl executables not found"); @@ -348,9 +336,9 @@ describe("stdinLayer over fd 0", () => { }, 30_000); it("answers prompts from a flooded pipe and leaves the rest for a child inheriting fd 0", async () => { - // The production adapter in a real process: 2 MiB of lines are piped in, three prompts - // take the first three, then a child inheriting fd 0 counts what is left in the pipe. - // A reader that drained stdin would leave it nothing; this one reads a chunk ahead. + // 2 MiB of lines piped in; three prompts take the first three, then a + // child inheriting fd 0 counts what's left. A reader that fully drained + // stdin would leave it nothing. const bun = Bun.which("bun"); if (!bun) throw new Error("Bun executable not found"); const here = (file: string) => JSON.stringify(fileURLToPath(new URL(file, import.meta.url))); From afd2a1667e0178ae94ef1b4ae8baedabf0001302 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:09:06 +0100 Subject: [PATCH 44/56] chore(repo): tighten bearer-jwt docs, re-anchor misplaced JSDoc (final pass 3) --- .../experimental/workers/workers.output.ts | 12 +++---- .../gen/bearer-jwt/bearer-jwt.flags.ts | 21 ++++-------- .../gen/bearer-jwt/bearer-jwt.signing-key.ts | 33 +++++++------------ 3 files changed, 23 insertions(+), 43 deletions(-) diff --git a/apps/cli/src/commands/experimental/workers/workers.output.ts b/apps/cli/src/commands/experimental/workers/workers.output.ts index 6125f77b19..30085eea79 100644 --- a/apps/cli/src/commands/experimental/workers/workers.output.ts +++ b/apps/cli/src/commands/experimental/workers/workers.output.ts @@ -8,12 +8,6 @@ import { } from "../../../command-internal/go-output.encoders.ts"; import { WorkersEnvNotSupportedError } from "./workers.errors.ts"; -/** - * Emits a command's payload in the format `-o`/`--output` asked for. - * - * Returns whether it emitted anything, so the caller can skip its own text - * rendering, which would otherwise also write to stdout and corrupt the payload. - */ /** * Which `-o` values these commands answer with a payload. * @@ -27,6 +21,12 @@ function emitsPayloadFor(goFormat: string | undefined): boolean { return goFormat !== undefined && PAYLOAD_FORMATS.has(goFormat); } +/** + * Emits a command's payload in the format `-o`/`--output` asked for. + * + * Returns whether it emitted anything, so the caller can skip its own text + * rendering, which would otherwise also write to stdout and corrupt the payload. + */ export const emitWorkersMachineOutput = Effect.fnUntraced(function* ( payload: Record<string, unknown>, ) { diff --git a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.flags.ts b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.flags.ts index a6d2125131..30ce1b785a 100644 --- a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.flags.ts +++ b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.flags.ts @@ -89,21 +89,12 @@ function rfc3339FlagError(trimmedValue: string): Error { } /** - * `--exp`, parsed the same way pflag's RFC3339 time flag does, at flag-parse - * time. The input is trimmed before parsing and before it's re-embedded in - * the error message. Any failure (syntax, an out-of-range calendar - * component, or an out-of-range zone offset) raises the same wrapped - * `invalid argument "<val>" for "--exp" flag: ...` message. - * - * The zone-offset range check accepts `+24:00`/60-minute offsets (`>`, not - * `>=`) and rejects `+99:99` outright, unlike `Date.parse`, which returns - * `NaN` for the latter — silently minting a token whose `exp`/`iat` claims - * serialize as `null` instead of failing the command. - * - * Fractional seconds are accepted even though RFC3339 has no fractional - * directive, truncated (not rounded) to 9 digits, and returned unfloored as - * an exact {@link BearerJwtInstant} so `buildBearerJwtClaims` can floor only - * the final `exp`/`iat`. + * Parses `--exp` as an RFC3339 instant at flag-parse time. Every failure (syntax, calendar + * component, or zone offset out of range) raises the same + * `invalid argument "<val>" for "--exp" flag: ...` message. Offsets are range-checked rather than + * left to `Date.parse`, which returns `NaN` for `+99:99` and would mint a token with `null` + * `exp`/`iat` claims. Fractional seconds are truncated to 9 digits and returned unfloored so + * `buildBearerJwtClaims` floors only the final claims. */ export function parseBearerJwtExp(value: string): BearerJwtInstant { const trimmedValue = value.trim(); diff --git a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts index a93dc60834..6e9ba44d24 100644 --- a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts +++ b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.signing-key.ts @@ -53,16 +53,11 @@ const consolePromptText = Effect.fnUntraced(function* (label: string) { }); /** - * Narrows an untrusted JSON record (a `signing_keys_path` entry, or a pasted - * stdin JWK) into `Jwk`'s shape, defaulting each missing field the way a Go - * zero-value struct would. - * - * Throws a bare `Error` with the unwrapped `encoding/json` type-mismatch - * text the moment any field has the wrong JSON type; each call site wraps it - * with its own prefix. Checks fields in a fixed order rather than the - * document's own key order, so on a payload with multiple malformed fields - * the reported field may not always match a real decoder — accepted gap, - * every field is still rejected either way. + * Narrows an untrusted JSON record (a `signing_keys_path` entry or a pasted stdin JWK) into + * `Jwk`, defaulting missing fields to their zero values. Throws a bare `Error` with the + * established type-mismatch text on the first wrongly-typed field; call sites add their own + * prefix. Fields are checked in a fixed order, so with several malformed fields the reported one + * is deterministic rather than document-ordered. */ function normalizeStoredJwk(record: Record<string, unknown>): Jwk { const keyOps = readOptionalStringArray(record, "key_ops"); @@ -154,19 +149,13 @@ const resolveSigningKeyFromStdinJwk = Effect.fnUntraced(function* () { }); /** - * Branches B/C (reached when `[auth].signing_keys_path` is configured): a - * non-TTY prompts for a kid by exact match, falling back to the first key on - * a blank answer; a real TTY presents an interactive picker instead. - * - * Both the picker and its "Selected key ID: ..." line are routed to stderr - * explicitly rather than the shared `promptSelect`/`info` defaults, since - * this command's stdout is the signed-token payload even in text mode (see - * `bearer-jwt.handler.ts`) and clack's own defaults write to stdout. + * Picks a signing key when `[auth].signing_keys_path` is configured: a non-TTY prompts for a kid + * by exact match (blank answer selects the first key); a TTY shows an interactive picker. * - * The picker tries the ambient `Output` first (what every test here mocks), - * falling back to a fresh {@link textOutputLayer} only when that raises - * `NonInteractiveError` (json/stream-json layers reject prompts outright), - * so the picker still renders on a real TTY under a non-text ambient layer. + * The picker and its "Selected key ID" line go to stderr because stdout is the signed-token + * payload even in text mode. The picker tries the ambient `Output` first and falls back to a + * fresh {@link textOutputLayer} only on `NonInteractiveError`, so it still renders on a real TTY + * under a json/stream-json layer. */ const resolveSigningKeyFromConfigured = Effect.fnUntraced(function* ( availableKeys: ReadonlyArray<Jwk>, From 0737002250e6d5cce16908eee77b7283782c7fa3 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:09:53 +0100 Subject: [PATCH 45/56] chore(repo): trim comments, shard 33 (1560 -> 723 comment lines) --- apps/cli/src/commands/sso/add/add.command.ts | 11 +- .../commands/sso/add/add.command.unit.test.ts | 5 - apps/cli/src/commands/sso/add/add.handler.ts | 174 ++++--------- .../commands/sso/add/add.integration.test.ts | 170 +----------- .../sso/info/info.integration.test.ts | 6 - .../sso/list/list.integration.test.ts | 8 +- .../cli/src/commands/sso/show/show.handler.ts | 7 +- .../sso/show/show.integration.test.ts | 6 - apps/cli/src/commands/sso/sso.e2e.test.ts | 15 +- apps/cli/src/commands/sso/sso.errors.ts | 68 ++--- apps/cli/src/commands/sso/sso.format.ts | 53 ++-- .../src/commands/sso/sso.format.unit.test.ts | 19 +- apps/cli/src/commands/sso/sso.go-payload.ts | 6 +- apps/cli/src/commands/sso/sso.saml.ts | 21 +- ...sso.string-slice-flags.integration.test.ts | 17 +- .../src/commands/sso/update/update.command.ts | 10 +- .../sso/update/update.command.unit.test.ts | 11 +- .../src/commands/sso/update/update.handler.ts | 195 +++++--------- .../sso/update/update.integration.test.ts | 243 ++---------------- .../src/commands/start/lib/env-or-default.ts | 19 +- .../src/commands/start/lib/template-render.ts | 16 +- .../edge-runtime.service.integration.test.ts | 45 ++-- .../start/services/edge-runtime.service.ts | 154 +++-------- .../commands/start/services/gotrue.service.ts | 105 +++----- .../services/gotrue.service.unit.test.ts | 6 - .../start/services/imgproxy.service.ts | 26 +- .../commands/start/services/kong.service.ts | 162 ++++-------- .../start/services/kong.service.unit.test.ts | 15 +- 28 files changed, 377 insertions(+), 1216 deletions(-) diff --git a/apps/cli/src/commands/sso/add/add.command.ts b/apps/cli/src/commands/sso/add/add.command.ts index 8171b211ac..e145f0bdc8 100644 --- a/apps/cli/src/commands/sso/add/add.command.ts +++ b/apps/cli/src/commands/sso/add/add.command.ts @@ -8,8 +8,7 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { SSO_NAME_ID_FORMATS } from "../sso.saml.ts"; import { ssoAdd } from "./add.handler.ts"; -// `--domains` is a CSV string-slice flag; malformed CSV fails at parse time -// with pflag's exact diagnostic (see `stringSliceFlag`). +// `--domains` is a CSV string-slice flag; malformed CSV reports pflag's diagnostic (see `stringSliceFlag`). export const ssoAddDomainsFlag = stringSliceFlag( "domains", "Comma separated list of email domains to associate with the added identity provider.", @@ -20,8 +19,7 @@ const config = { Flag.withDescription("Project ref of the Supabase project."), Flag.optional, ), - // `--type` is required — leave off `Flag.optional` so the CLI parser - // enforces presence at parse time. + // No `Flag.optional`: `--type` is required, enforced by the CLI parser. type: Flag.choice("type", ["saml"] as const).pipe( Flag.withAlias("t"), Flag.withDescription("Type of identity provider (according to supported protocol)."), @@ -78,10 +76,7 @@ export const ssoAddCommand = Command.make("add", config).pipe( flags, safeFlags: ["project-ref"], config, - // `--type` registers `-t` (Flag.withAlias above); without this, `-t saml` - // never resolves to the canonical `type` name in extractChangedFlagNames, - // so it wouldn't appear in telemetry at all — the canonical name must be - // reported regardless of shorthand. + // Maps the `-t` alias back to `type` so telemetry records it as changed. aliases: { t: "type" }, }), withJsonErrorHandling, diff --git a/apps/cli/src/commands/sso/add/add.command.unit.test.ts b/apps/cli/src/commands/sso/add/add.command.unit.test.ts index a2916c11f4..54b47ec456 100644 --- a/apps/cli/src/commands/sso/add/add.command.unit.test.ts +++ b/apps/cli/src/commands/sso/add/add.command.unit.test.ts @@ -45,9 +45,6 @@ describe("sso add --domains flag (pflag StringSlice parity)", () => { }); test("keeps only the first CSV record of a multiline value (pflag reads ONE record)", async () => { - // Go-verified (CLI-2005): `sso add --domains $'a.com\nb"c'` raises no - // parse error — pflag calls `csv.Reader.Read()` once, so the malformed - // second line is silently dropped. const [, domains] = await Effect.runPromise( ssoAddDomainsFlag .parse({ @@ -80,8 +77,6 @@ describe("sso add --domains flag (pflag StringSlice parity)", () => { }); test("rejects a blank-only value with pflag's EOF diagnostic", async () => { - // Go-verified (CLI-2005): `sso add --domains $'\n'` → - // `invalid argument "\n" for "--domains" flag: EOF`. const exit = await Effect.runPromise( ssoAddDomainsFlag .parse({ diff --git a/apps/cli/src/commands/sso/add/add.handler.ts b/apps/cli/src/commands/sso/add/add.handler.ts index 8f70c826e2..4ff38bd523 100644 --- a/apps/cli/src/commands/sso/add/add.handler.ts +++ b/apps/cli/src/commands/sso/add/add.handler.ts @@ -66,22 +66,12 @@ const readAttributeMapping = readAttributeMappingFile({ const SSO_ADD_COMMAND_PATH = ["sso", "add"] as const; -/** - * `sso add`'s single mutually-exclusive group. Declaration order determines - * the first bracket of the mutual-exclusion error template; only the - * violating subset gets sorted. - */ +// Declaration order sets the mutex error message's bracket order. const SSO_ADD_MUTEX_GROUP = ["metadata-file", "metadata-url"] as const; -/** - * Every value-taking (non-boolean) flag reachable when `sso add` parses: - * the command's own (`add.command.ts`) plus the root's persistent value - * flags — these tell `pflagArgvScan` which bare tokens consume the next - * argv token as their value. `--skip-url-validation` is this command's only - * boolean flag and is deliberately excluded; booleans never consume a - * following token. `--type`'s `-t` shorthand is covered via the shorthand - * map so a genuine `-t saml` invocation is seen exactly as pflag sees it. - */ +// Value-taking flags for `pflagArgvScan`: each consumes the next argv token +// as its value. `--skip-url-validation` is this command's only boolean flag, +// so it's excluded. const SSO_ADD_SCAN_SPEC = { valueFlagNames: new Set([ "project-ref", @@ -108,34 +98,17 @@ export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { const rawArgs = yield* stdio.args; yield* Effect.gen(function* () { - // Required-flag and flag-group validation must precede everything else - // in the handler. Keep this block first. - // - // "Set" means the flag was passed at all — not the resulting value: - // `--metadata-file= --metadata-url x` must still trip the error even - // though the file path is empty. Scanning raw argv keeps detection - // aligned with pflag's semantics rather than with whatever the TS parser - // produced — e.g. a bare `--metadata-file --metadata-url` parses to two - // `none`s here but is a single consumed value in pflag. + // Required-flag and mutex validation run first, against raw argv rather + // than the parsed flags: pflag treats a flag as "set" once passed + // regardless of value, and consumes tokens differently than the TS + // parser does. const scan = pflagArgvScan(rawArgs, SSO_ADD_COMMAND_PATH, SSO_ADD_SCAN_SPEC); const occurrences = scan.occurrences; - // pflag calls `Value.Set` for every occurrence in argv order, and an - // invalid value fails flag parsing before required-flag/mutex validation - // and the handler body — reachable here because the Effect parser - // resolves repeated flags first-wins without validating later - // occurrences (`--type saml --type bogus` parses here, but pflag rejects - // `bogus` and never lets the request happen) and accepts `yes`/`no`, - // which `strconv.ParseBool` rejects. These checks precede the - // missing-value check because a missing value can only arise at the - // final argv token, so every recorded occurrence pflag would reject - // sits earlier in its sequential walk. Flags are checked in declaration - // order; pflag itself errors in argv order when several flags carry - // invalid occurrences at once — accepted micro-divergence. - // The enum helpers also yield the pflag-effective (last-occurrence) - // values; `--type`'s stays unused because every valid occurrence is the - // enum's single member, so the parsed `flags.type` is already - // pflag-effective whenever this validation passes. + // Validate against pflag's accepted values before the missing-value, + // required-flag, and mutex checks — pflag fails on the first invalid + // occurrence even if a later one overrides it, and its bool parsing + // excludes `yes`/`no`. yield* Result.match(pflagEnumValue(occurrences, "type", ["saml"], "-t, --type"), { onFailure: (message: string) => Effect.fail(new SsoInvalidFlagValueError({ message })), onSuccess: Effect.succeed, @@ -155,41 +128,24 @@ export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { }, ); - // Flag parsing fails when a bare value-taking flag is the final token - // (`sso add --type saml --domains`) — before every validation and the - // handler body, so no POST is ever made. The Effect parser accepts that - // argv (the flag parses as unset), hence the emulation. Keep this ahead - // of the profile/workdir/required-flag/mutex checks. + // A bare value-taking flag as the final token (e.g. `--domains` with + // nothing after) is a pflag parse error; the TS parser accepts it as + // unset, so this must run before every other validation. if (scan.missingValueError !== undefined) { return yield* Effect.fail(new SsoFlagNeedsArgumentError({ message: scan.missingValueError })); } - // The effective `--profile`/`SUPABASE_PROFILE` is resolved immediately - // before the workdir check, so an unloadable profile aborts before the - // workdir check, the required-type check, the mutex check, and any - // POST — and a loadable one decides which API host receives the POST. - // Reachable exactly where the scan and the parser disagree: in `sso add - // --type saml --domains --profile alternate.yml` pflag hands `--profile` - // to `--domains` and targets the env/default profile, while the Effect - // parser read `alternate.yml` as the profile and built `CommandSettings` - // from it — without this reconciliation the POST goes to an API host the - // established behavior never contacts. Where the scan and the parser - // agree, this resolves to `none` and the config layer's apiUrl below is - // already pflag-effective. + // Reconcile the effective `--profile` before the workdir check: when the + // scan and the TS parser disagree on which token `--profile` consumed, + // the reconciled profile decides the API host, not the parser's. Where + // they agree this resolves to `none` and the config layer's apiUrl is + // already correct. const reconciledProfile = yield* resolvePflagProfile(scan); const profileApiUrl = Option.map(reconciledProfile, (profile) => profile.apiUrl); - // Reconciled-profile credentials, resolved ONCE for the main request and - // every auxiliary call (linked-project cache fill, upgrade-gate fallback - // GETs): the reconciled profile's credentials apply process-wide. - // `undefined` when the scan and the parser agree — every consumer then - // resolves from the config-layer services as before. - // Reconciled-profile credentials, resolved LAZILY (memoized) so the first - // read happens at the request site — the token gate fires AFTER - // required/mutex/workdir validation, so a missing or invalid reconciled - // token must not pre-empt those errors. Missing → the missing-token - // error; invalid → the validation failure propagates. The auxiliary - // calls (cache fill, upgrade-gate GETs) use the absorbed variant: - // failures skip, best-effort. + // Resolved once (memoized) so the token read happens after + // required/mutex/workdir validation — a missing or invalid reconciled + // token must not preempt those errors. Auxiliary calls (cache fill, + // upgrade-gate GETs) use the failure-absorbing variant below instead. const reconciledTokenCached = Option.isSome(reconciledProfile) ? yield* Effect.cached(accessTokenForProfile(reconciledProfile.value.name)) : undefined; @@ -200,26 +156,16 @@ export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { Effect.succeed(Option.none<Redacted.Redacted<string>>()), ); - // The effective `--workdir`/`SUPABASE_WORKDIR` is validated after flag - // parsing and before the required-type check and the mutex check, so a - // missing directory aborts before those checks and any POST. Reachable - // exactly where the scan and the parser disagree: in `sso add --type - // saml --project-ref <ref> --workdir --metadata-file missing.xml` pflag - // binds `"--metadata-file"` to `--workdir` and the established behavior - // exits at chdir, while the Effect parser refused that flag-shaped value - // and read `missing.xml` as metadata — without this check the - // reconciliation below would silently drop the metadata source and - // POST a provider that should never be created. + // Validate the effective `--workdir` before the required-type and mutex + // checks: when the scan and the parser disagree on what `--workdir` + // consumed, this stops a POST that would otherwise fire against the + // wrong metadata source. yield* validatePflagWorkdir(scan); - // `--type` is required: when pflag consumed the `--type` or `-t` token - // as another flag's value (e.g. `--domains --type saml` or `--domains -t - // saml`), pflag never marks `type` changed and the required-flag check - // fails before the handler runs — no POST is ever made. - // The Effect parser can't see this (it refuses flag-shaped values, so it - // read `--type saml` / `-t saml` as a normal flag), hence the emulation - // here. A genuine `-t saml` records a `type` occurrence via the scan's - // shorthand map and never trips this. + // `--type` is required. If pflag would have consumed the `--type`/`-t` + // token as another flag's value rather than registering it, the + // required-flag check must still fail — the TS parser can't see that + // (it refuses flag-shaped values), so this reproduces it from the scan. if (!occurrences.has("type") && scan.consumedFlagNames.has("type")) { return yield* Effect.fail( new SsoAddRequiredFlagError({ message: `required flag(s) "type" not set` }), @@ -235,20 +181,9 @@ export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { ); } - // The scan and the Effect parser can disagree on more than the mutex: - // pflag consumes flag-shaped tokens as values, the Effect parser does - // not. Everything the handler acts on below is therefore reconciled to - // the pflag-effective values from the same scan, so a suppressed mutex - // can never pair with a metadata source pflag never set (e.g. - // `--project-ref --metadata-file x.xml --metadata-url u`, where pflag - // hands `--metadata-file` to `--project-ref` and fails ref validation - // without ever touching metadata). `--type` keeps its parsed value: the - // enum has a single member every occurrence was validated against - // above, so whenever the handler runs at all the parsed value equals - // the pflag-effective one (the consumed-token case is rejected by the - // required-flag check above). `--name-id-format` and - // `--skip-url-validation` were reconciled above, alongside their pflag - // value validation. + // Everything below reads pflag-effective values from the scan rather + // than the TS-parsed flags, since pflag consumes flag-shaped tokens as + // values where the parser doesn't. const projectRef = pflagStringValue(occurrences, "project-ref"); const metadataFile = pflagStringValue(occurrences, "metadata-file"); const metadataUrl = pflagStringValue(occurrences, "metadata-url"); @@ -257,18 +192,14 @@ export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { const ref = yield* resolver.resolve(projectRef); - // Effective API base URL: the pflag-reconciled profile's when the scan - // and the parser disagreed on `--profile`, the config layer's otherwise. - // The reconciled profile applies process-wide, so the POST, the - // upgrade-gate fallback GETs, and the linked-project cache GET all - // target the same host. + // Use the pflag-reconciled profile's host when it disagreed with + // `--profile`, otherwise the config layer's — applies to the POST and + // every auxiliary call alike. const apiUrl = Option.getOrElse(profileApiUrl, () => cliSettings.apiUrl); yield* Effect.gen(function* () { - // Permissive request body. We POST as raw JSON to preserve any - // user-supplied keys inside `attribute_mapping.keys.<x>` (notably the - // `default` field that Go encodes via an inline anonymous struct and - // that the generated `V1CreateASsoProviderInput` schema omits). + // Posted as raw JSON, not the generated schema, so unlisted + // `attribute_mapping.keys.<x>` fields (e.g. `default`) survive. const body: Record<string, unknown> = { type: flags.type, }; @@ -279,8 +210,7 @@ export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { } else if (Option.isSome(metadataUrl)) { if (!skipUrlValidation) { yield* validateMetadataUrl(metadataUrl.value).pipe( - // Note: this error message suffix has no trailing period - // (differs from `update`, which does). + // No trailing period on this suffix, unlike `update`'s. Effect.mapError( (cause) => new SsoAddMetadataFileError({ @@ -318,17 +248,16 @@ export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { ) : yield* resolveAccessToken; - // Use `HttpClientRequest.bearerToken(Redacted)` rather than unwrapping the - // redacted token into a plain string ourselves — this preserves the - // redaction marker on the Authorization header so that any future debug - // serialisation of the request stays opaque about the bearer token value. + // `bearerToken(Redacted)` keeps the Authorization header's redaction + // marker, so any future debug serialization of the request stays + // opaque about the token. const request = HttpClientRequest.post( `${apiUrl}/v1/projects/${ref}/config/auth/sso/providers`, ).pipe( Option.isSome(tokenOpt) ? HttpClientRequest.bearerToken(tokenOpt.value) : (req) => req, HttpClientRequest.setHeader("User-Agent", cliSettings.userAgent), - // Body keys serialised in Go-struct order (alphabetical) so the - // cli-e2e replay server's string-compare body match succeeds. + // Alphabetical key order so the cli-e2e replay server's + // string-compare body match succeeds. HttpClientRequest.bodyText(encodeGoStructJsonBody(body), "application/json"), ); @@ -344,9 +273,8 @@ export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { if (response.status !== 201) { const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - // Apply the same cap + control-character sanitisation the typed-client error - // mapper uses (`mapHttpError`) so error output stays bounded and - // shell-safe — the raw-HTTP path must not skip these defences. + // Same cap + control-character sanitization as the typed-client + // error mapper (`mapHttpError`), so this raw-HTTP path doesn't skip it. const bodyText = sanitizeErrorBody(rawBody); const upgradeSuggested = yield* suggestUpgrade({ projectRef: ref, @@ -388,7 +316,7 @@ export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { return; } if (goFmt === "toml") { - // TOML encode failure wrapping — same pattern as list/show. + // Same TOML-encode-failure pattern as list/show. const toml = yield* Effect.try({ try: () => encodeGoToml(parsedJson, GO_SSO_PROVIDER_RESPONSE), catch: (cause) => @@ -419,8 +347,8 @@ export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { // Linked-project cache fill GETs `/v1/projects/{ref}` through the // reconciled host, never the config layer's. Effect.ensuring( - // Resolved INSIDE the ensuring effect — the memoized token read must - // not run before the handler body (see the token-gate ordering above). + // Resolved inside `ensuring` so the memoized token read doesn't run + // before the handler body. Effect.flatMap(reconciledTokenForAux, (token) => linkedProjectCache.cache(ref, undefined, Option.getOrUndefined(profileApiUrl), token), ), diff --git a/apps/cli/src/commands/sso/add/add.integration.test.ts b/apps/cli/src/commands/sso/add/add.integration.test.ts index a51575a964..26da20bc98 100644 --- a/apps/cli/src/commands/sso/add/add.integration.test.ts +++ b/apps/cli/src/commands/sso/add/add.integration.test.ts @@ -42,18 +42,14 @@ interface SetupOpts { // Metadata-URL fetch responses keyed by URL prefix. metadataUrlResponse?: { status: number; body: string }; /** - * Raw argv the handler sees via `Stdio.Stdio` — drives the pflag-faithful - * scan (`pflagArgvScan`) behind the required-flag check, the mutex check, - * and the value reconciliation. Defaults to a bare invocation with no optional - * flags present; tests that pass flags must pass matching argv here - * (usually via `cliArgsFor`), exactly as the real parser guarantees. + * Raw argv the handler sees via `Stdio.Stdio`, driving the pflag-faithful + * scan behind the required-flag, mutex, and value-reconciliation checks. + * Keep in sync with the flags passed to `ssoAdd` (usually via `cliArgsFor`). */ cliArgs?: ReadonlyArray<string>; /** - * The Effect-parsed `--profile` value (`ProfileFlag`), which the real - * parser sets for any `--profile` it accepted. Tests whose `cliArgs` carry a - * `--profile` the parser would have consumed must provide it, exactly as the - * real CLI tree would. + * The Effect-parsed `--profile` value. Must be set whenever `cliArgs` + * carries a `--profile` the parser would have consumed. */ profileFlag?: string; } @@ -185,11 +181,9 @@ const defaultFlags = { }; /** - * Serializes a flags record into the raw argv the real CLI would have been - * invoked with. The handler reconciles every value it acts on against a - * pflag-faithful scan of this argv, so tests must keep the two consistent — - * a flag passed in the record but absent from argv reconciles to "not set", - * exactly as it would be for a real invocation. + * Serializes a flags record into matching raw argv, since the handler + * reconciles the values it acts on against a scan of argv, not the flags + * record itself. */ function cliArgsFor(flags: typeof defaultFlags): ReadonlyArray<string> { const argv: string[] = ["sso", "add", "--type", flags.type]; @@ -256,8 +250,6 @@ describe("sso add integration", () => { if (Exit.isFailure(exit)) { const dump = JSON.stringify(exit.cause); expect(dump).toContain("SsoMutexFlagError"); - // Established mutual-exclusion error template: group in - // declaration order, changed flags sorted alphabetically. expect(dump).toContain( "if any flags in the group [metadata-file metadata-url] are set none of the others can be; [metadata-file metadata-url] were all set", ); @@ -269,10 +261,6 @@ describe("sso add integration", () => { it.live( "mutex check: an explicit but empty --metadata-file= still conflicts with --metadata-url (changed, not truthy)", () => { - // `--metadata-file=` parses to an empty string, but cobra's - // `pflag.Changed` tracks that the flag was passed at all, not the - // resulting value — the mutex must trip on an explicit empty value, - // and with cobra's exact template, not the old hand-written message. const { layer } = setup({ cliArgs: [ "sso", @@ -307,15 +295,6 @@ describe("sso add integration", () => { it.live( "mutex check: a bare --metadata-file followed by --metadata-url is not a violation, and the consumed token is the file", () => { - // pflag's `--flag arg` branch consumes the very next argv token as the - // value unconditionally (`flag.go:1013-1031`), so real cobra parses - // this as `metadata-file` receiving the literal value - // `"--metadata-url"` — `metadata-url` is never parsed as its own flag - // and stays unset. The raw-argv scan must reach the same conclusion: - // no mutex violation, and the handler must then behave exactly like Go - // — try to open a file literally named `--metadata-url` (Go: `failed - // to open metadata file: open --metadata-url: no such file or - // directory`), not silently succeed with no metadata at all. const { layer, api } = setup({ cliArgs: ["sso", "add", "--type", "saml", "--metadata-file", "--metadata-url"], }); @@ -335,16 +314,6 @@ describe("sso add integration", () => { it.live( "reconciles project-ref consuming --metadata-file: fails ref validation like Go, never reads metadata", () => { - // `sso add --type saml --project-ref --metadata-file file.xml - // --metadata-url URL`: pflag hands `--metadata-file` to `--project-ref` - // as its value, `file.xml` becomes a positional, and only - // `metadata-url` is set — no mutex violation. The Effect parser instead - // drops the bare `--project-ref` and parses both metadata options, so - // without reconciliation the handler would read `file.xml` and POST - // `metadata_xml` — an API call that should never happen: ref - // validation must fail on the value `--metadata-file` before touching - // metadata. The reconciled handler must fail with the established - // invalid-ref error and make no API request. const { layer, api } = setup({ cliArgs: [ "sso", @@ -380,13 +349,6 @@ describe("sso add integration", () => { it.live( "required emulation: a bare --domains consuming --type fails the required-flag check, no POST", () => { - // `sso add --domains --type saml`: pflag hands `--type` to `--domains` - // as its value and `saml` becomes a positional — `type` is never - // marked changed, so Go fails cobra's `ValidateRequiredFlags` - // (`command.go:1007`, `MarkFlagRequired("type")` at `cmd/sso.go:165`) - // before `RunE` and never POSTs. The Effect parser read `--type saml` - // as a normal flag, so the handler must re-derive the required check - // from the scan (PR #5974 review). const { layer, api } = setup({ cliArgs: ["sso", "add", "--domains", "--type", "saml"], }); @@ -404,10 +366,6 @@ describe("sso add integration", () => { ); it.live("required emulation: the required-flag error wins over a mutex violation", () => { - // cobra runs `ValidateRequiredFlags` (`command.go:1007`) before - // `ValidateFlagGroups` (`command.go:1010`), so when `--domains` swallows - // `--type` AND both metadata flags are set, Go reports the required-flag - // error, not the mutex template (binary-verified). const { layer, api } = setup({ cliArgs: [ "sso", @@ -442,13 +400,6 @@ describe("sso add integration", () => { it.live( "workdir emulation: --workdir consuming --metadata-file fails at Go's chdir, never POSTs", () => { - // `sso add --type saml --project-ref <ref> --workdir --metadata-file - // missing.xml`: pflag binds `"--metadata-file"` to the persistent - // `--workdir` and the workdir change exits before the handler runs - // with zero HTTP traffic. The Effect parser refused the flag-shaped - // value (workdir stayed unset) and read `missing.xml` as metadata — - // without the workdir emulation the reconciliation discarded that - // metadata and POSTed a provider that should never be created. const { layer, api } = setup({ cliArgs: [ "sso", @@ -486,9 +437,6 @@ describe("sso add integration", () => { it.live( "workdir emulation: the chdir failure wins over required-type and mutex violations", () => { - // The workdir change runs before required-flag and flag-group - // validation, so a missing workdir beats both the missing required - // `--type` and the metadata mutex. const { layer, api } = setup({ cliArgs: [ "sso", @@ -525,8 +473,6 @@ describe("sso add integration", () => { ); it.live("workdir emulation: an existing --workdir directory proceeds to the POST", () => { - // Go chdir's into an existing workdir and continues to `RunE` — the - // emulation must only reject what `os.Chdir` would reject. const { layer, api } = setup({ cliArgs: ["sso", "add", "--type", "saml", "--workdir", tempRoot.current], }); @@ -538,9 +484,6 @@ describe("sso add integration", () => { }); it.live("required emulation: a -t shorthand invocation POSTs normally", () => { - // The scan resolves `-t saml` to a `type` occurrence via the shorthand - // map (`cmd/sso.go:157` registers `VarP`), so a genuine shorthand - // invocation must never trip the emulated required-flag check. const { layer, api } = setup({ cliArgs: ["sso", "add", "-t", "saml"], }); @@ -554,9 +497,6 @@ describe("sso add integration", () => { it.live( "required emulation: -t saml plus a consumed --type still POSTs, like pflag (type IS changed)", () => { - // `-t saml --domains --type saml`: pflag sets `type` via the shorthand - // first, then `--domains` swallows the long `--type` token — the flag - // is changed, so Go proceeds and POSTs with `domains: ["--type"]`. const { layer, api } = setup({ cliArgs: ["sso", "add", "-t", "saml", "--domains", "--type", "saml"], }); @@ -574,13 +514,6 @@ describe("sso add integration", () => { it.live( "required emulation: a bare --domains consuming -t fails the required-flag check, no POST", () => { - // `sso add --domains -t saml` (and `-t=saml`) fails the required-flag - // check — pflag hands the `-t` token to `--domains` as its value, so - // `type` is never marked changed and `saml` becomes a positional (the - // `add` command has no arity validation and accepts it). The Effect - // parser read `-t saml` as a normal flag, so without the scan's - // consumed-shorthand tracking the handler would POST `type: "saml"`, - // `domains: ["-t"]`. const { layer, api } = setup({ cliArgs: ["sso", "add", "--domains", "-t", "saml"], }); @@ -600,12 +533,6 @@ describe("sso add integration", () => { it.live( "invalid-value emulation: a later invalid --type occurrence fails with pflag's shorthand-labelled error, no POST", () => { - // `--type saml --type bogus`: the Effect parser resolves repeats - // first-wins and never validates the rest, so it parses; pflag Sets - // every occurrence in order and rejects `bogus` at ParseFlags — - // before every hook, the required-flag check, and the POST - // (binary-verified, PR #5974 review round 4). pflag names the flag - // with its shorthand (`-t, --type`, errors.go:39-41). const { layer, api } = setup({ cliArgs: ["sso", "add", "--type", "saml", "--type", "bogus"], }); @@ -627,12 +554,6 @@ describe("sso add integration", () => { it.live( "invalid-value emulation: a later inline-empty --skip-url-validation= fails like pflag, no POST", () => { - // `--skip-url-validation=false --skip-url-validation=`: the Effect - // parser resolves repeats first-wins and never validates the second - // occurrence, so it parses; pflag hands `""` to strconv.ParseBool - // (`flag.go:1014-1016`) and aborts ParseFlags before every hook and - // the POST — only a *bare* repeat means NoOptDefVal true - // (binary-verified, PR #5974 review round 5). const { layer, api } = setup({ cliArgs: [ "sso", @@ -669,10 +590,6 @@ describe("sso add integration", () => { it.live( "value reconciliation: repeated --skip-url-validation resolves last-wins like pflag and skips validation", () => { - // `--skip-url-validation=false --skip-url-validation` ends true for - // pflag (Sets every occurrence) but false for the Effect parser - // (first-wins) — Go skips URL validation and POSTs the non-HTTPS URL - // (binary-verified, PR #5974 review round 4). const { layer, api } = setup({ cliArgs: [ "sso", @@ -702,9 +619,6 @@ describe("sso add integration", () => { it.live( "value reconciliation: repeated --name-id-format resolves last-wins like pflag in the POST body", () => { - // pflag's Set runs per occurrence, so the last one wins; the Effect - // parser resolved first-wins (binary-verified, PR #5974 review - // round 4). const transient = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" as const; const persistent = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"; const { layer, api } = setup({ @@ -729,12 +643,6 @@ describe("sso add integration", () => { ); it.live("missing-value emulation: a trailing bare --domains fails pflag parse, no POST", () => { - // Binary-verified: `sso add --type saml --domains` errors - // `flag needs an argument: --domains` — pflag fails `ParseFlags` (cobra - // `command.go:919`) before the required-flag and mutex validations and - // Go never POSTs. The Effect parser accepts the argv (the flag parses - // as unset), so the handler must reject it before any side effect - // (PR #5974 review round 3). const { layer, api } = setup({ cliArgs: ["sso", "add", "--type", "saml", "--domains"], }); @@ -753,12 +661,6 @@ describe("sso add integration", () => { it.live( "reconciles a bare --domains consuming --metadata-file: POSTs the domain pflag saw, no metadata", () => { - // `--domains --metadata-file x.xml`: pflag appends the literal string - // `--metadata-file` to the domains slice and `x.xml` becomes a - // positional — `metadata-file` is never set. The Effect parser drops - // the bare `--domains` and parses `--metadata-file x.xml` instead, so - // without reconciliation the request body would carry `metadata_xml` - // and no domains — the opposite of the established body. const { layer, api } = setup({ cliArgs: ["sso", "add", "--type", "saml", "--domains", "--metadata-file", "x.xml"], }); @@ -776,11 +678,6 @@ describe("sso add integration", () => { it.live( "reconciles a bare --metadata-url consuming --name-id-format: validates the consumed token as the URL", () => { - // `--metadata-url --name-id-format urn:…`: pflag hands - // `--name-id-format` to `--metadata-url` as its value and the urn - // becomes a positional — `name-id-format` is never set. Go then fails - // URL validation on the literal string `--name-id-format`; the body - // must not pick up the parsed name-id-format either. const { layer, api } = setup({ cliArgs: [ "sso", @@ -802,10 +699,6 @@ describe("sso add integration", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const dump = JSON.stringify(exit.cause); - // URL validation runs against the consumed token, not the parsed - // Option — it is not a valid HTTPS URL, so the command fails before - // any request, like Go. URL implementations do not consistently - // include the rejected input in their exception text. expect(dump).toContain("SsoAddMetadataFileError"); expect(dump).toContain("Use --skip-url-validation to suppress this error"); } @@ -815,9 +708,7 @@ describe("sso add integration", () => { ); it.live("falls back to the parsed domains when the scan's raw values are malformed CSV", () => { - // Unreachable through the real CLI (the parser rejects malformed CSV at - // parse time), but the reconciliation must not crash if the consumed - // token is un-parseable — it keeps the parser's values instead. + // Unreachable via the real CLI; only tests that reconciliation doesn't crash on malformed input. const { layer, api } = setup({ cliArgs: ["sso", "add", "--type", "saml", "--domains", '--x"y'], }); @@ -831,7 +722,6 @@ describe("sso add integration", () => { it.live("reads metadata file and sends as metadata_xml", () => { const path = join(tempRoot.current, "good.xml"); writeFileSync(path, '<?xml version="1.0"?><md/>'); - // A single metadata flag on the raw argv must sail through the mutex scan. const { layer, api } = setup({ cliArgs: ["sso", "add", "--type", "saml", "--metadata-file", path], }); @@ -857,7 +747,6 @@ describe("sso add integration", () => { }); it.live("sends metadata_url verbatim when --skip-url-validation", () => { - // A single metadata flag on the raw argv must sail through the mutex scan. const { layer, api } = setup({ cliArgs: [ "sso", @@ -1068,17 +957,14 @@ describe("sso add integration", () => { if (Exit.isFailure(exit)) { const dump = JSON.stringify(exit.cause); expect(dump).toContain("SsoAddMetadataFileError"); - // Error tail is `… Use --skip-url-validation to suppress this error` - // (no trailing period). expect(dump).toContain("Use --skip-url-validation to suppress this error"); } }).pipe(Effect.provide(layer)); }); - // Non-UTF-8 body coverage lives in `sso.saml.unit.test.ts` — the test runtime - // here passes the response body through `Response`'s constructor which always - // emits valid UTF-8 bytes regardless of the input string, so a byte-level - // invalid sequence cannot be expressed without bypassing the Response API. + // Non-UTF-8 body coverage lives in `sso.saml.unit.test.ts`: this runtime's + // `Response` constructor always emits valid UTF-8, so that case can't be + // expressed here. it.live("malformed metadata URL surfaces invalid URI error", () => { const flags = { @@ -1125,13 +1011,6 @@ describe("sso add integration", () => { }).pipe(Effect.provide(layer)); }); - // ------------------------------------------------------------------------- - // Profile emulation: the effective `--profile`/`SUPABASE_PROFILE` is - // resolved immediately before the workdir change — it decides which API - // host receives the POST and aborts the command when the profile cannot - // be loaded. - // ------------------------------------------------------------------------- - const writeProfileYaml = (name: string, apiUrl: string): string => { const path = join(tempRoot.current, name); writeFileSync( @@ -1165,13 +1044,6 @@ describe("sso add integration", () => { it.live( "profile emulation: --domains consuming --profile POSTs to the env profile's host, not the parsed file's", () => { - // `sso add --type saml --domains --profile alternate.yml`: pflag hands - // `--profile` to `--domains` and never marks profile changed, so viper - // falls to SUPABASE_PROFILE — while the Effect parser read - // `alternate.yml` as the profile and built `CommandSettings` from it. - // Binary-verified (the demonstrated divergent input, PR #5974 round 7): - // Go POSTs `{"domains":["--profile"],"type":"saml"}` to the env - // profile's api_url; the parsed file's host receives nothing. const envProfile = writeProfileYaml("env-profile.yml", "http://reconciled.example"); const alternate = writeProfileYaml("alternate.yml", "http://alternate.example"); const restoreEnv = withProfileEnv(envProfile); @@ -1189,8 +1061,6 @@ describe("sso add integration", () => { expect((posts[0]?.body as { domains?: ReadonlyArray<string> })?.domains).toEqual([ "--profile", ]); - // The linked-project cache fill targets the reconciled host too — - // it uses the process-wide profile. expect(cache.cachedApiUrl).toBe("http://reconciled.example"); }).pipe(Effect.ensuring(restoreEnv), Effect.provide(layer)); }, @@ -1199,10 +1069,6 @@ describe("sso add integration", () => { it.live( "profile emulation: --profile consuming a flag-shaped token fails LoadProfile, never POSTs", () => { - // `sso add --type saml --profile --metadata-url u`: pflag binds - // `"--metadata-url"` as the profile value; viper's extension gate - // rejects it before any request (binary-verified: `failed to read - // profile: Unsupported Config Type ""`). const restoreEnv = withProfileEnv(undefined); const { layer, api } = setup({ cliArgs: [ @@ -1234,9 +1100,6 @@ describe("sso add integration", () => { ); it.live("profile emulation: repeated --profile resolves last-wins, matching pflag", () => { - // `--profile a.yml --profile b.yml`: the Effect parser is first-wins (the - // config layer resolved a.yml) while pflag Sets every occurrence and ends - // on b.yml — binary-verified: Go POSTs to b.yml's api_url. const first = writeProfileYaml("first.yml", "http://first.example"); const second = writeProfileYaml("second.yml", "http://second.example"); const restoreEnv = withProfileEnv(undefined); @@ -1257,9 +1120,6 @@ describe("sso add integration", () => { it.live( "profile emulation: the LoadProfile failure wins over the workdir, required-type, and mutex checks", () => { - // Go loads the profile BEFORE ChangeWorkDir (`cmd/root.go:98-105` — - // "Load profile before changing workdir"), and both run before - // `ValidateRequiredFlags` and `ValidateFlagGroups`. const restoreEnv = withProfileEnv(undefined); const { layer, api } = setup({ cliArgs: [ @@ -1298,10 +1158,6 @@ describe("sso add integration", () => { it.live( "profile emulation: an agreeing --profile keeps the config layer's resolution (no override)", () => { - // When the scan and the parser saw the same token (every normal - // invocation), the reconciliation resolves to `none` and the POST - // targets `CommandSettings.apiUrl` — the layer already loaded exactly - // the profile Go would. const agreed = writeProfileYaml("agreed.yml", "http://agreed.example"); const restoreEnv = withProfileEnv(undefined); const { layer, api } = setup({ @@ -1312,8 +1168,6 @@ describe("sso add integration", () => { yield* ssoAdd(defaultFlags); const posts = api.requests.filter((r) => r.method === "POST"); expect(posts.length).toBe(1); - // The mock layer's apiUrl, NOT agreed.yml's — the layer is authoritative - // when there is no scan/parser disagreement. expect(posts[0]?.url).toBe( `${DEFAULT_API_URL}/v1/projects/${VALID_REF}/config/auth/sso/providers`, ); diff --git a/apps/cli/src/commands/sso/info/info.integration.test.ts b/apps/cli/src/commands/sso/info/info.integration.test.ts index 0358040c4d..6a8fe76f2b 100644 --- a/apps/cli/src/commands/sso/info/info.integration.test.ts +++ b/apps/cli/src/commands/sso/info/info.integration.test.ts @@ -59,11 +59,6 @@ describe("sso info integration", () => { }).pipe(Effect.provide(layer)); }); - // Established output contract: the ACS URL row renders as - // `Single sign-on URL (ACS URL) | https://…` (single space before the - // column separator). An earlier attempt to preserve the markdown source's - // trailing space produced two spaces against our flat renderer's padding. - // Pin the exact byte sequence so it can't drift back. it.live("emits exactly one space between the ACS URL label and the column separator", () => { const { layer, out } = setup(); return Effect.gen(function* () { @@ -104,7 +99,6 @@ describe("sso info integration", () => { expect(out.stdoutText).toContain('"acs_url"'); expect(out.stdoutText).toContain('"entity_id"'); expect(out.stdoutText).toContain('"relay_state"'); - // Keys are alphabetized — acs_url should appear before entity_id. const acsIndex = out.stdoutText.indexOf("acs_url"); const entityIndex = out.stdoutText.indexOf("entity_id"); expect(acsIndex).toBeLessThan(entityIndex); diff --git a/apps/cli/src/commands/sso/list/list.integration.test.ts b/apps/cli/src/commands/sso/list/list.integration.test.ts index 406db91496..23730cb38b 100644 --- a/apps/cli/src/commands/sso/list/list.integration.test.ts +++ b/apps/cli/src/commands/sso/list/list.integration.test.ts @@ -17,9 +17,7 @@ import { EventUpgradeSuggested } from "../../../shared/telemetry/event-catalog.t import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; import { ssoList } from "./list.handler.ts"; -// Mirrors what the Management API returns: neither `saml.id` nor -// `domains[].id` is part of the provider response (nor of Go's -// `api.ListProvidersResponse`). +// Neither `saml.id` nor `domains[].id` is part of the actual provider response. const PROVIDER_ITEM = { id: "0b0d48f6-878b-4190-88d7-2ca33ed800bc", saml: { @@ -153,8 +151,8 @@ describe("sso list integration", () => { }).pipe(Effect.provide(layer)); }); - // Some projects still echo the nested IDs the spec dropped. Go ignores them - // (no struct field), so they must neither break decoding nor reach `-o json`. + // Some projects still echo nested IDs the schema dropped; these must not + // break decoding or reach `-o json`. it.live("ignores nested saml.id / domains[].id when the API still sends them", () => { const item = { ...PROVIDER_ITEM, diff --git a/apps/cli/src/commands/sso/show/show.handler.ts b/apps/cli/src/commands/sso/show/show.handler.ts index 09a5292ec1..8a8588de52 100644 --- a/apps/cli/src/commands/sso/show/show.handler.ts +++ b/apps/cli/src/commands/sso/show/show.handler.ts @@ -31,8 +31,7 @@ const mapStatusOrNetwork = mapHttpError({ const handleShowError = (providerId: string, cause: SupabaseApiError) => Effect.gen(function* () { const mapped = yield* Effect.flip(mapStatusOrNetwork(cause)); - // `show` is intentionally omitted from the upgrade-suggestion paths - // (see plan §"Telemetry parity"). + // `show` does not fire upgrade-suggestion telemetry, unlike add/update/list. if (mapped._tag === "SsoShowUnexpectedStatusError" && mapped.status === 404) { return yield* Effect.fail( new SsoShowNotFoundError({ @@ -76,7 +75,6 @@ export const ssoShow = Effect.fn("sso.show")(function* (flags: SsoShowFlags) { const goFmt = Option.getOrUndefined(goOutputFlag); if (goFmt === "env") { - // Established `--output env` unsupported error message. return yield* Effect.fail( new SsoShowEnvNotSupportedError({ message: "--output env flag is not supported", @@ -92,8 +90,7 @@ export const ssoShow = Effect.fn("sso.show")(function* (flags: SsoShowFlags) { return; } if (goFmt === "toml") { - // TOML encode failure wrapping (e.g. a nil element in an - // attribute-mapping `default` array). + // TOML encoding can fail on a nil element in an attribute-mapping `default` array. const toml = yield* Effect.try({ try: () => encodeGoToml(response, GO_SSO_PROVIDER_RESPONSE), catch: (cause) => diff --git a/apps/cli/src/commands/sso/show/show.integration.test.ts b/apps/cli/src/commands/sso/show/show.integration.test.ts index a8b0d0a2a1..5664ca0bdd 100644 --- a/apps/cli/src/commands/sso/show/show.integration.test.ts +++ b/apps/cli/src/commands/sso/show/show.integration.test.ts @@ -182,8 +182,6 @@ describe("sso show integration", () => { }); expect(out.stdoutText.startsWith("{")).toBe(true); expect(out.stdoutText).toContain(VALID_PROVIDER_ID); - // The JSON encoder escapes `<` / `>` / `&` by default, so metadata_xml - // must carry \u003c-style escapes byte-for-byte. expect(out.stdoutText).toContain('"metadata_xml": "\\u003c?xml version=\\"2.0\\"?\\u003e"'); expect(out.stdoutText).not.toContain('"metadata_xml": "<?xml'); }).pipe(Effect.provide(layer)); @@ -197,8 +195,6 @@ describe("sso show integration", () => { providerId: VALID_PROVIDER_ID, metadata: false, }); - // Established output contract: lowercased field names, explicit nulls - // for absent values, 4-column nesting, quoted string timestamps. expect(out.stdoutText).toBe(`createdat: "2023-03-28T13:50:14.464Z" domains: - createdat: null @@ -224,8 +220,6 @@ updatedat: "2023-03-28T13:50:14.464Z" providerId: VALID_PROVIDER_ID, metadata: false, }); - // Established output contract: PascalCase field names, absent values - // omitted, sub-tables after primitives. expect(out.stdoutText).toBe(`CreatedAt = "2023-03-28T13:50:14.464Z" Id = "${VALID_PROVIDER_ID}" UpdatedAt = "2023-03-28T13:50:14.464Z" diff --git a/apps/cli/src/commands/sso/sso.e2e.test.ts b/apps/cli/src/commands/sso/sso.e2e.test.ts index 0d8ff01ea6..1c551680cc 100644 --- a/apps/cli/src/commands/sso/sso.e2e.test.ts +++ b/apps/cli/src/commands/sso/sso.e2e.test.ts @@ -70,17 +70,10 @@ describe("supabase sso", () => { }, ); - // `add`'s `--type` has no `Flag.optional` (see `add.command.ts`) — it is a - // required flag — so a missing/invalid `--type` used to dump the full help - // doc to stdout AND print the error twice on stderr. No auth/network call - // ever happens for either case: flag parsing fails before the handler runs. - // - // A missing required flag and an invalid choice value get different - // treatment: usage-silencing is set BEFORE required-flag validation runs, - // so a missing `--type` is a single clean stderr line with no usage - // block — but `Flag.choice` validation happens during parsing, BEFORE - // that point, so an invalid `--type` value still shows a usage block, - // always on stderr, never stdout. + // Usage-silencing is set before required-flag validation, so a missing + // `--type` prints a single clean stderr line with no usage block — but + // `Flag.choice` validation runs during parsing, before that point, so an + // invalid `--type` value still shows a usage block. test( "add without --type: stdout stays clean, stderr is a single Go-parity line (no usage block)", { timeout: E2E_TIMEOUT_MS }, diff --git a/apps/cli/src/commands/sso/sso.errors.ts b/apps/cli/src/commands/sso/sso.errors.ts index 2b6ff3f46e..3421bdaea4 100644 --- a/apps/cli/src/commands/sso/sso.errors.ts +++ b/apps/cli/src/commands/sso/sso.errors.ts @@ -25,10 +25,8 @@ function ssoFileActionability(reason: SsoFileErrorReason): CliErrorActionability } /** - * The SAML feature is entitlement-gated: handlers thread the typed result of - * `suggestUpgrade` (`upgradeSuggested`) into these errors so telemetry - * can distinguish plan-gated failures from ordinary API failures without - * sniffing message text. + * SAML is entitlement-gated: `upgradeSuggested` lets telemetry distinguish + * plan-gated failures from ordinary API failures without parsing message text. */ const samlDisabledActionability = ( upgradeSuggested: boolean | undefined, @@ -42,9 +40,7 @@ const gatedNotFoundActionability = ( ): CliErrorActionabilityDeclaration => upgradeSuggested === true ? planLimitGatedActionability : actionability.invalidInput; -// Shared across show / update / remove: invalid identity provider ID. -// Message is a short, directly user-actionable string — -// `identity provider ID %q is not a UUID` — tested in e2e. +// Shared across show/update/remove. Message: `identity provider ID %q is not a UUID`. export class SsoInvalidUuidError extends Data.TaggedError("SsoInvalidUuidError")<{ readonly providerId: string; readonly message: string; @@ -54,9 +50,8 @@ export class SsoInvalidUuidError extends Data.TaggedError("SsoInvalidUuidError") } } -// Shared across list / show: TOML encode failure ("failed to output toml: %w") -// — reachable when an `attribute_mapping` `default` value cannot be encoded -// (e.g. an array with a nil element). +// Shared across list/show. Reachable when an `attribute_mapping` `default` +// value can't be encoded (e.g. an array with a nil element). export class SsoTomlEncodeError extends Data.TaggedError("SsoTomlEncodeError")<{ readonly message: string; }> { @@ -65,7 +60,6 @@ export class SsoTomlEncodeError extends Data.TaggedError("SsoTomlEncodeError")<{ } } -// `sso list` export class SsoListNetworkError extends Data.TaggedError("SsoListNetworkError")<{ readonly message: string; readonly decode?: boolean; @@ -97,7 +91,6 @@ export class SsoListUnexpectedStatusError extends Data.TaggedError("SsoListUnexp } } -// `sso add` export class SsoAddNetworkError extends Data.TaggedError("SsoAddNetworkError")<{ readonly message: string; }> { @@ -154,12 +147,9 @@ export class SsoMutexFlagError extends Data.TaggedError("SsoMutexFlagError")<{ } } -// pflag's `ValueRequiredError` (`errors.go:63-78`), emulated for the case the -// Effect parser accepts but pflag rejects: a bare value-taking flag as the -// final argv token (`sso update <id> --domains`). pflag fails `ParseFlags` -// (cobra `command.go:919`) before `ValidateArgs`, every hook, and `RunE`, so -// Go exits without any API call. Shared across add + update; message -// byte-matches pflag's template. +// Emulates pflag's rejection of a bare value-taking flag as the final argv +// token, a case the Effect parser accepts. Shared by add + update; the +// message matches pflag's template. export class SsoFlagNeedsArgumentError extends Data.TaggedError("SsoFlagNeedsArgumentError")<{ readonly message: string; }> { @@ -168,15 +158,11 @@ export class SsoFlagNeedsArgumentError extends Data.TaggedError("SsoFlagNeedsArg } } -// pflag's `InvalidValueError` (`errors.go:32-48`, raised when a flag's -// `Value.Set` rejects an occurrence), emulated for values the Effect parser -// accepts but pflag does not: a repeated flag whose later occurrence is -// invalid (the Effect parser resolves repeats first-wins and never validates -// the rest — `--type saml --type bogus`), and boolean literals outside Go's -// `strconv.ParseBool` set (`--skip-url-validation=yes`). pflag fails -// `ParseFlags` (cobra `command.go:919`) before `ValidateArgs`, every hook, -// and `RunE`, so Go exits without any API call. Shared across add + update; -// message byte-matches pflag's template. +// Emulates pflag's rejection of an invalid flag value in cases the Effect +// parser accepts: a later occurrence of a repeated flag (the parser resolves +// repeats first-wins and never validates the rest), or a boolean literal +// outside pflag's accepted set. Shared by add + update; the message matches +// pflag's template. export class SsoInvalidFlagValueError extends Data.TaggedError("SsoInvalidFlagValueError")<{ readonly message: string; }> { @@ -185,10 +171,9 @@ export class SsoInvalidFlagValueError extends Data.TaggedError("SsoInvalidFlagVa } } -// Emulates an edge case the flag parser cannot see directly: a required -// flag's own token gets consumed as another flag's value, so the flag is -// never marked as present and validation fails before any request is made. -// Message text is an established output contract. +// Fires when a required flag's own token is consumed as another flag's +// value, so it's never marked present. The message text is a stable output +// contract. export class SsoAddRequiredFlagError extends Data.TaggedError("SsoAddRequiredFlagError")<{ readonly message: string; }> { @@ -210,10 +195,8 @@ export class SsoMetadataUrlNetworkError extends Data.TaggedError("SsoMetadataUrl readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // Fired only during preflight validation of the USER-SUPPLIED - // `--metadata-url` (a third-party SAML IDP endpoint), never a Supabase - // service — a bad URL that times out / non-200s / is too large is user - // input, like its `MetadataUrlInvalid` / `NonUtf8` siblings. + // Fires only for the user-supplied `--metadata-url` endpoint, never a + // Supabase service, so failures here are user input like the sibling errors. return actionability.provideFlags; } } @@ -226,7 +209,6 @@ export class SsoMetadataUrlNonUtf8Error extends Data.TaggedError("SsoMetadataUrl } } -// `sso show` export class SsoShowNetworkError extends Data.TaggedError("SsoShowNetworkError")<{ readonly message: string; readonly decode?: boolean; @@ -264,11 +246,9 @@ export class SsoShowEnvNotSupportedError extends Data.TaggedError("SsoShowEnvNot } } -// `sso update` -// Emulates an edge case the flag parser cannot see directly: a flag token -// gets consumed as another flag's value, shifting what the parser read as a -// flag's value into the positional list, so the arg count is rejected before -// any hook or request. Message text is an established output contract. +// Fires when a flag token is consumed as another flag's value, shifting a +// value into the positional list so arg-count validation rejects it first. +// The message text is a stable output contract. export class SsoUpdateArityError extends Data.TaggedError("SsoUpdateArityError")<{ readonly message: string; }> { @@ -333,7 +313,6 @@ export class SsoUpdateAttributeMappingFileError extends Data.TaggedError( } } -// `sso remove` export class SsoRemoveNetworkError extends Data.TaggedError("SsoRemoveNetworkError")<{ readonly message: string; readonly decode?: boolean; @@ -368,9 +347,8 @@ export class SsoRemoveUnexpectedStatusError extends Data.TaggedError( } /** - * Token gate: fired when the reconciled profile's token lookup finds - * nothing — at first client use, AFTER required/mutex/workdir validation - * (PR #5974 review round 10). + * Fired when the reconciled profile's token lookup finds nothing, at first + * use — after required/mutex/workdir validation runs. */ export class SsoAccessTokenError extends Data.TaggedError("SsoAccessTokenError")<{ readonly message: string; diff --git a/apps/cli/src/commands/sso/sso.format.ts b/apps/cli/src/commands/sso/sso.format.ts index 30ac1f8a2f..5f115da884 100644 --- a/apps/cli/src/commands/sso/sso.format.ts +++ b/apps/cli/src/commands/sso/sso.format.ts @@ -3,8 +3,6 @@ import { Result } from "effect"; import { renderGlamourTable } from "../../output/glamour-table.ts"; import { SsoInvalidUuidError } from "./sso.errors.ts"; -// UUIDs are matched case-insensitively so callers passing -// `B5AE62F9-…` (mixed- or upper-case) succeed. const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** @@ -140,30 +138,25 @@ export function formatMetadataSource(saml: SsoProviderView["saml"]): string { return saml.metadata_url !== undefined && saml.metadata_url !== "" ? saml.metadata_url : "FILE"; } -// Tag matcher mirroring go-xmlfmt's `reg`: captures opening, closing, comment, -// declaration, and self-closing tags. Non-greedy inner body so we don't span -// across consecutive `<…>` clusters. +// Matches opening, closing, comment, declaration, and self-closing tags; +// non-greedy so it doesn't span across consecutive `<…>` clusters. const XMLFMT_TAG_RE = /<([/!]?)([^>]+?)(\/?)>/g; const XMLFMT_INTERTAG_SPACES_RE = />\s+</g; /** * Pretty-prints an XML document by inserting newlines and indentation between - * tags. Behaviour-compatible port of `github.com/go-xmlfmt/xmlfmt@v1.1.3` - * `FormatXML(xml, prefix, indent)` (without the nested-tags-in-comments - * branch, which the SSO render call site never enables). Each tag boundary - * becomes `\n + prefix + indent.repeat(depth) + <tag>`; opening tags increment - * depth after emission, closing tags decrement before. Text content between - * adjacent open/close tags (e.g. `<b>text</b>`) is preserved inline so the - * close tag rides the same line. + * tags, matching `go-xmlfmt/xmlfmt@v1.1.3`'s output byte-for-byte (excluding + * its unused nested-tags-in-comments branch). Text between adjacent + * open/close tags (e.g. `<b>text</b>`) stays inline; other tags start a new, + * indented line. */ export function formatSsoMetadataXml(xml: string, prefix = " ", indent = " "): string { // Collapse whitespace between adjacent tags so we control the layout. const src = xml.replace(XMLFMT_INTERTAG_SPACES_RE, "><"); let depth = 0; - // Tracks whether the previous tag was a closing or self-closing tag. - // Used to decide whether a closing tag rides the same line as adjacent - // content (lastEndElem=false → inline close, e.g. `<b>text</b>`). + // Tracks whether the previous tag was closing/self-closing; when false, a + // closing tag stays inline (e.g. `<b>text</b>`). let lastEndElem = true; const replaced = src.replace(XMLFMT_TAG_RE, (match) => { @@ -185,7 +178,6 @@ export function formatSsoMetadataXml(xml: string, prefix = " ", indent = " "): lastEndElem = true; return match; } - // Opening tag: emit at the current depth, then descend for children. lastEndElem = false; const result = "\n" + prefix + indent.repeat(depth) + match; depth++; @@ -195,11 +187,9 @@ export function formatSsoMetadataXml(xml: string, prefix = " ", indent = " "): return prefix + replaced; } -// The markdown header is `SAML 2.0 \`EntityID\``; Glamour renders the -// backticks as an inline-code span which is stripped to plain text under -// AsciiStyle. `renderGlamourTable` is a flat ASCII renderer with no markdown -// awareness, so we drop the backticks here at the source for byte parity -// with the established rendered output. +// Backticks stripped: Glamour renders `SAML 2.0 \`EntityID\`` as an +// inline-code span (plain text under AsciiStyle), but `renderGlamourTable` +// has no markdown awareness, so we drop them here for byte parity. const LIST_HEADERS = [ "TYPE", "IDENTITY PROVIDER ID", @@ -229,13 +219,10 @@ export function renderListProviders(items: ReadonlyArray<SsoProviderView>): stri /** * Renders the single-provider view: property/value table plus optional - * `## Attribute Mapping` (JSON-indented) and `## SAML 2.0 Metadata XML` - * sections. + * `## Attribute Mapping` and `## SAML 2.0 Metadata XML` sections. * - * The optional sections are emitted as plain markdown (heading + fenced code - * block); we don't run them through Glamour, so visual styling differs from - * the table above. Tests assert on substring presence (`toContain`) rather - * than full byte equality. Documented in each subcommand's `SIDE_EFFECTS.md`. + * The optional sections are plain markdown, not run through Glamour, so their + * styling differs from the table above. See each subcommand's `SIDE_EFFECTS.md`. */ export function renderSingleProvider(provider: SsoProviderView): string { const rows: Array<readonly [string, string]> = [ @@ -250,8 +237,7 @@ export function renderSingleProvider(provider: SsoProviderView): string { rows.push(["NAMEID FORMAT", formatNameIdFormat(provider.saml)]); } rows.push(["CREATED AT (UTC)", formatSsoTimestamp(provider.created_at)]); - // Both CREATED AT and UPDATED AT rows use provider.created_at — an - // established output contract documented in SIDE_EFFECTS.md. + // Both rows read provider.created_at — see SIDE_EFFECTS.md. rows.push(["UPDATED AT (UTC)", formatSsoTimestamp(provider.created_at)]); const table = renderGlamourTable(["PROPERTY", "VALUE"], rows); @@ -304,12 +290,9 @@ export function renderInfoMarkdown(ref: string): string { return renderGlamourTable( ["PROPERTY", "VALUE"], [ - // The markdown label is "Single sign-on URL (ACS URL) " (trailing - // space), but Glamour collapses it when computing column widths so the - // rendered output has a single space between the label and the column - // separator. Our flat ASCII renderer would preserve the trailing space - // and double it up against the cell padding — so we drop it here to - // match the established rendered output. + // Glamour collapses the label's trailing space when computing column + // widths; our flat renderer would double it against cell padding, so + // it's dropped here to match the established output. ["Single sign-on URL (ACS URL)", payload.acs_url], ["Audience URI (SP Entity ID)", payload.entity_id], ["Default Relay State", payload.relay_state], diff --git a/apps/cli/src/commands/sso/sso.format.unit.test.ts b/apps/cli/src/commands/sso/sso.format.unit.test.ts index 5ea3939a11..b19a124b61 100644 --- a/apps/cli/src/commands/sso/sso.format.unit.test.ts +++ b/apps/cli/src/commands/sso/sso.format.unit.test.ts @@ -152,24 +152,17 @@ describe("renderListProviders / renderSingleProvider markdown surface", () => { expect(out).not.toContain("## SAML 2.0 Metadata XML"); }); - // PARITY GUARD — do not "fix" this: - // This intentionally populates the `UPDATED AT (UTC)` row with - // `provider.created_at`, not `provider.updated_at` — an established output - // contract, not a rendering bug. - // If you find yourself reaching to change line 194 of sso.format.ts because - // "obviously UPDATED AT should use updated_at", this test will fail. - // Update the contract deliberately (renderer + this test) if that's ever - // truly desired. + // Not a bug: `renderSingleProvider` renders UPDATED AT from `created_at`, + // an established output contract. Changing this needs the renderer and + // this test updated together. it("intentionally renders UPDATED AT using created_at (Go-bug parity guard)", () => { const out = renderSingleProvider({ id: "abc", created_at: "2023-01-01T00:00:00Z", updated_at: "2099-12-31T23:59:59Z", }); - // CREATED row uses created_at: expect(out).toContain("CREATED AT (UTC)"); expect(out).toContain("2023-01-01 00:00:00"); - // UPDATED row uses created_at (the bug), not updated_at: expect(out).not.toContain("2099-12-31 23:59:59"); }); }); @@ -195,15 +188,11 @@ describe("buildInfoPayload + renderInfoMarkdown", () => { describe("formatSsoMetadataXml (xmlfmt parity port)", () => { it("indents nested tags by depth × indent", () => { - // Reference output captured by running go-xmlfmt v1.1.3 - // `FormatXML("<a><b><c/></b></a>", " ", " ")`. const out = formatSsoMetadataXml("<a><b><c/></b></a>", " ", " "); expect(out).toBe(" \n <a>\n <b>\n <c/>\n </b>\n </a>"); }); it("keeps close-tags inline with content (e.g. <b>text</b>)", () => { - // Reference: go-xmlfmt's `lastEndElem` flag suppresses the newline before - // a closing tag whose preceding sibling was an opening tag with text. const out = formatSsoMetadataXml("<a><b>text</b></a>", " ", " "); expect(out).toBe(" \n <a>\n <b>text</b>\n </a>"); }); @@ -225,8 +214,6 @@ describe("formatSsoMetadataXml (xmlfmt parity port)", () => { '<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="https://example.com/sso"/>' + "</IDPSSODescriptor>" + "</EntityDescriptor>"; - // Verified against `xmlfmt.FormatXML(saml, " ", " ")` in a Go program - // using `github.com/go-xmlfmt/xmlfmt@v1.1.3`. const expected = " \n" + ' <EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata" entityID="https://example.com">\n' + diff --git a/apps/cli/src/commands/sso/sso.go-payload.ts b/apps/cli/src/commands/sso/sso.go-payload.ts index c902f85dff..4a496b58a4 100644 --- a/apps/cli/src/commands/sso/sso.go-payload.ts +++ b/apps/cli/src/commands/sso/sso.go-payload.ts @@ -61,11 +61,7 @@ export const GO_SSO_PROVIDER_RESPONSE: GoType = goStruct([ ["updated_at", goPtr(goString)], ]); -/** - * `sso list` encodes `map[string]any{"providers": resp.JSON200.Items}` - * (`list.go:35-37`) — a single lowercase key wrapping the provider structs, - * which renders identically to a one-field tagged wrapper struct. - */ +/** `sso list` wraps the provider structs in a single lowercase `providers` key. */ export const GO_SSO_PROVIDERS_WRAPPER: GoType = goTomlListWrapper( "providers", GO_SSO_PROVIDER_RESPONSE, diff --git a/apps/cli/src/commands/sso/sso.saml.ts b/apps/cli/src/commands/sso/sso.saml.ts index c1b5243792..77164f0e02 100644 --- a/apps/cli/src/commands/sso/sso.saml.ts +++ b/apps/cli/src/commands/sso/sso.saml.ts @@ -15,11 +15,10 @@ function fileErrorReason(cause: PlatformError): SsoFileErrorReason { } /** - * The `--name-id-format` value set, shared by `sso add` and `sso update` - * (both commands bind the same Go `ssoNameIDFormat` enum var, - * `cmd/sso.go:158,176`). Order matters twice: it drives the CLI help text - * and it is joined verbatim into pflag's `invalid argument … must be one of - * [ … ]` error (`pflagEnumValue`), which must byte-match Go. + * The `--name-id-format` value set, shared by `sso add` and `sso update`. + * Order matters: it drives the CLI help text and is joined verbatim into + * pflag's `invalid argument … must be one of [ … ]` error (`pflagEnumValue`), + * which must byte-match pflag's format. */ export const SSO_NAME_ID_FORMATS = [ "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", @@ -46,7 +45,6 @@ export function validateMetadataXmlBytes<E>( catch: () => undefined, }).pipe( Effect.mapError(() => - // Verbatim Go message from `saml/files.go:55-57`. nonUtf8Error({ source, message: `SAML Metadata XML at ${JSON.stringify(source)} is not UTF-8 encoded`, @@ -58,9 +56,8 @@ export function validateMetadataXmlBytes<E>( /** * Reads a SAML 2.0 metadata XML file and validates UTF-8 encoding. - * Subcommands inject their own open-error / non-UTF-8 error classes so - * each handler returns errors in its own tagged-error family - * (matches Go, which raises `failed to open metadata file:` / etc.). + * Subcommands inject their own open-error / non-UTF-8 error classes so each + * handler returns errors in its own tagged-error family. */ export const readMetadataFile = <Eopen, Eutf>(factory: { @@ -73,10 +70,8 @@ export const readMetadataFile = (path: string): Effect.Effect<string, Eopen | Eutf, FileSystem.FileSystem> => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; - // Go uses afero `fsys.Open(path)` + `io.ReadAll(file)`; collapsed to a - // single error branch here (any open / read failure surfaces as - // `failed to open metadata file:` to match the externally observable - // string for the common case — missing file). + // Any open or read failure surfaces as `failed to open metadata file:`, + // matching the established message for the common case (a missing file). const bytes = yield* fs.readFile(path).pipe( Effect.mapError((cause) => factory.openError({ diff --git a/apps/cli/src/commands/sso/sso.string-slice-flags.integration.test.ts b/apps/cli/src/commands/sso/sso.string-slice-flags.integration.test.ts index 214206997d..68c01b743c 100644 --- a/apps/cli/src/commands/sso/sso.string-slice-flags.integration.test.ts +++ b/apps/cli/src/commands/sso/sso.string-slice-flags.integration.test.ts @@ -16,13 +16,10 @@ import { } from "../../../tests/helpers/command-mocks.ts"; import { ssoCommand } from "./sso.command.ts"; -// All four sso domain-list flags are CSV string-slice flags, so malformed -// CSV aborts flag parsing before the handler runs — and before -// `managementApiRuntimeLayer`'s eager access-token resolution — with -// an `invalid argument %q for %q flag: %v` line on stderr. These scenarios -// run the whole command tree (`Command.runWith`) so the assertion covers -// the real flag wiring plus the renderer's pflag passthrough -// (`formatInvalidValueMessage`). +// All four sso domain-list flags are CSV string-slice flags, so malformed CSV +// aborts flag parsing — before the handler and its eager token resolution — +// with an `invalid argument %q for %q flag: %v` line on stderr. These run +// through the whole command tree to cover the real flag wiring and renderer. const tempRoot = useTempWorkdir("supabase-sso-string-slice-int-"); @@ -48,9 +45,7 @@ function setup() { runtime, CliOutput.layer(textCliOutputFormatter()), // An ambient SUPABASE_ACCESS_TOKEN or keyring entry would let a - // hypothetical regression (parse error NOT winning) reach the real - // Management API layer nondeterministically. Wipe process.env and disable - // the keyring fallback. + // regression reach the real Management API layer nondeterministically. processEnvLayer({ SUPABASE_NO_KEYRING: "1" }), Layer.succeed( TelemetryRuntime, @@ -113,8 +108,6 @@ describe("sso StringSlice flags (pflag CSV parity)", () => { const exit = yield* Effect.exit(Command.runWith(testRoot, { version: "0.0.0-test" })(args)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - // Parse-time failure: the command's Management API layer (and its - // eager token resolution) must never have been built. expect(JSON.stringify(exit.cause)).not.toContain("AccessTokenRequiredError"); expect(normalizeCause(exit.cause).message).toBe(message); } diff --git a/apps/cli/src/commands/sso/update/update.command.ts b/apps/cli/src/commands/sso/update/update.command.ts index 34a74dc289..eab6c0a3cc 100644 --- a/apps/cli/src/commands/sso/update/update.command.ts +++ b/apps/cli/src/commands/sso/update/update.command.ts @@ -8,9 +8,8 @@ import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { SSO_NAME_ID_FORMATS } from "../sso.saml.ts"; import { ssoUpdate } from "./update.handler.ts"; -// Go declares all three domain flags with pflag's `StringSliceVar` -// (`cmd/sso.go:170-172`); malformed CSV fails at parse time with pflag's -// exact diagnostic (CLI-2005, see `stringSliceFlag`). +// All three domain flags are CSV string-slice flags; malformed CSV reports +// pflag's diagnostic (see `stringSliceFlag`). export const ssoUpdateDomainsFlag = stringSliceFlag( "domains", "Replace domains with this comma separated list of email domains.", @@ -71,9 +70,8 @@ const config = { export type SsoUpdateFlags = CliCommand.Command.Config.Infer<typeof config>; export const ssoUpdateCommand = Command.make("update", config).pipe( - // This description includes the `of a already added` grammar slip - // verbatim and intentionally — it is an established output string; do not - // "fix" the grammar here. + // The `of a already added` grammar slip is part of the established output + // string — don't fix it here. Command.withDescription( "Update the configuration settings of a already added SSO identity provider.", ), diff --git a/apps/cli/src/commands/sso/update/update.command.unit.test.ts b/apps/cli/src/commands/sso/update/update.command.unit.test.ts index b7327d3a04..e1b97f85f5 100644 --- a/apps/cli/src/commands/sso/update/update.command.unit.test.ts +++ b/apps/cli/src/commands/sso/update/update.command.unit.test.ts @@ -89,9 +89,8 @@ describe("sso update domain flags (pflag StringSlice parity)", () => { test("--domains= (explicit empty value) parses to an empty array, not a missing flag", async () => { // The handler's `hasExplicitLongFlag` reads raw argv rather than this - // parsed value precisely because `--domains=` collapses to `[]` here, - // indistinguishable from the flag never being passed at all if you only - // looked at `.length`. + // parsed value, since `--domains=` collapses to `[]` here — indistinguishable + // from an absent flag by `.length` alone. const [, domains] = await Effect.runPromise( ssoUpdateDomainsFlag .parse({ @@ -105,9 +104,6 @@ describe("sso update domain flags (pflag StringSlice parity)", () => { }); test("keeps only the first CSV record of a multiline value (pflag reads ONE record)", async () => { - // `sso update <id> --domains $'a.com\nb"c'` raises no parse error — - // pflag calls `csv.Reader.Read()` once, so the malformed second line is - // silently dropped. const [, domains] = await Effect.runPromise( ssoUpdateDomainsFlag .parse({ @@ -152,7 +148,6 @@ describe("sso update domain flags (pflag StringSlice parity)", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - // Go-verified (CLI-2005): `"x` is 2 bytes → EOF at column 3. expect(normalizeCause(exit.cause).message).toBe( 'invalid argument "\\"x" for "--add-domains" flag: parse error on line 1, column 3: extraneous or missing " in quoted-field', ); @@ -179,8 +174,6 @@ describe("sso update domain flags (pflag StringSlice parity)", () => { }); test("rejects a blank-only value with pflag's EOF diagnostic", async () => { - // Go-verified (CLI-2005): `sso update <id> --add-domains $'\n\n'` → - // `invalid argument "\n\n" for "--add-domains" flag: EOF`. const exit = await Effect.runPromise( ssoUpdateAddDomainsFlag .parse({ diff --git a/apps/cli/src/commands/sso/update/update.handler.ts b/apps/cli/src/commands/sso/update/update.handler.ts index fe0442f285..e93fe26eb8 100644 --- a/apps/cli/src/commands/sso/update/update.handler.ts +++ b/apps/cli/src/commands/sso/update/update.handler.ts @@ -74,11 +74,9 @@ const mapGetStatusOrNetwork = mapHttpError({ const SSO_UPDATE_COMMAND_PATH = ["sso", "update"] as const; /** - * Three independent mutual-exclusion groups (`metadata-file`/`metadata-url` - * plus two 2-element groups sharing `--domains`, not one 3-way group). - * Groups are checked in alphabetical order of the joined group key, which - * happens to match declaration order here: "domains add-domains" < - * "domains remove-domains" < "metadata-file metadata-url" alphabetically. + * Three independent mutex groups (not one 3-way group covering `--domains`). + * Checked in alphabetical order of the joined group key, which matches + * declaration order here. */ const SSO_UPDATE_MUTEX_GROUPS = [ ["domains", "add-domains"], @@ -87,14 +85,10 @@ const SSO_UPDATE_MUTEX_GROUPS = [ ] as const; /** - * Every value-taking (non-boolean) flag reachable when `sso update` parses: - * the command's own (`update.command.ts`) plus the root's persistent value - * flags — these tell `pflagArgvScan` which bare tokens consume the next argv - * token as their value (and therefore which tokens are pflag-effective - * positionals). `--skip-url-validation` is this command's only boolean flag - * and is deliberately excluded; booleans never consume a following token. - * `sso update` declares no shorthands of its own, so only the persistent - * `-o` is mapped. + * Value-taking flags for `pflagArgvScan`: each consumes the next argv token + * as its value. `--skip-url-validation` is this command's only boolean flag, + * so it's excluded; `sso update` declares no shorthands beyond the + * persistent `-o`. */ const SSO_UPDATE_SCAN_SPEC = { valueFlagNames: new Set([ @@ -147,8 +141,7 @@ interface ExistingDomainItem { /** * Narrows a raw GET-provider JSON body to the `domains` shape `mergeDomains` - * consumes — the untyped counterpart of the generated client's provider - * schema, for the reconciled-profile GET path. + * consumes. */ function extractDomainItems(parsed: unknown): ReadonlyArray<ExistingDomainItem> | undefined { if (parsed === null || typeof parsed !== "object") { @@ -172,10 +165,9 @@ function mergeDomains( add: ReadonlyArray<string>, remove: ReadonlyArray<string>, ): ReadonlyArray<string> { - // Seed from current domains, apply removals, then add new entries. Uses a - // Set, so iteration order is unspecified; integration tests sort before - // asserting. The seed check is nil-ness only, so an empty-string domain - // from the GET response is kept, not filtered. + // Uses a Set, so iteration order is unspecified; integration tests sort + // before asserting. The seed check is nil-ness only, so an empty-string + // domain from the GET response is kept, not filtered. const set = new Set<string>(); if (existing !== undefined) { for (const item of existing) { @@ -203,40 +195,20 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag const rawArgs = yield* stdio.args; yield* Effect.gen(function* () { - // Arity validation, then flag-group validation, then the handler body, - // and the provider-ID format check runs inside the handler — so an - // arity violation must win over a mutex violation, and both must win - // over an invalid provider ID. Keep this block ahead of `validateUuid` - // below to match that precedence. + // Precedence: arity validation, then mutex, then the handler body (which + // validates the provider ID). Keep this block ahead of `validateUuid` + // below. // - // "Set" means the flag was passed at all — not the resulting value. - // `--domains`/`--add-domains`/`--remove-domains` all default to `[]`, - // so `--domains=` (parses to an empty array) must still count as "set"; - // gating on `.length > 0` would miss it — the same "changed vs truthy" - // gap `functions download`'s `--use-docker` had. - // - // The scan is pflag-faithful: a bare `--metadata-file --metadata-url` is - // pflag consuming `--metadata-url` as `metadata-file`'s (oddly named) - // value, not two flags being set — see `pflagArgvScan`. + // "Set" means passed at all, not the resulting value — `--domains=` + // parses to `[]` but must still count as set, so gating on + // `.length > 0` would miss it. const scan = pflagArgvScan(rawArgs, SSO_UPDATE_COMMAND_PATH, SSO_UPDATE_SCAN_SPEC); const occurrences = scan.occurrences; - // pflag calls `Value.Set` for every occurrence in argv order, and an - // invalid value fails flag parsing before arity/mutex validation and the - // handler body — reachable here because the Effect parser resolves - // repeated flags first-wins without validating later occurrences - // (`--name-id-format=<valid> --name-id-format=bogus` parses here) and - // accepts `yes`/`no`, which `strconv.ParseBool` rejects. These checks - // precede the missing-value check because a missing value can only - // arise at the final argv token, so every recorded occurrence pflag - // would reject sits earlier in its sequential walk - // (`--skip-url-validation=yes --domains` names the invalid argument, not - // the missing one). Flags are checked in declaration order; when a - // single argv holds invalid occurrences of BOTH flags, pflag names - // whichever comes first in argv — a divergence this fixed order cannot - // see, accepted as unreachable through sane usage. The same helpers - // yield the pflag-effective (last-occurrence) values the handler acts - // on below. + // Validate against pflag's accepted values before the missing-value, + // arity, and mutex checks — pflag fails on the first invalid occurrence + // even if a later one overrides it, and its bool parsing excludes + // `yes`/`no`. const skipUrlValidation = yield* Result.match( pflagBoolValue(occurrences, "skip-url-validation"), { @@ -252,24 +224,17 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag }, ); - // Flag parsing fails when a bare value-taking flag is the final token - // (`sso update <id> --domains`) — before arity validation, every hook, - // and the handler body, so the missing argument is reported even when - // the arg count is also wrong (e.g. `sso update a b --domains`). The - // Effect parser accepts that argv (the flag parses as unset), so no - // GET/PUT may happen here either. Keep this ahead of the arity check. + // A bare value-taking flag as the final token is a pflag parse error, + // reported even when the arg count is also wrong. The TS parser accepts + // it as unset, so this must run before the arity check. if (scan.missingValueError !== undefined) { return yield* Effect.fail(new SsoFlagNeedsArgumentError({ message: scan.missingValueError })); } - // Arity validation counts pflag-effective positionals, which shift away - // from what the Effect parser saw whenever pflag consumed a flag token - // as a value: `--domains --metadata-url u <id>` is pflag handing - // `--metadata-url` to `--domains` and leaving BOTH `u` and `<id>` - // positional — the arg count is rejected before any hook, flag - // validation, or request. The parser's own arity check can't see this, - // so re-count from the scan (gated on `anchored`: an unscoped scan has - // no positional information). + // Arity is counted from pflag-effective positionals, which shift + // whenever pflag consumed a flag token as another flag's value — the TS + // parser's own arity check can't see that. Gated on `anchored`: an + // unscoped scan has no positional information. if (scan.anchored && scan.positionals.length !== 1) { return yield* Effect.fail( new SsoUpdateArityError({ @@ -278,28 +243,16 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag ); } - // The effective `--profile`/`SUPABASE_PROFILE` is resolved immediately - // before the workdir change, so an unloadable profile loses to an - // arity violation but beats the workdir check, the mutex checks, and - // any GET/PUT — and a loadable one decides which API host receives - // them. Reachable exactly where the scan and the parser disagree (see - // `add.handler.ts` and `resolvePflagProfile`); where they agree - // this is `none` and the config layer's client/apiUrl below are already - // pflag-effective. + // Reconcile the effective `--profile` before the workdir check: an + // unloadable profile loses to an arity violation but beats the workdir + // and mutex checks. Where the scan and parser agree this resolves to + // `none` and the config layer's apiUrl is already correct. const reconciledProfile = yield* resolvePflagProfile(scan); const profileApiUrl = Option.map(reconciledProfile, (profile) => profile.apiUrl); - // Reconciled-profile credentials, resolved ONCE for the main request and - // every auxiliary call (linked-project cache fill, upgrade-gate fallback - // GETs): the reconciled profile's credentials apply process-wide. - // `undefined` when the scan and the parser agree — every consumer then - // resolves from the config-layer services as before. - // Reconciled-profile credentials, resolved LAZILY (memoized) so the first - // read happens at the request site — the token gate fires AFTER - // required/mutex/workdir validation, so a missing or invalid reconciled - // token must not pre-empt those errors. Missing → the missing-token - // error; invalid → the validation failure propagates. The auxiliary - // calls (cache fill, upgrade-gate GETs) use the absorbed variant: - // failures skip, best-effort. + // Resolved once (memoized) so the token read happens after + // required/mutex/workdir validation — a missing or invalid reconciled + // token must not preempt those errors. Auxiliary calls (cache fill, + // upgrade-gate GETs) use the failure-absorbing variant below instead. const reconciledTokenCached = Option.isSome(reconciledProfile) ? yield* Effect.cached(accessTokenForProfile(reconciledProfile.value.name)) : undefined; @@ -310,12 +263,9 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag Effect.succeed(Option.none<Redacted.Redacted<string>>()), ); - // The effective `--workdir`/`SUPABASE_WORKDIR` is validated after arity - // validation and before flag-group validation, so a missing directory - // loses to an arity violation but beats a mutex violation and any - // GET/PUT (e.g. `sso update a b --workdir /missing` reports the arity - // error; `sso update <id> --workdir /missing --domains a --add-domains - // b` reports the chdir failure). + // Validate the effective `--workdir` after arity but before the mutex + // checks: it loses to an arity violation but beats a mutex violation and + // any GET/PUT. yield* validatePflagWorkdir(scan); for (const group of SSO_UPDATE_MUTEX_GROUPS) { @@ -329,14 +279,8 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag } } - // Reconcile everything the handler acts on to the pflag-effective values - // from the same scan — the Effect parser refuses to consume flag-shaped - // tokens as values while pflag consumes them unconditionally, and - // resolves repeated flags first-wins while pflag is last-wins, so the - // two can disagree on which flags are set and what they hold. See - // `add.handler.ts` and `pflag-reconcile.ts` for the full - // rationale. `--name-id-format` and `--skip-url-validation` were - // reconciled above, alongside their pflag value validation. + // Everything below reads pflag-effective values from the scan rather + // than the TS-parsed flags — see `add.handler.ts` and `pflag-reconcile.ts`. const projectRefFlag = pflagStringValue(occurrences, "project-ref"); const metadataFile = pflagStringValue(occurrences, "metadata-file"); const metadataUrl = pflagStringValue(occurrences, "metadata-url"); @@ -351,21 +295,18 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag const ref = yield* resolver.resolve(projectRefFlag); - // Effective API base URL: the pflag-reconciled profile's when the scan - // and the parser disagreed on `--profile`, the config layer's otherwise. + // Use the pflag-reconciled profile's host when it disagreed with + // `--profile`, otherwise the config layer's. const apiUrl = Option.getOrElse(profileApiUrl, () => cliSettings.apiUrl); yield* Effect.gen(function* () { const fetching = output.format === "text" ? yield* output.task("Updating SSO provider...") : undefined; - // The typed client bakes the layer's apiUrl in at construction, so - // when the reconciled profile differs the GET must be issued raw - // against the effective host — the GET and PUT must target the same - // profile host, and a GET to the layer's host would be a request that - // should never happen. The error mapping and the spinner-fail/ - // suggestion stderr ordering mirror the typed path (`handleGetError`) - // exactly. + // The typed client bakes the layer's apiUrl in at construction, so when + // the reconciled profile differs, the GET must be issued raw against + // the effective host, matching the PUT's host. Error mapping and the + // spinner/suggestion ordering mirror the typed path (`handleGetError`). const rawGetProvider = Effect.gen(function* () { const tokenOpt = reconciledTokenCached !== undefined @@ -390,18 +331,13 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag }), ), ); - // Every Management API response is wrapped by the identity stitch; - // the typed client stitches via its response transform - // (`command-platform-api.layer.ts`), so this raw GET must stitch - // through the same once-per-command guard — before the status gate, - // like the linked-project cache's raw GET. `serviceOption`: absent - // outside the real CLI tree (handler-level tests), where no - // telemetry runtime exists to stitch into. + // Every Management API response goes through the identity stitch + // once per command; this raw GET must too, before the status gate. + // `serviceOption`: absent outside the real CLI tree (handler-level + // tests), where no telemetry runtime exists to stitch into. if (Option.isSome(identityStitch)) { yield* identityStitch.value.stitch(response); } - // The body is read up front; the read error surfaces as - // `failed to get sso provider: %w`. const rawBody = yield* response.text.pipe( Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.mapError( @@ -413,9 +349,8 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag ); const contentType = response.headers["content-type"] ?? ""; if (response.status === 200 && contentType.includes("json")) { - // A 200 JSON body that fails to parse exits with the parse error - // before any PUT; detail text is `JSON.parse`'s (documented - // micro-divergence). + // A 200 body that fails JSON.parse exits with JSON.parse's own + // message, before any PUT. let parsed: unknown; try { parsed = JSON.parse(rawBody); @@ -430,8 +365,7 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag } return { domains: extractDomainItems(parsed) }; } - // Non-200 — or a 200 without a JSON content type, which falls into - // the same branch: gate check, then 404 / unexpected-status. + // A 200 without a JSON content type falls into this branch too. yield* fetching?.fail() ?? Effect.void; const bodyText = sanitizeErrorBody(rawBody); const upgradeSuggested = yield* suggestUpgrade({ @@ -478,8 +412,7 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag } else if (Option.isSome(metadataUrl)) { if (!skipUrlValidation) { yield* validateMetadataUrl(metadataUrl.value).pipe( - // Note: single space between cause and `Use`, with a trailing - // period — `sso add` uses the same format minus the period. + // Trailing period here, unlike `sso add`'s version of this message. Effect.mapError( (cause) => new SsoUpdateMetadataFileError({ @@ -500,11 +433,8 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag if (domains.length > 0) { body["domains"] = [...domains]; } else { - // Both domain flags default to a non-nil empty array and are passed - // unconditionally, so every `sso update` PUT recomputes and sends - // `domains`, even when no domain flag was passed. An empty merged - // set serializes as `"domains":[]` — never omitted. Established - // output contract. + // `domains` is always recomputed and sent, even when no domain flag + // was passed; an empty merged set serializes as `"domains":[]`, never omitted. body["domains"] = mergeDomains(existing.domains, addDomains, removeDomains); } @@ -527,7 +457,7 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag ).pipe( Option.isSome(tokenOpt) ? HttpClientRequest.bearerToken(tokenOpt.value) : (req) => req, HttpClientRequest.setHeader("User-Agent", cliSettings.userAgent), - // See `add.handler.ts` — Go-struct key order required for cli-e2e parity. + // See `add.handler.ts` — key order matters for cli-e2e parity. HttpClientRequest.bodyText(encodeGoStructJsonBody(body), "application/json"), ); @@ -543,8 +473,7 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag if (response.status !== 200) { const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - // Cap + sanitise to match `mapHttpError`'s defences — see add handler - // for the rationale; the raw-HTTP path must not bypass these. + // Cap + sanitize to match `mapHttpError`'s defenses; see add handler for the rationale. const bodyText = sanitizeErrorBody(rawBody); const upgradeSuggested = yield* suggestUpgrade({ projectRef: ref, @@ -582,7 +511,7 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag return; } if (goFmt === "toml") { - // TOML encode failure wrapping — same pattern as list/show. + // Same TOML-encode-failure pattern as list/show. const toml = yield* Effect.try({ try: () => encodeGoToml(parsedJson, GO_SSO_PROVIDER_RESPONSE), catch: (cause) => @@ -612,8 +541,8 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag // Linked-project cache fill GETs `/v1/projects/{ref}` through the // reconciled host, never the config layer's. Effect.ensuring( - // Resolved INSIDE the ensuring effect — the memoized token read must - // not run before the handler body (see the token-gate ordering above). + // Resolved inside `ensuring` so the memoized token read doesn't run + // before the handler body. Effect.flatMap(reconciledTokenForAux, (token) => linkedProjectCache.cache(ref, undefined, Option.getOrUndefined(profileApiUrl), token), ), diff --git a/apps/cli/src/commands/sso/update/update.integration.test.ts b/apps/cli/src/commands/sso/update/update.integration.test.ts index e73e26e4c0..b0aaef804a 100644 --- a/apps/cli/src/commands/sso/update/update.integration.test.ts +++ b/apps/cli/src/commands/sso/update/update.integration.test.ts @@ -44,7 +44,7 @@ interface SetupOpts { getBody?: unknown; /** * Serves the provider GET as a raw body + content type instead of - * `jsonResponse` — for the reconciled-profile raw GET's decode branches + * `jsonResponse`, for the reconciled-profile raw GET's decode branches * (invalid JSON, non-JSON content type). */ getRaw?: { status: number; body: string; contentType: string }; @@ -52,18 +52,14 @@ interface SetupOpts { putBody?: unknown; upgradeGate?: "gated" | "notGated"; /** - * Raw argv the handler sees via `Stdio.Stdio` — drives the pflag-faithful - * scan (`pflagArgvScan`) behind the arity check, the mutex checks, and the - * value reconciliation. Defaults to a bare invocation with no optional - * flags present; tests that pass flags must pass matching argv here - * (usually via `cliArgsFor`), exactly as the real parser guarantees. + * Raw argv the handler sees via `Stdio.Stdio`, driving the pflag-faithful + * scan behind the arity, mutex, and value-reconciliation checks. Keep in + * sync with the flags passed to `ssoUpdate` (usually via `cliArgsFor`). */ cliArgs?: ReadonlyArray<string>; /** - * The Effect-parsed `--profile` value (`ProfileFlag`), which the real - * parser sets for any `--profile` it accepted. Tests whose `cliArgs` carry a - * `--profile` the parser would have consumed must provide it, exactly as the - * real CLI tree would. + * The Effect-parsed `--profile` value. Must be set whenever `cliArgs` + * carries a `--profile` the parser would have consumed. */ profileFlag?: string; /** @@ -160,9 +156,8 @@ function setup(opts: SetupOpts = {}) { }, }); - // Tracked identity stitcher: the reconciled-profile raw GET must stitch - // through the shared per-command guard exactly like the typed client's - // response transform. + // Tracks that the reconciled-profile raw GET stitches identity through the + // same shared guard as the typed client's response transform. let stitchedResponses = 0; const stitchLayer = Layer.succeed(IdentityStitch, { stitch: () => @@ -226,11 +221,9 @@ const defaultFlags = { }; /** - * Serializes a flags record into the raw argv the real CLI would have been - * invoked with. The handler reconciles every value it acts on against a - * pflag-faithful scan of this argv, so tests must keep the two consistent — - * a flag passed in the record but absent from argv reconciles to "not set", - * exactly as it would be for a real invocation. + * Serializes a flags record into matching raw argv, since the handler + * reconciles the values it acts on against a scan of argv, not the flags + * record itself. */ function cliArgsFor(flags: typeof defaultFlags): ReadonlyArray<string> { const argv: string[] = ["sso", "update", flags.providerId]; @@ -321,9 +314,6 @@ describe("sso update integration", () => { if (Exit.isFailure(exit)) { const dump = JSON.stringify(exit.cause); expect(dump).toContain("SsoMutexFlagError"); - // Byte-matches cobra's `validateExclusiveFlagGroups` template - // (`flag_groups.go:204`): group in registration order, changed flags - // sorted alphabetically — "add-domains" < "domains". expect(dump).toContain( "if any flags in the group [domains add-domains] are set none of the others can be; [add-domains domains] were all set", ); @@ -361,10 +351,6 @@ describe("sso update integration", () => { it.live( "mutex check: an explicit but empty --domains= still conflicts with --add-domains (changed, not truthy)", () => { - // `--domains=` parses to an empty array, but cobra's `pflag.Changed` - // tracks that the flag was passed at all, not the resulting value — the - // same "changed vs truthy" gap CLI-1860 fixed for `functions download`'s - // `--use-docker`. Gating on `.length > 0` would miss this combination. const { layer } = setup({ cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--domains=", "--add-domains", "b.com"], }); @@ -383,9 +369,6 @@ describe("sso update integration", () => { it.live( "mutex check: --add-domains and --remove-domains together are not mutually exclusive", () => { - // Go only registers ("domains","add-domains") and ("domains","remove-domains") - // as separate 2-element groups (`cmd/sso.go:179-180`) — add-domains and - // remove-domains together, without --domains, is not a violation. const { layer } = setup({ cliArgs: [ "sso", @@ -407,10 +390,6 @@ describe("sso update integration", () => { ); it.live("mutex check: all three domain flags set reports the --add-domains group first", () => { - // Pins the `SSO_UPDATE_MUTEX_GROUPS` array order: cobra's sorted-key - // iteration ("domains add-domains" < "domains remove-domains") means the - // add-domains group is checked — and its error returned — first when all - // three domain flags collide at once. const { layer } = setup({ cliArgs: [ "sso", @@ -445,10 +424,6 @@ describe("sso update integration", () => { }); it.live("mutex check: a flag-group violation wins over an invalid provider ID", () => { - // Flag-group validation runs before the handler body, and the - // provider-ID format check lives inside the handler. So an invalid UUID - // combined with a mutex violation must surface the mutex error, not - // `SsoInvalidUuidError`. const { layer } = setup({ cliArgs: ["sso", "update", "not-a-uuid", "--domains", "a.com", "--add-domains", "b.com"], }); @@ -496,10 +471,6 @@ describe("sso update integration", () => { if (Exit.isFailure(exit)) { const dump = JSON.stringify(exit.cause); expect(dump).toContain("SsoMutexFlagError"); - // Go registers this pair too (`cmd/sso.go:178`) — it was left emitting - // a hand-written message alongside the domains groups' custom text - // before this fix; now all three of `sso update`'s mutex groups on - // this command share the same byte-exact cobra template. expect(dump).toContain( "if any flags in the group [metadata-file metadata-url] are set none of the others can be; [metadata-file metadata-url] were all set", ); @@ -511,16 +482,6 @@ describe("sso update integration", () => { it.live( "mutex check: a bare --metadata-file followed by --metadata-url is not a violation, and the consumed token is the file", () => { - // pflag's `--flag arg` branch consumes the very next argv token as the - // value unconditionally (`flag.go:1013-1031`), so real cobra parses this - // as `metadata-file` receiving the literal value `"--metadata-url"` — - // `metadata-url` is never parsed as its own flag and stays unset. The - // TS CLI's own parser (unlike pflag) never hands a dash-prefixed token - // to a non-boolean flag as a bare value, so here both flags resolve to - // `Option.none()` — the raw-argv scan must reach the same "not a - // violation" conclusion pflag does, and the handler must then behave - // like Go: try to open a file literally named `--metadata-url` instead - // of silently PUTting with no metadata at all. const { layer, api } = setup({ cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--metadata-file", "--metadata-url"], }); @@ -540,11 +501,6 @@ describe("sso update integration", () => { it.live( "mutex check: a bare --add-domains followed by --domains=... is not a violation, and the consumed token is the domain", () => { - // Same consumed-value class as the metadata-file/metadata-url case - // above, but for the domains group: pflag hands `add-domains` the - // literal value `"--domains=x.com"` and never parses `--domains` at - // all — so Go merges that odd-looking string into the existing domain - // list and PUTs it. The reconciled handler must produce the same body. const { layer, api } = setup({ cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--add-domains", "--domains=x.com"], }); @@ -561,15 +517,6 @@ describe("sso update integration", () => { it.live( "arity emulation: project-ref consuming --metadata-file orphans x.xml — fails ExactArgs like Go, no API calls", () => { - // `<id> --project-ref --metadata-file x.xml --metadata-url u`: pflag - // hands `--metadata-file` to `--project-ref` as its value, which makes - // `x.xml` a positional — cobra's `ValidateArgs`/`ExactArgs(1)` - // (`command.go:968`, `cmd/sso.go:87`) then rejects the arg count - // before any hook, mutex check, or request. The Effect parser read - // `--metadata-file x.xml` as a normal flag and saw exactly one - // positional, so the handler must re-count from the scan (PR #5974 - // review; this refines the earlier ref-validation expectation — Go - // never even reaches the ref check here). const { layer, api } = setup({ cliArgs: [ "sso", @@ -604,12 +551,6 @@ describe("sso update integration", () => { it.live( "arity emulation: a bare --domains consuming --metadata-url orphans the URL — fails ExactArgs like Go, no GET/PUT", () => { - // `--domains --metadata-url https://… <id>`: pflag consumes - // `--metadata-url` as the domains value, leaving BOTH the URL and the - // provider ID positional — Go rejects via `ExactArgs(1)` before any - // request. The Effect parser instead read the URL as metadata-url's - // value and saw one positional, so without the re-count the handler - // would GET and PUT (PR #5974 review, Codex thread). const { layer, api } = setup({ cliArgs: [ "sso", @@ -641,10 +582,6 @@ describe("sso update integration", () => { it.live( "arity emulation: a bare --domains consuming a persistent global flag orphans its value", () => { - // Binary-verified Go behaviour: `--domains --profile staging <id>` - // arity-errors because pflag hands `--profile` to `--domains` and - // `staging` becomes positional. The scan must know the root's - // persistent value flags (`cmd/root.go:337-348`) to see this. const { layer, api } = setup({ cliArgs: ["sso", "update", "--domains", "--profile", "staging", VALID_PROVIDER_ID], }); @@ -664,15 +601,6 @@ describe("sso update integration", () => { it.live( "arity emulation: persistent global value flags and -o do not miscount positionals", () => { - // Regression guards for the re-count: pflag consumes these globals' - // values (`--workdir .`, `--output-format json`, `-o json`), so none - // of them may register as a second positional — each invocation must - // sail through to the PUT exactly as before. The post-path - // `--log-level` variant pins issue #6482 (its value mis-counted as a - // positional); the pre-path variant already passed pre-fix via the - // unanchored fail-open scan and guards that the NEWLY ANCHORED path - // keeps accepting a valid invocation — the anchoring assertion itself - // lives in `cobra-flag-groups.unit.test.ts`. const argvVariants: ReadonlyArray<ReadonlyArray<string>> = [ ["sso", "update", "--workdir", ".", VALID_PROVIDER_ID], ["sso", "update", "--output-format", "json", VALID_PROVIDER_ID], @@ -691,11 +619,6 @@ describe("sso update integration", () => { ); it.live("arity emulation: the arity error wins over a mutex violation", () => { - // cobra's `ValidateArgs` (`command.go:968`) runs before - // `ValidateFlagGroups` (`command.go:1010`): with `--domains` + - // `--add-domains` both set AND `--metadata-file` swallowing - // `--metadata-url` (orphaning `u` as a second positional), Go reports - // the arg-count error, not the mutex template. const { layer, api } = setup({ cliArgs: [ "sso", @@ -732,12 +655,6 @@ describe("sso update integration", () => { it.live( "workdir emulation: --workdir consuming a trailing --metadata-file fails at Go's chdir, no GET/PUT", () => { - // `sso update <id> --project-ref <ref> --workdir --metadata-file`: - // pflag binds `"--metadata-file"` to the persistent `--workdir` (the - // positional count stays 1) and the workdir change exits before the - // handler runs with zero HTTP traffic. The Effect parser refused the - // flag-shaped value and left both flags unset — without the workdir - // emulation the handler proceeded to GET + PUT. const { layer, api } = setup({ cliArgs: [ "sso", @@ -769,10 +686,6 @@ describe("sso update integration", () => { it.live( "workdir emulation: the chdir failure loses to an arity violation but wins over a mutex violation", () => { - // The workdir change runs after arity validation, before flag-group - // validation: `sso update a b --workdir /missing` reports the arity - // error, while `sso update <id> --workdir /missing --domains a - // --add-domains b` reports the chdir failure. const { layer, api } = setup({ cliArgs: [ "sso", @@ -822,8 +735,6 @@ describe("sso update integration", () => { }); it.live("arity emulation: the arity error wins over an invalid provider ID", () => { - // The provider-ID format check lives inside the handler, long after - // arity validation — a bad UUID must not mask the arg-count error. const { layer, api } = setup({ cliArgs: ["sso", "update", "--domains", "--metadata-url", "u", "not-a-uuid"], }); @@ -848,11 +759,6 @@ describe("sso update integration", () => { it.live( "arity emulation: a consumed boolean global keeps the count at 1 and PUTs, like Go", () => { - // `--domains --yes <id>`: pflag hands `--yes` to `--domains` (a - // consumed token is a value no matter what it looks like), so only the - // provider ID stays positional — Go proceeds and PUTs - // `domains: ["--yes"]` (binary-verified). The re-count must not turn - // this into an arity error. const { layer, api } = setup({ cliArgs: ["sso", "update", "--domains", "--yes", VALID_PROVIDER_ID], }); @@ -867,12 +773,6 @@ describe("sso update integration", () => { it.live( "missing-value emulation: a trailing bare --domains fails pflag parse, no GET/PUT", () => { - // Binary-verified: `sso update <id> --domains` errors - // `flag needs an argument: --domains` — pflag fails `ParseFlags` - // (cobra `command.go:919`) before `ValidateArgs`, every hook, and - // `RunE`, so Go makes no API call. The Effect parser accepts the argv - // (the flag parses as unset), so without this check the handler would - // GET and PUT with an empty domain list (PR #5974 review round 3). const { layer, api } = setup({ cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--domains"], }); @@ -890,11 +790,6 @@ describe("sso update integration", () => { ); it.live("missing-value emulation: the pflag parse error wins over an arity violation", () => { - // pflag fails parsing (`command.go:919`) before cobra's `ValidateArgs` - // (`command.go:968`), so when `--domains` swallows `--metadata-url` - // (orphaning `u` as a second positional) AND `--add-domains` trails - // bare, Go reports the missing argument, not the arg count - // (binary-verified: `sso update a b --domains`). const { layer, api } = setup({ cliArgs: [ "sso", @@ -927,12 +822,6 @@ describe("sso update integration", () => { it.live( "anchoring: a persistent flag between sso and update still enforces arity, like Go", () => { - // Binary-verified: `sso --profile foo update --domains --metadata-url - // u <id>` errors `accepts 1 arg(s), received 2` — cobra routes through - // the interspersed persistent flag (`Find`/`stripFlags`) and pflag - // still hands `--metadata-url` to `--domains`. The scan must anchor - // across the interspersed flag or the arity re-count silently - // vanishes and the handler GETs/PUTs (PR #5974 review round 3). const { layer, api } = setup({ cliArgs: [ "sso", @@ -964,9 +853,6 @@ describe("sso update integration", () => { ); it.live("anchoring: a persistent flag between sso and update sails through to the PUT", () => { - // Regression guard for the anchor walk: a well-formed interspersed - // invocation (`sso --profile supabase update <id>`) must behave exactly - // like the contiguous one. const { layer, api } = setup({ cliArgs: ["sso", "--profile", "supabase", "update", VALID_PROVIDER_ID], }); @@ -979,11 +865,6 @@ describe("sso update integration", () => { it.live( "value reconciliation: repeated --skip-url-validation resolves last-wins like pflag (=false then bare ends true, skips validation, PUTs)", () => { - // `--skip-url-validation=false --skip-url-validation --metadata-url - // http://…`: pflag Sets every occurrence in order, ending true, so Go - // skips URL validation and PUTs. The Effect parser resolves repeats - // first-wins (false) and would have validated — and rejected — the - // non-HTTPS URL (PR #5974 review round 4, binary-verified). const { layer, api } = setup({ cliArgs: [ "sso", @@ -1012,10 +893,6 @@ describe("sso update integration", () => { it.live( "value reconciliation: bare then =false ends false like pflag — URL validation runs and rejects, no PUT", () => { - // The mirror case: `--skip-url-validation --skip-url-validation=false` - // is false to pflag (last-wins) but true to the Effect parser - // (first-wins), so without reconciliation the handler would skip the - // validation Go performs and PUT an unvalidated URL. const { layer, api } = setup({ cliArgs: [ "sso", @@ -1041,7 +918,6 @@ describe("sso update integration", () => { expect(dump).toContain("SsoUpdateMetadataFileError"); expect(dump).toContain("only HTTPS Metadata URLs are supported"); } - // Go GETs first (`update.go:42`), then fails validation before the PUT. expect(api.requests.some((r) => r.method === "PUT")).toBe(false); }).pipe(Effect.provide(layer)); }, @@ -1050,11 +926,6 @@ describe("sso update integration", () => { it.live( "value reconciliation: --domains consuming one --name-id-format leaves the other as pflag's effective value in the PUT", () => { - // `--domains --name-id-format=T --name-id-format P`: pflag hands the - // first name-id-format token to `--domains` as its value, so the only - // occurrence it Sets is P. The Effect parser read both and resolved - // first-wins to T — the PUT body must carry P, exactly what the Go - // binary sends (PR #5974 review round 4). const transient = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient" as const; const persistent = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"; const { layer, api } = setup({ @@ -1081,7 +952,6 @@ describe("sso update integration", () => { const putReq = api.requests.find((r) => r.method === "PUT"); const body = putReq?.body as { name_id_format?: string; domains?: string[] }; expect(body?.name_id_format).toBe(persistent); - // The consumed token is pflag's literal domains value. expect(body?.domains).toEqual([`--name-id-format=${transient}`]); }).pipe(Effect.provide(layer)); }, @@ -1090,8 +960,6 @@ describe("sso update integration", () => { it.live( "invalid-value emulation: --skip-url-validation=yes fails with pflag's strconv.ParseBool error, no API calls", () => { - // The Effect parser accepts `yes`; pflag's strconv.ParseBool does not — - // pflag fails flag parsing before every hook and request. const { layer, api } = setup({ cliArgs: [ "sso", @@ -1126,12 +994,6 @@ describe("sso update integration", () => { it.live( "invalid-value emulation: a later inline-empty --skip-url-validation= fails like pflag, no API calls", () => { - // `--skip-url-validation=false --skip-url-validation=`: the Effect - // parser resolves repeats first-wins and never validates the second - // occurrence, so it parses; pflag hands `""` to strconv.ParseBool - // (`flag.go:1014-1016`) and aborts ParseFlags before every hook and - // any GET/PUT — only a *bare* repeat means NoOptDefVal true - // (binary-verified, PR #5974 review round 5). const { layer, api } = setup({ cliArgs: [ "sso", @@ -1167,10 +1029,6 @@ describe("sso update integration", () => { it.live( "invalid-value emulation: a later invalid --name-id-format occurrence fails like pflag, no API calls", () => { - // The Effect parser resolves repeats first-wins and never validates - // the rest, so `--name-id-format=<valid> --name-id-format=bogus` - // parses; pflag Sets every occurrence and aborts on `bogus` - // (binary-verified, PR #5974 review round 4). const persistent = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent" as const; const { layer, api } = setup({ cliArgs: [ @@ -1205,9 +1063,6 @@ describe("sso update integration", () => { it.live( "invalid-value emulation: an invalid occurrence beats a trailing missing value, matching pflag's sequential walk", () => { - // `--skip-url-validation=yes --domains`: pflag walks argv in order and - // rejects `yes` before ever reaching the bare trailing `--domains` - // (binary-verified: Go names the invalid argument, not the missing one). const { layer, api } = setup({ cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--skip-url-validation=yes", "--domains"], }); @@ -1241,7 +1096,7 @@ describe("sso update integration", () => { yield* ssoUpdate(flags); const putReq = api.requests.find((r) => r.method === "PUT"); const domains = (putReq?.body as { domains: string[] })?.domains; - // Go map iteration is unordered — sort before asserting. + // Order is unspecified; sort before asserting. expect([...domains].sort()).toEqual(["new.com", "old1.com", "old2.com"]); }).pipe(Effect.provide(layer)); }); @@ -1258,16 +1113,14 @@ describe("sso update integration", () => { }); it.live("no domain flag set → PUT still sends the recomputed existing domain set", () => { - // `--add-domains`/`--remove-domains` default to a non-nil empty array, - // so every `sso update` enters the merge and sends `domains`, even when - // no domain flag was passed. Established PUT body: - // `{"domains":["old1.com","old2.com"]}`. + // `--add-domains`/`--remove-domains` default to an empty array, so every + // update enters the merge and sends `domains`, even with no domain flag. const { layer, api } = setup(); return Effect.gen(function* () { yield* ssoUpdate(defaultFlags); const putReq = api.requests.find((r) => r.method === "PUT"); const domains = (putReq?.body as { domains: string[] })?.domains; - // Go map iteration is unordered — sort before asserting. + // Order is unspecified; sort before asserting. expect([...domains].sort()).toEqual(["old1.com", "old2.com"]); }).pipe(Effect.provide(layer)); }); @@ -1275,8 +1128,7 @@ describe("sso update integration", () => { it.live( "no domain flags + provider with no domains → PUT sends domains: [] (not omitted)", () => { - // `domains` is always populated (never omitted) — established PUT - // body is exactly `{"domains":[]}`. + // `domains` is always populated, never omitted, even when empty. const { layer, api } = setup({ getBody: { ...EXISTING_PROVIDER, domains: [] } }); return Effect.gen(function* () { yield* ssoUpdate(defaultFlags); @@ -1290,8 +1142,7 @@ describe("sso update integration", () => { it.live("no domain flags + GET response missing domains entirely → PUT sends domains: []", () => { // The seed loop is skipped when the GET response has no domains, - // leaving the merged set empty — same `{"domains":[]}` bytes as the - // empty-list case. + // leaving the merged set empty. const { domains: _omitted, ...providerWithoutDomains } = EXISTING_PROVIDER; const { layer, api } = setup({ getBody: providerWithoutDomains }); return Effect.gen(function* () { @@ -1304,10 +1155,8 @@ describe("sso update integration", () => { }); it.live("explicit empty --domains= falls into the merge and resends the existing set", () => { - // `--domains=` parses to an empty slice, so the replace gate is false - // and the merge branch runs with no add/remove — the established PUT - // resends the existing domains, it does NOT replace them with an empty - // list. + // `--domains=` parses to an empty slice, so the replace gate is false and + // the merge branch resends the existing domains instead of replacing them. const { layer, api } = setup({ cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--domains="], }); @@ -1320,9 +1169,8 @@ describe("sso update integration", () => { }); it.live("merge keeps empty-string domains and skips entries without a domain field", () => { - // The seed check is nil-ness only: an empty-string domain from the GET - // response stays in the merged set, while an entry missing the field - // entirely is skipped. + // The seed check is nil-ness only: an empty-string domain stays in the + // merged set, while an entry missing the field entirely is skipped. const { layer, api } = setup({ getBody: { ...EXISTING_PROVIDER, @@ -1464,8 +1312,6 @@ describe("sso update integration", () => { if (Exit.isFailure(exit)) { const dump = JSON.stringify(exit.cause); expect(dump).toContain("SsoUpdateMetadataFileError"); - // Error tail is `… Use --skip-url-validation to suppress this error.` - // (trailing period). expect(dump).toContain("Use --skip-url-validation to suppress this error."); expect(classifyCliCauseActionability(exit.cause)).toMatchObject({ error_category: "invalid_input", @@ -1497,18 +1343,11 @@ describe("sso update integration", () => { yield* ssoUpdate(flags); const putReq = api.requests.find((r) => r.method === "PUT"); const domains = (putReq?.body as { domains: string[] })?.domains; - // Go uses map iteration → unordered; sort before asserting. + // Order is unspecified; sort before asserting. expect([...domains].sort()).toEqual(["new.com", "old2.com"]); }).pipe(Effect.provide(layer)); }); - // ------------------------------------------------------------------------- - // Profile emulation: the effective `--profile`/`SUPABASE_PROFILE` is - // resolved immediately before the workdir change — it decides which API - // host receives the GET *and* the PUT (the same host targets both), and - // aborts the command when the profile cannot be loaded. - // ------------------------------------------------------------------------- - const writeProfileYaml = (name: string, apiUrl: string): string => { const path = join(tempRoot.current, name); writeFileSync( @@ -1549,11 +1388,6 @@ describe("sso update integration", () => { it.live( "profile emulation: repeated --profile resolves last-wins — GET and PUT both target the last file's host", () => { - // `sso update <id> --profile first.yml --profile second.yml`: the - // Effect parser is first-wins (the config layer — and the typed client - // — resolved first.yml) while pflag Sets every occurrence and ends on - // second.yml. Go GETs and PUTs second.yml's api_url (`update.go:42`); - // first.yml's host receives nothing. const first = writeProfileYaml("first.yml", "http://first.example"); const second = writeProfileYaml("second.yml", "http://second.example"); const restoreEnv = withProfileEnv(undefined); @@ -1569,15 +1403,10 @@ describe("sso update integration", () => { const put = api.requests.find((r) => r.method === "PUT"); expect(get?.url).toBe(providerUrl); expect(put?.url).toBe(providerUrl); - // The merge seeds from the reconciled host's GET response. const domains = (put?.body as { domains?: string[] })?.domains ?? []; expect([...domains].sort()).toEqual(["old1.com", "old2.com"]); expect(api.requests.some((r) => r.url.startsWith("http://first.example/"))).toBe(false); - // The raw GET stitches identity through the shared per-command - // guard, like every other Management API response. expect(testSetup.stitchedResponses).toBeGreaterThan(0); - // The linked-project cache fill targets the reconciled host too — - // it uses the process-wide profile. expect(cache.cachedApiUrl).toBe("http://second.example"); }).pipe(Effect.ensuring(restoreEnv), Effect.provide(layer)); }, @@ -1638,9 +1467,6 @@ describe("sso update integration", () => { it.live( "profile emulation: the reconciled GET narrows odd JSON shapes when merging domains", () => { - // Covers the raw GET's JSON-narrowing fallbacks: a `domains` entry - // that isn't an object and one whose `domain` isn't a string are - // skipped, matching the typed client's schema behavior. const first = writeProfileYaml("first-merge.yml", "http://first.example"); const second = writeProfileYaml("second-merge.yml", "http://second.example"); const restoreEnv = withProfileEnv(undefined); @@ -1670,20 +1496,12 @@ describe("sso update integration", () => { ); const domains = (put?.body as { domains?: string[] })?.domains ?? []; expect([...domains].sort()).toEqual(["new.com", "old1.com"]); - // The linked-project cache fill receives the RECONCILED profile's - // token explicitly (here the profile-independent env token) — the - // stale profile's keyring token must never follow the reconciled URL - // (review r3684524241). `undefined` would fall back to the config - // layer's credentials service. expect(cache.cachedAccessToken).toBeDefined(); }).pipe(Effect.ensuring(restoreEnv), Effect.provide(layer)); }, ); it.live("profile emulation: a reconciled profile with no resolvable token aborts like Go", () => { - // The token gate fires at first client use when the RECONCILED profile's - // lookup finds nothing — the stale profile's token must never be - // substituted, and no request may be issued. const first = writeProfileYaml("first-notoken.yml", "http://first.example"); const second = writeProfileYaml("second-notoken.yml", "http://second.example"); const restoreEnv = withProfileEnv(undefined); @@ -1724,9 +1542,6 @@ describe("sso update integration", () => { }); it.live("profile emulation: the missing-token gate fires AFTER the mutex check, like Go", () => { - // cobra: ParseFlags → PreRunE → required → GROUPS → RunE(GetSupabase) — - // the token gate lives in RunE, so a mutex violation must win even when - // the reconciled profile has no token (validation-order parity). const first = writeProfileYaml("first-order.yml", "http://first.example"); const second = writeProfileYaml("second-order.yml", "http://second.example"); const restoreEnv = withProfileEnv(undefined); @@ -1790,9 +1605,6 @@ describe("sso update integration", () => { it.live( "profile emulation: --profile consuming a trailing flag token fails LoadProfile, never GETs", () => { - // `sso update <id> --profile --add-domains`: pflag binds - // `"--add-domains"` as the profile value (positional count stays 1); - // viper's extension gate rejects it before any request. const restoreEnv = withProfileEnv(undefined); const { layer, api } = setup({ cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--profile", "--add-domains"], @@ -1813,8 +1625,6 @@ describe("sso update integration", () => { it.live( "profile emulation: an undecodable 200 body from the reconciled GET aborts before the PUT", () => { - // The 200 JSON body is unmarshalled; a parse error exits with - // `failed to get sso provider: %w` before any PUT. const first = writeProfileYaml("first-badjson.yml", "http://first.example"); const second = writeProfileYaml("second-badjson.yml", "http://second.example"); const restoreEnv = withProfileEnv(undefined); @@ -1843,8 +1653,6 @@ describe("sso update integration", () => { it.live( "profile emulation: a 200 without a JSON content type maps to the unexpected-status branch, like Go's nil JSON200", () => { - // A 200 whose content type isn't JSON runs the gate check and errors - // with the raw body — no PUT. const first = writeProfileYaml("first-nonjson.yml", "http://first.example"); const second = writeProfileYaml("second-nonjson.yml", "http://second.example"); const restoreEnv = withProfileEnv(undefined); @@ -1869,9 +1677,6 @@ describe("sso update integration", () => { it.live( "profile emulation: a gated 4xx on the reconciled GET sends the fallback gate requests to the reconciled host", () => { - // The upgrade-suggestion path goes through the process-wide reconciled - // profile; the project + entitlement fallback GETs must hit the same - // host as the main call. const first = writeProfileYaml("first-gate.yml", "http://first.example"); const second = writeProfileYaml("second-gate.yml", "http://second.example"); const restoreEnv = withProfileEnv(undefined); @@ -1901,10 +1706,6 @@ describe("sso update integration", () => { ); it.live("profile emulation: the LoadProfile failure loses to the arity check, like Go", () => { - // cobra: `ValidateArgs` runs before every hook (`command.go:968`), so a - // wrong arg count is reported even when the profile is also unloadable - // (binary-verified for workdir in round 6; LoadProfile sits in the same - // PersistentPreRunE, before ChangeWorkDir). const restoreEnv = withProfileEnv(undefined); const { layer, api } = setup({ cliArgs: ["sso", "update", "a", "b", "--profile", "--metadata-url", "u"], diff --git a/apps/cli/src/commands/start/lib/env-or-default.ts b/apps/cli/src/commands/start/lib/env-or-default.ts index 22420d1983..6ef5239dde 100644 --- a/apps/cli/src/commands/start/lib/env-or-default.ts +++ b/apps/cli/src/commands/start/lib/env-or-default.ts @@ -1,19 +1,8 @@ /** - * `envOrDefault(key, def)`: env-var-if-set-else-default — an env var that is - * SET but empty is used verbatim (unlike `local-config-values.ts`'s - * `envOverride`, which treats an empty resolved value as unset). `projectEnvValues` - * mirrors that module's own merged (dotenv + ambient shell, ambient-wins) map; - * `??` only skips a `null`/`undefined` operand, never an empty string, so - * this naturally reproduces "set, even if empty" semantics without a separate - * presence check. No `SUPABASE_` prefix and no `env(VAR)` indirection — this - * reads the raw env var directly, bypassing the decode-hook chain those only - * apply to. - * - * Hoisted here (`start/lib/`, the `start` command family's shared root) per - * `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" rule: Storage's - * vector-bucket env (`services/storage.service.ts`) and Kong's - * `KONG_NGINX_WORKER_PROCESSES` (`services/kong.service.ts`) both need this - * exact env/dotenv-vs-default derivation. + * Returns the env var if set, even to an empty string — unlike + * `local-config-values.ts`'s `envOverride`, which treats an empty value as + * unset. Falls back to `def` only when the var is absent. Reads + * `process.env` directly, bypassing the `SUPABASE_`-prefixed decode-hook chain. */ export function envOrDefault( key: string, diff --git a/apps/cli/src/commands/start/lib/template-render.ts b/apps/cli/src/commands/start/lib/template-render.ts index 618808d64e..fc2c34a592 100644 --- a/apps/cli/src/commands/start/lib/template-render.ts +++ b/apps/cli/src/commands/start/lib/template-render.ts @@ -1,15 +1,11 @@ /** - * Pure renderer for the `text/template`-style sources under `../templates/`: - * every placeholder is a bare `{{ .FieldName }}` reference (no pipelines, - * functions, or control structures), and a placeholder with no matching - * field always throws — it never silently renders as empty. + * Pure renderer for `{{ .FieldName }}`-style templates under `../templates/`: + * a placeholder referencing a missing field throws — it never silently + * renders as empty. * - * `custom_nginx.template` is deliberately NOT rendered here: it is not one of - * these templates at all (see the comment on - * `START_CUSTOM_NGINX_TEMPLATE`) and is injected byte-for-byte, with - * its `${{VAR}}` placeholders substituted by Kong itself at container boot. - * Callers needing that file should import `START_CUSTOM_NGINX_TEMPLATE` - * directly from `../templates/custom_nginx.template.ts`. + * `custom_nginx.template` is not rendered here: its `${{VAR}}` placeholders + * are substituted by Kong itself at container boot. Import + * `START_CUSTOM_NGINX_TEMPLATE` directly for that file. */ import { START_KONG_YML_TEMPLATE } from "../templates/kong.yml.ts"; import { START_POOLER_EXS_TEMPLATE } from "../templates/pooler.exs.ts"; diff --git a/apps/cli/src/commands/start/services/edge-runtime.service.integration.test.ts b/apps/cli/src/commands/start/services/edge-runtime.service.integration.test.ts index bd1f73c28c..df47393095 100644 --- a/apps/cli/src/commands/start/services/edge-runtime.service.integration.test.ts +++ b/apps/cli/src/commands/start/services/edge-runtime.service.integration.test.ts @@ -15,20 +15,12 @@ import { } from "./edge-runtime.service.ts"; /** - * A spawner that answers every `docker` invocation - * `startStackEdgeRuntimeContainer`'s call chain makes - * (`ensureDockerNamedVolume`/`ensureDockerNetwork`/the create → cp → start - * bring-up itself) with success, recording every invocation's argv (plus its - * `stdin` option, for the `docker cp` archive) for assertions — same shape as - * `health-check.unit.test.ts`'s `mockHealthSpawner`. Secret - * values are delivered to the `create` call via `--env-file`/a bind-mounted - * script, not this spawned process's own environment (see - * `edge-runtime.service.ts`'s header for why), so there is nothing to capture - * beyond argv and stdin. Note `startStackEdgeRuntimeContainer` never issues a - * `docker exec ... kong reload` — that only happens in `functions serve`'s own - * `restartEdgeRuntime`-equivalent wrapper (`shared/functions/serve.ts`'s - * `startEdgeRuntime`), not in the shared bring-up core this module calls - * directly — see the "does not reload Kong" test below. + * Answers every `docker` invocation with success, recording each call's argv + * and `stdin` option (for `docker cp` archives) for assertions. + * + * Never issues `docker exec ... kong reload` — that only happens in + * `functions serve`'s own wrapper, not this shared bring-up core (see the + * "does not reload Kong" test below). */ function mockDockerSpawner( handler?: (args: ReadonlyArray<string>) => { exitCode: number; stderr?: string }, @@ -73,7 +65,7 @@ function mockDockerSpawner( return calls; }, get runCall() { - // The container-level `docker create` (`docker volume create` starts with "volume"). + // Excludes `docker volume create`, whose first arg is "volume", not "create". return calls.find((call) => call.args[0] === "create"); }, }; @@ -85,8 +77,7 @@ function baseInput(workdir: string): EdgeRuntimeBringUpInput { networkId: "supabase_network_proj", image: "registry.example.com/supabase/edge-runtime:v1.74.2", workdir, - // `authenticator:postgres@127.0.0.1:54322/postgres` — password "postgres", matching every - // other service's `dbUrl` input shape (`LocalConfigValues.dbUrl`). + // Matches every other service's `dbUrl` input shape (`LocalConfigValues.dbUrl`). dbUrl: "postgresql://postgres:postgres@127.0.0.1:54322/postgres", apiPort: 54321, edgeRuntimePolicy: "oneshot", @@ -123,9 +114,8 @@ function envEntries(runCall: { describe("startStackEdgeRuntimeContainer", () => { const tempWorkdir = useTempWorkdir("supabase-edge-runtime-service-int-"); - // An empty functions directory — every scenario here has zero declared - // functions (`configDeclaredFunctions`/`configFunctions` in `baseInput`), - // so nothing under it is ever read; it only needs to exist. + // Only needs to exist — every scenario here has zero declared functions, + // so nothing under it is read. beforeEach(() => { mkdirSync(join(tempWorkdir.current, "supabase", "functions"), { recursive: true }); }); @@ -209,7 +199,6 @@ describe("startStackEdgeRuntimeContainer", () => { const runCall = mock.runCall!; const ulimitIndex = runCall.args.indexOf("--ulimit"); expect(runCall.args[ulimitIndex + 1]).toBe(edgeRuntimeNofileUlimit("darwin").arg); - // Off Linux the raise is never clamped, so no clamp warning is emitted. expect(out.messages.filter((message) => message.type === "warn")).toEqual([]); }), ); @@ -320,7 +309,6 @@ describe("startStackEdgeRuntimeContainer", () => { .filter((step) => step === "create" || step === "cp" || step === "start"); expect(containerSteps).toEqual(["create", "cp", "start"]); - // No bind delivers /root/index.ts — the single-file host bind is what broke #6254. const createArgs = mock.runCall!.args; const bindValues = createArgs.flatMap((arg, index) => createArgs[index - 1] === "-v" ? [arg] : [], @@ -444,7 +432,6 @@ describe("startStackEdgeRuntimeContainer", () => { 'failed to copy edge runtime main service into container: destination "supabase_edge_runtime_proj:/" must be a directory', ); expect(mock.calls.some((call) => call.args[0] === "start")).toBe(false); - // Removal stays with the callers, matching `docker run -d` behavior. expect(mock.calls.some((call) => call.args[0] === "container")).toBe(false); }), ); @@ -479,18 +466,16 @@ describe("startStackEdgeRuntimeContainer", () => { "start-secrets", "supabase_edge_runtime_proj", ); - // Simulates a leftover from an earlier invocation (e.g. `functions serve`'s watch-mode - // restart loop) that was never reclaimed — `writeDockerEnvFile`'s own header explains why - // this path is deterministic/reused rather than a fresh mkdtemp per call. + // Simulates a stale leftover from an earlier invocation; `writeDockerEnvFile` reuses + // this path deterministically rather than a fresh mkdtemp each call. mkdirSync(join(stagingDir, "env"), { recursive: true }); writeFileSync(join(stagingDir, "env", "docker.env"), "STALE=1"); const input = { ...baseInput(tempWorkdir.current), - // A multiline secret with a name that fails `validateDockerMultilineEnvNames` (must - // match a shell variable name) — this throws before any of THIS invocation's staging - // writes happen, proving cleanup covers the whole staging-write window, not just a - // failure at (or after) the docker create/cp/start steps. + // An invalid shell-variable name fails before this invocation's staging writes + // happen, so cleanup must cover the whole staging-write window, not just + // create/cp/start failures. edgeRuntimeSecrets: { "1BAD_NAME": "line one\nline two" }, }; diff --git a/apps/cli/src/commands/start/services/edge-runtime.service.ts b/apps/cli/src/commands/start/services/edge-runtime.service.ts index 2952cd38e5..07be99a086 100644 --- a/apps/cli/src/commands/start/services/edge-runtime.service.ts +++ b/apps/cli/src/commands/start/services/edge-runtime.service.ts @@ -1,52 +1,18 @@ /** - * Real bring-up for the Edge Runtime container, gated on + * Bring-up for the Edge Runtime container the `start` command launches, + * gated like its siblings on * `config.edge_runtime.enabled && !isContainerExcluded(...)`. * - * Unlike its 12 siblings in this directory, this module does NOT build a - * `StartContainerSpec` for `createContainer` - * (`../../../shared/db-bootstrap/container-lifecycle.ts`) to create+start uniformly. That - * unification (`docker create`/`docker start`, `-e KEY`-only env with values - * supplied via the spawned process's own environment) was evaluated against - * what `shared/functions/serve.ts`'s `startEdgeRuntimeContainer` actually - * spawns and rejected as NOT a clean mapping: + * Delegates to `functions serve`'s bring-up (`shared/functions/serve.ts`) + * rather than the shared `createContainer` spec, since Edge Runtime needs + * `--workdir`/`--ulimit` flags and a secret-delivery mechanism (env-file plus + * a bind-mounted multiline-env script) that spec can't express. + * `start.handler.ts` must call {@link startStackEdgeRuntimeContainer} directly, the + * same way it already special-cases Postgres. * - * - `docker-create-args.ts`'s own header explicitly excludes `WorkingDir` - * and `Ulimits` from `StartContainerSpec` ("none of the 13 [other] - * call sites... set them") — Edge Runtime needs BOTH (`--workdir`, - * `--ulimit nofile`, host-clamped), so "mapping cleanly" would mean - * extending the shared spec for a single caller. - * - Every other service's env travels as bare `-e KEY` flags whose values - * come from the spawned `docker create` process's own environment - * (`container-lifecycle.ts`'s `dockerCreateContainer`). Edge - * Runtime instead needs an `--env-file` for ordinary secrets AND a - * separate bind-mounted, `sh`-sourced multiline-env script for any secret - * containing a newline (`docker env-file` is a line-oriented format that - * cannot represent one) — a second env-delivery mechanism the shared spec - * has no concept of. - * - Edge Runtime's `docker create` → `docker cp` → `docker start` sequence - * (`shared/functions/serve.ts`) superficially resembles - * `createContainer`'s, but its `docker cp` payload is the bundled - * main-service template (not `secretFiles`), so the shared spec's - * secret-file contract does not map onto it. - * - * So this module keeps `startEdgeRuntimeContainer`'s own create/cp/start - * bring-up exactly as `functions serve` already spawns it (see that module's - * own doc comment), and exposes {@link startStackEdgeRuntimeContainer} as a direct - * bring-up `Effect` for `start.handler.ts` to call from its own bring-up loop - * — NOT a spec for `createContainer` to create. `start.handler.ts`'s - * wiring must special-case Edge Runtime's bring-up call, the same way it - * already special-cases Postgres's (also called directly, not through the - * generic `buildSpecForService` switch, since it needs its own health-wait - * pass before the other services start). - * - * `SUPABASE_DB_URL` is the one thing that genuinely differs from what - * `functions serve` sends: standalone `functions serve` hardcodes the `db` - * network alias (`shared/functions/serve.ts`'s `defaultServeDbUrl`), - * but `start`'s own direct call uses the real `dbConfig` — the `db` - * container's own sanitized name and `config.db.password` — exactly like the - * other 12 services' `dbHost`/`dbPassword` derivation - * (`../../../shared/db-bootstrap/internal-db-connection.ts`). {@link startStackEdgeRuntimeContainer} - * reproduces that real value, not `functions serve`'s alias-based default. + * `SUPABASE_DB_URL` is the one input that genuinely differs from `functions + * serve`'s own default: this module derives it from `start`'s real `dbConfig` + * instead of the network-alias default `functions serve` uses standalone. */ import { Effect, Option } from "effect"; @@ -65,56 +31,38 @@ import { export interface EdgeRuntimeBringUpInput { /** The sanitized project id — see `serviceContainerName`'s callers. */ readonly projectId: string; - /** `container.HostConfig.NetworkMode`/`network.NetworkingConfig` target — the `--network-id` override or `utils.NetId`. */ + /** Docker network to attach to — the `--network-id` override or the project's default network. */ readonly networkId: string; - /** `utils.Config.EdgeRuntime.Image`, already resolved/pulled by the caller (`image-prepull.ts`/`resolveEdgeRuntimeImage`). */ + /** Already resolved/pulled by the caller (`resolveEdgeRuntimeImage`). */ readonly image: string; /** - * `cliSettings.workdir` in `start.handler.ts` — used as `functions serve`'s - * `projectRoot`/`flagCwd` (no separate "flag cwd" exists for `start`) and - * to derive `supabaseDir` (`<workdir>/supabase`). + * Used as both `functions serve`'s `projectRoot` and `flagCwd`, and to + * derive `supabaseDir` (`<workdir>/supabase`). */ readonly workdir: string; - /** `LocalConfigValues.dbUrl` — reused, not recomputed, to derive the internal DB host/password (matches every other service builder's own `dbUrl` input). */ + /** Reused, not recomputed, to derive the internal DB host/password. */ readonly dbUrl: string; - /** `config.api.port` — `SUPABASE_INTERNAL_HOST_PORT`. */ + /** `config.api.port`, exposed to the container as `SUPABASE_INTERNAL_HOST_PORT`. */ readonly apiPort: number; /** `config.edge_runtime.policy`. */ readonly edgeRuntimePolicy: string; /** `config.edge_runtime.inspector_port` — only published when `inspectMode` is set, which `start` never does. */ readonly edgeRuntimeInspectorPort: number; /** - * `config.edge_runtime.secrets`, already unwrapped — keys UPPERCASED, - * empty and unresolved-`env()` values filtered out. Build via - * `shared/functions/serve.ts`'s exported - * `toPlainEdgeRuntimeConfig(config.edge_runtime).secrets` rather than - * re-deriving the uppercase/`Redacted`-unwrap/zero-hash-filter logic here. + * `config.edge_runtime.secrets`, unwrapped via `shared/functions/serve.ts`'s + * `toPlainEdgeRuntimeConfig` (keys uppercased, empty/unresolved values filtered). */ readonly edgeRuntimeSecrets: Readonly<Record<string, string>>; - /** - * `config.functions`, already unwrapped — build via - * `shared/functions/serve.ts`'s exported `toPlainFunctionRecord(config.functions)`. - */ + /** `config.functions`, unwrapped via `shared/functions/serve.ts`'s `toPlainFunctionRecord`. */ readonly configDeclaredFunctions: ServeEdgeRuntimeContainerConfig["configDeclaredFunctions"]; - /** - * The functions directory manifest — build via `@supabase/config`'s - * `inferFunctionsManifest({ cwd: workdir, config })`, same as - * `shared/functions/serve.ts`'s own `resolveServeConfig` does. - */ + /** The functions directory manifest, from `@supabase/config`'s `inferFunctionsManifest({ cwd: workdir, config })`. */ readonly configFunctions: ServeEdgeRuntimeContainerConfig["configFunctions"]; /** - * The raw, pre-schema `[functions.*]` TOML table — build via - * `shared/functions/deploy.ts`'s exported `rawFunctionConfigRecord(document)` - * against the same raw document `start.handler.ts` already loads (its own - * `context.loaded?.document`). + * The raw, pre-schema `[functions.*]` TOML table, from + * `shared/functions/deploy.ts`'s `rawFunctionConfigRecord(document)`. */ readonly rawConfigFunctions: ServeEdgeRuntimeContainerConfig["rawConfigFunctions"]; - /** - * Every already-resolved secret/key this container needs — `start`'s own - * `values.{publishableKey,secretKey,jwtSecret,anonKey,serviceRoleKey}` plus - * `jwks` (`resolveLocalJwks`'s result), NOT independently re-resolved - * (see this module's header). - */ + /** Every already-resolved secret/key this container needs; not independently re-resolved here. */ readonly authArtifacts: ServeAuthArtifacts; /** The global `--debug` flag. */ readonly debug: boolean; @@ -123,44 +71,22 @@ export interface EdgeRuntimeBringUpInput { } /** - * Bring up the Edge Runtime container for `start`, delegating the entire - * create/cp/start argv/env assembly to `shared/functions/serve.ts`'s - * `startEdgeRuntimeContainer` (already ported for `functions serve`) with - * `start`'s own already-resolved config/secrets in place of that command's - * independent config-loading pipeline. `start.handler.ts`'s bring-up loop - * should call this directly (NOT `createContainer`) for the Edge Runtime - * entry in its service list, gated the same way as every other service on - * `config.edge_runtime.enabled && !isContainerExcluded(...)`. + * Brings up the Edge Runtime container for `start`, using `start`'s own + * already-resolved config/secrets in place of `functions serve`'s + * independent config-loading pipeline. Call this directly from the bring-up + * loop, not through `createContainer`. * - * Resolves to the same `StartedRuntime` shape `functions serve` itself - * gets back. `containerId` is what the caller adds to its post-bring-up - * health-wait list (pairing it with an `edgeRuntime` gateway on - * `WaitForHealthyServicesOptions`, `../../../shared/db-bootstrap/health-check.ts` — the same - * shape as the existing `postgrest` gateway). `watchSpecs` is - * `functions serve`-only file-watch plumbing and can be ignored here. + * `containerId` is added to the caller's post-bring-up health-wait list + * (paired with an `edgeRuntime` gateway, the same shape as `postgrest`'s). + * `watchSpecs` is `functions serve`-only file-watch plumbing and can be + * ignored here. * - * `cleanup` (removing the temp env-file/multiline-env-script files this call - * writes to the host) is intentionally left to the - * caller, and the caller must NOT invoke it on a successful bring-up. Unlike - * every other `start` service (`createContainer`'s `restartPolicy: - * "unless-stopped"`), Edge Runtime's bring-up sets NO Docker restart policy - * at all — its lifecycle is deliberately reconciled at the CLI level, not - * the Docker daemon level — so this container's own bring-up - * (`shared/functions/serve.ts`) intentionally omits `--restart` too. Its - * bind-mounted host paths must - * still exist for as long as the container itself can be reattached to - * (e.g. a plain `docker start` by the user, or discovery by a later CLI - * invocation) — unlike Kong/Postgres/Supavisor's `secretFiles` and Edge - * Runtime's own bootstrap template, which are `docker cp`-streamed straight - * into the created container instead of staged on host disk (see - * `copyStartSecretFilesIntoContainer`'s doc comment and - * `startEdgeRuntimeContainer`'s own delivery comment), Edge Runtime's - * bind-mounted multiline-env-script artifacts still need this host - * persistence. `startEdgeRuntimeContainer` (`shared/functions/ - * serve.ts`) already runs `cleanup` internally on any failed or interrupted - * bring-up (`Effect.onError`, covering the whole staging-write-through- - * `docker start` window, not just a non-zero exit code), so the caller only - * needs to leave the returned `cleanup` unused on success. + * Sets no Docker restart policy — unlike every other `start` service, Edge + * Runtime's lifecycle is reconciled at the CLI level instead. Leave the + * returned `cleanup` unused on success: its bind-mounted host files must + * outlive the call for as long as the container can be reattached to, and + * `startEdgeRuntimeContainer` already runs `cleanup` on any failed or + * interrupted bring-up. */ export const startStackEdgeRuntimeContainer = Effect.fn("start.edgeRuntime")(function* ( input: EdgeRuntimeBringUpInput, @@ -189,9 +115,7 @@ export const startStackEdgeRuntimeContainer = Effect.fn("start.edgeRuntime")(fun platform: input.platform, debug: input.debug, networkId: input.networkId, - // `start` has no CLI flags of its own for any of these, so they're all - // passed as their zero value: no inspect mode, no `envFilePath`, no - // `noVerifyJWT`, no `importMapPath`. + // `start` has no CLI flags of its own for any of these; all zero values. envFile: Option.none(), discoverFunctionEnvFiles: false, importMap: Option.none(), diff --git a/apps/cli/src/commands/start/services/gotrue.service.ts b/apps/cli/src/commands/start/services/gotrue.service.ts index eebf083b8c..8de0ff43f1 100644 --- a/apps/cli/src/commands/start/services/gotrue.service.ts +++ b/apps/cli/src/commands/start/services/gotrue.service.ts @@ -1,47 +1,26 @@ /** * GoTrue/Auth env + container spec builder, gated on `config.auth.enabled` - * and `!isContainerExcluded(config.auth.image, excluded)` — see - * `service-catalog.ts`'s `gotrue` entry (`excludeKey: "gotrue"`, gated - * on `auth.enabled`). Gating and image resolution/pre-pull are the caller's - * job (a future `start.handler.ts`); this module only assembles the - * env/container spec once the caller has already decided to start it. + * and `!isContainerExcluded(config.auth.image, excluded)`. Gating and image + * resolution/pre-pull are the caller's job; this module only assembles the + * env/container spec once the caller has decided to start it. * - * {@link buildGotrueEnv} deliberately reproduces the FULL env surface - * GoTrue's container actually receives, including every conditional env var - * on top of the base set: JWT signing keys, SMTP/Mailpit fallback, mailer - * template/notification URLs, the fixed-priority SMS provider switch, - * CAPTCHA, the six auth hooks, MFA phone extras, passkey/WebAuthn, external - * OAuth providers, Web3, OAuth server. See `gotrue.service.unit.test.ts` for - * coverage. + * {@link buildGotrueEnv} reproduces the full env surface GoTrue's container + * receives, including every conditional var: JWT signing keys, SMTP/Mailpit + * fallback, mailer URLs, the fixed-priority SMS provider switch, CAPTCHA, + * auth hooks, MFA phone extras, passkey/WebAuthn, external OAuth providers, + * Web3, OAuth server. See `gotrue.service.unit.test.ts` for coverage. * - * `@supabase/config` schema gaps this module works around (all pre-existing, - * not introduced here — see each input field's own doc comment): - * - `auth.external_url` has no schema field at all, so the caller reads it - * off the raw TOML document (same pattern as the gaps below) and passes - * the resolved value in as {@link BuildGotrueEnvInput.authExternalUrl} - * — when set, it wins over the `apiUrl`-derived fallback for - * `API_EXTERNAL_URL`/`GOTRUE_JWT_ISSUER`'s default/the mailer verify URL/ - * OAuth redirect-URI fallbacks. `config push`'s auth update body has no - * such gap to share: it has no `external_url`/`jwt_issuer` field at all, - * so there's nothing for that command to derive or resolve. + * `@supabase/config` schema gaps this module's inputs work around (see each + * field's own doc comment for detail): + * - `auth.external_url` has no schema field; the caller reads it off the + * raw TOML document and passes it as {@link BuildGotrueEnvInput.authExternalUrl}. * - `auth.captcha`/`auth.passkey`/`auth.webauthn`/`auth.email.smtp`'s - * presence-and-default quirks (an explicitly-omitted `enabled` is - * treated differently depending on whether the surrounding TOML table is - * present at all) can't be recovered from the decoded `CliConfig` - * alone — the caller resolves these the same way - * `local-config-values.ts` already does (reading the raw TOML - * document) and passes the final, presence-resolved values in. - * - `auth.external` decodes as a FIXED struct of ~19 known providers, each - * always present with `enabled: false` when unconfigured — but the real - * provider set is whatever a user's `config.toml` actually mentions, and - * `appendGotrueExternalProviderEnv` iterates that real map - * unconditionally (emitting `_ENABLED=false` etc. for a - * configured-but-disabled provider, never for an unconfigured one). - * {@link BuildGotrueEnvInput.externalProviders} must therefore - * already be presence-filtered by the caller (only the providers whose - * `[auth.external.<name>]` section actually exists in the TOML - * document), the same way `local-config-values.ts`'s - * `validateAuthExternalProviders` already filters for its own purposes. + * presence-vs-default quirks can't be recovered from the decoded + * `CliConfig` alone, so the caller resolves them from the raw TOML + * document, the same way `local-config-values.ts` does. + * - `auth.external` always decodes all ~19 known providers with + * `enabled: false`, so {@link BuildGotrueEnvInput.externalProviders} + * must already be filtered to only the providers present in the TOML. */ import type { CliConfig } from "@supabase/config"; @@ -76,17 +55,10 @@ const GOTRUE_DEFAULT_INBUCKET_SENDER_NAME = "Admin"; const GOTRUE_DB_ROLE = "supabase_auth_admin"; /** - * RFC 7517 JWK fields, in the exact field declaration order — needed so - * {@link buildGotrueEnv}'s `JSON.stringify` reproduces a stable, - * canonical serialization byte-for-byte (`JSON.stringify` serializes object - * keys in insertion order). - * Structurally near-identical to `command-internal/go-jwt.ts`'s `Jwk` - * (the only difference is `key_ops`'s mutable `string[]` there, needed for - * assignability into Node's `createPrivateKey`/`JsonWebKey` input — see that - * type's own doc comment) and a superset of `shared/auth/jwks.ts`'s `JwkLike` - * (which omits the private-key fields `d`/`p`/`q`/`dp`/`dq`/`qi`) — kept local - * rather than reusing either shared type, since neither of their existing - * callers needs the union of all seventeen fields. + * RFC 7517 JWK fields in fixed declaration order, so {@link buildGotrueEnv}'s + * `JSON.stringify` produces a stable, canonical serialization. Kept separate + * from `go-jwt.ts`'s `Jwk` and `shared/auth/jwks.ts`'s `JwkLike` since + * neither covers this full field set. */ export interface GotrueSigningKey { readonly kty: string; @@ -109,11 +81,9 @@ export interface GotrueSigningKey { } /** - * The default single signing key — used whenever `auth.signing_keys_path` - * is unset. Hoisted to `go-jwt.ts` (as `DEFAULT_SIGNING_KEY`) - * so `resolveLocalJwks` publishes the exact same key's public form in - * the JWKS this default signs with — the two must never disagree on which - * key is "the" default. + * The default single signing key, used whenever `auth.signing_keys_path` is + * unset. Hoisted to `go-jwt.ts` so `resolveLocalJwks` publishes this same + * key's public form in the JWKS it signs with. */ const GOTRUE_DEFAULT_SIGNING_KEY: GotrueSigningKey = DEFAULT_SIGNING_KEY; @@ -208,9 +178,7 @@ export interface BuildGotrueEnvInput { /** * `config.auth.email.smtp`, already presence-and-default resolved by the - * caller (`@supabase/config` can't reproduce the "TOML table present, - * `enabled` key absent → true" default on its own, see this module's - * header). `undefined` means the SMTP section is absent or disabled — the + * caller. `undefined` means the SMTP section is absent or disabled — the * mailpit fallback below applies instead. */ readonly smtp?: { @@ -254,12 +222,9 @@ export interface BuildGotrueEnvInput { }; /** - * `config.auth.captcha`, already presence-resolved AND env-override-resolved - * by the caller (`resolveAuthCaptcha` — `SUPABASE_AUTH_CAPTCHA_ENABLED`/ - * `_PROVIDER`/`_SECRET`, `secret` decrypted like every other `Secret`-typed - * field). `@supabase/config`'s `withDecodingDefaultKey` fills in `{enabled: - * false}` even when `[auth.captcha]` is absent — see this module's header. - * `undefined` means the section itself is absent. + * `config.auth.captcha`, already presence-resolved and + * env-override-resolved by the caller (`resolveAuthCaptcha`). `undefined` + * means the section itself is absent. */ readonly captcha?: { readonly enabled: boolean; @@ -281,9 +246,8 @@ function trimTrailingSlashes(value: string): string { } /** - * Two INDEPENDENT presence gates for passkey/WebAuthn env vars — config - * validation requires `Auth.Webauthn` whenever `Auth.Passkey.Enabled`, but - * this function doesn't assume that invariant. + * Passkey and WebAuthn env vars are gated independently, even though config + * validation requires `webauthn` whenever `passkey.enabled` is set. */ function appendGotruePasskeyEnv( env: Record<string, string>, @@ -489,11 +453,8 @@ export function buildGotrueEnv(input: BuildGotrueEnvInput): Record<string, strin } } - // Mailer template/notification URLs and subjects. `subject !== undefined` - // is the "explicit empty string" vs "absent" distinction: the caller - // (`resolveAuthEmail`) has already recovered it from the raw - // document, so `undefined` here means omit the env var entirely and `""` - // means an explicit blank subject (still emit it). + // `subject !== undefined` distinguishes an explicit empty string (still + // emitted) from an absent value (omitted) — already resolved by the caller. const addMailerEnvVars = (id: string, contentPath: string, subject: string | undefined): void => { if (contentPath.length > 0) { env[`GOTRUE_MAILER_TEMPLATES_${id.toUpperCase()}`] = diff --git a/apps/cli/src/commands/start/services/gotrue.service.unit.test.ts b/apps/cli/src/commands/start/services/gotrue.service.unit.test.ts index 6aeb3fbc2a..06dba5223c 100644 --- a/apps/cli/src/commands/start/services/gotrue.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/gotrue.service.unit.test.ts @@ -211,8 +211,6 @@ describe("buildGotrueEnv", () => { }); }); - // Exercised through GOTRUE_SMS_TEST_OTP since `formatMapForEnvConfig` - // is only ever called from inside `buildGotrueEnv`. describe("GOTRUE_SMS_TEST_OTP / formatMapForEnvConfig parity", () => { test("formatMapForEnvConfig produces key:value pairs with no trailing comma", () => { expect(formatMapForEnvConfig({})).toBe(""); @@ -518,9 +516,6 @@ describe("buildGotrueEnv", () => { expect(env["GOTRUE_MAILER_SUBJECTS_CONFIRMATION"]).toBe("Confirm your signup"); }); - // The email template's subject is optional; the gate is strictly - // `subject !== undefined`, not on string length — an explicit blank - // subject is still emitted, distinct from an absent one below. test("still emits an explicit empty subject, distinct from an absent one", () => { const env = buildGotrueEnv({ ...baseEnvInput, @@ -709,7 +704,6 @@ describe("buildGotrueContainerSpec", () => { expect(spec.networkAliases).toEqual(["auth"]); expect(spec.labels).toEqual({}); - // dbHost/dbPassword flow from projectId/dbUrl into the env's connection string. expect(spec.env["GOTRUE_DB_DATABASE_URL"]).toBe( "postgresql://supabase_auth_admin:secret@supabase_db_proj:5432/postgres", ); diff --git a/apps/cli/src/commands/start/services/imgproxy.service.ts b/apps/cli/src/commands/start/services/imgproxy.service.ts index 580fadd06a..bcc9ddaf7d 100644 --- a/apps/cli/src/commands/start/services/imgproxy.service.ts +++ b/apps/cli/src/commands/start/services/imgproxy.service.ts @@ -1,18 +1,12 @@ /** * ImgProxy container spec builder. * - * Enabled gate: `isStorageEnabled && isImgProxyEnabled` — i.e. - * `config.storage.enabled && config.storage.image_transformation?.enabled - * && !isContainerExcluded(imgproxyImage, excluded)`, AND Storage itself must - * be enabled (ImgProxy mounts Storage's own volumes via `VolumesFrom` below, - * so it cannot meaningfully run without it). Gating the actual container - * start is the future `start.handler.ts` orchestrator's responsibility — - * see `start.services.ts`'s `imgproxy` catalog entry (`enabledGate: - * "storage.enabled && storage.image_transformation.enabled"`, `dependsOn: - * ["storage"]`) — this module only builds the container spec once called. - * The caller must pass the SAME `isImgProxyEnabled` boolean it used for this - * gating decision into `storage.service.ts`'s `StorageEnvInput. - * imageTransformationEnabled` — see that file's header. + * Enabled when `storage.enabled && storage.image_transformation.enabled && + * !isContainerExcluded(...)` — ImgProxy mounts Storage's own volumes via + * `VolumesFrom`, so it can't run without Storage. Gating the actual start is + * the `start` orchestrator's job; this module only builds the spec. The + * caller must pass the same enabled value into `storage.service.ts`'s + * `StorageEnvInput.imageTransformationEnabled`. */ import { serviceContainerName } from "../../../command-internal/docker-ids.ts"; @@ -26,8 +20,7 @@ export function buildImgproxyEnv(): Record<string, string> { return { IMGPROXY_BIND: ":5001", IMGPROXY_LOCAL_FILESYSTEM_ROOT: "/", - // The literal value here really is `"/"`, not a boolean-looking value — - // this is not a typo. + // `"/"` is the correct value here, not a boolean-looking typo. IMGPROXY_USE_ETAG: "/", IMGPROXY_MAX_SRC_RESOLUTION: "50", IMGPROXY_MAX_SRC_FILE_SIZE: "25000000", @@ -41,9 +34,9 @@ export function buildImgproxyEnv(): Record<string, string> { export interface ImgproxyContainerSpecInput { /** The sanitized project id — see `serviceContainerName`'s callers. */ readonly projectId: string; - /** `container.HostConfig.NetworkMode`/`network.NetworkingConfig` target — the `--network-id` override or `utils.NetId`. */ + /** Docker network to attach to — the `--network-id` override or the project's default network. */ readonly networkId: string; - /** `utils.Config.Storage.ImgProxyImage`, already resolved/pulled by the caller (`image-prepull.ts`). */ + /** Already resolved/pulled by the caller (`image-prepull.ts`). */ readonly image: string; } @@ -68,7 +61,6 @@ export function buildImgproxyContainerSpec(input: ImgproxyContainerSpecInput): S }, restartPolicy: "unless-stopped", networkId: input.networkId, - // The ImgProxy network alias. networkAliases: ["imgproxy"], labels: {}, }; diff --git a/apps/cli/src/commands/start/services/kong.service.ts b/apps/cli/src/commands/start/services/kong.service.ts index 7dc0269129..60098fd940 100644 --- a/apps/cli/src/commands/start/services/kong.service.ts +++ b/apps/cli/src/commands/start/services/kong.service.ts @@ -1,52 +1,22 @@ /** * Kong container spec builder, gated on - * `!isContainerExcluded(config.api.kong_image, excluded)` (Kong has no - * `enabled` flag of its own — it is the stack's mandatory gateway) — see - * `service-catalog.ts`'s `kong` entry (`excludeKey: "kong"`). Gating and - * image resolution/pre-pull are the caller's job (a future `start.handler.ts`); - * this module only assembles the container spec once the caller has already - * decided to start it, matching `docker-create-args.ts`'s "image already - * resolved/pulled" contract. + * `!isContainerExcluded(config.api.kong_image, excluded)` — Kong has no + * `enabled` flag of its own; it's the stack's mandatory gateway. * - * How `kong.yml`/`custom_nginx.template`/the TLS cert+key get injected into - * the container: a single `sh -c` entrypoint script could chain FOUR - * `cat <<'EOF' > <path> && \` heredocs (joined by shell line-continuation - * into one logical command line), landing all four bodies — including - * `kong.yml`'s embedded service-role-key-derived bearer/query tokens and the - * TLS private key — directly in the container's own `Cmd`. THIS PORT SHELLS - * OUT to a real `docker create`, where that `Cmd` string would become a - * subprocess's own argv and leak via `ps aux`/`/proc/<pid>/cmdline` - * (CWE-214/522), so it deliberately diverges here: `kong.yml` (the - * service-role key) and the TLS cert/key (the highest-value secret — a - * private key) travel via {@link StartContainerSpec.secretFiles} - * instead — an in-memory tar entry, mode `0644` (world-readable — - * Kong's image runs its process as uid 100 `kong`, a non-root user, and - * `0600` would make it unreadable in-container; see - * `copyStartSecretFilesIntoContainer`'s doc comment), streamed via - * `docker cp - <id>:/` into the container at the exact fixed paths - * `KONG_DECLARATIVE_CONFIG`/`KONG_SSL_CERT`/`KONG_SSL_CERT_KEY` already - * reference — and never appear in this process's own argv. Only - * `custom_nginx.template`, which carries no secret content, still travels - * via the heredoc entrypoint script. + * `kong.yml` and the TLS cert/key travel via + * {@link StartContainerSpec.secretFiles} (an in-memory `docker cp` tar + * entry), not the entrypoint script: embedding them in a heredoc would put + * the service-role key and the TLS private key into the container's own + * `Cmd`, leaking via `ps aux`/`/proc/<pid>/cmdline` (CWE-214/522). These + * `secretFiles` entries are always present, since `KONG_SSL_CERT`/ + * `KONG_SSL_CERT_KEY` reference fixed in-container paths unconditionally and + * their content is never empty (see {@link KongContainerSpecInput.tlsCertContent}). + * `custom_nginx.template` carries no secret content, so it still travels via + * {@link buildKongEntrypointScript}'s heredoc, `exec`'d so Kong runs as PID 1. * - * The TLS cert/key `secretFiles` entries are still ALWAYS present — never a - * conditional bind — because `KONG_SSL_CERT`/`KONG_SSL_CERT_KEY` reference - * fixed in-container paths unconditionally. Their content is never empty - * either: the default config seeds `Api.Tls.{CertContent,KeyContent}` with - * the embedded default localhost cert/key, and only overwrites them from - * disk when TLS is enabled AND both `cert_path`/`key_path` are configured — - * see {@link KongContainerSpecInput.tlsCertContent}'s doc comment. - * {@link buildKongEntrypointScript} reproduces the remaining - * `custom_nginx.template` heredoc; the final command is `exec`'d so Kong - * is PID 1 and `docker stop` reaches it directly. - * - * Kong mints no JWTs of its own: `BearerToken`/`QueryToken` are Kong - * `request-transformer`/lua expression STRINGS built from the four - * already-generated API keys (`secretKey`/`serviceRoleKey`/`publishableKey`/ - * `anonKey` — see `local-config-values.ts`'s `LocalConfigValues`, - * which already resolves all four). {@link buildKongBearerToken}/ - * {@link buildKongQueryToken} build those two strings; nothing in this - * module calls `generateGoJwt` itself. + * Kong mints no JWTs itself: {@link buildKongBearerToken}/ + * {@link buildKongQueryToken} build lua-expression strings from the four + * already-generated API keys. */ import * as nodePath from "node:path"; @@ -106,18 +76,11 @@ export function buildKongQueryToken(apiKeys: KongApiKeys): string { } /** - * `KONG_NGINX_WORKER_PROCESSES`, env-or-default: the operator's own shell - * value wins when set (e.g. `KONG_NGINX_WORKER_PROCESSES=auto` for one - * worker per CPU core), otherwise a default of a single worker to minimize - * local-stack memory usage (Ref: supabase/cli#1271). `projectEnvValues` is - * the merged (dotenv + ambient shell, ambient-wins) view, so a - * `KONG_NGINX_WORKER_PROCESSES` set only in a project dotenv file (not the - * ambient shell) is honored too, matching Storage's identical `VECTOR_*`-env - * handling (`storage.service.ts`). Kept separate from - * {@link buildKongContainerSpec} (which stays a pure function of - * already-resolved values, matching every other `start`-service builder) so - * this one ambient-env read is independently testable and the builder itself - * never touches `process.env`. + * `KONG_NGINX_WORKER_PROCESSES`, env-or-default: honors an operator's own + * value (from a project dotenv file or the ambient shell), defaulting to a + * single worker to minimize local-stack memory use (supabase/cli#1271). Kept + * separate from {@link buildKongContainerSpec} so that builder never touches + * `process.env`. */ export function resolveKongNginxWorkerProcesses( projectEnvValues?: Readonly<Record<string, string>>, @@ -127,49 +90,30 @@ export function resolveKongNginxWorkerProcesses( export interface KongEmailTemplateMount { /** - * The raw `config.auth.email.template` key for a template mount, or - * `<key>_notification` for an enabled `config.auth.email.notification` - * entry — the caller is responsible for that suffixing and for filtering - * notifications down to `enabled` ones; this module only derives the - * per-mount path. + * The `config.auth.email.template` key, or `<key>_notification` for a + * notification entry — suffixing and enabled-filtering are the caller's job. */ readonly id: string; /** - * Absolute HOST path, already resolved, containment-checked, AND - * read-verified by the caller (`start.handler.ts`'s - * `resolveKongEmailTemplateMounts`, via `resolveEmailTemplateContentPath` - * plus a discarded `readFileSync`) — never a raw, unresolved - * `content_path`. There is no "not configured" sentinel here: the caller - * omits an entry entirely instead of including one with an empty path. + * Absolute host path, already resolved, containment-checked, and + * read-verified by the caller. Omitted entirely (not an empty string) when + * not configured. */ readonly resolvedPath: string; /** - * `true` for a mount derived from an ENABLED `auth.email.notification.*` - * entry (vs a `auth.email.template.*` entry) — caller-side bookkeeping - * only; this module no longer branches on it, since resolution (including - * the notification-specific legacy `supabase/`-relative fallback) already - * happened upstream, once, before `resolvedPath` was set. + * `true` for a mount derived from an enabled `auth.email.notification.*` + * entry — caller-side bookkeeping only; this module doesn't branch on it. */ readonly notification?: boolean; } /** - * Formats one email-template bind mount: joins `mount.resolvedPath` onto the - * fixed in-container email-template directory as `<id><ext-of-resolvedPath>` - * (POSIX — the container is always Linux regardless of the host OS, hence - * `nodePath.posix.join`, not the platform-dependent `nodePath.join`), and - * formats the `rw,z` bind. `z` is the shared SELinux relabel that lets Kong - * read the template under enforcement; these are user-owned project files - * remounted across `start`/`db reset`, so the private `Z` relabel used for - * `functions serve`'s CLI-staged dir would be wrong here. The relabel - * persistently rewrites the host file's security label and is a no-op where - * SELinux is not enabled. - * - * A pure formatter over an already-validated path — it makes no containment - * or existence claims of its own. `start.handler.ts` resolves, confines to - * the project root, and read-verifies every mount's `resolvedPath` exactly - * once, before any Docker work runs (see - * `KongEmailTemplateMount.resolvedPath`'s doc comment). + * Formats one email-template bind mount at + * `<KONG_NGINX_EMAIL_TEMPLATE_DIR>/<id><ext-of-resolvedPath>`, using the + * shared `z` SELinux relabel (not the private `Z`) since these are + * user-owned project files remounted across `start`/`db reset`. Assumes + * `resolvedPath` is already validated; makes no containment or existence + * checks of its own. */ export function buildKongEmailTemplateBind(mount: KongEmailTemplateMount): string { const dockerPath = nodePath.posix.join( @@ -184,13 +128,10 @@ const KONG_ENTRYPOINT_HEAD = "exec ./docker-entrypoint.sh kong docker-start --nginx-conf /home/kong/custom_nginx.template\n"; /** - * Builds the surviving (non-secret) half of the Kong entrypoint: only the - * `custom_nginx.template` heredoc and the final `docker-entrypoint.sh` exec - * line — `KONG_ENTRYPOINT_HEAD + nginxTemplate + "\nEOF\n"`. The - * other three heredocs that could otherwise chain ahead of this one - * (`kong.yml`, the TLS cert, the TLS key) don't travel through this script - * at all — see this module's header comment for why (`secretFiles`, - * CWE-214/522). + * Builds the non-secret half of the Kong entrypoint: the + * `custom_nginx.template` heredoc plus the final `docker-entrypoint.sh` exec + * line. `kong.yml` and the TLS cert/key travel via `secretFiles` instead — + * see this module's header. */ export function buildKongEntrypointScript(nginxTemplate: string): string { return KONG_ENTRYPOINT_HEAD + nginxTemplate + "\nEOF\n"; @@ -214,12 +155,9 @@ export interface KongContainerSpecInput { /** `config.api.tls.enabled`, post-override — selects the published container port (`8443` vs `8000`). */ readonly apiTlsEnabled: boolean; /** - * The resolved TLS cert content. NOT empty-by-default: the default config - * seeds this with the embedded default cert (`KONG_LOCAL_TLS_CERT`) - * and only config validation overwrites it from `api.tls.cert_path` when - * TLS is enabled AND both `cert_path`/`key_path` are configured — the - * caller must pass the embedded default here otherwise, since this field - * is always written to `/home/kong/localhost.crt` unconditionally. + * The resolved TLS cert content. Never empty: defaults to the embedded + * cert, only overwritten from `cert_path` when TLS is enabled and both + * paths are configured. Always written to `/home/kong/localhost.crt`. */ readonly tlsCertContent: string; /** The resolved TLS key content — see {@link tlsCertContent} for the same embedded-default requirement. */ @@ -231,11 +169,8 @@ export interface KongContainerSpecInput { /** PostgREST's own container name. */ readonly restId: string; /** - * `config.realtime.tenant_id` — NOT Realtime's container name/id. - * Realtime is reachable under this same value because it is ALSO - * Realtime's own network alias (`["realtime", tenantId]`), so - * `kong.yml`'s `url: http://{{ .RealtimeId }}:4000/...` resolves via that - * alias, not via `serviceContainerName("realtime", projectId)`. + * `config.realtime.tenant_id`, not Realtime's container name. Realtime is + * reachable under this value because it's also Realtime's own network alias. */ readonly realtimeTenantId: string; /** Storage's own container name. */ @@ -250,18 +185,11 @@ export interface KongContainerSpecInput { readonly logflareId: string; /** Supavisor's own container name. */ readonly poolerId: string; - /** - * `envOrDefault("KONG_NGINX_WORKER_PROCESSES", "1")` — already resolved by - * the caller via {@link resolveKongNginxWorkerProcesses}, keeping - * this builder a pure function of its `input`. - */ + /** Already resolved by the caller via {@link resolveKongNginxWorkerProcesses}, keeping this builder pure. */ readonly nginxWorkerProcesses: string; /** - * Every `config.auth.email.template.*`/enabled - * `config.auth.email.notification.*` entry the caller has already - * gathered — see {@link KongEmailTemplateMount}'s doc comment for - * the notification `id` suffixing/filtering the caller owns. Defaults to - * `[]` (no email template mounts). + * Every `config.auth.email.template.*`/enabled `notification.*` entry the + * caller has gathered — see {@link KongEmailTemplateMount}. Defaults to `[]`. */ readonly emailTemplateMounts?: ReadonlyArray<KongEmailTemplateMount>; } diff --git a/apps/cli/src/commands/start/services/kong.service.unit.test.ts b/apps/cli/src/commands/start/services/kong.service.unit.test.ts index 4b872f4d90..4780501308 100644 --- a/apps/cli/src/commands/start/services/kong.service.unit.test.ts +++ b/apps/cli/src/commands/start/services/kong.service.unit.test.ts @@ -50,14 +50,9 @@ describe("resolveKongNginxWorkerProcesses", () => { }); describe("buildKongEmailTemplateBind", () => { - // Resolution (workdir-relative joins, the notification-specific legacy - // supabase/-relative fallback, absolute-path passthrough, containment, and - // read-verification) all moved to `start.handler.ts`'s - // `resolveKongEmailTemplateMounts` (CLI-2339's Kong-mount hardening pass) — - // see that function's own test coverage in `start.integration.test.ts` and - // `config-validate.unit.test.ts`'s `resolveEmailTemplateContentPath` - // suite. This function is now a pure formatter over an already-resolved - // `mount.resolvedPath`; the remaining tests below only cover that formatting. + // Resolution and containment checks live in `start.handler.ts`'s + // `resolveKongEmailTemplateMounts`; this function only formats an + // already-resolved `mount.resolvedPath`. test("builds the bind string from an already-resolved resolvedPath", () => { expect(buildKongEmailTemplateBind({ id: "invite", resolvedPath: "/work/invite.html" })).toBe( @@ -188,10 +183,6 @@ describe("buildKongContainerSpec", () => { }); test("mounts every resolved email template bind (start.go:544-558)", () => { - // Every entry here is already resolved+containment-checked+read-verified by the - // caller (`start.handler.ts`'s `resolveKongEmailTemplateMounts`) — there is no - // "unconfigured" entry to filter downstream anymore, since the caller omits those - // entirely before this input is ever built. const spec = buildKongContainerSpec({ ...base, emailTemplateMounts: [ From 36b1b4106dbc2cfa831491abf02193ec4651877b Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:09:54 +0100 Subject: [PATCH 46/56] chore(cli): re-anchor describeWorker doc comment --- .../commands/experimental/workers/workers.shared.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/commands/experimental/workers/workers.shared.ts b/apps/cli/src/commands/experimental/workers/workers.shared.ts index 14783fb4c2..6f9e9d1e40 100644 --- a/apps/cli/src/commands/experimental/workers/workers.shared.ts +++ b/apps/cli/src/commands/experimental/workers/workers.shared.ts @@ -133,11 +133,6 @@ interface ResolvedWorker { readonly sourceResolved: boolean; } -/** - * Effectful because confining `sourceDir` to the project needs the - * filesystem: `source` comes from a committed `config.toml`, and an - * in-project directory can symlink outside it. - */ /** * As {@link describeWorker}, but never failing on the source path. * @@ -166,6 +161,11 @@ export const describeWorkerForReporting = Effect.fnUntraced(function* ( } satisfies ResolvedWorker; }); +/** + * Effectful because confining `sourceDir` to the project needs the + * filesystem: `source` comes from a committed `config.toml`, and an + * in-project directory can symlink outside it. + */ export const describeWorker = Effect.fnUntraced(function* (project: WorkersProject, name: string) { const fs = yield* FileSystem.FileSystem; const entry = project.section.workers[name]; From b4a483e9c6be0cf0a99941ae95ff298202a533a0 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:12:49 +0100 Subject: [PATCH 47/56] chore(repo): trim comments, shard 41 (1688 -> 1104 comment lines) --- apps/cli/src/shared/workers/worker-paths.ts | 84 ++---- .../shared/workers/worker-paths.unit.test.ts | 31 +-- .../cli/src/shared/workers/worker-runtimes.ts | 74 ++--- .../src/shared/workers/worker-stacks.macro.ts | 30 +- apps/cli/src/shared/workers/worker-stacks.ts | 11 +- apps/cli/src/shared/workers/worker-url.ts | 11 +- .../src/shared/workers/workers-api-status.ts | 19 +- apps/cli/src/shared/workers/workers-api.ts | 87 ++---- apps/cli/src/shared/workers/workers.errors.ts | 82 +++--- apps/cli/src/telemetry/analytics.layer.ts | 26 +- apps/cli/src/telemetry/command-telemetry.ts | 240 ++++++---------- .../telemetry/command-telemetry.unit.test.ts | 125 +-------- .../linked-project-cache.integration.test.ts | 21 +- .../telemetry/linked-project-cache.layer.ts | 96 +++---- .../telemetry/linked-project-cache.service.ts | 38 ++- .../telemetry-output-format.service.ts | 10 +- .../src/telemetry/telemetry-state.layer.ts | 258 +++++++----------- .../telemetry-state.layer.unit.test.ts | 137 +++------- .../src/telemetry/telemetry-state.service.ts | 31 +-- apps/cli/tests/helpers/command-mocks.ts | 232 ++++++---------- apps/cli/tests/helpers/config-fixtures.ts | 21 +- apps/cli/tests/helpers/docker-image.ts | 62 ++--- .../tests/helpers/docker-image.unit.test.ts | 8 +- apps/cli/tests/helpers/local-reset.ts | 11 +- apps/cli/tests/helpers/macos-signature.ts | 11 +- 25 files changed, 578 insertions(+), 1178 deletions(-) diff --git a/apps/cli/src/shared/workers/worker-paths.ts b/apps/cli/src/shared/workers/worker-paths.ts index 6b4143ea54..9df7d2bd18 100644 --- a/apps/cli/src/shared/workers/worker-paths.ts +++ b/apps/cli/src/shared/workers/worker-paths.ts @@ -3,21 +3,10 @@ import { Effect, FileSystem, Option } from "effect"; import { InvalidWorkerSourceError } from "./workers.errors.ts"; /** - * The project layout every worker command resolves against: - * - * supabase/ - * config.toml project config — workers record `[workers.<name>]` here - * workers/<name>/ one directory per worker; the name IS the directory - * - * This mirrors `supabase/functions/<slug>/` on purpose: `supabase experimental workers` is a - * sibling of `supabase functions`, not a separate tool with its own - * conventions. A worker's name and its directory are the same fact, so - * `push`/`status`/`delete <name>` needs no separate lookup, and running from - * inside the directory needs no name at all. - * - * `supabase/workers/` is where they live. One worker whose code belongs - * somewhere else uses `[workers.<name>] source`, relative to the project root, - * which is the only key that moves anything. + * A worker's canonical location is `supabase/workers/<name>/`, mirroring `supabase/functions/<slug>/` + * — the directory name doubles as the worker's name, so no separate name-to-directory lookup + * exists. `[workers.<name>] source` (relative to the project root) is the only way to point a + * worker's code elsewhere. */ /** The directory workers live in, under `supabase/`. */ @@ -49,12 +38,9 @@ function isAtOrUnder(parent: string, candidate: string): boolean { } /** - * `target` with every symlink in it resolved, as far as it exists. - * - * `realPath` fails outright on a path that is not there yet, and the whole point - * of canonicalizing here is to vet a destination *before* creating it. So this - * walks up to the deepest ancestor that does exist, resolves that, and re-joins - * the part that doesn't. + * `target` with every symlink resolved, walking up to the deepest existing ancestor since + * `realPath` fails on a path that doesn't exist yet — the point here is to vet a destination + * before creating it. */ const canonicalize = Effect.fnUntraced(function* (target: string) { const fs = yield* FileSystem.FileSystem; @@ -78,17 +64,11 @@ const canonicalize = Effect.fnUntraced(function* (target: string) { }); /** - * Confine a resolved worker path to the project, on the filesystem's terms - * rather than the string's. - * - * A string comparison cannot see a symlink: `packages/external -> /other-repo` - * makes `--source packages/external/api` write into `/other-repo`. So both the - * target and the project root are canonicalized before comparing — the root too, - * or a project under a symlink (macOS `/tmp` -> `/private/tmp`, most CI - * checkouts) fails containment against itself. - * - * Returns the path as given, not the canonical form, so what gets displayed and - * persisted stays the path the user named. + * Confines a resolved worker path to the project by comparing canonicalized paths, not raw + * strings — a symlink such as `packages/external -> /other-repo` could otherwise let `--source + * packages/external/api` write outside the project. The project root is canonicalized too, since + * it may itself sit behind a symlink (macOS `/tmp`, most CI checkouts). Returns the path as given, + * not canonicalized, so it displays and persists as the user typed it. */ export const confineWorkerPath = Effect.fnUntraced(function* (options: { readonly projectRoot: string; @@ -133,14 +113,10 @@ export const confineWorkerPath = Effect.fnUntraced(function* (options: { }); /** - * `--source`, resolved against the directory the user typed it in and validated - * before anything is written. - * - * The resolved path is where the starter files land, so a value naming the - * project root, `supabase/`, or anywhere outside the project is refused. - * `source` is the key that may leave the workers directory, but not the project; - * `functions/` and `migrations/` are refused because the CLI already owns them, - * and a worker scaffolded on top would be read as a function or a migration. + * Resolves `--source` against the directory it was typed in and validates it before anything is + * written: a value naming the project root, `supabase/`, or anywhere outside the project is + * refused, since the resolved path is where the starter files land. `source` may point anywhere + * inside the project except `functions/` and `migrations/`, which the CLI already owns. */ export const resolveWorkerSource = Effect.fnUntraced(function* (options: { readonly projectRoot: string; @@ -150,12 +126,8 @@ export const resolveWorkerSource = Effect.fnUntraced(function* (options: { const suggestion = "Point --source at a directory inside the project, for example --source packages/api."; - // Whitespace is not trimmed. A directory name may legally begin or end with a - // space on Unix, and the shell only delivers one in a single argv entry if the - // user quoted it — so trimming would silently retarget the scaffold at a - // neighbouring directory. Only the trailing separator, which is syntax rather - // than part of the name, comes off. An argument that is nothing but - // whitespace is refused rather than trimmed into something else. + // A directory name may legally start or end with a space on Unix, so whitespace isn't trimmed + // (only the trailing path separator is) — trimming could silently retarget the scaffold. if (options.raw.trim() === "") { return yield* Effect.fail( new InvalidWorkerSourceError({ @@ -179,15 +151,10 @@ export function workerDir(projectRoot: string, name: string): string { } /** - * A worker's source directory: `[workers.<name>] source` when one is recorded, - * resolved against the project root, otherwise the default directory. - * - * Confined, not just resolved. `source` arrives from `config.toml`, which is - * committed and shared — so it is as much an input as `--source` is, and a - * checkout carrying `source = "../../.."` or an absolute path would otherwise - * have `push` package and upload a directory that has nothing to do with the - * project. The default directory goes through the same guard so a symlinked - * `supabase/workers` cannot escape either. + * A worker's source directory: `[workers.<name>] source` when one is recorded (resolved against + * the project root), otherwise the default directory. Both are confined, not just resolved — + * `source` comes from a committed `config.toml`, so a checkout carrying `source = "../../.."` or + * an absolute path must still be rejected, and a symlinked `supabase/workers` can't escape either. */ export const workerSourceDir = Effect.fnUntraced(function* (options: { readonly projectRoot: string; @@ -211,10 +178,9 @@ export const workerSourceDir = Effect.fnUntraced(function* (options: { }); /** - * A path as it should be shown to the user: relative to the current directory, - * which is how they referred to it in the first place. Falls back to the - * absolute form when the relative one would climb out of the tree, where `../../` - * chains stop being clearer than the truth. + * A path as it should be shown to the user: relative to the current directory. Falls back to the + * absolute form when the relative one would climb out of the tree, where `../../` chains stop + * being clearer than the truth. */ export function displayPath(cwd: string, target: string): string { const rel = relative(resolve(cwd), resolve(target)); diff --git a/apps/cli/src/shared/workers/worker-paths.unit.test.ts b/apps/cli/src/shared/workers/worker-paths.unit.test.ts index 79cc357a58..dffd49da22 100644 --- a/apps/cli/src/shared/workers/worker-paths.unit.test.ts +++ b/apps/cli/src/shared/workers/worker-paths.unit.test.ts @@ -15,12 +15,9 @@ import { InvalidWorkerSourceError } from "./workers.errors.ts"; const PROJECT = "/repo"; -/** - * Confinement is decided on the filesystem's terms, so these need a real one. - * A path that does not exist still resolves — `canonicalize` walks up to the - * deepest existing ancestor — which is what lets the `/repo` cases below stay - * pure string scenarios. - */ +// Confinement compares against a real filesystem, but `canonicalize` walks up to the deepest +// existing ancestor for a path that doesn't exist yet — so the `/repo` cases below can stay pure +// string scenarios. const runFs = <A, E>(effect: Effect.Effect<A, E, FileSystem.FileSystem>) => Effect.runPromise(effect.pipe(Effect.provide(BunServices.layer))); @@ -40,8 +37,6 @@ describe("worker directories", () => { expect(await sourceDir("packages/api")).toBe(join(PROJECT, "packages", "api")); }); - // `source` arrives from a committed `config.toml`, so it is as much an input - // as `--source` is — and `push` packages and uploads whatever it resolves to. test.each([["../../elsewhere"], ["/etc"], ["supabase/functions/hello"]])( "refuses a recorded source of %j", async (configuredSource) => { @@ -83,8 +78,6 @@ describe("resolveWorkerSource", () => { ).toBe(join(PROJECT, "packages", "api")); }); - // The starter files land in whatever this resolves to, so each of these would - // write into work belonging to the project or to the machine. test.each([ [".", "the project root itself"], ["", "empty"], @@ -97,10 +90,7 @@ describe("resolveWorkerSource", () => { ["supabase/migrations", "supabase/migrations/"], ["supabase/.temp", "supabase/.temp/"], ["supabase/.temp/project-ref", "supabase/.temp/"], - // Refusing the reserved directories is not enough on its own: this path is - // inside the project, is not `supabase/` itself, and is in no reserved - // subdirectory — so without this it would be authorized as a scaffold - // destination, and the project's config file is not that. + // Not covered by the reserved directories above: `config.toml` sits directly under `supabase/`. ["supabase/config.toml", "supabase/config.toml"], ["supabase/config.json", "supabase/config.json"], ])("refuses %j", async (raw, reason) => { @@ -112,9 +102,8 @@ describe("resolveWorkerSource", () => { }); }); -// Containment on a real filesystem, because a string comparison cannot see a -// symlink: a directory inside the project is free to point anywhere outside it, -// and the starter files land wherever the path really resolves. +// Containment must be checked on a real filesystem: a string comparison can't see a symlink that +// leads outside the project. describe("resolveWorkerSource containment on a real filesystem", () => { let project = ""; let outside = ""; @@ -168,12 +157,8 @@ describe("resolveWorkerSource containment on a real filesystem", () => { expect(error.detail).toContain("supabase/functions/"); }); - // A destination that does not exist yet is the normal case for `new`, and the - // project root itself is usually behind a symlink on macOS (`/var` -> - // `/private/var`). Both have to compare equal, not fail containment. - // A name that ends in a space is legal on Unix, and only reaches argv as one - // entry if the user quoted it. Trimming it pointed the scaffold at a different - // directory than the one asked for. + // A trailing space is legal in a Unix directory name and only survives to argv as one entry + // when the user quotes it; trimming it would retarget the scaffold at a different directory. test("keeps whitespace that is part of the directory name", async () => { expect( await runFs( diff --git a/apps/cli/src/shared/workers/worker-runtimes.ts b/apps/cli/src/shared/workers/worker-runtimes.ts index f5f5b96a84..9f1a3b5a1a 100644 --- a/apps/cli/src/shared/workers/worker-runtimes.ts +++ b/apps/cli/src/shared/workers/worker-runtimes.ts @@ -1,30 +1,22 @@ /** - * The alpha envelope a worker is described by: which runtime it is built on, how - * big an instance it runs as, and whether it is reachable from the internet. - * - * All three are deliberately small closed sets, and the CLI's own rather than - * the API's: the Workers API takes `spec.size` as one opaque string - * (`2gb-1vcpu`) rather than independent cpu/memory dials, and `spec.exposure` as - * an unconstrained string. So the CLI offers exactly the sizes that string has - * values for and derives the vCPU count from the memory the user picked — one - * choice, not two that could be combined into a shape the platform does not run. + * Runtime, size, and exposure are the CLI's own small closed sets, not the API's: the Workers API + * takes `spec.size` as one opaque string (`2gb-1vcpu`) and `spec.exposure` as an unconstrained + * string, so the CLI offers exactly the sizes that exist and derives vCPU count from memory rather + * than letting the two be picked independently. */ -/** A worker's runtime: its own Dockerfile, or one of the catalog base images. */ /** - * Kept in step with the directories under `./stacks/` — a runtime offered here - * with no starter files there would scaffold an empty worker, which - * `worker-stacks.macro.ts` refuses at build time. + * A worker's runtime: its own Dockerfile, or one of the catalog base images, kept in sync with + * `./stacks/` — a runtime offered here with no starter files there scaffolds an empty worker, + * which `worker-stacks.macro.ts` refuses at build time. */ export const WORKER_RUNTIMES = ["dockerfile", "node", "deno"] as const; export type WorkerRuntime = (typeof WORKER_RUNTIMES)[number]; /** - * The runtime a worker gets when nobody names one: what `new`'s prompt - * pre-selects, and what the classifier falls back to for a directory it does - * not recognize. Deno, because it is the runtime the rest of the Supabase CLI's - * function tooling assumes. + * The runtime `new` pre-selects and the classifier falls back to for an unrecognized directory. + * Deno, since it's the runtime the rest of the CLI's function tooling assumes. */ export const DEFAULT_WORKER_RUNTIME: WorkerRuntime = "deno"; @@ -33,14 +25,9 @@ function isWorkerRuntime(value: string): value is WorkerRuntime { } /** - * The runtime a config file named, case-insensitively. The canonical lowercase - * form is what gets recorded. - * - * This is for hand-written `[workers.<name>] runtime` values, where the casing - * is the user's own and `Runtime = "Node"` plainly means `node`. It is not what - * validates `--runtime`: that is a `Flag.choice` over the same catalog, so the - * parser rejects anything outside it — including a case variant — before a - * handler runs, and lists the accepted values when it does. + * Parses a config-file `[workers.<name>] runtime` value case-insensitively (hand-written casing + * like `Runtime = "Node"` should still mean `node`). Not used for `--runtime`, which validates + * through a `Flag.choice` over the same catalog instead. */ export function parseWorkerRuntime(value: string): WorkerRuntime | undefined { const canonical = value.trim().toLowerCase(); @@ -55,9 +42,8 @@ export const WORKER_RUNTIME_DESCRIPTIONS: Record<WorkerRuntime, string> = { }; /** - * The only instance sizes the alpha envelope offers, denominated by memory. - * There is no resize — a different size later means a new worker, not a flag on - * `push`. + * The only instance sizes offered, denominated by memory. There is no resize — a different size + * means a new worker, not a `push` flag. */ export const WORKER_SIZES = ["2gb", "4gb"] as const; @@ -67,9 +53,8 @@ export type WorkerSize = (typeof WORKER_SIZES)[number]; export const DEFAULT_WORKER_SIZE: WorkerSize = "2gb"; /** - * Instances a worker runs when neither `--instances` nor `[workers.<name>] - * instances` says otherwise. One, because a deploy has to name a count — the - * API's spec requires it — and a worker nobody has scaled is a single instance. + * Instance count used when neither `--instances` nor `[workers.<name>] instances` is set. One, + * since the API's deploy spec requires a count and an unscaled worker is a single instance. */ export const DEFAULT_WORKER_INSTANCES = 1; @@ -84,22 +69,18 @@ export function parseWorkerSize(value: string): WorkerSize | undefined { } /** - * How a worker is reached: `public` gives it an internet-facing URL, `private` - * keeps it reachable only from inside the project. - * - * `spec.exposure` is an unconstrained string in the Management API's schema, so - * this closed set is the CLI's own — the same arrangement as {@link WORKER_SIZES}, - * and the reason output renders the *accepted* exposure verbatim rather than - * forcing it back into this enum. + * How a worker is reached: `public` gets an internet-facing URL, `private` is reachable only + * from inside the project. Like {@link WORKER_SIZES}, this closed set is the CLI's own — the + * API's `spec.exposure` is an unconstrained string — so output renders the *accepted* exposure + * verbatim rather than forcing it back into this enum. */ export const WORKER_EXPOSURES = ["public", "private"] as const; export type WorkerExposure = (typeof WORKER_EXPOSURES)[number]; /** - * The exposure a worker gets when neither `--exposure` nor `[workers.<name>] - * exposure` says otherwise. Public, because every runtime offered today serves - * HTTP and a worker nobody has locked down is one you can call. + * Exposure used when neither `--exposure` nor `[workers.<name>] exposure` is set. Public, since + * every runtime offered today serves HTTP and an unlocked-down worker is one you can call. */ export const DEFAULT_WORKER_EXPOSURE: WorkerExposure = "public"; @@ -132,9 +113,8 @@ export function apiSizeFor(size: WorkerSize): string { } /** - * How a size reads in output: `2gb · 1 vCPU`. Takes the API's own spelling so a - * worker deployed at a size this CLI never offered still renders, verbatim, - * rather than being forced into the local enum. + * Formats a size for output as `2gb · 1 vCPU`, from the API's own spelling — a worker deployed at + * a size this CLI never offered still renders verbatim instead of being forced into the local enum. */ export function formatApiSize(apiSize: string): string { const match = /^(\d+gb)-(\d+)vcpu$/.exec(apiSize.trim().toLowerCase()); @@ -154,10 +134,8 @@ const workerNameRequirement = "Use lowercase letters, digits and hyphens, starting and ending with a letter or digit."; /** - * `undefined` when `name` is a name this CLI can *record*, else why it is not. - * - * For commands that write `[workers.<name>]` — which is `new`, and `push` only - * because it deploys what `new` wrote. + * `undefined` when `name` can be recorded as `[workers.<name>]`, else the reason it can't — used + * by `new` (which writes the section) and `push` (which deploys what `new` wrote). */ export function validateWorkerNameMessage(name: string): string | undefined { return WORKER_NAME_PATTERN.test(name) ? undefined : workerNameRequirement; diff --git a/apps/cli/src/shared/workers/worker-stacks.macro.ts b/apps/cli/src/shared/workers/worker-stacks.macro.ts index 6c7538dca7..b91401b3aa 100644 --- a/apps/cli/src/shared/workers/worker-stacks.macro.ts +++ b/apps/cli/src/shared/workers/worker-stacks.macro.ts @@ -7,13 +7,9 @@ import { WORKER_RUNTIMES, type WorkerRuntime } from "./worker-runtimes.ts"; export type WorkerStack = Readonly<Record<string, string>>; /** - * Fails unless every offered runtime has a non-empty stack, and every stack - * belongs to an offered runtime. - * - * The two lists are declared separately — `WORKER_RUNTIMES` drives `--runtime` - * and the type union, the directory holds the content — so this is what stops - * them drifting into a runtime users can pick that scaffolds nothing. It runs - * as the macro is expanded, which is to say at build time. + * Fails unless every offered runtime has a non-empty stack and every stack matches an offered + * runtime — the two lists (`WORKER_RUNTIMES` and this directory) are declared separately, so this + * is what stops them drifting apart. Runs at build time, as the macro is expanded. */ function assertCompleteWorkerStacks( stacks: Record<string, WorkerStack>, @@ -41,20 +37,12 @@ function assertCompleteWorkerStacks( /** * Every runtime's starter files, discovered by reading `./stacks/`. * - * Expanded as a Bun macro, so this runs while the importing module is - * transpiled and its return value is inlined as a literal — a compiled binary - * carries the content with no `stacks/` directory beside it and no `--define` - * to forget at a build site. Adding a runtime is adding a directory; nothing - * here names the files. - * - * Bun expands macros in the runtime transpiler too, so running from source - * behaves the same. Vitest does not implement them, and degrades to calling - * this as an ordinary function against the source tree — which is why the path - * comes from `import.meta.url` rather than Bun's `import.meta.dir`, undefined - * once the test runner has bundled the module. - * - * Throwing here fails the build. Bun reports it as a macro that could not be - * coerced to AST, so the reason is logged first to make the diagnostic legible. + * Expanded as a Bun macro so the content is inlined into the transpiled output — a compiled + * binary needs no `stacks/` directory beside it. Bun expands macros in the source-tree transpiler + * too, but Vitest doesn't, so it calls this as an ordinary function against the source tree — + * hence `import.meta.url` rather than `import.meta.dir`, which is undefined once bundled. + * Throwing here fails the build; Bun reports only that the macro couldn't be coerced to AST, so + * the reason is logged first to keep the diagnostic legible. */ export function readWorkerStacks(): Record<WorkerRuntime, WorkerStack> { const root = fileURLToPath(new URL("stacks", import.meta.url)); diff --git a/apps/cli/src/shared/workers/worker-stacks.ts b/apps/cli/src/shared/workers/worker-stacks.ts index 5c627a2960..1ec4f45394 100644 --- a/apps/cli/src/shared/workers/worker-stacks.ts +++ b/apps/cli/src/shared/workers/worker-stacks.ts @@ -5,12 +5,9 @@ import { import type { WorkerRuntime } from "./worker-runtimes.ts"; /** - * The starter files `supabase experimental workers new` writes, per runtime — the contents - * of `./stacks/<runtime>/`, keyed by the name each file is scaffolded as. - * - * The content lives there as ordinary files, authored in the language they are - * written in rather than as string literals, and is discovered by reading the - * directory: a new runtime is a new directory, with nothing to wire up here. - * `worker-stacks.macro.ts` explains how that survives compilation. + * The starter files `supabase experimental workers new` writes, per runtime — the contents of + * `./stacks/<runtime>/`, keyed by the name each is scaffolded as. Discovered by reading the + * directory, as ordinary files rather than string literals, so a new runtime is just a new + * directory. See `worker-stacks.macro.ts` for how this survives compilation. */ export const WORKER_STACKS: Record<WorkerRuntime, WorkerStack> = readWorkerStacks(); diff --git a/apps/cli/src/shared/workers/worker-url.ts b/apps/cli/src/shared/workers/worker-url.ts index d82da066f3..549a20de70 100644 --- a/apps/cli/src/shared/workers/worker-url.ts +++ b/apps/cli/src/shared/workers/worker-url.ts @@ -1,11 +1,8 @@ /** - * Where a worker is served. - * - * Every worker gets a path on the project's own API host, exactly like an Edge - * Function — `<ref>.supabase.co/workers/v1/<name>` next to - * `<ref>.supabase.co/functions/v1/<name>`. One host per project, one path per - * worker: nothing per-worker is provisioned in DNS, so the URL is derived from - * the name rather than returned by the API. + * Every worker gets a path on the project's own API host, like an Edge Function: + * `<ref>.supabase.co/workers/v1/<name>` next to `.../functions/v1/<name>`. Nothing is + * provisioned per-worker in DNS, so the URL is derived from the name rather than returned by the + * API. */ /** Path prefix workers are served under, mirroring `functions/v1`. */ diff --git a/apps/cli/src/shared/workers/workers-api-status.ts b/apps/cli/src/shared/workers/workers-api-status.ts index de530d3064..5aef13d04a 100644 --- a/apps/cli/src/shared/workers/workers-api-status.ts +++ b/apps/cli/src/shared/workers/workers-api-status.ts @@ -5,15 +5,10 @@ import { CLI_UPGRADE_GUIDE_URL } from "../cli/version.ts"; import { WorkersApiNetworkError, WorkersApiUnexpectedStatusError } from "./workers.errors.ts"; /** - * Status handling shared by every Workers API seam. - * - * The worker routes and the analytics logs endpoint sit on different API - * families but fail the same three ways — the request never left, the server - * answered something unexpected, or the body could not be read. - * - * Route-specific status meaning stays with its route rather than here. - * `projectScoped404` is the example: it disambiguates a `/v2/workers` 404 by - * response body and means nothing anywhere else. + * Status handling shared by every Workers API seam: the worker routes and the analytics logs + * endpoint fail the same three ways (the request never left, the server answered something + * unexpected, or the body couldn't be read). Route-specific status meaning — like + * `projectScoped404`, which disambiguates a `/v2/workers` 404 by body — stays with its own route. */ /** @@ -28,10 +23,8 @@ export function mapRequestError(operation: string) { return markSupabaseApiInputErrorAsUserInput(error); } if (HttpClientError.isHttpClientError(error)) { - // `message` is the library's own rendering of the reason — its label, the - // description when there is one, and the method and URL that failed. - // These requests all go to the Management API, so that URL is safe to - // show and is the most useful thing in the sentence. + // `message` is the library's own rendering of the failure (label, description, method, + // URL); the URL is always the Management API here, so it's safe to show. return new WorkersApiNetworkError({ detail: `Could not reach the Workers API while trying to ${operation}: ${error.message}.`, suggestion: "Check your network connection and retry.", diff --git a/apps/cli/src/shared/workers/workers-api.ts b/apps/cli/src/shared/workers/workers-api.ts index d2ae6dfb30..70834526cc 100644 --- a/apps/cli/src/shared/workers/workers-api.ts +++ b/apps/cli/src/shared/workers/workers-api.ts @@ -18,16 +18,11 @@ import { } from "./workers.errors.ts"; /** - * The seam every worker command talks to: `/v2/projects/{ref}/workers` on the - * Management API. - * - * The routes are deliberately few — list, get, mint an upload slot, deploy, - * delete — so this module is thin, and what it mostly adds is status handling. - * A 404 is overloaded on these routes: it is the answer for a project outside - * the alpha's allow-list, for a project ref that names nothing, and for a - * worker that is not deployed. A 404 on a named worker is reported by the - * caller as "not deployed"; one on a collection endpoint, where no worker name - * could have been wrong, is split by its body — see {@link projectScoped404}. + * The seam every worker command talks to: `/v2/projects/{ref}/workers` on the Management API. A + * 404 here is overloaded — a project outside the alpha's allow-list, an unknown project ref, and + * an undeployed worker all answer the same way. A named-worker 404 is reported as "not deployed"; + * a collection-endpoint 404, where no worker name could be wrong, is split by its body instead — + * see {@link projectScoped404}. */ /** The worker shape the API returns, flattened out of its JSON:API envelope. */ @@ -62,7 +57,7 @@ export interface WorkerUploadSlot { readonly expiresAt: string; } -/** The `spec` a deploy sends. Mirrors the API's own field names exactly. */ +/** The `spec` a deploy sends. Mirrors the API's own field names. */ export interface WorkerDeploySpec { readonly runtime?: string; readonly size: string; @@ -89,9 +84,8 @@ const workersSuggestion = "Workers are in private alpha. Ask in the Supabase dashboard to have this project enrolled."; /** - * The `error.code` a 404 carries, which is the only thing separating a project - * outside the alpha's allow-list from one that does not exist. Both answer 404 - * on the same routes; the bodies differ: + * The `error.code` a 404 carries — the only way to tell an unenrolled project from one that + * doesn't exist, since both answer 404 on the same routes: * * - not enrolled -> `{"error":{"code":"generic_not_found","message":"Workers are not available for this project"}}` * - no such project -> `{"error":{"code":"not_found","message":"Not Found"}}` @@ -101,12 +95,9 @@ const NotFoundBody = Schema.Struct({ }); /** - * Which of the two a project-scoped 404 was. - * - * Only `not_found` is read as a missing project — an unrecognized body keeps - * the enrolment answer, because that is what the alpha's allow-list has - * historically returned and guessing the other way would send someone to check - * a ref that is fine. + * Which of the two a project-scoped 404 was. Only `not_found` is read as a missing project — an + * unrecognized body defaults to the enrolment answer, since guessing the other way would send + * someone to check a ref that's actually fine. */ const projectScoped404 = Effect.fnUntraced(function* (options: { readonly projectRef: string; @@ -227,14 +218,9 @@ export const createWorkerUpload = Effect.fnUntraced(function* ( }); /** - * PUT the archive straight at the presigned slot. The bytes never pass through - * the Management API, so this goes out with no Supabase credentials attached — - * the signature in the URL is the authorization. - * - * That signature is why `httpClientLayer` redacts query strings before - * logging them — under `--debug` this URL is a write-capable credential. Done - * there rather than here, so the client stays injectable and every presigned URL - * is covered rather than this one call site. + * PUTs the archive straight at the presigned slot, with no Supabase credentials attached — the + * signature in the URL is the authorization. That's also why `httpClientLayer` redacts query + * strings before logging: under `--debug` this URL is a write-capable credential. */ export const uploadBuildContext = Effect.fnUntraced(function* ( slot: WorkerUploadSlot, @@ -242,9 +228,8 @@ export const uploadBuildContext = Effect.fnUntraced(function* ( ) { const client = yield* HttpClient.HttpClient; - // The slot names its own method; the API documents `PUT` and nothing else is - // meaningful for a presigned object-store destination, so anything unexpected - // falls back to it rather than assembling a request we cannot build. + // The slot names its own method; anything other than `POST` falls back to `PUT`, the only + // method documented for a presigned object-store destination. const request = ( slot.method.toUpperCase() === "POST" ? HttpClientRequest.post(slot.url) @@ -255,11 +240,9 @@ export const uploadBuildContext = Effect.fnUntraced(function* ( Effect.mapError( (error) => new WorkerUploadFailedError({ - // Deliberately not `error.message`, which is what the other transport - // failures in this module use: it appends the URL that failed, and - // here that URL is the write-capable signature. The reason's own - // description is the part worth showing, and the destination is - // already named by the step the user is watching. + // Not `error.message` (used elsewhere in this module): it appends the failed URL, and + // here that URL is the write-capable signature, so only the reason's own description + // is shown. detail: `Uploading the build context failed: ${ error.reason.description ?? "the upload request did not complete" }.`, @@ -349,25 +332,18 @@ export const deleteWorker = Effect.fnUntraced(function* ( }); /** - * The build runs asynchronously — deploy answers 202 and the worker reaches - * `active` or `failed` later — so `push` polls `get` until `build_state` leaves - * `building`. - * - * The schedule is a parameter so tests can drive the same loop without waiting - * on wall-clock delays. + * The build runs asynchronously — deploy answers 202 and the worker reaches `active` or `failed` + * later — so `push` polls `get` until `build_state` leaves `building`. Overridable so tests can + * drive the loop without waiting on wall-clock delays. */ const WORKER_BUILD_POLL_SCHEDULE = Schedule.spaced("2 seconds").pipe( Schedule.upTo({ duration: "10 minutes" }), ); /** - * How long one poll read is allowed to keep failing before the deploy is called - * off. - * - * Bounded by elapsed time, not attempts: unspaced attempts are exhausted by a - * two-second blip, abandoning a build the server is still running. Half a minute - * of spaced retries rides that out, and anything still failing after it is the - * real error. + * How long one poll read may keep failing before the deploy is called off. Bounded by elapsed + * time rather than attempts, so a brief network blip can't exhaust the retries and abandon a + * build the server is still running. */ const WORKER_POLL_READ_RETRY = Schedule.spaced("2 seconds").pipe( Schedule.upTo({ duration: "30 seconds" }), @@ -379,19 +355,14 @@ export const awaitWorkerBuild = Effect.fnUntraced(function* ( name: string, options: { readonly schedule?: Schedule.Schedule<unknown>; - /** - * Retry schedule for one poll read. A parameter for the same reason - * `schedule` is: it is spaced in seconds, and a test exercising the - * transient-failure path should not wait on a real clock to do it. - */ + /** Retry schedule for one poll read; overridable so a test can exercise the transient-failure path without a real clock. */ readonly retrySchedule?: Schedule.Schedule<unknown>; /** Called with each poll's result, for progress reporting. */ readonly onPoll?: (worker: WorkerRecord) => Effect.Effect<void>; /** - * ` --project-ref <ref>` to append to the suggestion below, when the caller - * reached this project through the flag rather than the link. The suggestion - * is copy-pasted verbatim, so dropping it re-resolves against whatever this - * checkout happens to be linked to. + * ` --project-ref <ref>` to append to the suggestion below, when the caller reached this + * project via the flag rather than a link. The suggestion is copied verbatim, so omitting it + * would re-resolve against whatever this checkout happens to be linked to. */ readonly refSuffix?: string; } = {}, diff --git a/apps/cli/src/shared/workers/workers.errors.ts b/apps/cli/src/shared/workers/workers.errors.ts index e5edc0897f..0641e74850 100644 --- a/apps/cli/src/shared/workers/workers.errors.ts +++ b/apps/cli/src/shared/workers/workers.errors.ts @@ -22,11 +22,9 @@ export class InvalidWorkerNameError extends Data.TaggedError("InvalidWorkerNameE } /** - * A bare `new` had no name to scaffold under, and nowhere to ask for one. - * - * The name is the one input this command cannot default — it is the directory, - * the `config.toml` key and the hostname all at once — so with `-o` in force or - * no interactive terminal there is nothing to do but say so. + * A bare `new` had no name to scaffold under, and nowhere to ask for one. The name is the one + * input this command can't default — it's the directory, the `config.toml` key, and the hostname + * all at once — so with `-o` or no interactive terminal there's nothing to do but say so. */ export class MissingWorkerNameError extends Data.TaggedError("MissingWorkerNameError")<{ readonly detail: string; @@ -40,15 +38,11 @@ export class MissingWorkerNameError extends Data.TaggedError("MissingWorkerNameE /** * A symlink in the worker source points outside the build context. * - * The archive is everything the server gets — it runs no install step and has - * no view of the surrounding repository — so a link whose target is not also - * packaged arrives dangling. The catalog runtimes then boot without the - * dependency and a Dockerfile build fails on the `COPY`, both of them minutes - * later and with nothing naming the cause. Refused here instead. - * - * The common source is a package manager that hoists: a worker directory that - * is a pnpm workspace member links its dependencies at the repository root - * rather than under its own `node_modules`. + * The archive is everything the server gets, with no install step and no view of the surrounding + * repository, so a link whose target isn't also packaged arrives dangling — the catalog runtimes + * boot without the dependency, or a Dockerfile build fails on `COPY`, both minutes later with + * nothing naming the cause. Refused here instead. The common source is a package manager that + * hoists dependencies to the repository root, outside the worker's own `node_modules`. */ export class WorkerSourceEscapingLinkError extends Data.TaggedError( "WorkerSourceEscapingLinkError", @@ -72,10 +66,8 @@ export class NoWorkersToDeployError extends Data.TaggedError("NoWorkersToDeployE } /** - * `config.toml` records a runtime this CLI does not offer. - * - * Raised by `push`, the command that reads a worker's runtime back out of - * config; `new` writes one and never reads it. + * `config.toml` records a runtime this CLI does not offer. Raised by `push`, which reads a + * worker's runtime back out of config; `new` writes one but never reads it. */ export class UnknownWorkerRuntimeError extends Data.TaggedError("UnknownWorkerRuntimeError")<{ readonly detail: string; @@ -124,10 +116,9 @@ export class WorkerSourceMissingError extends Data.TaggedError("WorkerSourceMiss } /** - * `--source` names a directory it is not allowed to name. Worth its own error - * because the destination is where the starter files land, so a value that - * resolves to the project root, `supabase/`, or anywhere outside the project has - * to be refused before anything is written. + * `--source` names a directory it isn't allowed to name — the starter files land at the resolved + * destination, so a value resolving to the project root, `supabase/`, or anywhere outside the + * project must be refused before anything is written. */ export class InvalidWorkerSourceError extends Data.TaggedError("InvalidWorkerSourceError")<{ readonly detail: string; @@ -192,10 +183,9 @@ export class WorkerNotDeployedError extends Data.TaggedError("WorkerNotDeployedE } /** - * Workers are in private alpha: the routes answer 404 for a project that is not - * enrolled, which is indistinguishable from an unknown worker at the transport - * level — so this is only raised for the collection endpoints, where there is - * no worker name that could have been wrong. + * Workers are in private alpha: an unenrolled project's routes answer 404, indistinguishable at + * the transport level from an unknown worker — so this is only raised on collection endpoints, + * where there's no worker name that could have been wrong. */ export class WorkersUnavailableError extends Data.TaggedError("WorkersUnavailableError")<{ readonly detail: string; @@ -207,11 +197,9 @@ export class WorkersUnavailableError extends Data.TaggedError("WorkersUnavailabl } /** - * The project ref names no project this account can see. - * - * Separated from {@link WorkersUnavailableError} because both arrive as a 404 - * on the same routes, and telling someone to request alpha enrolment for a - * project that does not exist sends them somewhere that cannot help. + * The project ref names no project this account can see. Separated from {@link + * WorkersUnavailableError} because both arrive as a 404 on the same routes, and suggesting alpha + * enrolment for a project that doesn't exist would send someone somewhere that can't help. */ export class WorkerProjectNotFoundError extends Data.TaggedError("WorkerProjectNotFoundError")<{ readonly detail: string; @@ -223,12 +211,9 @@ export class WorkerProjectNotFoundError extends Data.TaggedError("WorkerProjectN } /** - * Any other status the Workers routes answered with. - * - * Classified from the status it carries rather than bucketed as a service - * failure: a 401 is the user's to fix by logging in and a 403 by getting access, - * and reporting either as `api_status` both misleads the user and blurs the - * actionability signal for every Workers endpoint at once. + * Any other status the Workers routes answered with. Classified from the status it carries rather + * than bucketed as a generic service failure — a 401 is the user's to fix by logging in, a 403 by + * getting access, and reporting either as `api_status` would blur the actionability signal. */ export class WorkersApiUnexpectedStatusError extends Data.TaggedError( "WorkersApiUnexpectedStatusError", @@ -274,16 +259,12 @@ export class WorkerDeleteConfirmationRequiredError extends Data.TaggedError( } /** - * The logs query itself failed. - * - * The analytics endpoint can answer **HTTP 200** with a populated `error` field, - * so this is not reachable from a status code alone. It also covers the server's - * 30-second query timeout, which arrives as a non-2xx. - * - * Classified apart from {@link WorkersApiUnexpectedStatusError} on purpose: the - * SQL is this CLI's, not the user's input, so a rejected query means a projection - * or a filter here is wrong. Its own fingerprint keeps that visible in telemetry - * instead of grouped with transport noise from every other Workers route. + * The logs query itself failed. The analytics endpoint can answer HTTP 200 with a populated + * `error` field, so this isn't reachable from a status code alone; it also covers the server's + * 30-second query timeout, which arrives as a non-2xx. Kept apart from {@link + * WorkersApiUnexpectedStatusError} because the SQL here is the CLI's own, not user input — its + * own fingerprint keeps a broken projection or filter visible instead of blending into transport + * noise. */ export class WorkerLogsQueryFailedError extends Data.TaggedError("WorkerLogsQueryFailedError")<{ readonly detail: string; @@ -305,10 +286,9 @@ export class WorkerLogsUsageExceededError extends Data.TaggedError("WorkerLogsUs } /** - * The analytics endpoints allow 10 requests per 60 seconds, which `--follow` - * polls against — so 429 is an ordinary outcome here rather than an edge case, - * and it gets its own error so the suggestion can name the poll interval as the - * thing to slow down. + * The analytics endpoints allow 10 requests per 60 seconds, which `--follow` polls against — so + * 429 is an ordinary outcome here, not an edge case. Its own error lets the suggestion name the + * poll interval as the thing to slow down. */ export class WorkerLogsRateLimitedError extends Data.TaggedError("WorkerLogsRateLimitedError")<{ readonly detail: string; diff --git a/apps/cli/src/telemetry/analytics.layer.ts b/apps/cli/src/telemetry/analytics.layer.ts index d5fc56512e..0e737b049d 100644 --- a/apps/cli/src/telemetry/analytics.layer.ts +++ b/apps/cli/src/telemetry/analytics.layer.ts @@ -49,14 +49,11 @@ function contextProperties(context: AnalyticsContext): Record<string, unknown> { }); } -// Builds the PostHog `groups` map for a captured event, or `undefined` when no -// group attribution applies. Go keys the organization group by the org ID (not -// the slug) for BOTH the GroupIdentify and the event groups -// (`linkedProjectGroups`, apps/cli-go/internal/telemetry/project.go:99-103); the -// slug is only a GroupIdentify property value. Keying events by slug would -// attach cli_command_executed to a different group than the identify published. -// Go also omits the org group when the ID is empty (project.go:99-100 -// `if linked.OrganizationID != ""`). +/** + * Builds the PostHog `groups` map for a captured event, or `undefined` when no group attribution + * applies. Keys the organization group by ID, not slug, so `cli_command_executed` attaches to + * the same group `groupIdentify` published; omits the org group entirely when the ID is empty. + */ export function resolveGroups( context: AnalyticsContext, linkedProject: Option.Option<LinkedProjectCacheValue>, @@ -77,7 +74,6 @@ export function resolveGroups( }; } -// Mirrors apps/cli-go/cmd/root_analytics.go:149-165 envSignals(). export function collectEnvSignals(): Record<string, true | string> | undefined { const signals: Record<string, true | string> = {}; @@ -102,14 +98,10 @@ export function collectEnvSignals(): Record<string, true | string> | undefined { return Object.keys(signals).length === 0 ? undefined : signals; } -// Mirrors apps/cli-go/internal/telemetry/project.go:40 LoadLinkedProject(fsys). -// Best-effort: any error returns None. Resolves workdir from `SUPABASE_WORKDIR` -// env or `process.cwd()`. The `--workdir` flag value is not accessible at -// root scope where the analytics layer is constructed (Effect CLI's global -// flag services are only available inside Command.runWith). When `--workdir` is -// set but the user invokes from outside that directory, the linked-project -// cache lookup misses and the event loses group attribution — matches Go's -// behaviour when the user runs without first `cd`-ing into the project. +// Best-effort: any error returns None. Resolves workdir from `SUPABASE_WORKDIR` or +// `process.cwd()` since global flag services (`--workdir`) aren't accessible at this +// construction scope — so a lookup can miss group attribution when the user invokes from outside +// that directory. function makeLoadLinkedProject( fs: FileSystem.FileSystem, path: Path.Path, diff --git a/apps/cli/src/telemetry/command-telemetry.ts b/apps/cli/src/telemetry/command-telemetry.ts index b0556130cb..7ea7f45223 100644 --- a/apps/cli/src/telemetry/command-telemetry.ts +++ b/apps/cli/src/telemetry/command-telemetry.ts @@ -41,11 +41,10 @@ import { import { unwrapToSingleParam } from "../command-internal/param-introspection.ts"; /** - * Classifies a command that succeeded its Effect but recorded a nonzero exit - * code through ProcessControl. `db lint`/`db advisors` do this deliberately in - * machine mode after a `--fail-on` trigger (to keep the JSON payload on stdout - * intact), so their telemetry derives from the same typed error their text - * mode raises — the classification stays declared on the error class itself. + * Classifies a command that succeeded its Effect but recorded a nonzero exit code through + * ProcessControl. `db lint`/`db advisors` do this in machine mode after a `--fail-on` trigger, to + * keep the JSON payload on stdout intact, so their telemetry derives from the same typed error + * their text mode raises. */ function processControlledFailureActionability(command: string): CliErrorActionability { if (command === "db lint") { @@ -60,55 +59,31 @@ function processControlledFailureActionability(command: string): CliErrorActiona interface CommandTelemetryOptions<Flags extends Record<string, unknown> = never> { readonly analytics?: boolean; readonly flags?: Flags; - // Flag names (kebab-case) whose values are safe to log verbatim, mirroring - // Go's `markFlagTelemetrySafe` annotation in cmd/root_analytics.go. Boolean - // flag values are always passed through, matching Go's isBooleanFlag branch. + // Flag names (kebab-case) whose values are safe to log verbatim. Boolean flag values are always + // passed through regardless of this list. readonly safeFlags?: ReadonlyArray<string>; - // A command's flag config record (the object passed to `Command.make`). - // Any flag built with `Flag.choice`/`Flag.choiceWithValue` is treated as - // telemetry-safe automatically, mirroring Go's `isEnumFlag` branch - // (`cmd/root_analytics.go:110-116`, which checks `flag.Value.(*utils.EnumFlag)` - // unconditionally — no per-flag annotation required). Passing `config` here - // covers every current and future enum flag on the command without having to - // hand-list each one in `safeFlags`. The three global choice flags - // (`--output`, `--dns-resolver`, `--agent`) are covered separately and - // automatically via `GLOBAL_CHOICE_FLAG_NAMES` below — no need to redeclare - // them here. + // A command's flag config record (the object passed to `Command.make`). Any + // `Flag.choice`/`Flag.choiceWithValue` flag in it is treated as telemetry-safe automatically, so + // enum flags don't need hand-listing in `safeFlags`. The three global choice flags (`--output`, + // `--dns-resolver`, `--agent`) are covered separately via `GLOBAL_CHOICE_FLAG_NAMES` below. readonly config?: Record<string, Param.Any>; - // The `-o`/`--output` values this command accepts, mirroring Go's per-command - // `--output` enum (`internal/utils/enum.go`). Defaults to the resource-command - // set; `db query` overrides with `json|table|csv`. The shared global - // `OutputFlag` accepts the union of all commands' values, so the wrapper - // re-validates against the command's own set and rejects out-of-enum values - // exactly as Go's flag parser does. See `go-output-flag.ts`. + // The `-o`/`--output` values this command accepts. Defaults to the resource-command set; `db + // query` overrides with `json|table|csv`. The shared global `OutputFlag` accepts the union of + // every command's values, so this re-validates against the command's own narrower set. See + // `go-output-flag.ts`. readonly outputFormats?: ReadonlyArray<string>; - // Short-flag → canonical-flag-name map (e.g. `{ s: "schema" }`) for this - // command's OWN flags. Go's `changedFlags()` uses pflag's `Visit`, which - // reports the CANONICAL flag name whether the user typed the long form - // (`--schema`) or the registered shorthand (`-s`). Pass a command's - // shorthands here so a `-s public` invocation records the `schema` flag in - // telemetry, matching Go (cmd/root_analytics.go:53-76). Global shorthands - // (currently just `-o` for `--output`, cmd/root.go:330) are merged in - // automatically via `GLOBAL_SHORT_ALIASES` below — no per-command wiring - // needed for those (CLI-1896 review follow-up). + // Short-flag → canonical-flag-name map (e.g. `{ s: "schema" }`) for this command's own flags, + // so a `-s public` invocation records the `schema` flag rather than `s`. Global shorthands + // (`-o` for `--output`) are merged in automatically via `GLOBAL_SHORT_ALIASES` below. readonly aliases?: Readonly<Record<string, string>>; } /** - * Reject an out-of-enum `-o`/`--output` value before the command runs, matching - * Go's parse-time rejection (which happens before telemetry fires, so no event - * is emitted for a rejected flag). `OutputFlag` is read optionally: it is a - * root global in production but is absent from focused wrapper tests, where - * validation is simply skipped. - * - * Exported so experimental-gated commands can call it explicitly BEFORE - * `requireExperimental`: Go's cobra parses flags (rejecting an - * out-of-enum `-o` via `EnumFlag.Set`, `internal/utils/enum.go:21-27`) before - * `PersistentPreRunE` ever runs (`cobra@v1.10.2/command.go:919,985`), so an - * invalid `-o` value must win over a missing `--experimental` flag, not the - * other way around. `withCommandTelemetry` below still calls this - * too (a harmless redundant check on the success path) so non-gated commands - * keep working unchanged. + * Rejects an out-of-enum `-o`/`--output` value before the command runs, so no + * `cli_command_executed` event fires for a rejected flag. `OutputFlag` is read optionally, since + * it's a root global in production but absent from focused wrapper tests. Exported so + * experimental-gated commands can call it explicitly before `requireExperimental` — an invalid + * `-o` must be reported ahead of a missing `--experimental` flag, not the other way around. */ export const validateOutputFormat = (allowed: ReadonlyArray<string>) => Effect.gen(function* () { @@ -124,10 +99,9 @@ export const validateOutputFormat = (allowed: ReadonlyArray<string>) => }); const REDACTED_VALUE = "<redacted>"; -// Fallback `-o` → telemetry derivation for commands that don't record a resolved -// format in `TelemetryOutputFormat`. `db query` records its resolved -// `json|table|csv` in that cell (so `table` / the human default report correctly); -// this set only governs the fallback, where a non-machine `-o` (`table`/`pretty`) +// Fallback `-o` → telemetry derivation for commands that don't record a resolved format in +// `TelemetryOutputFormat` (`db query` does, so its `json|table|csv` reports correctly there +// instead); this set only governs the fallback, where a non-machine `-o` (`table`/`pretty`) // collapses to the resolved text format. const GO_MACHINE_OUTPUT_FORMATS = new Set(["env", "json", "toml", "yaml", "csv"]); const GO_OUTPUT_FORMATS = new Set([...GO_MACHINE_OUTPUT_FORMATS, "pretty"]); @@ -189,10 +163,9 @@ function extractChangedFlagNames( continue; } - // End-of-options sentinel: pflag stops parsing flags at a bare `--`, so - // everything after it is positional (e.g. `test db -- --linked` makes - // `--linked` a path arg). changedFlags() never sees those, so stop scanning. - // Mirrors resolveDbTargetFlags's `--` handling. + // End-of-options sentinel: pflag-style parsing stops at a bare `--`, so everything after it + // is positional (e.g. `test db -- --linked` makes `--linked` a path arg, not a flag). Mirrors + // `resolveDbTargetFlags`'s `--` handling. if (arg === "--") break; if (arg.startsWith("--")) { @@ -202,35 +175,30 @@ function extractChangedFlagNames( const isBare = eqIdx === -1; if (flagName.length === 0) continue; used.add(flagName); - // If this is a bare value-consuming flag, the next token is its value - // (Go's pflag space-separated form). Skip it so it is not recorded as a - // changed flag. This mirrors Go's pflag.Changed — only the flag name - // itself is recorded, not the value token that follows it. + // A bare value-consuming flag's next token is its value (pflag space-separated form) — + // skip it so only the flag name itself is recorded, not the value that follows. if (isBare && VALUE_CONSUMING_LONG_FLAGS.has(flagName)) { skipNext = true; } continue; } - // pflag shorthand: `-s`, `-s=value`, and `-svalue` all key off the first - // character after the single dash. Map it to the canonical flag name (Go's - // `flag.Visit` reports the canonical name regardless of long/short form). - // Only declared aliases are resolved; unknown shorthands are ignored. + // Shorthand forms `-s`, `-s=value`, and `-svalue` all key off the first character after the + // single dash; map it to the canonical flag name. Only declared aliases are resolved — + // unknown shorthands are ignored. if (arg.startsWith("-") && arg.length > 1) { const short = arg[1]; if (short === undefined) continue; const canonical = aliases[short]; if (canonical !== undefined) used.add(canonical); - // Bare short value-consuming flag (`-s` alone, length === 2): next token - // is the value. Skip it. Attached forms (`-svalue`, `-s=value`, length > 2) - // carry the value inline — no skip needed. + // A bare short flag (`-s`, length 2) takes its value from the next token; attached forms + // (`-svalue`, `-s=value`) carry it inline, so no skip is needed there. if (arg.length === 2 && VALUE_CONSUMING_SHORT_FLAGS.has(short)) { skipNext = true; } } } - // Match Go's sort.Slice(...flag.Name < flag.Name) in changedFlags(). return [...used].sort((left, right) => left.localeCompare(right)); } @@ -241,12 +209,9 @@ function normalizeFlagValue(value: unknown): unknown { return normalizeFlagValue(value.value); } -// Mirrors Go's `isEnumFlag` (`cmd/root_analytics.go:110-116`), which checks -// `flag.Value.(*utils.EnumFlag)` unconditionally — every enum flag is -// telemetry-safe, no per-flag annotation needed. Checks the unwrapped -// `Single`'s primitive `_tag` for `Flag.choice`/`Flag.choiceWithValue`. -// Restricted to `kind === Param.flagKind` so a same-named `Argument.choice` -// positional (none exist today) can never be mistaken for a `--flag`. +// Every `Flag.choice`/`Flag.choiceWithValue` flag is treated as telemetry-safe automatically — +// checks the unwrapped `Single`'s primitive `_tag`. Restricted to `kind === Param.flagKind` so a +// same-named `Argument.choice` positional can never be mistaken for a `--flag`. function getChoiceFlagNames(config: Record<string, Param.Any> | undefined): ReadonlySet<string> { const names = new Set<string>(); if (config === undefined) return names; @@ -264,19 +229,10 @@ function getChoiceFlagNames(config: Record<string, Param.Any> | undefined): Read return names; } -// Short-flag → canonical-name entries for every global/persistent flag that -// declares a shorthand alias, derived from `GLOBAL_FLAGS` itself so -// this never drifts from the single source of truth — today that resolves to -// just `{ o: "output" }`, from `OutputFlag`'s own `Flag.withAlias("o")`. -// Mirrors Go's persistent-flag shorthand registration: `-o` is the only -// global with a real shorthand (`cmd/root.go:330` registers it on -// `--output`; every other persistent flag has none). `pflag.Visit` reports -// the canonical `flag.Name` for either form (`cmd/root_analytics.go:53-76`), -// so `-o json` must resolve to `output` here the same way `--output json` -// already does. Merged ahead of each command's own `aliases` in -// `extractChangedFlagNames` so a command's own alias still wins on conflict, -// consistent with `buildFlagsMap`'s local-flag-shadows-global rule -// (CLI-1896 review follow-up). +// Short-flag → canonical-name entries for every global/persistent flag with a shorthand alias, +// derived from `GLOBAL_FLAGS` so it never drifts from that source of truth (today just `{ o: +// "output" }`). Merged ahead of each command's own `aliases` in `extractChangedFlagNames`, so a +// command's own alias still wins on conflict. const GLOBAL_SHORT_ALIASES: Readonly<Record<string, string>> = (() => { const aliases: Record<string, string> = {}; for (const globalFlag of GLOBAL_FLAGS) { @@ -289,39 +245,23 @@ const GLOBAL_SHORT_ALIASES: Readonly<Record<string, string>> = (() => { return aliases; })(); -// CLI-name set for every global/persistent flag that is itself a -// `Flag.choice`/`Flag.choiceWithValue` — today `output`, `dns-resolver`, and -// `agent` (`command-internal/global-flags.ts`). Reuses `getChoiceFlagNames` -// itself (keyed by `.id` rather than CLI name — `getChoiceFlagNames` only -// ever reads `single.name` off the unwrapped param, so the record key is -// irrelevant) rather than re-implementing the choice-detection predicate, so -// the two can never silently drift apart. Derived from `GLOBAL_FLAGS` -// the same way `GLOBAL_SHORT_ALIASES` is, so this never drifts from that -// single source of truth either. Mirrors Go's `isEnumFlag` -// (`cmd/root_analytics.go:110-116`) checked against the actual -// `*utils.EnumFlag`-backed persistent flag object registered on root -// (`cmd/root.go:330,331,333`). Applied ONLY to the global-fallback path in -// `buildFlagsMap` below (`!isFromHandler`): when a command registers its OWN -// differently-typed local flag under the same CLI name (e.g. `db diff`'s -// local string `--output`, `cmd/db.go:622`), Go's own `isEnumFlag` check runs -// against THAT command's local flag object instead — which fails the type -// assertion, so it stays redacted — exactly mirrored by -// `choiceFlagNames`/`isFromHandler` continuing to govern the handler-owned -// path. Invariant this depends on: any command whose Go counterpart locally -// shadows one of these three with a NON-enum flag (only `db diff`'s `output` -// today) must pass that flag in its own `flags` record, so `isFromHandler` -// is true and this global set is never consulted for it — otherwise the -// fallback would report verbatim here even though Go redacts it. +/** + * CLI-name set for every global/persistent flag that is itself a `Flag.choice`/ + * `Flag.choiceWithValue` (today `output`, `dns-resolver`, `agent`), derived from `GLOBAL_FLAGS` + * the same way `GLOBAL_SHORT_ALIASES` is. Applied only to the global-fallback path in + * `buildFlagsMap` (`!isFromHandler`): a command that registers its own differently-typed local + * flag under the same CLI name (e.g. `db diff`'s local string `--output`) must pass that flag in + * its own `flags` record so this global set is never consulted for it — otherwise the fallback + * would report it verbatim even though it isn't actually a choice flag there. + */ const GLOBAL_CHOICE_FLAG_NAMES: ReadonlySet<string> = getChoiceFlagNames( Object.fromEntries(GLOBAL_FLAGS.map((globalFlag) => [globalFlag.id, globalFlag.flag])), ); function buildFlagsMap<Flags extends Record<string, unknown>>(options: { readonly flags: Flags | undefined; - // Live global/persistent flag values (`globalFlagValues`), keyed by - // CLI flag name — the fallback source for a changed flag the handler never - // declared locally (e.g. `debug`), matching Go's `changedFlags()` walking - // `cmd.Parent()`'s `PersistentFlags()` (`cmd/root_analytics.go:53-76`). + // Live global/persistent flag values, keyed by CLI flag name — the fallback source for a + // changed flag the handler never declared locally (e.g. `debug`). readonly globalFlagValues: Record<string, unknown>; readonly safeFlagSet: ReadonlySet<string>; readonly changedFlagNames: ReadonlyArray<string>; @@ -345,26 +285,18 @@ function buildFlagsMap<Flags extends Record<string, unknown>>(options: { } for (const cliName of changedFlagNames) { - // A command's own flag always wins over a global/persistent flag sharing - // the same CLI name — mirrored from Go's cobra flag-shadowing, e.g. `db - // diff`'s local `--output` file-path flag (`cmd/db.go:622`) shadows the - // root's global `--output` enum (`cmd/root.go:330`). Only fall back to - // the live global-flag value when the handler never declared this name. + // A command's own flag always wins over a global/persistent flag sharing the same CLI name + // (e.g. `db diff`'s local `--output` file-path flag shadows the global `--output` enum) — + // only fall back to the live global-flag value when the handler never declared this name. const isFromHandler = handlerFlagsByCliName.has(cliName); const rawValue = isFromHandler ? handlerFlagsByCliName.get(cliName) : globalFlags[cliName]; const value = normalizeFlagValue(rawValue); - // `safeFlagSet`/`choiceFlagNames` classify a flag as safe by CLI NAME, - // sourced from this command's own `safeFlags`/`config` options — they may - // only vouch for a value that actually came from this command's own - // `flags` record; a value resolved from the global-flag fallback must - // never inherit a *different* command's per-flag safe/choice annotation - // just because the CLI name matches. The global-fallback path instead - // consults `GLOBAL_CHOICE_FLAG_NAMES` (CLI-1904) — the global flag's OWN - // choice-ness, exactly mirroring Go's `isEnumFlag` type-asserting the - // actual persistent flag object rather than any per-command annotation. - // Boolean values are safe unconditionally regardless of source (Go's - // `isBooleanFlag` branch applies unconditionally too). + // `safeFlagSet`/`choiceFlagNames` vouch for a value only when it actually came from this + // command's own `flags` record — a value resolved from the global-flag fallback instead + // consults `GLOBAL_CHOICE_FLAG_NAMES`, the global flag's own choice-ness, so it can't inherit + // a different command's per-flag safe/choice annotation just because the CLI name matches. + // Boolean values are always safe, regardless of source. const isSafe = typeof value === "boolean" || (isFromHandler @@ -436,28 +368,22 @@ function withCommandAnalyticsImplementation<Flags extends Record<string, unknown const exit = yield* self.pipe(withAnalyticsContext(analyticsContext), Effect.exit); const finishedAt = yield* Clock.currentTimeMillis; - // A command that resolves its own `--output` (e.g. `db query`, default - // `table`/`json` by agent mode) records it in this cell; Go mirrors that - // resolved value onto the global the event reads. Read optionally so - // commands that don't provide the cell keep the default derivation. + // A command that resolves its own `--output` (e.g. `db query`, defaulting `table`/`json` + // by agent mode) records it here; read optionally so commands that don't provide the + // cell keep the default derivation. const outputFormatCell = yield* Effect.serviceOption(TelemetryOutputFormat); const resolvedOutputFormat = Option.isSome(outputFormatCell) ? yield* outputFormatCell.value.get : Option.none<string>(); - // Go records the telemetry exit code from the real process exit code - // (`cmd/root.go:177` -> `exitCode(err)`), which is 1 whenever the command - // exits non-zero. A handler can signal a non-zero exit WITHOUT failing the - // Effect — `db lint`/`db advisors` set `ProcessControl`'s exit code in - // json/stream-json mode after a `--fail-on` trigger so the machine payload - // on stdout stays intact. Treat a non-zero process exit code as 1 even when - // the Effect succeeded, matching Go; otherwise fall back to the Effect exit. + // A handler can signal a non-zero exit without failing the Effect — `db lint`/`db + // advisors` set `ProcessControl`'s exit code in json/stream-json mode after a `--fail-on` + // trigger so the machine payload on stdout stays intact. Treat a non-zero process exit + // code as 1 even when the Effect succeeded; otherwise fall back to the Effect's own exit. // - // `config diff --exit-code` is the one exception: it sets exit code 2 to - // signal drift, deliberately WITHOUT failing the Effect (`diff.handler.ts`'s - // own 0/1/2 convention — 2 means "drift found", not "command failed"). This - // is TS-native behavior with no Go counterpart, so record the real exit code - // truthfully instead of collapsing it into the process-controlled failure - // bucket below. + // `config diff --exit-code` is the one exception: it sets exit code 2 to signal drift + // without failing the Effect (`diff.handler.ts`'s own 0/1/2 convention — 2 means "drift + // found", not "command failed"), so its real exit code is recorded truthfully instead of + // collapsing into the process-controlled failure bucket below. const processExitCode = yield* processControl.getExitCode; const isConfigDiffDriftSignal = Exit.isSuccess(exit) && command === "config diff" && processExitCode === 2; @@ -467,13 +393,10 @@ function withCommandAnalyticsImplementation<Flags extends Record<string, unknown ? 1 : 0; - // Go's Execute() reads s.distinctID() AFTER the command handler runs - // (cmd/root.go:177), which returns the just-stitched gotrue id when - // StitchLogin mutated the live telemetry service during the command. - // Mirror that: read IdentityStitch optionally (serviceOption adds no - // R requirement) and override distinct_id only for the post-run capture, - // leaving the analyticsContext that wrapped the handler's in-flight events - // unchanged. + // Reads the stitched distinct ID (if `StitchLogin` mutated it during the command) and + // overrides `distinct_id` only for this post-run capture — the `analyticsContext` + // wrapping the handler's in-flight events stays unchanged. `serviceOption` adds no `R` + // requirement. const stitchService = yield* Effect.serviceOption(IdentityStitch); const stitchedDistinctId: Option.Option<string> = Option.flatMap(stitchService, (svc) => { const id = svc.stitchedDistinctId(); @@ -500,9 +423,9 @@ function withCommandAnalyticsImplementation<Flags extends Record<string, unknown }) .pipe( withAnalyticsContext(captureContext), - // Best-effort: a capture failure or defect must never replace the - // command's own result, but fiber interruption (Ctrl+C landing - // during this trailing capture) must still propagate. + // Best-effort: a capture failure or defect must never replace the command's own + // result, but a fiber interruption (e.g. Ctrl+C during this trailing capture) must + // still propagate. Effect.catchCause((cause) => Cause.hasInterruptsOnly(cause) ? Effect.failCause(cause) : Effect.void, ), @@ -541,8 +464,7 @@ export function withCommandTelemetry<Flags extends Record<string, unknown>>( ? withCommandTracingImplementation() : withCommandAnalyticsImplementation(options); return <A, E, R>(self: Effect.Effect<A, E, R>) => - // Validate the `-o` enum first, before instrumentation runs the handler, so a - // rejected flag fails without emitting a `cli_command_executed` event — Go - // rejects it at parse time, before telemetry. + // Validate the `-o` enum before instrumentation runs the handler, so a rejected flag fails + // without emitting a `cli_command_executed` event. Effect.andThen(validateOutputFormat(allowed), instrument(self)); } diff --git a/apps/cli/src/telemetry/command-telemetry.unit.test.ts b/apps/cli/src/telemetry/command-telemetry.unit.test.ts index ce5e199ddc..98400e92c2 100644 --- a/apps/cli/src/telemetry/command-telemetry.unit.test.ts +++ b/apps/cli/src/telemetry/command-telemetry.unit.test.ts @@ -245,9 +245,6 @@ describe("withCommandTelemetry", () => { }); it.live("records a flag set via its shorthand under the canonical name", () => { - // Go's changedFlags() uses pflag Visit, which reports the canonical `schema` - // name even when the user typed the `-s` shorthand (cmd/db.go:506). The alias - // map lets the TS instrumentation match the single-dash form. const analytics = mockContextualAnalytics(); return Effect.void.pipe( @@ -267,7 +264,6 @@ describe("withCommandTelemetry", () => { Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; - // Slice flag stays redacted (not an EnumFlag/bool), but it IS recorded. expect(event?.properties.flags).toEqual({ schema: "<redacted>" }); }), ), @@ -275,8 +271,6 @@ describe("withCommandTelemetry", () => { }); it.live("records db dump shorthand flags (-x/-f) under their canonical names", () => { - // db dump declares -s/-x/-f/-p shorthands; Go's changedFlags() reports the - // canonical long names, so the instrumentation alias map must map all of them. const analytics = mockContextualAnalytics(); return Effect.void.pipe( @@ -303,8 +297,6 @@ describe("withCommandTelemetry", () => { }); it.live("records db query shorthand -f under its canonical name file", () => { - // db query declares only the -f/file shorthand; Go's changedFlags() reports the - // canonical `file`, so `db query -f query.sql` must log `file`, not `f`. const analytics = mockContextualAnalytics(); return Effect.void.pipe( @@ -331,8 +323,6 @@ describe("withCommandTelemetry", () => { }); it.live("records declarative generate shorthands -s/-p under canonical names", () => { - // Go registers --schema/-s and --password/-p (cmd/db_schema_declarative.go:495,500); - // changedFlags() reports the canonical schema/password. const analytics = mockContextualAnalytics(); return Effect.void.pipe( @@ -368,8 +358,6 @@ describe("withCommandTelemetry", () => { }); it.live("records declarative sync shorthands -s/-f under canonical names", () => { - // Go registers --schema/-s and --file/-f (cmd/db_schema_declarative.go:484-485); - // changedFlags() reports the canonical schema/file. const analytics = mockContextualAnalytics(); return Effect.void.pipe( @@ -499,7 +487,6 @@ describe("withCommandTelemetry", () => { "passes a Flag.withDefault-wrapped Flag.choice value through verbatim (Map(Optional(Single)))", () => { const analytics = mockContextualAnalytics(); - // Mirrors gen signing-key's real `algorithm` flag construction exactly — // `.pipe(Flag.withDefault(...))` composes as `Map(Optional(Single))`. const config = { algorithm: Flag.choice("algorithm", ["RS256", "ES256"] as const).pipe( @@ -533,9 +520,6 @@ describe("withCommandTelemetry", () => { "resolves a Flag.choice's shorthand alias to its canonical name (Go parity: pflag.Visit)", () => { const analytics = mockContextualAnalytics(); - // Mirrors sso add's real `type` flag construction exactly — `-t` is a - // registered alias (Flag.withAlias("t")) that must be mapped to the - // canonical "type" name for extractChangedFlagNames to record it at all. const config = { type: Flag.choice("type", ["saml"] as const).pipe(Flag.withAlias("t")), }; @@ -693,9 +677,8 @@ describe("withCommandTelemetry", () => { }); it.live("propagates fiber interruption from telemetry capture", () => { - // A capture failure or defect is swallowed (best-effort telemetry), but an - // interruption landing during the trailing capture must not be — the fiber - // is being cancelled and swallowing would fight the cancellation. + // A capture failure or defect is swallowed (best-effort telemetry), but an interruption + // landing during the trailing capture must not be. return Effect.void.pipe( withCommandTelemetry(), Effect.provide(interruptingAnalytics()), @@ -717,11 +700,6 @@ describe("withCommandTelemetry", () => { }); it.live("classifies db lint machine-mode fail-on like its typed text-mode error", () => { - // Go records the telemetry exit code from the real process exit code - // (`cmd/root.go:177` -> `exitCode(err)` = 1). `db lint`/`db advisors` set - // ProcessControl's exit code in json/stream-json mode after a --fail-on - // trigger and return success (to keep the machine payload on stdout intact), - // so the instrumentation must report 1, not the Effect's success. const analytics = mockContextualAnalytics(); const processControl = mockProcessControl(); @@ -781,12 +759,8 @@ describe("withCommandTelemetry", () => { }); it.live("classifies a command that sets its exit code outside the instrumentation", () => { - // `db dump` converts its run failure into an exit code in the command pipe - // (`Effect.catchTag(...)` applied AFTER this wrapper), not inside the - // handler like `db lint`/`db advisors`. Instrumentation is the innermost - // wrapper, so it still sees the typed failure and must classify it rather - // than fall back to the process-controlled `unknown` bucket. Reordering - // that pipe would silently degrade this command's telemetry. + // Instrumentation is the innermost wrapper here, so it still sees `db dump`'s typed failure + // and must classify it rather than falling back to the process-controlled `unknown` bucket. const analytics = mockContextualAnalytics(); const processControl = mockProcessControl(); const failure = new DbDumpRunError({ message: "container exited 1" }); @@ -850,11 +824,6 @@ describe("withCommandTelemetry", () => { it.live( "records config diff --exit-code drift (exit 2) truthfully, without failure metadata", () => { - // `config diff --exit-code` sets ProcessControl's exit code to 2 to signal - // drift WITHOUT failing the Effect (diff.handler.ts's own 0/1/2 convention: - // 2 means "drift found", not "command failed"). That is the command's own - // successful outcome, not a process-controlled failure — it must not - // collapse to 1 or attach db lint/db advisors-style failure classification. const analytics = mockContextualAnalytics(); const processControl = mockProcessControl(); @@ -920,10 +889,7 @@ describe("withCommandTelemetry", () => { ); it.live("classifies a config diff load failure normally (exit 1, cause-derived metadata)", () => { - // A real command failure on `config diff` (e.g. an unparseable config - // file) must not be swept into the drift-signal exception above — it - // still fails the Effect, so recordedExitCode stays 1 with the usual - // cause-derived classification. + // Must not be swept into the drift-signal exception above — this still fails the Effect. const analytics = mockContextualAnalytics(); const failure = new ConfigDiffLoadConfigError({ message: "invalid config" }); @@ -998,7 +964,6 @@ describe("withCommandTelemetry", () => { Effect.sync(() => { const event = analytics.captured[0]; const flags = event?.properties.flags as Record<string, unknown>; - // Keys should be insertion-ordered alphabetically. expect(Object.keys(flags)).toEqual(["project-ref", "timestamp"]); }), ), @@ -1024,7 +989,6 @@ describe("withCommandTelemetry", () => { expect((error as InvalidOutputFormatError).message).toBe( 'invalid argument "table" for "-o, --output" flag: must be one of [ env | pretty | json | toml | yaml ]', ); - // Go rejects at parse time, before telemetry — so no event is emitted. expect(analytics.captured).toEqual([]); }), ), @@ -1051,10 +1015,6 @@ describe("withCommandTelemetry", () => { ); }); - // Identity stitching parity: Go's Execute() reads s.distinctID() after the - // command handler runs (cmd/root.go:177) and the post-run cli_command_executed - // capture uses the stitched id. Mirror that with Effect.serviceOption. - it.live("attributes cli_command_executed to the stitched gotrue id", () => { const analytics = mockContextualAnalytics(); const stitch = mockIdentityStitch({ stitchedDistinctId: "gotrue-user-123" }); @@ -1122,8 +1082,6 @@ describe("withCommandTelemetry", () => { it.live( "does not require IdentityStitch — capture fires and distinct_id is absent when service is not provided", () => { - // Proves Effect.serviceOption adds no hard R requirement: the stitch layer is - // intentionally absent and the instrumentation must still fire the event. const analytics = mockContextualAnalytics(); return Effect.void.pipe( @@ -1133,7 +1091,6 @@ describe("withCommandTelemetry", () => { Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list"]) })), Effect.provide(commandRuntimeLayer(["backups", "list"])), - // Note: no stitch layer provided — serviceOption must default to None Effect.tap(() => Effect.sync(() => { expect(analytics.captured).toHaveLength(1); @@ -1144,14 +1101,7 @@ describe("withCommandTelemetry", () => { }, ); - // Value-consuming flag skip parity: Go's pflag.Changed records only the flag - // name, not the value token that follows it in space-separated form. - // `--schema --linked` must record only `schema` (--linked is the value for - // --schema, consumed by pflag, so pflag.Changed("linked") is false). - it.live("does not record a flag token that was consumed as another flag's value", () => { - // `db lint --schema --linked`: Go pflag consumes `--linked` as the value - // for `--schema`. changedFlags() sees only `schema`. const analytics = mockContextualAnalytics(); return Effect.void.pipe( @@ -1171,7 +1121,6 @@ describe("withCommandTelemetry", () => { Effect.tap(() => Effect.sync(() => { const flags = analytics.captured[0]?.properties.flags as Record<string, unknown>; - // Only `schema` should be recorded; `linked` was consumed as the value. expect(flags).toEqual({ schema: "<redacted>" }); expect(Object.keys(flags)).not.toContain("linked"); }), @@ -1180,7 +1129,6 @@ describe("withCommandTelemetry", () => { }); it.live("records both flags when the value is attached via = (--schema=public --linked)", () => { - // `--schema=public` carries the value inline; `--linked` is a separate flag. const analytics = mockContextualAnalytics(); return Effect.void.pipe( @@ -1200,7 +1148,6 @@ describe("withCommandTelemetry", () => { Effect.tap(() => Effect.sync(() => { const flags = analytics.captured[0]?.properties.flags as Record<string, unknown>; - // Both flags recorded: `schema` (= form, no skip) and `linked` (boolean). expect(Object.keys(flags).sort()).toEqual(["linked", "schema"]); }), ), @@ -1208,8 +1155,6 @@ describe("withCommandTelemetry", () => { }); it.live("skips value token for bare short value-consuming flag (-s public --linked)", () => { - // `-s public` bare short form: `public` is consumed as the schema value. - // `--linked` is a separate boolean flag and IS recorded. const analytics = mockContextualAnalytics(); return Effect.void.pipe( @@ -1229,9 +1174,7 @@ describe("withCommandTelemetry", () => { Effect.tap(() => Effect.sync(() => { const flags = analytics.captured[0]?.properties.flags as Record<string, unknown>; - // `schema` (via -s alias) and `linked` (separate boolean flag) recorded. expect(Object.keys(flags).sort()).toEqual(["linked", "schema"]); - // `public` was consumed as the -s value, not treated as a flag name. expect(Object.keys(flags)).not.toContain("public"); }), ), @@ -1239,8 +1182,6 @@ describe("withCommandTelemetry", () => { }); it.live("skips value token after bare --db-url and records only db-url", () => { - // `--db-url x --local`: `x` is consumed as the db-url value; `--local` is - // a separate boolean flag and is recorded. This mirrors Go's pflag.Changed. const analytics = mockContextualAnalytics(); return Effect.void.pipe( @@ -1260,25 +1201,13 @@ describe("withCommandTelemetry", () => { Effect.sync(() => { const flags = analytics.captured[0]?.properties.flags as Record<string, unknown>; expect(Object.keys(flags).sort()).toEqual(["db-url", "local"]); - // "x" must not appear as a recorded flag name. expect(Object.keys(flags)).not.toContain("x"); }), ), ); }); - // Global/persistent flag parity (CLI-1896): Go's changedFlags() walks - // cmd.Parent()'s PersistentFlags() in addition to the leaf's own flags - // (cmd/root_analytics.go:53-76), so a global flag like --debug resolves to - // its real value even though no command declares it locally. The wrapper - // reads command-internal/global-flags.ts itself rather than relying on the - // per-command `flags` option to carry global flag values. - it.live("records a changed global boolean flag's real value (e.g. --debug)", () => { - // Go reports `flags: {debug: true}` for `supabase --debug telemetry disable` - // (isBooleanFlag is always safe, regardless of markFlagTelemetrySafe) — the - // TS port previously had no way to resolve `debug` at all and fell back to - // "<redacted>" for every global flag, even booleans. const analytics = mockContextualAnalytics(); return Effect.void.pipe( @@ -1417,14 +1346,10 @@ describe("withCommandTelemetry", () => { it.live( "still redacts a global choice flag shadowed by a command's own differently-typed local flag (db diff's local string --output, Go parity)", () => { - // `db diff` declares its own local `output: Flag.string("output")` (a - // file path, `cmd/db.go:622`) rather than a `Flag.choice` — mirroring - // Go, where that command's own non-enum flag object governs - // `isEnumFlag`, not root's persistent `*utils.EnumFlag`. Simulate that - // shape here: `output` is declared in the handler's own `flags` record - // (so `isFromHandler` is true) but absent from `config`, so it must NOT - // inherit safety from `GLOBAL_CHOICE_FLAG_NAMES` just because the CLI - // name collides with the global `--output` choice flag. + // `db diff` declares its own local `output: Flag.string("output")` (a file path) rather + // than a `Flag.choice`. Simulated here: `output` is in the handler's own `flags` record + // (so `isFromHandler` is true) but absent from `config`, so it must not inherit safety from + // `GLOBAL_CHOICE_FLAG_NAMES` just because the CLI name collides with the global flag. const analytics = mockContextualAnalytics(); return Effect.void.pipe( @@ -1449,10 +1374,6 @@ describe("withCommandTelemetry", () => { ); it.live("falls back to redacted when a changed global flag's service isn't wired", () => { - // Defensive case: `Effect.serviceOption` must never throw/defect when a - // narrow harness (or, hypothetically, an incompletely-wired real command) - // doesn't provide a global flag's context — it degrades to the prior - // REDACTED_VALUE behavior instead of crashing. const analytics = mockContextualAnalytics(); return Effect.void.pipe( @@ -1462,7 +1383,6 @@ describe("withCommandTelemetry", () => { Effect.provide(mockOutput({ format: "text" }).layer), Effect.provide(Stdio.layerTest({ args: Effect.succeed(["backups", "list", "--debug"]) })), Effect.provide(commandRuntimeLayer(["backups", "list"])), - // Note: no DebugFlag layer provided. Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; @@ -1473,8 +1393,6 @@ describe("withCommandTelemetry", () => { }); it.live("stops recording flags at the -- end-of-options sentinel", () => { - // `test db -- --linked`: pflag stops parsing flags at `--`, so `--linked` - // is a positional arg, not a changed flag. changedFlags() never sees it. const analytics = mockContextualAnalytics(); return Effect.void.pipe( @@ -1490,8 +1408,6 @@ describe("withCommandTelemetry", () => { Effect.provide(commandRuntimeLayer(["test", "db"])), Effect.tap(() => Effect.sync(() => { - // No changed flags → the flags map is omitted entirely; `--linked` - // after `--` must never be recorded. const flags = analytics.captured[0]?.properties.flags; expect(flags).toBeUndefined(); }), @@ -1499,14 +1415,7 @@ describe("withCommandTelemetry", () => { ); }); - // CLI-1896 review follow-up (Codex): a global flag's SHORTHAND must resolve - // through the same fallback its long form already does. - it.live("resolves a global flag's shorthand (-o) through the global fallback", () => { - // `-o json` must resolve to the canonical `output` flag the same way - // `--output json` already does: Go's `pflag.Visit` reports the canonical - // `flag.Name` for either form (`cmd/root_analytics.go:53-76`), and `-o` is - // `--output`'s only registered persistent shorthand (`cmd/root.go:330`). const analytics = mockContextualAnalytics(); return Effect.void.pipe( @@ -1520,9 +1429,6 @@ describe("withCommandTelemetry", () => { Effect.tap(() => Effect.sync(() => { const event = analytics.captured[0]; - // `output` is a global choice flag — passed through verbatim - // (Go parity: isEnumFlag, CLI-1904), and it must be PRESENT, not - // silently dropped. expect(event?.properties.flags).toEqual({ output: "json" }); }), ), @@ -1532,15 +1438,10 @@ describe("withCommandTelemetry", () => { it.live( "does not fabricate a global flag from a local flag's value token (secrets set --env-file --debug)", () => { - // `--env-file` is a value-consuming local string flag. In bare - // space-separated form, pflag consumes the very next token as its - // VALUE regardless of its shape, so Go's changedFlags() never marks - // `debug` as changed for this invocation — the whole token is - // `env-file`'s value. Without `env-file` registered in - // VALUE_CONSUMING_LONG_FLAGS, extractChangedFlagNames would wrongly - // treat the trailing `--debug` as a separate flag, and CLI-1896's - // global-flag fallback would then fabricate a `flags.debug` value Go - // never records. + // `--env-file` is a value-consuming local string flag, so in bare space-separated form the + // next token is its value regardless of shape. Without `env-file` registered in + // `VALUE_CONSUMING_LONG_FLAGS`, `extractChangedFlagNames` would wrongly treat the trailing + // `--debug` as a separate flag and the global-flag fallback would fabricate a value for it. const analytics = mockContextualAnalytics(); return Effect.void.pipe( diff --git a/apps/cli/src/telemetry/linked-project-cache.integration.test.ts b/apps/cli/src/telemetry/linked-project-cache.integration.test.ts index 99a991ba6d..bd110bf772 100644 --- a/apps/cli/src/telemetry/linked-project-cache.integration.test.ts +++ b/apps/cli/src/telemetry/linked-project-cache.integration.test.ts @@ -21,9 +21,6 @@ describe("linkedProjectCacheLayer", () => { it.live( "stitches session identity from the cache GET's X-Gotrue-Id (Go identityTransport)", () => { - // Go runs ensureProjectGroupsCached's GET through GetSupabase()'s - // identityTransport, so the X-Gotrue-Id stitches the session identity — the - // only stitch opportunity for a password-only `--linked` run. Mirror that here. const workdir = mkdtempSync(join(tmpdir(), "linked-cache-")); const analytics = mockAnalytics(); const api = mockCommandPlatformApi({ @@ -68,23 +65,19 @@ describe("linkedProjectCacheLayer", () => { Layer.provide(mockCommandSettings({ workdir })), Layer.provide(mockCommandCredentialsLayer), Layer.provide(identityStitch), - // The cache now also fires org/project groupIdentify (Go parity); it reads - // Analytics directly, so provide the same mock the stitcher uses. + // The cache also fires org/project groupIdentify; it reads Analytics directly, so + // provide the same mock the stitcher uses. Layer.provide(analytics.layer), Layer.provide(BunServices.layer), ); return Effect.gen(function* () { const cache = yield* LinkedProjectCache; yield* cache.cache(VALID_REF, workdir); - // Identity stitched from the cache response's X-Gotrue-Id. expect(JSON.stringify(analytics.aliased)).toContain("gotrue-abc"); - // The linked-project cache is still written. const written: unknown = JSON.parse( readFileSync(join(workdir, "supabase", ".temp", "linked-project.json"), "utf8"), ); expect((written as { ref: string }).ref).toBe(VALID_REF); - // Go's CacheProjectAndIdentifyGroups also publishes org + project groups on - // the same cache miss (telemetry/project.go:66-88). expect(analytics.groupIdentified).toEqual([ { groupType: "organization", @@ -103,8 +96,6 @@ describe("linkedProjectCacheLayer", () => { ); it.live("does not re-identify groups when the linked-project cache already exists", () => { - // Cache hit → Go's HasLinkedProject guard returns early, so no write and no - // GroupIdentify. The TS `exists` early-return must match. const workdir = mkdtempSync(join(tmpdir(), "linked-cache-hit-")); mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); writeFileSync( @@ -147,10 +138,10 @@ describe("linkedProjectCacheLayer", () => { it.live( "skips the fill when project-ref names a DIFFERENT ref: the cache must describe the linked workdir, not the resolved ref (PR #6168 review)", () => { - // A mid-flight `link --project-ref B` failure still reaches the fill via - // Effect.ensuring while `project-ref` holds the OLD link — caching B - // would make the parent chain prefer a never-linked project. The guard - // runs before any token/network work, so the GET must never fire. + // A mid-flight `link --project-ref B` failure still reaches the fill via `Effect.ensuring` + // while `project-ref` holds the old link — caching B would make the parent chain prefer a + // never-linked project. The guard runs before any token/network work, so the GET must + // never fire. const OTHER_REF = "otherprojectrefabcde"; const workdir = mkdtempSync(join(tmpdir(), "linked-cache-diverge-")); mkdirSync(join(workdir, "supabase", ".temp"), { recursive: true }); diff --git a/apps/cli/src/telemetry/linked-project-cache.layer.ts b/apps/cli/src/telemetry/linked-project-cache.layer.ts index 02f1017c1b..3e3cb86e87 100644 --- a/apps/cli/src/telemetry/linked-project-cache.layer.ts +++ b/apps/cli/src/telemetry/linked-project-cache.layer.ts @@ -19,31 +19,18 @@ function readString(obj: unknown, key: string): string { } /** - * Writes `<workdir>/supabase/.temp/linked-project.json` after a `--project-ref` - * has been resolved. Mirrors Go's `ensureProjectGroupsCached` - * (`apps/cli-go/cmd/root.go:213-234`): + * Writes `<workdir>/supabase/.temp/linked-project.json` after a `--project-ref` has been + * resolved. No write if the cache already exists (`supabase link` is authoritative), and any + * API/filesystem/parse error is swallowed. * - * - No write if the cache already exists (`supabase link` is authoritative). - * - Best-effort: any API / filesystem / parse error is swallowed. - * - Body shape matches `LinkedProject` from - * `apps/cli-go/internal/telemetry/project.go:15-20`. + * Bypasses `CommandPlatformApi`'s strict schema decode by calling the API directly with + * `HttpClient`. The generated `V1ProjectWithDatabaseResponse` schema enforces a 20-char + * project-ref length that the cli-e2e replay fixtures (which store `__PROJECT_REF__` + * placeholders) cannot satisfy; the cache only needs four string fields and doesn't validate them. * - * Bypasses `CommandPlatformApi`'s strict schema decode by calling the API - * directly with `HttpClient`. The generated `V1ProjectWithDatabaseResponse` - * schema enforces a 20-char project-ref length that the cli-e2e replay - * fixtures (which store `__PROJECT_REF__` placeholders) cannot satisfy. - * The cache only needs four string fields and doesn't validate them. - * - * DELIBERATE TS DIVERGENCE FROM GO (PR #6168 review): unlike - * `ensureProjectGroupsCached`, which unconditionally caches whatever `ref` - * it's given, this additionally skips the write when `<workdir>/supabase/.temp/project-ref` - * exists, is non-empty, and names a DIFFERENT ref than `ref` — see the inline - * comment at the check itself for the exact failure mode this closes. This - * cache now feeds `resolveLinkedParentRef`'s parent chain (CLI-2167 - * follow-up), so correctness there is prioritized over exact - * telemetry-cache parity with Go (Go-authority scoping, ADR 0016) — Go never - * reads this file back for anything, so this file has no comparable - * behavior to preserve there. + * Also skips the write when `<workdir>/supabase/.temp/project-ref` exists, is non-empty, and + * names a different ref than `ref` — see the inline comment at the check itself for the failure + * mode this closes. This cache feeds `resolveLinkedParentRef`'s parent chain. */ export const linkedProjectCacheLayer = Layer.effect( LinkedProjectCache, @@ -54,14 +41,10 @@ export const linkedProjectCacheLayer = Layer.effect( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const analytics = yield* Analytics; - // Go's `ensureProjectGroupsCached` GETs `/v1/projects/{ref}` through - // `GetSupabase()`'s identityTransport (`cmd/root.go:226`, `api.go:128-134`), - // so the X-Gotrue-Id on that response stitches the session identity. For a - // password-only `db lint`/`db advisors --linked` run this cache GET can be the - // ONLY Management API response, so it must stitch too. Consume the single - // per-command stitcher service (shared with the typed client + advisor GETs) - // so the alias + persist fire at most once per command, matching Go's one - // root-context `sync.Once`. + // The X-Gotrue-Id on this GET's response stitches the session identity — for a password-only + // `db lint`/`db advisors --linked` run, this cache GET can be the only Management API + // response, so it must stitch too. Consumes the single per-command stitcher service (shared + // with the typed client + advisor GETs) so the alias + persist fire at most once per command. const { stitch } = yield* IdentityStitch; return LinkedProjectCache.of({ @@ -77,36 +60,25 @@ export const linkedProjectCacheLayer = Layer.effect( const exists = yield* fs.exists(cachePath).pipe(Effect.orElseSucceed(() => false)); if (exists) return; - // The cache must describe the LINKED WORKDIR's own state, not - // whatever ref the calling command happens to have resolved (PR - // #6168 review): a mid-flight `link --project-ref B` failure still - // reaches this fill via `Effect.ensuring`, and if `getProject(B)` - // returns 200 (e.g. B is merely paused, not gone/forbidden), this - // would otherwise cache B as the linked project even though - // `project-ref` itself was never updated to B — `link`'s own - // mandatory write happens BEFORE this fill can ever fire, so a - // `project-ref` naming something else here means the workdir is - // still actually linked to that something else. Skip the write - // entirely when the file names a DIFFERENT ref: a deliberate TS - // divergence from Go's `ensureProjectGroupsCached`, which caches - // whatever ref it's given unconditionally — this cache now feeds - // `resolveLinkedParentRef`'s parent chain, so correctness - // there outweighs 1:1 telemetry-cache parity (Go-authority - // scoping, ADR 0016). A file that's absent entirely keeps today's - // behavior (falls through to the write below) — the read side - // (`resolveLinkedParentRef`) already refuses to trust a - // cache with no `project-ref` file at all, so there is nothing to - // protect there yet. + // The cache must describe the linked workdir's own state, not whatever ref the calling + // command happens to have resolved: a mid-flight `link --project-ref B` failure still + // reaches this fill via `Effect.ensuring`, and if `getProject(B)` returns 200 (e.g. B + // is merely paused, not gone/forbidden), this would otherwise cache B as the linked + // project even though `project-ref` itself was never updated to B — `link`'s own + // mandatory write happens before this fill can ever fire, so a `project-ref` naming + // something else here means the workdir is still actually linked to that something + // else. Skip the write entirely when the file names a different ref. A file that's + // absent entirely falls through to the write below — the read side + // (`resolveLinkedParentRef`) already refuses to trust a cache with no `project-ref` + // file at all, so there is nothing to protect there yet. const fileRef = yield* readProjectRefFile(fs, path, resolvedWorkdir).pipe( Effect.orElseSucceed(() => Option.none<string>()), ); if (Option.isSome(fileRef) && fileRef.value !== ref) return; - // Resolve token: an explicit reconciled-profile token wins outright - // (Some → use, None → the reconciled profile HAS no token, so skip - // like Go's failed lookup — never fall back to the stale profile's - // token, review r3684524241); otherwise env wins over keyring/file - // lookup (Go-parity). + // An explicit reconciled-profile token wins outright (`Some` → use, `None` → the + // reconciled profile has no token, so skip rather than fall back to a stale profile's + // token); otherwise env wins over keyring/file lookup. const tokenOpt = accessToken ?? (Option.isSome(cliSettings.accessToken) @@ -122,8 +94,7 @@ export const linkedProjectCacheLayer = Layer.effect( HttpClientRequest.setHeader("User-Agent", cliSettings.userAgent), ); const response = yield* httpClient.execute(request); - // Stitch identity from the response (Go's identityTransport fires on - // every response regardless of status), before the status gate. + // Stitch identity from the response before the status gate, regardless of status. yield* stitch(response); if (response.status !== 200) return; const body = yield* response.json; @@ -138,12 +109,9 @@ export const linkedProjectCacheLayer = Layer.effect( yield* fs.makeDirectory(path.dirname(cachePath), { recursive: true }); yield* fs.writeFileString(cachePath, JSON.stringify(linked)); - // Go's CacheProjectAndIdentifyGroups (telemetry/project.go:66-88) does - // not just write the file — on the same cache miss it also publishes the - // org/project group metadata via GroupIdentify before the post-run - // cli_command_executed capture. Reproduce both calls (same payload shape - // as the link handler) so the first linked run after a port doesn't drop - // the group properties Go sends. Best-effort like Go (wrapped in ignore). + // On the same cache miss, also publish the org/project group metadata via + // `groupIdentify` (same payload shape as the link handler) before the post-run + // `cli_command_executed` capture. Best-effort, wrapped in `Effect.ignore` below. if (linked.organization_id.length > 0) { yield* analytics.groupIdentify(GroupOrganization, linked.organization_id, { organization_slug: linked.organization_slug, diff --git a/apps/cli/src/telemetry/linked-project-cache.service.ts b/apps/cli/src/telemetry/linked-project-cache.service.ts index a830931858..71bae766e6 100644 --- a/apps/cli/src/telemetry/linked-project-cache.service.ts +++ b/apps/cli/src/telemetry/linked-project-cache.service.ts @@ -3,32 +3,26 @@ import { Context } from "effect"; interface LinkedProjectCacheShape { /** - * Fire-and-forget: fetches the project metadata from the Management API and - * writes `<workdir>/supabase/.temp/linked-project.json` if no cache exists yet. + * Fire-and-forget: fetches the project metadata from the Management API and writes + * `<workdir>/supabase/.temp/linked-project.json` if no cache exists yet. * - * `workdir` overrides the directory the cache resolves against. Callers that have - * already changed the working directory (e.g. `bootstrap`, whose target workdir can - * come from an interactive prompt rather than `cliSettings.workdir`) pass their resolved - * workdir so the cache lands beside the other `supabase/.temp/` files. When omitted it - * falls back to `cliSettings.workdir` (the cwd-walk result), matching every other caller. + * `workdir` overrides the directory the cache resolves against. Callers that have already + * changed the working directory (e.g. `bootstrap`, whose target workdir can come from an + * interactive prompt rather than `cliSettings.workdir`) pass their resolved workdir so the + * cache lands beside the other `supabase/.temp/` files. When omitted it falls back to + * `cliSettings.workdir` (the cwd-walk result), matching every other caller. * - * Best-effort. Never fails the calling effect — auth errors, network errors, - * and write errors are all swallowed (matches Go's `ensureProjectGroupsCached` - * which logs to debug and returns). + * Best-effort. Never fails the calling effect — auth errors, network errors, and write errors + * are all swallowed. * - * `apiUrl` overrides the Management API base URL of the cache-fill GET. - * Go's `ensureProjectGroupsCached` goes through `GetSupabase()` and the - * process-wide `CurrentProfile` — commands that reconcile a pflag-effective - * profile differing from the config layer's (sso add/update, PR #5974 - * round 7) pass that profile's URL. Defaults to `cliSettings.apiUrl`. + * `apiUrl` overrides the Management API base URL of the cache-fill GET: commands that reconcile + * a flag-effective profile differing from the config layer's (e.g. `sso add`/`update`) pass + * that profile's URL. Defaults to `cliSettings.apiUrl`. * - * `accessToken` complements `apiUrl`: Go resolves credentials for the same - * process-wide reconciled profile (`access_token.go:43`), so a reconciled - * caller passes the reconciled profile's token with the URL — the stale - * profile's bearer token must never be sent to the reconciled host (review - * r3684524241). `Some` uses that token, `None` skips the GET entirely - * (Go's token lookup fails before any request), `undefined` resolves from - * the config/credentials services. + * `accessToken` complements `apiUrl`: a reconciled caller passes the reconciled profile's token + * along with its URL, since the stale profile's bearer token must never be sent to the + * reconciled host. `Some` uses that token, `None` skips the GET entirely, `undefined` resolves + * from the config/credentials services. */ readonly cache: ( ref: string, diff --git a/apps/cli/src/telemetry/telemetry-output-format.service.ts b/apps/cli/src/telemetry/telemetry-output-format.service.ts index 18ac92ebe9..3bc03dc493 100644 --- a/apps/cli/src/telemetry/telemetry-output-format.service.ts +++ b/apps/cli/src/telemetry/telemetry-output-format.service.ts @@ -3,12 +3,10 @@ import { Context } from "effect"; interface TelemetryOutputFormatShape { /** - * Record the resolved telemetry `output_format`. Mirrors Go's `db query`, which - * resolves its command-local `--output` (`json|table|csv`, defaulting to `table` - * for humans and `json` for agents) and mirrors it onto the global - * `utils.OutputFormat.Value` the `cli_command_executed` event reads - * (`apps/cli-go/cmd/db.go:316-328` → `cmd/root.go:177-181`). Commands that don't - * set this fall back to the default `-o`/`--output-format` derivation. + * Records the resolved telemetry `output_format`, e.g. for `db query`, which resolves its + * command-local `--output` (`json|table|csv`, defaulting to `table` for humans and `json` for + * agents) and needs that value reflected in the `cli_command_executed` event. Commands that + * don't set this fall back to the default `-o`/`--output-format` derivation. */ readonly set: (format: string) => Effect.Effect<void>; /** The recorded format, or `None` when the command never set one. */ diff --git a/apps/cli/src/telemetry/telemetry-state.layer.ts b/apps/cli/src/telemetry/telemetry-state.layer.ts index 99ec0345c0..7dedabc190 100644 --- a/apps/cli/src/telemetry/telemetry-state.layer.ts +++ b/apps/cli/src/telemetry/telemetry-state.layer.ts @@ -15,11 +15,9 @@ interface State { readonly distinct_id?: string; readonly schema_version: number; /** - * Exact decoded `schema_version` token, carried for re-serialization and - * stripped from the written JSON by {@link serializeTelemetryState}. - * Go decodes the field into a 64-bit `int` and `json.Marshal` re-emits it - * verbatim; a JS `Number` above 2^53 rounds (9007199254740993 → …992) and - * would persist the altered version. + * Exact decoded `schema_version` token, carried for re-serialization and stripped from the + * written JSON by {@link serializeTelemetryState}. `schema_version` is a 64-bit int on disk; a + * JS `Number` above 2^53 rounds (9007199254740993 → …992) and would persist the altered value. */ readonly schemaVersionToken?: string; } @@ -32,11 +30,9 @@ function telemetryPath(env: Record<string, string | undefined>, pathSvc: Path.Pa } /** - * Serializes the state like Go's `json.Marshal` of `State` (`state.go:25-31`): - * a carried exact `schema_version` token is spliced back in verbatim via - * `JSON.rawJSON` (review r3683813242 — `Number` rounds valid int64 tokens - * above 2^53, so `9007199254740993` would persist as `…992` where Go - * re-encodes the decoded `int` exactly). Field order matches Go's struct. + * Serializes the state, splicing a carried exact `schema_version` token back in verbatim via + * `JSON.rawJSON` — `Number` rounds valid int64 tokens above 2^53, so `9007199254740993` would + * otherwise persist as `…992`. */ function serializeTelemetryState(state: State): string { const { schemaVersionToken, ...fields } = state; @@ -48,50 +44,41 @@ export interface PriorState { readonly enabled: boolean; readonly device_id: string; readonly session_id: string; - /** Epoch millis of `session_last_active`, from the Go-shape parse below. */ + /** Epoch millis of `session_last_active`, from the RFC3339Nano parse below. */ readonly sessionLastActiveMs: number; readonly distinct_id?: string; /** - * Exact raw token of the decoded non-zero `schema_version`, absent when Go - * would fall back to the `SchemaVersion` constant (`state.go:103-106`). - * Kept as the token — not a `Number` — so re-serialization is int64-exact. + * Exact raw token of the decoded non-zero `schema_version`, absent when the loader falls back + * to the `SCHEMA_VERSION` constant. Kept as the token — not a `Number` — so re-serialization is + * int64-exact. */ readonly schemaVersionToken?: string; } -// Go's `time.Parse(time.RFC3339Nano, …)` shape: date, `T`, time, optional -// fraction, `Z` or a `±hh:mm` offset. JS `new Date(…)` alone accepts far more -// (bare dates, RFC 2822, …) that Go rejects as malformed. The fractional -// separator is `.` OR `,` — Go's parser accepts either (`commaOrPeriod`, -// `time/format.go`; verified against go1.26: `…T00:00:00,1Z` parses) — while -// the digits after it stay mandatory (`…T00:00:00,Z` is rejected). +// The on-disk RFC3339Nano shape: date, `T`, time, optional fraction, `Z` or a `±hh:mm` offset. +// JS `new Date(…)` alone accepts far more (bare dates, RFC 2822, …) that this format rejects as +// malformed. The fractional separator is `.` or `,`, but the digits after it stay mandatory +// (`…T00:00:00,Z` is rejected). const RFC3339_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:[.,](\d+))?(?:Z|([+-])(\d{2}):(\d{2}))$/; const DAYS_PER_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] as const; -// Gregorian leap rule, mirroring Go's `isLeap` (`time/time.go`). +// Gregorian leap rule. function daysInMonth(year: number, month: number): number { if (month === 2 && year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)) return 29; return DAYS_PER_MONTH[month - 1] ?? 0; } /** - * Component-level port of Go's `time.Parse(time.RFC3339Nano, …)` - * (`parseSessionLastActive`, `state.go:69-85`): validates like Go and, when - * valid, returns the epoch milliseconds of the parsed instant. `Date.parse` / - * `new Date(…)` cannot stand in for it in either direction (verified against - * go1.26 and Bun 1.3): - * - JS silently normalizes valid-range day overflow (`2025-02-29` → Mar 1, - * `2025-04-31` → May 1) and hour 24 (`T24:00:00Z` → next day) that Go - * rejects as "day/hour out of range"; - * - JS rejects forms Go accepts — a `,` fractional separator, and zone - * offsets bounded at hour 24 / minute 60 (`+24:00` and `+05:60` both - * parse) — where JS returns NaN. - * The epoch therefore also has to come from these components, NOT from a - * second `new Date(string)` pass: a Go-valid form JS cannot parse would - * NaN there and wrongly count as session-expired (see the rotation check in - * `loadOrCreateTelemetryState`). + * Validates an RFC3339Nano timestamp against the on-disk format's rules and, when valid, returns + * the epoch milliseconds of the parsed instant. `Date.parse`/`new Date(…)` cannot stand in for it + * in either direction: JS silently normalizes valid-range day overflow (`2025-02-29` → Mar 1, + * `T24:00:00Z` → next day) that this format rejects, and it rejects forms this format accepts (a + * `,` fractional separator; zone offsets bounded at hour 24/minute 60) by returning NaN instead. + * The epoch therefore also has to come from these components, not a second `new Date(string)` + * pass, since a valid form JS can't parse would NaN there and wrongly count as session-expired + * (see the rotation check in `loadOrCreateTelemetryState`). */ function parseGoRfc3339Ms(text: string): number | undefined { const match = RFC3339_RE.exec(text); @@ -111,8 +98,8 @@ function parseGoRfc3339Ms(text: string): number | undefined { const date = new Date(0); date.setUTCFullYear(year, month - 1, day); date.setUTCHours(hour, minute, second, 0); - // Go reads at most 9 fractional digits (nanoseconds); ms precision is - // exact for the 30-minute comparison this feeds. + // At most 9 fractional digits (nanoseconds) are read; ms precision is exact for the 30-minute + // comparison this feeds. const fractionMs = match[7] !== undefined ? Number(`0.${match[7].slice(0, 9)}`) * 1000 : 0; const offsetMs = match[8] !== undefined @@ -130,20 +117,13 @@ const GO_INT64_MAX = 2n ** 63n - 1n; const INT64_TOKEN_RE = /^-?\d+$/; /** - * Whether a raw JSON number token would decode into a Go signed 64-bit - * integer. Go decodes both the consent-form unix millis (`int64`, - * `state.go:69-85`) and `schema_version` (`int`, 64-bit on every supported - * platform, `state.go:41`) by unmarshaling the RAW JSON number token, which - * accepts only lexically-integer decimal tokens within the int64 range: - * integer-VALUED tokens like `1.0`, `2.0`, and `1e3` are UnmarshalTypeErrors, - * as are integer tokens outside [-2^63, 2^63-1] (verified against go1.26: - * `json.Unmarshal` into `int64` rejects `1.0`/`1e3`/`1e100`/ - * `9223372036854775808`, and the repo's own `decodeState` maps each to - * `errMalformedState` → full regeneration, `state.go:87-90`). `JSON.parse` - * collapses those tokens to plain integer Numbers, so parsed VALUES alone - * cannot reproduce Go — validation runs on the raw token text, with exact - * BigInt bounds (the doubles for int64-max and int64-max+1 are - * indistinguishable; the tokens are not). + * Whether a raw JSON number token would decode into a signed 64-bit integer, the type both the + * consent-form unix millis and `schema_version` are stored as on disk. That format accepts only + * lexically-integer decimal tokens within the int64 range: integer-valued tokens like `1.0`, + * `2.0`, and `1e3` are rejected, as are integer tokens outside [-2^63, 2^63-1]. `JSON.parse` + * collapses those tokens to plain integer Numbers, so parsed values alone can't reproduce this — + * validation runs on the raw token text, with exact BigInt bounds (the doubles for int64-max and + * int64-max+1 are indistinguishable; the tokens are not). */ function isInt64Token(token: string): boolean { return ( @@ -154,17 +134,14 @@ function isInt64Token(token: string): boolean { const JSON_WS = new Set([" ", "\t", "\n", "\r"]); /** - * Scans the ROOT object of an already-syntax-validated JSON text (it runs - * only after `JSON.parse(text)` has succeeded) and returns every - * `[key, raw value token]` pair in source order — INCLUDING duplicate keys. - * `JSON.parse` collapses duplicates to the final occurrence before any user - * code runs (even a stage-3 source-access reviver only ever sees the final - * token), but Go's `encoding/json` decodes every occurrence in order, so - * reproducing its behaviour needs the full occurrence list. Keys are - * unescaped (Go matches the escaped key `"\u0063onsent"` to the `consent` - * field). Only depth-1 pairs are emitted: a nested `{"x":{"enabled":"bad"}}` never shadows - * a root field, matching Go's struct decoding. Returns `undefined` when the - * root is not an object. + * Scans the root object of an already-syntax-validated JSON text (it runs only after + * `JSON.parse(text)` has succeeded) and returns every `[key, raw value token]` pair in source + * order — including duplicate keys, which `JSON.parse` collapses to the final occurrence before + * any user code runs. The on-disk format decodes every occurrence in order, so reproducing that + * needs the full occurrence list. Keys are unescaped (so the escaped key `"\u0063onsent"` + * matches the `consent` field). Only depth-1 pairs are emitted: a nested + * `{"x":{"enabled":"bad"}}` never shadows a root field. Returns `undefined` when the root is + * not an object. */ function scanRootJsonEntries( text: string, @@ -233,25 +210,16 @@ function scanRootJsonEntries( } /** - * Go's single `json.Unmarshal` into `rawState` (`state.go:34-42`) records an - * `UnmarshalTypeError` for EVERY wrong-typed occurrence of a known field — - * even when a later duplicate is valid and overwrites the value — and any - * such error classifies the whole file as malformed (verified against the - * repo's own `decodeState` on go1.26: - * `{"consent":false,"consent":"denied",…}` fails to decode while - * `{"enabled":true,"enabled":false,…}` decodes cleanly with `Enabled=false`). - * JSON `null` decodes into every field without error (nil for the pointer - * fields, no-op for the rest); `session_last_active` is `json.RawMessage` and - * unknown keys are skipped untyped — any token is fine for those. + * A wrong-typed occurrence of a known field — even one shadowed by a later valid duplicate — is + * enough to classify the whole file as malformed on this on-disk format + * (`{"consent":false,"consent":"denied",…}` fails to decode while + * `{"enabled":true,"enabled":false,…}` decodes cleanly with `enabled: false`). JSON `null` + * decodes into every field without error; `session_last_active` is untyped raw JSON and unknown + * keys are skipped untyped — any token is fine for those. * - * DOCUMENTED BOUND (review r3689624837): `encoding/json` also matches field - * names case-INsensitively when no exact match exists, so Go would treat a - * hand-edited `"Enabled": …` as the `enabled` field where this port (here and - * in `lastToken`/`lastNonNullToken`) treats it as unknown. Both CLIs only - * ever WRITE canonical lowercase keys, so case-variant keys require a - * hand-edited file; this emulation intentionally stops at exact tag names — - * do not extend it to fold casing (that path ends at reproducing - * `strings.EqualFold`'s Unicode simple folding). + * Known bound: this only matches exact lowercase field names. Both CLIs only ever write + * canonical lowercase keys, so a case-variant key (`"Enabled": …`) requires a hand-edited file and + * is treated as unknown here rather than folded to `enabled`. */ function hasGoDecodableFieldTokens( entries: ReadonlyArray<readonly [key: string, token: string]>, @@ -290,10 +258,9 @@ function lastToken( } /** - * Raw token of the last NON-NULL occurrence of `key`. This is Go's effective - * value for the non-pointer `rawState` fields: JSON `null` is a decode no-op - * (the field keeps its previous value), so `{"device_id":"a","device_id":null}` - * keeps `"a"` where `JSON.parse` surfaces `null` (verified against go1.26). + * Raw token of the last non-null occurrence of `key`. JSON `null` is a decode no-op (the field + * keeps its previous value), so `{"device_id":"a","device_id":null}` keeps `"a"` where + * `JSON.parse` surfaces `null`. */ function lastNonNullToken( entries: ReadonlyArray<readonly [key: string, token: string]>, @@ -319,27 +286,21 @@ function lastNonNullString( } /** - * Faithful port of Go's `decodeState` (`internal/telemetry/state.go:87-115`): - * ALL-OR-NOTHING. Go decodes the whole file or classifies it as - * `errMalformedState` — it never salvages individual fields. A file missing - * (or mistyping) any required piece — an `enabled` bool (or a - * `granted`/`denied` `consent`), a parseable `session_last_active`, and - * non-empty `device_id` AND `session_id` — is treated as wholly malformed, so - * `LoadOrCreateState` recreates EVERYTHING fresh: `enabled` back to `true`, - * new `device_id`, new `session_id`. Notably, a corrupt file that still says - * `"enabled": false` does NOT stay disabled. + * Decodes the on-disk telemetry state: all-or-nothing, never salvaging individual fields. A file + * missing (or mistyping) any required piece — an `enabled` bool (or a `granted`/`denied` + * `consent`), a parseable `session_last_active`, and non-empty `device_id` and `session_id` — is + * treated as wholly malformed, so the caller recreates everything fresh: `enabled` back to + * `true`, new `device_id`, new `session_id`. A corrupt file that still says `"enabled": false` + * does not stay disabled. * - * Go's unmarshal strictness is reproduced at the TOKEN level, over EVERY - * occurrence of every root field ({@link scanRootJsonEntries} + - * {@link hasGoDecodableFieldTokens}): `JSON.parse` collapses `2.0` → `2`, - * `1e3` → `1000`, and duplicated keys down to their final occurrence, so - * parsed values alone would preserve files Go rejects as wholly malformed — - * non-integer number tokens, magnitudes outside the int64 range, and - * wrong-typed non-final duplicates (`{"consent":false,"consent":"denied"}`) - * alike. (Unix millis in-range but beyond ECMAScript's ±8.64e15 `Date` range - * do NOT regenerate: the epoch is kept as a plain number, so — like Go's - * `time.UnixMilli` — the state is preserved and the far-future comparison - * simply never expires the session.) + * Strictness is reproduced at the token level, over every occurrence of every root field + * ({@link scanRootJsonEntries} + {@link hasGoDecodableFieldTokens}): `JSON.parse` collapses + * `2.0` → `2`, `1e3` → `1000`, and duplicated keys down to their final occurrence, so parsed + * values alone would preserve files this format rejects as wholly malformed — non-integer number + * tokens, magnitudes outside the int64 range, and wrong-typed non-final duplicates + * (`{"consent":false,"consent":"denied"}`) alike. Unix millis in-range but beyond ECMAScript's + * ±8.64e15 `Date` range do not regenerate: the epoch is kept as a plain number, so the state is + * preserved and the far-future comparison simply never expires the session. */ export function readExistingState(text: string): PriorState | undefined { try { @@ -347,17 +308,13 @@ export function readExistingState(text: string): PriorState | undefined { if (!isRecord(parsed)) return undefined; const record = parsed; - // Per-OCCURRENCE typing first: Go's single-shot unmarshal fails on any - // wrong-typed occurrence — including one shadowed by a later valid - // duplicate that `JSON.parse` would surface (`state.go:88-91`). + // Per-occurrence typing first: fails on any wrong-typed occurrence, including one shadowed + // by a later valid duplicate that `JSON.parse` would surface. const entries = scanRootJsonEntries(text); if (entries === undefined || !hasGoDecodableFieldTokens(entries)) return undefined; - // Go's `parseConsent` (`state.go:52-67`): a non-null `consent` must be - // `granted`/`denied` (and unlocks the unix-millis timestamp form); - // otherwise a bool `enabled` is required. Field TYPING — a non-boolean - // `enabled`, a non-string `consent`, on any occurrence — was already - // validated above. + // A non-null `consent` must be `granted`/`denied` (and unlocks the unix-millis timestamp + // form); otherwise a bool `enabled` is required. Field typing was already validated above. let enabled: boolean; let allowUnixMillis = false; const consent = record.consent; @@ -377,10 +334,9 @@ export function readExistingState(text: string): PriorState | undefined { return undefined; } - // Go's `parseSessionLastActive` (`state.go:69-85`): an RFC3339Nano string, - // or — only on the consent form — integer unix millis (`time.UnixMilli`). - // The field is `json.RawMessage`, so plain last-occurrence overwrite - // applies (nulls included) and only the FINAL token is ever parsed. + // An RFC3339Nano string, or — only on the consent form — integer unix millis. The field is + // untyped raw JSON, so plain last-occurrence overwrite applies (nulls included) and only the + // final token is ever parsed. const rawLastActive = record.session_last_active; let sessionLastActiveMs: number; if (typeof rawLastActive === "string") { @@ -399,9 +355,8 @@ export function readExistingState(text: string): PriorState | undefined { return undefined; } - // Go: `if raw.DeviceID == "" || raw.SessionID == ""` → "missing identity". - // Effective values are the last NON-NULL occurrences — `null` decodes as - // a no-op into these non-pointer string fields. + // Empty `device_id`/`session_id` means missing identity. Effective values are the last + // non-null occurrences — `null` decodes as a no-op into these fields. const deviceId = lastNonNullString(entries, "device_id"); if (deviceId === undefined || deviceId === "") return undefined; const sessionId = lastNonNullString(entries, "session_id"); @@ -409,11 +364,10 @@ export function readExistingState(text: string): PriorState | undefined { const distinctId = lastNonNullString(entries, "distinct_id"); - // `SchemaVersion int`: absent (or only null occurrences) → zero value; - // Go keeps a decoded file's non-zero schema_version (`state.go:103-106`). - // The zero test and the kept value both use the exact TOKEN — `BigInt` - // for the comparison, the raw text for re-serialization — because - // `Number` rounds valid int64 magnitudes above 2^53. + // Absent (or only null occurrences) means zero value; a decoded file's non-zero + // schema_version is kept. The zero test and the kept value both use the exact token — BigInt + // for the comparison, the raw text for re-serialization — since `Number` rounds valid int64 + // magnitudes above 2^53. const schemaVersionToken = lastNonNullToken(entries, "schema_version"); const keptSchemaVersionToken = schemaVersionToken !== undefined && BigInt(schemaVersionToken) !== 0n @@ -447,13 +401,10 @@ export const loadOrCreateTelemetryState = Effect.fn("telemetry.loadOrCreateState const now = opts.now ?? new Date(); const nowIso = now.toISOString(); - // The expiry comparison uses the epoch computed by `parseGoRfc3339Ms` - // during decode — NOT a `new Date(string)` re-parse. Go-valid forms JS - // cannot parse (comma fraction `…00,5Z`, offsets `+24:00`/`+05:60`) - // would NaN there and read as expired, rotating `session_id` where Go — - // which decoded the instant fine — retains it inside the 30-minute - // window (`LoadOrCreateState`, `state.go:140-148`; verified against the - // Go binary: a recent `…00,5Z` keeps the seeded session id). + // The expiry comparison uses the epoch computed by `parseGoRfc3339Ms` during decode, not a + // `new Date(string)` re-parse — a valid form JS can't parse (comma fraction `…00,5Z`, offsets + // `+24:00`/`+05:60`) would NaN there and wrongly read as expired, rotating `session_id` even + // though the instant decoded fine and is still inside the 30-minute window. const priorActiveMs = prior?.sessionLastActiveMs; const expired = priorActiveMs === undefined || now.getTime() - priorActiveMs > SESSION_ROTATION_MS; @@ -465,9 +416,8 @@ export const loadOrCreateTelemetryState = Effect.fn("telemetry.loadOrCreateState !expired && prior?.session_id !== undefined ? prior.session_id : crypto.randomUUID(), session_last_active: nowIso, ...(prior?.distinct_id !== undefined ? { distinct_id: prior.distinct_id } : {}), - // Go keeps a decoded file's non-zero schema_version (`state.go:103-106`). - // The numeric field is for in-memory readers; the exact token rides - // along for the write so magnitudes above 2^53 round-trip like Go. + // A decoded file's non-zero schema_version is kept. The numeric field is for in-memory + // readers; the exact token rides along for the write so magnitudes above 2^53 round-trip. schema_version: prior?.schemaVersionToken !== undefined ? Number(prior.schemaVersionToken) : SCHEMA_VERSION, ...(prior?.schemaVersionToken !== undefined @@ -497,11 +447,9 @@ export const setTelemetryEnabled = Effect.fn("telemetry.setEnabled")(function* ( }); /** - * Re-derives the current telemetry state (reusing `loadOrCreateTelemetryState`'s - * read / session-rotation / merge — no third copy of that logic) and writes it - * back with the `distinct_id` field set (`stitchLogin`) or removed - * (`clearDistinctId`). Mirrors Go's `SaveState(s.state, fsys)` after mutating - * `s.state.DistinctID` (`service.go:141-150`). + * Re-derives the current telemetry state (reusing `loadOrCreateTelemetryState`'s read / + * session-rotation / merge, rather than a third copy of that logic) and writes it back with the + * `distinct_id` field set (`stitchLogin`) or removed (`clearDistinctId`). */ const persistDistinctId = Effect.fn("telemetry.persistDistinctId")(function* ( distinctId: string | undefined, @@ -529,18 +477,15 @@ const persistIdentityReset = Effect.fn("telemetry.persistIdentityReset")(functio }); /** - * Writes `<SUPABASE_HOME or ~/.supabase>/telemetry.json` on every command run. - * Mirrors Go's `LoadOrCreateState` (`apps/cli-go/internal/telemetry/state.go:74-98`): + * Writes `<SUPABASE_HOME or ~/.supabase>/telemetry.json` on every command run: * * - Reuses an existing `device_id` if the file is present. * - Rotates `session_id` if `session_last_active` is older than 30 minutes. - * - Always sets `enabled: true` on a fresh state (matches Go — the field is - * only flipped to `false` if the user has run `supabase telemetry disable`, - * in which case the prior value is preserved). The - * `SUPABASE_TELEMETRY_DISABLED` / `DO_NOT_TRACK` env vars suppress event - * delivery, not state-file writes. - * - Always writes — Go persists the state file even when telemetry is - * disabled; only event delivery is suppressed. + * - Always sets `enabled: true` on a fresh state — the field is only flipped to `false` if the + * user has run `supabase telemetry disable`, in which case the prior value is preserved. The + * `SUPABASE_TELEMETRY_DISABLED`/`DO_NOT_TRACK` env vars suppress event delivery, not + * state-file writes. + * - Always writes, even when telemetry is disabled; only event delivery is suppressed. * * Best-effort: filesystem or JSON parse errors are swallowed. */ @@ -561,15 +506,14 @@ export const telemetryStateLayer = Layer.effect( return TelemetryState.of({ flush: provide(loadOrCreateTelemetryState()).pipe(Effect.asVoid, Effect.ignore), stitchLogin: (distinctId: string) => - // Mirrors Go's `StitchLogin`: the in-memory stamp always happens so - // subsequent captures in this process carry the user's id; the alias - // (which merges pre-login history) and the `telemetry.json` write only - // happen in persistent runtimes. The alias is fire-and-forget so a - // PostHog delivery error never prevents the `distinct_id` persist. + // The in-memory stamp always happens so subsequent captures in this process carry the + // user's id; the alias (which merges pre-login history) and the `telemetry.json` write + // only happen in persistent runtimes. The alias is fire-and-forget so a PostHog delivery + // error never prevents the `distinct_id` persist. Effect.gen(function* () { - // Alias only the first identity this device ever sees — re-aliasing - // on re-login would merge a second user into the device's existing - // person graph in PostHog. Stamp and persist always. + // Alias only the first identity this device ever sees — re-aliasing on re-login would + // merge a second user into the device's existing person graph in PostHog. Stamp and + // persist always. const current = runtime.identity.current(); const firstIdentity = current === undefined || current.length === 0; runtime.identity.stamp(distinctId); diff --git a/apps/cli/src/telemetry/telemetry-state.layer.unit.test.ts b/apps/cli/src/telemetry/telemetry-state.layer.unit.test.ts index 4f3dd51ac1..b8241a7714 100644 --- a/apps/cli/src/telemetry/telemetry-state.layer.unit.test.ts +++ b/apps/cli/src/telemetry/telemetry-state.layer.unit.test.ts @@ -175,9 +175,6 @@ describe("telemetryStateLayer.stitchLogin / clearDistinctId", () => { ); }); -// Go's `decodeState` (`internal/telemetry/state.go:87-115`) is all-or-nothing: -// any missing/mistyped required field invalidates the WHOLE file, not just that -// field, so `LoadOrCreateState` regenerates enabled/device_id/session_id fresh. describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing recovery)", () => { const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; @@ -255,10 +252,6 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco ); it.effect("a mistyped enabled on the consent form is malformed and is wholly regenerated", () => { - // Go's single-shot `json.Unmarshal` type-checks `Enabled *bool` even when - // `consent` decides the value (`state.go:35`, `state.go:88-91`): - // `"enabled":"invalid"` is an UnmarshalTypeError → errMalformedState → - // fresh state with telemetry re-enabled and new identities. writeFileSync( telemetryPath(), JSON.stringify({ @@ -278,9 +271,6 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco }); it.effect("a null enabled on the consent form decodes and preserves the state", () => { - // JSON `null` unmarshals cleanly into Go's `Enabled *bool` (nil pointer, - // no error) and `parseConsent` then honors the consent value — only - // non-boolean, non-null types invalidate the file. writeFileSync( telemetryPath(), JSON.stringify({ @@ -317,10 +307,6 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco }); }); - // Go's `time.Parse(time.RFC3339Nano, …)` rejects calendar-invalid dates - // ("day out of range" / "hour out of range") that JS `Date.parse` silently - // normalizes (Feb 29 → Mar 1, T24 → next day) — verified against go1.26. - // A file carrying one must be wholly regenerated, not preserved. it.effect( "a calendar-invalid session_last_active (Feb 29, non-leap year) is wholly regenerated", () => { @@ -354,8 +340,6 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco ); return Effect.gen(function* () { const state = yield* runLoad(); - // The timestamp is long-stale so the session rotates, but the file - // decoded: enabled/device_id are preserved, exactly like Go. expect(state.enabled).toBe(false); expect(state.device_id).toBe("d"); }); @@ -381,10 +365,6 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco it.effect( "a Go-valid zone offset JS cannot parse (+24:00) still decodes and preserves the state", () => { - // Go's parser bounds the offset hour at 24 and minute at 60, so - // `+24:00` is a VALID Go timestamp — regenerating here (as a plain - // `Date.parse` validity check would) would wrongly reset `enabled` and - // rotate the device identity. writeFileSync( telemetryPath(), JSON.stringify({ @@ -403,10 +383,6 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco ); it.effect("a Go-valid comma fractional-second separator decodes and preserves the state", () => { - // Go's `time.Parse(time.RFC3339Nano, …)` accepts `,` as well as `.` - // before fractional seconds (`commaOrPeriod`, `time/format.go`; verified - // against go1.26). Classifying this as malformed would regenerate the - // file with telemetry re-enabled and fresh identities — Go preserves it. writeFileSync( telemetryPath(), JSON.stringify({ @@ -424,8 +400,6 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco }); it.effect("a comma with no fractional digits is malformed and is wholly regenerated", () => { - // Go rejects `…T00:00:00,Z` ("cannot parse \",Z\" as \"Z07:00\"") — the - // separator only participates when at least one digit follows. writeFileSync( telemetryPath(), JSON.stringify({ @@ -442,12 +416,6 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco }); }); - // Session expiry must be computed from the SAME component-level Go parse - // that validated the string — a `new Date(string)` re-parse NaNs on - // Go-valid forms (comma fraction, exotic offsets) and would count them as - // expired, rotating `session_id` where the Go binary retains it (verified: - // seeding `<now>,5Z` and running `supabase-go telemetry status` keeps the - // seeded session id; the TS CLI before this fix rotated it). it.effect("a recent comma-fraction timestamp keeps the session id within 30 minutes", () => { writeFileSync( telemetryPath(), @@ -467,9 +435,9 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco }); it.effect("a Go-exotic +05:60 offset participates in the expiry arithmetic", () => { - // `+05:60` normalizes to a 6-hour offset in Go, so this instant is - // 2025-01-01T00:00:00Z — 10 minutes before `now` → session retained. - // (JS `new Date` returns NaN for minute-60 offsets, which would rotate.) + // `+05:60` normalizes to a 6-hour offset, so this instant is 2025-01-01T00:00:00Z — 10 + // minutes before `now`, so the session is retained. (JS `new Date` returns NaN for + // minute-60 offsets, which would wrongly rotate it.) writeFileSync( telemetryPath(), JSON.stringify({ @@ -487,10 +455,9 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco }); it.effect("a +24:00 offset shifts the instant a full day back, expiring the session", () => { - // Wall clock 2025-01-01T00:00:00 at +24:00 is 2024-12-31T00:00:00Z, so at - // `now` = 2025-01-01T00:10:00Z the session is 24h10m stale → Go rotates. - // Reading the wall clock as UTC (ignoring the offset) would wrongly - // retain it. The decoded file is still preserved (enabled/device_id). + // Wall clock 2025-01-01T00:00:00 at +24:00 is 2024-12-31T00:00:00Z, so at `now` = + // 2025-01-01T00:10:00Z the session is 24h10m stale and rotates. Reading the wall clock as + // UTC (ignoring the offset) would wrongly retain it. writeFileSync( telemetryPath(), JSON.stringify({ @@ -509,11 +476,9 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco }); it.effect("consent-form unix millis beyond the JS Date range preserve the state like Go", () => { - // Go's `time.UnixMilli(9e15)` is a valid far-future instant (~year - // 287396): the state decodes, and `now.Sub(last)` is hugely negative → - // never expired, session retained. Kept as a plain number here so the - // comparison behaves identically (a `Date`/`toISOString` round-trip - // throws beyond ±8.64e15 and used to regenerate the whole file). + // A far-future instant (~year 287396): the elapsed-time comparison is hugely negative, so + // the session is never expired and the state decodes. Kept as a plain number here since a + // `Date`/`toISOString` round-trip throws beyond ±8.64e15. writeFileSync( telemetryPath(), JSON.stringify({ @@ -532,10 +497,8 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco }); it.effect("consent-form unix millis beyond the int64 range regenerate everything like Go", () => { - // Go's `json.Unmarshal` into `int64` rejects the exponent token 1e+100 - // outright (any float/exponent token is an UnmarshalTypeError for int64) - // → `errMalformedState` → wholesale regeneration: telemetry re-enabled, - // fresh identities — even though the file said "denied". + // A float/exponent token (`1e+100`) is rejected outright for an int64 field, so this + // regenerates wholesale even though the file said "denied". writeFileSync( telemetryPath(), JSON.stringify({ @@ -554,11 +517,10 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco }); it.effect("consent-form unix millis at Go's int64 bounds preserve the state", () => { - // Hand-built JSON so the raw text pins Go's exact max valid literal - // 9223372036854775807 (JSON.stringify of the rounded double would emit a - // different literal). The raw-token check accepts it via exact BigInt - // bounds — the parsed double rounds to 2^63 and could not distinguish it - // from Go-invalid 9223372036854775808 (see the companion test below). + // Hand-built JSON so the raw text pins the exact int64 max literal 9223372036854775807 + // (`JSON.stringify` of the rounded double would emit a different literal). The raw-token + // check accepts it via exact BigInt bounds — the parsed double rounds to 2^63 and can't be + // distinguished from the invalid 9223372036854775808 (see the companion test below). writeFileSync( telemetryPath(), '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":9223372036854775807}', @@ -572,11 +534,9 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco }); it.effect("consent-form unix millis at Go's int64 min decode but expire the session", () => { - // int64 min -9223372036854775808 = -(2^63) is exactly representable as a - // double, so this Go-valid literal round-trips precisely. The instant is - // far past, so — exactly like Go — the file DECODES (enabled/device_id - // preserved, no wholesale regeneration) while the >30-minute-stale - // session id rotates. + // int64 min -9223372036854775808 = -(2^63) is exactly representable as a double, so this + // literal round-trips precisely. The instant is far past, so the file decodes + // (enabled/device_id preserved, no wholesale regeneration) while the stale session id rotates. writeFileSync( telemetryPath(), '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":-9223372036854775808}', @@ -589,12 +549,6 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco }); }); - // Go's `json.Unmarshal` into `int64` validates the raw TOKEN, not the - // value: `1e3` and `…0.0` are UnmarshalTypeErrors even though `JSON.parse` - // collapses them to integer Numbers that pass `Number.isInteger` (verified - // against go1.26 via the repo's own `decodeState`). A value-level check - // would preserve `consent: "denied"` and the identities where Go - // regenerates a fresh telemetry-enabled state. it.effect("consent-form unix millis written as an exponent token regenerate like Go", () => { writeFileSync( telemetryPath(), @@ -625,9 +579,8 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco ); it.effect("consent-form unix millis one past int64 max regenerate exactly like Go", () => { - // 9223372036854775808 parses to the SAME double as Go's max valid literal - // 9223372036854775807 (both round to 2^63), so only the raw token can - // tell them apart — Go rejects this one with an UnmarshalTypeError. + // 9223372036854775808 parses to the same double as the int64 max literal 9223372036854775807 + // (both round to 2^63), so only the raw token can tell them apart. writeFileSync( telemetryPath(), '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":9223372036854775808}', @@ -641,9 +594,9 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco }); it.effect("a non-integer number token nested under an unknown key stays out of scope", () => { - // The raw-token capture is scoped to the ROOT object by holder identity. - // Go ignores unknown fields entirely, so a nested `session_last_active` - // must neither shadow nor invalidate the valid top-level millis. + // The raw-token capture is scoped to the root object by holder identity, and unknown fields + // are ignored entirely, so a nested `session_last_active` must neither shadow nor invalidate + // the valid top-level millis. writeFileSync( telemetryPath(), '{"consent":"denied","device_id":"d","session_id":"s","session_last_active":1750000000000,"extra":{"session_last_active":1.5}}', @@ -656,9 +609,8 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco }); it.effect("a schema_version written as an integer-valued float regenerates like Go", () => { - // `SchemaVersion int` sits in the single-shot unmarshal (`state.go:41`), - // where the token `1.0` is an UnmarshalTypeError → the WHOLE file is - // malformed and regenerated, even though `JSON.parse` reads it as 1. + // `schema_version` is an int field, where the token `1.0` is rejected, so the whole file is + // malformed and regenerated even though `JSON.parse` reads it as 1. writeFileSync( telemetryPath(), '{"enabled":false,"device_id":"d","session_id":"s","session_last_active":"2026-01-01T00:00:00Z","schema_version":1.0}', @@ -672,9 +624,6 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco }); it.effect("a schema_version beyond the int64 range regenerates everything like Go", () => { - // `SchemaVersion int` sits in the same single-shot unmarshal - // (`state.go:41`, `state.go:88-90`): an overflowing value malforms the - // whole file, not just the field. writeFileSync( telemetryPath(), JSON.stringify({ @@ -693,16 +642,8 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco }); }); - // Go's single `json.Unmarshal` decodes EVERY occurrence of a duplicated - // key: values overwrite last-wins, but a wrong-typed occurrence records an - // UnmarshalTypeError even when a later duplicate is valid — and any error - // malforms the whole file (`state.go:34-42`, `state.go:88-91`). `JSON.parse` - // only surfaces the final occurrence, so these matrices are pinned against - // the repo's own `decodeState` on go1.26. describe("duplicate root keys (Go per-occurrence decoding)", () => { it.effect("a wrong-typed earlier consent regenerates even when the final one is valid", () => { - // Go: `cannot unmarshal bool into … rawState.consent of type string` — - // the file must NOT stay disabled off the surviving `"denied"`. writeFileSync( telemetryPath(), '{"consent":false,"consent":"denied","session_last_active":1750000000000,"device_id":"d","session_id":"s"}', @@ -740,8 +681,8 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco }); it.effect("a non-integer earlier schema_version token regenerates like Go", () => { - // `1e3` into `SchemaVersion int` is an UnmarshalTypeError on the first - // occurrence; the valid `2` after it cannot save the file. + // `1e3` is rejected for the int field on its first occurrence; the valid `2` after it + // cannot save the file. writeFileSync( telemetryPath(), '{"consent":"granted","session_last_active":1750000000000,"device_id":"d","session_id":"s","schema_version":1e3,"schema_version":2}', @@ -754,8 +695,8 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco }); it.effect("duplicate session_last_active takes the last token (json.RawMessage)", () => { - // The RawMessage field is never type-checked per occurrence — only the - // FINAL token is parsed (`state.go:69-85`), so junk before it is fine. + // This field is never type-checked per occurrence — only the final token is parsed, so + // junk before it is fine. writeFileSync( telemetryPath(), '{"consent":"denied","session_last_active":true,"session_last_active":1750000000000,"device_id":"d","session_id":"s"}', @@ -783,8 +724,8 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco ); it.effect("null occurrences are decode-valid for pointer and string fields alike", () => { - // `null` → nil for `Enabled *bool` (later duplicate overwrites) and a - // no-op for `DeviceID string` — no UnmarshalTypeError anywhere. + // `null` decodes cleanly for both a boolean field (later duplicate overwrites) and a + // string field (no-op) — nothing here is rejected. writeFileSync( telemetryPath(), `{"enabled":null,"enabled":false,"device_id":null,"device_id":"d","session_id":"s","session_last_active":${JSON.stringify(new Date().toISOString())}}`, @@ -798,10 +739,9 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco }); it.effect("a null FINAL device_id keeps the earlier value (null is a decode no-op)", () => { - // Go keeps `DeviceID:"d"` — unmarshaling `null` into a non-pointer - // string leaves the previous occurrence's value in place, where - // `JSON.parse`'s last-value-wins would surface `null` and wrongly - // regenerate. + // `device_id` keeps `"d"` — decoding `null` into this field leaves the previous + // occurrence's value in place, where `JSON.parse`'s last-value-wins would surface `null` + // and wrongly regenerate. writeFileSync( telemetryPath(), `{"enabled":false,"device_id":"d","device_id":null,"session_id":"s","session_last_active":${JSON.stringify(new Date().toISOString())}}`, @@ -827,7 +767,7 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco }); it.effect("wrong-typed duplicates of UNKNOWN keys never invalidate the file", () => { - // Go skips unknown fields untyped — no occurrence of `junk` can error. + // Unknown fields are skipped untyped, so no occurrence of `junk` can error. writeFileSync( telemetryPath(), `{"enabled":false,"junk":false,"junk":"x","device_id":"d","session_id":"s","session_last_active":${JSON.stringify(new Date().toISOString())}}`, @@ -840,8 +780,8 @@ describe("loadOrCreateTelemetryState (Go decodeState parity: all-or-nothing reco }); it.effect("an escaped duplicate key is unescaped before field matching, like Go", () => { - // encoding/json unescapes key tokens before struct-field matching, so - // `"consent":false` is a wrong-typed `consent` occurrence. + // Key tokens are unescaped before field matching, so `"\u0063onsent":false` is a + // wrong-typed `consent` occurrence. writeFileSync( telemetryPath(), '{"\\u0063onsent":false,"consent":"denied","session_last_active":1750000000000,"device_id":"d","session_id":"s"}', @@ -866,9 +806,6 @@ describe("exact int64 schema_version round-trip (Go json.Marshal parity)", () => )},"schema_version":${schemaVersionToken}}`; it.effect("a valid schema_version above 2^53 is persisted verbatim, like Go's int64", () => { - // Go decodes 9007199254740993 into `SchemaVersion int` exactly and - // `json.Marshal` re-emits it verbatim; a `Number` round-trip persists the - // rounded …992 (review r3683813242). writeFileSync(telemetryPath(), fileWith("9007199254740993")); return Effect.gen(function* () { yield* runLoad(); diff --git a/apps/cli/src/telemetry/telemetry-state.service.ts b/apps/cli/src/telemetry/telemetry-state.service.ts index 06e103af66..4db2142cd6 100644 --- a/apps/cli/src/telemetry/telemetry-state.service.ts +++ b/apps/cli/src/telemetry/telemetry-state.service.ts @@ -2,35 +2,22 @@ import type { Effect } from "effect"; import { Context } from "effect"; interface TelemetryStateShape { - /** - * Persists the legacy telemetry state to disk (matches Go's - * `LoadOrCreateState` in `apps/cli-go/internal/telemetry/state.go:74-98`). - * - * Best-effort: any filesystem error is swallowed. - */ + /** Persists the telemetry state to disk. Best-effort: any filesystem error is swallowed. */ readonly flush: Effect.Effect<void>; /** - * Aliases the device id to the resolved gotrue id and persists it as the - * telemetry `distinct_id`. Mirrors Go's `Service.StitchLogin` - * (`service.go:132-143`): the alias is sent through the Analytics layer - * (which gates delivery on consent), and `distinct_id` is **always** written - * to `telemetry.json` — replacing any stale value. - * - * Best-effort: filesystem / analytics errors are swallowed. + * Aliases the device id to the resolved gotrue id and persists it as the telemetry + * `distinct_id`. The alias is sent through the Analytics layer (which gates delivery on + * consent), and `distinct_id` is always written to `telemetry.json`, replacing any stale value. + * Best-effort: filesystem/analytics errors are swallowed. */ readonly stitchLogin: (distinctId: string) => Effect.Effect<void>; /** - * Logout-only: forgets the user and rotates the persisted `device_id`, so a - * later login as a different account aliases a fresh device instead of one - * already merged into the previous user's person graph. + * Logout-only: forgets the user and rotates the persisted `device_id`, so a later login as a + * different account aliases a fresh device instead of one already merged into the previous + * user's person graph. */ readonly resetIdentity: Effect.Effect<void>; - /** - * Clears the persisted telemetry `distinct_id`. Mirrors Go's - * `Service.ClearDistinctID` (`service.go:145-151`). - * - * Best-effort: any filesystem error is swallowed. - */ + /** Clears the persisted telemetry `distinct_id`. Best-effort: any filesystem error is swallowed. */ readonly clearDistinctId: Effect.Effect<void>; } diff --git a/apps/cli/tests/helpers/command-mocks.ts b/apps/cli/tests/helpers/command-mocks.ts index 7c81b0fe4c..25c62b2ee2 100644 --- a/apps/cli/tests/helpers/command-mocks.ts +++ b/apps/cli/tests/helpers/command-mocks.ts @@ -65,21 +65,14 @@ import { processEnvLayer, } from "./mocks.ts"; -// --------------------------------------------------------------------------- -// Constants — Go-parity test fixtures used across every native-port integration -// test. Centralized so a change to the project-ref schema (e.g. updated length) -// only needs to update one constant. -// --------------------------------------------------------------------------- - +// Fixtures shared across every native-port integration test. Centralized so a change to the +// project-ref schema (e.g. updated length) only needs to update one constant. export const VALID_REF = "abcdefghijklmnopqrst"; export const VALID_TOKEN = "sbp_" + "a".repeat(40); export const DEFAULT_API_URL = "https://api.supabase.com"; export const DEFAULT_USER_AGENT = "SupabaseCLI/0.0.0-dev"; -// --------------------------------------------------------------------------- // No-op layers — drop-in for tests that don't assert on telemetry / cache state. -// --------------------------------------------------------------------------- - export const mockLinkedProjectCacheLayer = Layer.succeed(LinkedProjectCache, { cache: () => Effect.void, }); @@ -186,12 +179,9 @@ export function mockCommandCredentialsTracked( }; } -// --------------------------------------------------------------------------- -// Login crypto / API mocks. The crypto mock returns a dummy ECDH handle (the -// browser-flow integration tests never reach a real decrypt — the API mock -// supplies the ciphertext and the crypto mock returns the decrypted token). -// --------------------------------------------------------------------------- - +// The crypto mock returns a dummy ECDH handle: the browser-flow integration tests never reach a +// real decrypt — the API mock supplies the ciphertext and the crypto mock returns the decrypted +// token. export function mockLoginCrypto( opts: { readonly publicKeyHex?: string; @@ -272,21 +262,16 @@ export function mockLoginApi( }; } -// --------------------------------------------------------------------------- -// State-tracking factories — for PersistentPostRun-parity assertions -// (telemetry must flush, linked-project cache fires after ref resolution). -// Shape matches the inline helpers the 9 native-port tests used pre-extraction. -// --------------------------------------------------------------------------- - +// State-tracking factories, for asserting telemetry flushes and the linked-project cache fires +// after ref resolution. export function mockTelemetryStateTracked(): { readonly layer: Layer.Layer<TelemetryState>; readonly flushed: boolean; /** - * Number of `flush` calls — beyond the plain `flushed` boolean, this lets a - * test prove a command's own `Effect.ensuring` finalizer fired EXACTLY once - * even when its body calls an in-process helper (e.g. `resetLocalDatabase`, - * CLI-2062) that could, if it wrongly owned a second finalizer, double the - * count instead of leaving it at 1. + * Number of `flush` calls — beyond the plain `flushed` boolean, this lets a test prove a + * command's own `Effect.ensuring` finalizer fired once, even when its body calls an in-process + * helper (e.g. `resetLocalDatabase`) that could double the count if it wrongly owned a second + * finalizer. */ readonly flushCount: number; readonly stitchedDistinctId: string | undefined; @@ -389,12 +374,8 @@ export function mockLinkedProjectCacheTracked(): { }; } -// --------------------------------------------------------------------------- -// CLI config factory — defaults match the common case (linked project, valid -// access token, supabase.com API URL). Tests override individual fields when -// they need to exercise alternative resolution paths. -// --------------------------------------------------------------------------- - +// Defaults match the common case (linked project, valid access token, supabase.com API URL). +// Tests override individual fields when they need to exercise alternative resolution paths. export function mockCommandSettings(opts: { readonly workdir: string; readonly explicitWorkdir?: boolean; @@ -421,11 +402,7 @@ export function mockCommandSettings(opts: { }); } -// --------------------------------------------------------------------------- -// HTTP transport primitives — exported as low-level building blocks for tests -// that need a custom `handler` in `mockCommandPlatformApi`. -// --------------------------------------------------------------------------- - +// Low-level building blocks for tests that need a custom `handler` in `mockCommandPlatformApi`. export function jsonResponse( request: HttpClientRequest.HttpClientRequest, status: number, @@ -476,14 +453,9 @@ function makeHttpClientLayer( ); } -// --------------------------------------------------------------------------- -// Platform API factory — hybrid surface. -// -// Precedence (high → low): `network: "fail"` > `handler` > `byMethod` > `response`. -// `body` is JSON-decoded when the Uint8Array body parses; otherwise the raw -// decoded string is stored. Falsy bodies (no request body) record `undefined`. -// --------------------------------------------------------------------------- - +// Platform API factory — hybrid surface. Precedence (high → low): `network: "fail"` > +// `handler` > `byMethod` > `response`. `body` is JSON-decoded when the Uint8Array body parses; +// otherwise the raw decoded string is stored. Falsy bodies (no request body) record `undefined`. export type HttpMethod = "GET" | "POST" | "PATCH" | "PUT" | "DELETE"; export interface RecordedRequest { @@ -603,26 +575,16 @@ export function mockCommandPlatformApi( return { layer, httpClientLayer, requests, factoryLayer }; } -// --------------------------------------------------------------------------- -// Direct-service mock for CommandPlatformApi. +// Direct-service mock for CommandPlatformApi. Bypasses the real API client's input/output schema +// validation by providing `CommandPlatformApi` via `Layer.succeed` directly. Use this when the +// API schema is too strict for the test scenario (e.g. the `branch_id_or_ref` oneOf union rejects +// 20-letter project refs because the UUID branch has no actual UUID pattern check) and the +// handler logic under test does not depend on the byte-exact wire format of requests/responses. // -// Bypasses the real API client's input/output schema validation by providing -// `CommandPlatformApi` via `Layer.succeed` directly. Use this when: -// -// - the API schema is too strict for the test scenario (e.g. the -// `branch_id_or_ref` oneOf union rejects 20-letter project refs because -// the UUID branch has no actual UUID pattern check), AND -// - the handler logic under test does not depend on the byte-exact wire -// format of requests/responses. -// -// The recorded `requests` array tracks `{ method, input }` for every call — -// both typed `v1.*` calls and raw-execute calls (keyed by the operation's -// `id`, e.g. `"v2GetProjectConfig"`), so a single array captures a whole -// mocked call sequence regardless of which surface issued it. -// Methods not present in `v1Stubs`/`raw` die at call time so missing wiring -// shows up loud and clear instead of silently returning undefined. -// --------------------------------------------------------------------------- - +// The recorded `requests` array tracks `{ method, input }` for every call — both typed `v1.*` +// calls and raw-execute calls (keyed by the operation's `id`, e.g. `"v2GetProjectConfig"`), so a +// single array captures a whole mocked call sequence regardless of which surface issued it. +// Methods not present in `v1Stubs`/`raw` die at call time so missing wiring is loud and clear. type V1Stubs = Partial<{ readonly [K in keyof ApiClient["v1"]]: ( input: Parameters<ApiClient["v1"][K]>[0], @@ -672,12 +634,10 @@ export function mockCommandPlatformApiService( }, }); - // No typed v2 operation has stub support here: the CLI's only v2 - // call (the effective-project-config read) deliberately bypasses the typed - // `v2.*` surface for `executeRaw` (ADR 0019 rule 2 — the generated client's - // strict schema would reject the exact forward-compatible shapes the read - // needs to tolerate), so any typed `v2.*` call from command code is a wiring - // bug. + // No typed v2 operation has stub support here: the CLI's only v2 call (the + // effective-project-config read) bypasses the typed `v2.*` surface for `executeRaw` (ADR 0019 + // rule 2 — the generated client's strict schema would reject the forward-compatible shapes the + // read needs to tolerate), so any typed `v2.*` call from command code is a wiring bug. const v2Proxy = new Proxy({} as ApiClient["v2"], { get(_target, prop: string) { return () => Effect.die(`Unmocked CommandPlatformApi.v2.${prop}`); @@ -735,12 +695,8 @@ export function sequentialExecBatch( ).pipe(Effect.asVoid); } -// --------------------------------------------------------------------------- -// Temp workdir lifecycle — calls vitest beforeEach/afterEach internally, so -// the helper must be invoked at module scope (or inside the surrounding -// `describe`). Accessing `.current` outside a test throws. -// --------------------------------------------------------------------------- - +// Calls vitest beforeEach/afterEach internally, so the helper must be invoked at module scope +// (or inside the surrounding `describe`). Accessing `.current` outside a test throws. export function useTempWorkdir(prefix = "supabase-test-"): { readonly current: string; } { @@ -812,7 +768,7 @@ export function useShadowCacheDisabled(): void { } /** - * Ambient isolation for tests that construct the REAL `commandSettingsLayer` / + * Ambient isolation for tests that construct the real `commandSettingsLayer` / * `commandCredentialsLayer` (directly or inside a command runtime layer) against * a real filesystem. Those layers read `<homeDir>/.supabase/profile` and * `<homeDir>/.supabase/access-token`, resolving `SUPABASE_HOME` / @@ -831,16 +787,11 @@ export function isolatedHomeLayer( return Layer.mergeAll(mockRuntimeInfo({ homeDir }), processEnvLayer(env)); } -// --------------------------------------------------------------------------- -// Failing filesystem — wraps the real Bun `FileSystem` and fails a chosen -// `writeFileString` with a `PlatformError`, so cleanup-on-failure paths -// (e.g. the pg-delta multi-file migration writer) can be exercised -// deterministically. Every other call delegates to the real filesystem, so -// config reads / earlier writes behave normally. Merge this AFTER -// `BunServices.layer` (last-wins) so it overrides only `FileSystem`; `Path` -// still comes from `BunServices`. -// --------------------------------------------------------------------------- - +// Wraps the real Bun `FileSystem` and fails a chosen `writeFileString` with a `PlatformError`, +// so cleanup-on-failure paths (e.g. the pg-delta multi-file migration writer) can be exercised +// deterministically. Every other call delegates to the real filesystem, so config reads/earlier +// writes behave normally. Merge this after `BunServices.layer` (last-wins) so it overrides only +// `FileSystem`; `Path` still comes from `BunServices`. export function failWriteStringOnNthCallFsLayer( failOnCall: number, ): Layer.Layer<FileSystem.FileSystem> { @@ -889,63 +840,53 @@ function failWriteStringFsLayer( ).pipe(Layer.provide(BunServices.layer)); } -// --------------------------------------------------------------------------- -// Shadow-database container-CLI spawner — shared by `db diff`/`db pull`'s native -// shadow-provisioning integration tests (CLI-1956). Hoisted here (it was a verbatim -// ~55-line duplicate in both `diff.integration.test.ts` and `pull.integration.test.ts`) -// per `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" rule. -// --------------------------------------------------------------------------- +// Shadow-database container-CLI spawner, shared by `db diff`/`db pull`'s native +// shadow-provisioning integration tests. /** The shadow container's fake id — used both as `docker create`'s stdout and the `dbHost` `.slice(0, 12)` derives from. */ export const FAKE_SHADOW_CONTAINER_ID = "abc123456789shadow0".padEnd(64, "0").slice(0, 64); -/** Go's `container.HealthConfig`-shaped inspect JSON for a healthy container. */ +/** Inspect JSON for a healthy container. */ const SHADOW_HEALTHY_STATE = '{"Running":true,"Status":"running","Health":{"Status":"healthy"}}'; /** - * A real (Docker-valid) "still starting" state — NOT `Effect.never` — so + * A real (Docker-valid) "still starting" state, not `Effect.never`, so * {@link waitForHealthyServices}'s retry loop genuinely retries on its real 1-second - * `Schedule.spaced` backoff instead of hanging on a single probe forever. Mirrors - * `start.integration.test.ts`'s own "never healthy" containers (same rationale: a fiber - * interrupted mid-retry must be observed actually suspended inside the retry loop, not merely - * past the initial `create` call). + * `Schedule.spaced` backoff instead of hanging on a single probe forever — a fiber interrupted + * mid-retry must be observed actually suspended inside the retry loop, not merely past the + * initial `create` call. */ const SHADOW_STARTING_STATE = '{"Running":true,"Status":"running","Health":{"Status":"starting"}}'; /** - * Fakes every `docker`/`podman` subprocess call the native shadow-provisioning path issues - * (`buildLocalDbContainerInputs`'s image-cache check, `createShadowDatabase`'s - * network-create + container create/start, `waitForHealthyServices`'s container - * inspect, and `removeShadowDatabase`'s cleanup) — scoped-down port of - * `start.integration.test.ts`'s own `mockContainerCliSpawner`, since both callers only ever - * create one (shadow) container, never named. + * Fakes every `docker`/`podman` subprocess call the native shadow-provisioning path issues: + * `buildLocalDbContainerInputs`'s image-cache check, `createShadowDatabase`'s network-create + + * container create/start, `waitForHealthyServices`'s container inspect, and + * `removeShadowDatabase`'s cleanup. Both callers only ever create one (shadow) container, never + * named. * - * `neverHealthy` (default `false`) makes every `container inspect` report `"starting"` instead - * of `"healthy"` — for the interrupt-during-health-wait regression coverage (review: - * PRRT_kwDOErm0O86XMrID): with the default healthy-immediately response, a forked fiber can run - * the ENTIRE shadow-provisioning sequence to completion synchronously before a test's own - * polling loop is even scheduled, making `Fiber.interrupt` a no-op on an already-finished fiber. + * `neverHealthy` (default `false`) makes every `container inspect` report `"starting"` instead of + * `"healthy"`, for interrupt-during-health-wait coverage: with the default healthy-immediately + * response, a forked fiber can run the entire shadow-provisioning sequence to completion + * synchronously before a test's own polling loop is even scheduled, making `Fiber.interrupt` a + * no-op on an already-finished fiber. * * `failCreate`/`failRemove` (both default `false`) make `docker create`/`docker rm` exit - * non-zero instead — hoisted from `migration squash`'s own scoped-down copy of this mock - * (CLI-1969 review), which needed these two extra failure knobs `db diff`/`db pull`'s own - * scenarios never exercised. Defaulting both to `false` keeps every existing caller - * (`pull.integration.test.ts`, `declarative.orchestrate.integration.test.ts`, - * `diff.integration.test.ts`) byte-identical. + * non-zero instead. * - * `dbNotRunning`/`dbInspectFailsWith` (CLI-1968) fake the SEPARATE `docker container inspect - * supabase_db_<projectId>` probe `isLocalDbRunning` issues before `--use-pgadmin` - * provisions anything — distinguished from the shadow's own `container inspect <64-hex-id>` - * health probe by the target id's `supabase_db_` prefix, so both options leave the shadow's - * own health check on its normal (healthy/never-healthy) path. `dbNotRunning` reports the - * Go/Docker "container doesn't exist" shape (`isContainerNotFoundMessage`); mutually - * exclusive with `dbInspectFailsWith`, which instead reports a daemon-unreachable failure - * (`isDockerDaemonUnreachable`) with the given stderr text — enforced below (a test - * that sets both throws immediately, rather than one option silently winning). + * `dbNotRunning`/`dbInspectFailsWith` fake the separate `docker container inspect + * supabase_db_<projectId>` probe `isLocalDbRunning` issues before `--use-pgadmin` provisions + * anything — distinguished from the shadow's own `container inspect <64-hex-id>` health probe by + * the target id's `supabase_db_` prefix, so both options leave the shadow's own health check on + * its normal (healthy/never-healthy) path. `dbNotRunning` reports Docker's "container doesn't + * exist" shape (`isContainerNotFoundMessage`); mutually exclusive with `dbInspectFailsWith`, + * which instead reports a daemon-unreachable failure (`isDockerDaemonUnreachable`) with the given + * stderr text — enforced below (a test that sets both throws immediately, rather than one option + * silently winning). * - * `dbInspectImage` makes the same `supabase_db_`-prefixed inspect report a `Config.Image` - * value instead — for `ensureLocalPostgresImageCurrent`'s stale-image guard, which reads - * that field from the same call `isLocalDbRunning` only checks the exit code of. + * `dbInspectImage` makes the same `supabase_db_`-prefixed inspect report a `Config.Image` value + * instead, for `ensureLocalPostgresImageCurrent`'s stale-image guard, which reads that field from + * the same call `isLocalDbRunning` only checks the exit code of. */ export function mockShadowContainerCliSpawner( opts: { @@ -1071,13 +1012,10 @@ export function mockShadowContainerCliSpawner( return { layer, spawned }; } -// --------------------------------------------------------------------------- -// A minimal, stateful Docker model — the shadow BASELINE CACHE's round trip -// (`docker stop` -> `docker cp - <id>:PGDATA` (the baseline stamp) -> -// `docker cp <id>:PGDATA -` -> `docker start`, and the warm -// `docker cp - <id>:<pgdata parent>` restore) really moves bytes, which -// `mockShadowContainerCliSpawner` above deliberately does not model. -// --------------------------------------------------------------------------- +// A minimal, stateful Docker model — the shadow baseline cache's round trip (`docker stop` -> +// `docker cp - <id>:PGDATA` (the baseline stamp) -> `docker cp <id>:PGDATA -` -> `docker start`, +// and the warm `docker cp - <id>:<pgdata parent>` restore) really moves bytes, which +// `mockShadowContainerCliSpawner` above does not model. /** * A real (if tiny) POSIX tar, byte for byte — `validatePgDataArchive` @@ -1124,7 +1062,7 @@ const fakeTarEntry = (name: string, content: string, typeFlag: "0" | "5"): strin const FAKE_TAR_END = "\0".repeat(1024); /** - * What the fake `docker cp <id>:PGDATA -` emits for a container the export path has NOT stamped — + * What the fake `docker cp <id>:PGDATA -` emits for a container the export path has not stamped — * a real cluster (`data/` + `data/PG_VERSION`) and nothing more. Stands in both for a hand-placed * bare PGDATA archive and for a snapshot taken before the platform baseline ran: it restores, * starts, and answers, so only the missing marker tells it apart from a usable baseline. @@ -1136,7 +1074,7 @@ export const FAKE_UNSTAMPED_PGDATA_TAR = `${fakeTarEntry("data/", "", "5")}${fak * container — stands in for a real ~90MB PGDATA tar, with the same top-level `data/` member, * `data/PG_VERSION` cluster file, and `data/SUPABASE_BASELINE` marker a real export carries. * - * `markerContent` is whatever the export ACTUALLY stamped, carried through rather than fixed: the + * `markerContent` is whatever the export actually stamped, carried through rather than fixed: the * marker binds a snapshot to its cache key, so a fake that always emitted the same marker would * make every key-binding assertion pass vacuously — a production regression stamping the wrong key * (or a fixed one) has to show up as a warm-path mismatch here too. @@ -1166,7 +1104,7 @@ const fakeStampedMarkerContent = (stamp: string): string | undefined => { }; /** - * A syntactically VALID tar carrying no members at all — what a replaced or truncated cache + * A syntactically valid tar carrying no members at all — what a replaced or truncated cache * artifact looks like to `docker cp -`, which extracts it happily and lets the entrypoint `initdb` * a fresh cluster over the top. The pre-restore marker check is what catches it. */ @@ -1285,7 +1223,7 @@ export function mockDockerDaemonCliSpawner( else { container.running = false; // Docker destroys an `--rm` container the moment it exits, `docker stop` included — - // verified against Docker 29. This is exactly why the cold export path drops `--rm`. + // which is why the cold export path drops `--rm`. if (container.autoRemove) containers.delete(id); } } else if (args[0] === "rm") { @@ -1358,7 +1296,7 @@ export function mockDockerDaemonCliSpawner( ); /** - * One readable label per Docker call, so a test can assert the SEQUENCE of meaningful steps + * One readable label per Docker call, so a test can assert the sequence of meaningful steps * rather than raw argv. `cp` is split four ways because the shadow issues four different copies: * the pgsodium root key every shadow gets (`cp-secret`, `container-lifecycle.ts`), the baseline * marker stamped into PGDATA just before an export (`cp-stamp`), the baseline export (`cp-out`), @@ -1396,20 +1334,12 @@ export function mockDockerDaemonCliSpawner( }; } -// --------------------------------------------------------------------------- -// Runtime composition — bundles the entire Layer.mergeAll(...) graph that -// every native-port integration test re-builds, including the easy-to-mis-wire -// `projectRefLayer.pipe(Layer.provide(...))` subgraph -// (CLAUDE.md invariant 5: "Layer.provide does not share to siblings inside -// Layer.mergeAll" — centralising the subgraph here removes a recurring footgun). -// --------------------------------------------------------------------------- - +// Bundles the entire Layer.mergeAll(...) graph that every native-port integration test +// re-builds, including the easy-to-mis-wire `projectRefLayer.pipe(Layer.provide(...))` subgraph — +// `Layer.provide` does not share to siblings inside `Layer.mergeAll`, so centralizing the +// subgraph here removes a recurring footgun. type GoOutputValue = "env" | "pretty" | "json" | "toml" | "yaml" | "table" | "csv"; -// --------------------------------------------------------------------------- -// Analytics mock lives in `./mocks.ts` (`mockAnalytics`); this module no longer -// ships its own copy. Use `mockAnalytics()` from the shared mocks module directly. - export interface BuildTestRuntimeOpts { readonly out: { readonly layer: Layer.Layer<Output> }; // `Layer.Layer<CommandPlatformApi, SupabaseApiConfigError>` from diff --git a/apps/cli/tests/helpers/config-fixtures.ts b/apps/cli/tests/helpers/config-fixtures.ts index 2e0e928d9a..74a962a86d 100644 --- a/apps/cli/tests/helpers/config-fixtures.ts +++ b/apps/cli/tests/helpers/config-fixtures.ts @@ -1,11 +1,9 @@ import { VALID_REF } from "./command-mocks.ts"; /** - * Schema-valid `GET /v2/projects/{ref}/config` response body whose managed - * values all sit at the local schema defaults, so an empty `config.toml` - * diffs clean against it. Shared by `config diff` and `config push`'s - * integration suites — hoisted here (verbatim, CLI-2313 shard 5a) rather than - * duplicated, per `apps/cli/CLAUDE.md`'s "Hoist Before You Duplicate" rule. + * Schema-valid `GET /v2/projects/{ref}/config` response body whose managed values all sit at + * the local schema defaults, so an empty `config.toml` diffs clean against it. Shared by + * `config diff` and `config push`'s integration suites. */ export function v2ProjectConfigResponse( opts: { @@ -37,11 +35,10 @@ export function v2ProjectConfigResponse( default_pool_size: 20, max_client_conn: 100, }, - // A realistic fresh-project GoTrue record at platform defaults — the - // largest, most transform-heavy mapping surface (durations, inversions, - // unconfigured sentinels, provisioning-default subjects) must run end to - // end and classify CLEANLY against an empty config.toml. An `auth: {}` - // here previously let two classifier blockers through untested. + // A realistic fresh-project GoTrue record at platform defaults — the largest, most + // transform-heavy mapping surface (durations, inversions, unconfigured sentinels, + // provisioning-default subjects) must run end to end and classify cleanly against an empty + // config.toml. auth: { site_url: "http://127.0.0.1:3000", uri_allow_list: "https://127.0.0.1:3000", @@ -59,8 +56,8 @@ export function v2ProjectConfigResponse( rate_limit_verify: 30, rate_limit_sms_sent: 30, rate_limit_web3: 30, - // GoTrue reports 0 hours for unconfigured session bounds; the mapping - // canonicalizes them to the STRING "0s" (registry unconfiguredValue). + // GoTrue reports 0 hours for unconfigured session bounds; the mapping canonicalizes them + // to the string "0s" (registry unconfiguredValue). sessions_timebox: 0, sessions_inactivity_timeout: 0, external_email_enabled: true, diff --git a/apps/cli/tests/helpers/docker-image.ts b/apps/cli/tests/helpers/docker-image.ts index e52158da18..da49b91dee 100644 --- a/apps/cli/tests/helpers/docker-image.ts +++ b/apps/cli/tests/helpers/docker-image.ts @@ -8,31 +8,27 @@ import { makeDockerImageResolver } from "../../src/command-internal/docker-image type Spawner = ChildProcessSpawnerTag["Service"]; -// Overall per-image ceiling. Deliberately BELOW the tightest e2e test budget -// (120s): a stalled registry must leave the caller room to run its test body. +// Overall per-image ceiling, kept below the tightest e2e test budget (120s): a stalled registry +// must leave the caller room to run its test body. export const RESOLVE_BUDGET_MS = 90_000; const resolvedImages = new Map<string, Promise<string>>(); /** - * Serializes distinct-image resolution. The helper this replaced spawned - * synchronously, so a `Promise.all` over several images was serialized whether - * the caller meant it or not — and `serve-main-offline.e2e.test.ts` resolves - * two images exactly that way. Letting them run concurrently would put two cold - * pulls against the same registry at the same instant, which is the - * `toomanyrequests` failure this helper exists to avoid, so the old ordering is - * preserved deliberately rather than by accident. + * Serializes distinct-image resolution — `serve-main-offline.e2e.test.ts` resolves two images and + * relies on that ordering. Running them concurrently would put two cold pulls against the same + * registry at the same instant, which is the `toomanyrequests` failure this helper exists to + * avoid. */ let resolveQueue: Promise<unknown> = Promise.resolve(); /** - * Resolves an image for a raw e2e `docker run`/`docker pull` by running the - * PRODUCTION resolver (`makeDockerImageResolver`) against a real - * subprocess spawner — same candidate order, same local-cache-first check, same - * retry ladder the CLI itself uses, so this can never drift from it. A raw - * `docker run` of an uncached image implicit-pulls from a single registry, - * where CI regularly fails with `toomanyrequests: Rate exceeded`; resolving - * first lets the ECR → GHCR → Docker Hub fallback do its job. + * Resolves an image for a raw e2e `docker run`/`docker pull` by running the production resolver + * (`makeDockerImageResolver`) against a real subprocess spawner — same candidate order, same + * local-cache-first check, same retry ladder the CLI itself uses, so this can never drift from it. + * A raw `docker run` of an uncached image implicit-pulls from a single registry, where CI + * regularly fails with `toomanyrequests: Rate exceeded`; resolving first lets the ECR → GHCR → + * Docker Hub fallback do its job. * * Returns the resolved reference the caller must use in its own docker argv. * Results (including failures) are memoized per process so parallel/subsequent @@ -57,10 +53,10 @@ export function ensureImage( const resolving = resolveQueue.then(() => Effect.gen(function* () { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; - // A defaulted deadline is stamped when this image's turn STARTS, not when - // the caller enqueued it — otherwise time spent waiting behind another - // image's resolution would silently shrink this one's window. An explicit - // deadline is shared budget by contract and is left exactly as given. + // A defaulted deadline is stamped when this image's turn starts, not when the caller + // enqueued it — otherwise time spent waiting behind another image's resolution would + // silently shrink this one's window. An explicit deadline is shared budget by contract and + // is left as given. return yield* resolveImage(spawner, image, deadline ?? resolveDeadline()); }).pipe(Effect.provide(services), Effect.runPromise), ); @@ -84,13 +80,12 @@ export function resolveDeadline(budgetMs = RESOLVE_BUDGET_MS): number { } /** - * The production resolver spawns through `spawnContainerCli`, which falls back - * to podman when docker is absent — but every caller then runs raw `docker` - * argv, so an image pulled by podman would be invisible to the command under - * test. Assert the docker CLI itself is present, turning that into one clear - * failure instead of a confusing "docker: command not found" several steps on. - * Deliberately a client-only probe: an unreachable daemon is a different - * condition that the resolver already reports with its own message. + * The production resolver spawns through `spawnContainerCli`, which falls back to podman when + * docker is absent — but every caller then runs raw `docker` argv, so an image pulled by podman + * would be invisible to the command under test. Asserts the docker CLI itself is present, turning + * that into one clear failure instead of a confusing "docker: command not found" several steps + * on. A client-only probe: an unreachable daemon is a different condition that the resolver + * already reports with its own message. */ function requireDocker(spawner: Spawner): Effect.Effect<void, Error> { return spawner.exitCode(ChildProcess.make("docker", ["--version"])).pipe( @@ -114,13 +109,12 @@ export function resolveImage( ): Effect.Effect<string, Error> { const remainingMs = Math.max(1, deadline - Date.now()); return requireDocker(spawner).pipe( - // The deadline goes INTO the resolver, which divides it across the - // registry candidates — a stalled registry cannot starve the ECR → GHCR → - // Docker Hub fallbacks behind it, and an exhausted share is reported - // against the candidate that spent it. The outer timeout is only a - // backstop for the paths the resolver does not bound (a wedged daemon - // hanging `docker image inspect`); its 1s grace keeps the resolver's own - // richer per-candidate error winning every race it can. + // The deadline goes into the resolver, which divides it across the registry candidates — a + // stalled registry cannot starve the ECR → GHCR → Docker Hub fallbacks behind it, and an + // exhausted share is reported against the candidate that spent it. The outer timeout is only + // a backstop for paths the resolver does not bound (a wedged daemon hanging `docker image + // inspect`); its 1s grace keeps the resolver's own richer per-candidate error winning every + // race it can. Effect.andThen(() => makeDockerImageResolver(spawner)(image, deadline)), Effect.timeout(Duration.millis(remainingMs + 1_000)), Effect.mapError((cause) => { diff --git a/apps/cli/tests/helpers/docker-image.unit.test.ts b/apps/cli/tests/helpers/docker-image.unit.test.ts index ca6cb8b35a..e5c1373497 100644 --- a/apps/cli/tests/helpers/docker-image.unit.test.ts +++ b/apps/cli/tests/helpers/docker-image.unit.test.ts @@ -106,8 +106,8 @@ describe("resolveImage", () => { }); it.live("fails when the docker CLI is present but exits non-zero", () => { - // Regression guard: `spawner.exitCode` SUCCEEDS with the code, so a probe - // that only maps spawn errors would wave a broken docker through. + // `spawner.exitCode` succeeds with the code, so a probe that only maps spawn errors would + // wave a broken docker through. const mock = mockSpawner((args) => args[0] === "--version" ? { exitCode: 1 } : { exitCode: 0 }, ); @@ -140,8 +140,6 @@ describe("resolveImage", () => { Effect.map((error) => { expect(error.message).toContain(`failed to resolve ${IMAGE}`); expect(error.message).toContain("Cannot connect to the Docker daemon"); - // The registry-pin hint must NOT appear here: no registry pin can fix - // an unreachable daemon, and suggesting one misdirects CI triage. expect(error.message).not.toContain("SUPABASE_INTERNAL_IMAGE_REGISTRY"); expect(mock.spawned.some((args) => args[0] === "pull")).toBe(false); }), @@ -149,8 +147,6 @@ describe("resolveImage", () => { }); it.live("moves to the next registry when a pull attempt outlives its share", () => { - // The point of handing the deadline INTO the resolver: one wedged - // candidate must not consume the budget the fallbacks behind it need. const pulled: Array<string> = []; const mock = mockSpawner((args) => { if (args[0] === "--version") return { exitCode: 0 }; diff --git a/apps/cli/tests/helpers/local-reset.ts b/apps/cli/tests/helpers/local-reset.ts index 7459d935cc..22cce083fc 100644 --- a/apps/cli/tests/helpers/local-reset.ts +++ b/apps/cli/tests/helpers/local-reset.ts @@ -5,14 +5,9 @@ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; /** * A minimal `docker`/`podman` CLI spawner mock + default happy-path route for - * `resetLocalDatabase`'s real, native container-recreate flow — used - * wherever a test now drives a REAL in-process local reset instead of a - * subprocess/seam stub (CLI-2062: `db schema declarative`'s smart-target/sync - * recovery reset). Mirrors `commands/db/reset/reset.integration.test.ts`'s own - * `mockContainerCliSpawner`/`defaultLocalResetRoute` (that file predates this - * hoist and keeps its own copy, adapted for its container-REMOVE-then-recreate - * assertions) — same shape here, hoisted for the two `db schema declarative` - * callers so they don't each duplicate it again. + * `resetLocalDatabase`'s real, native container-recreate flow — used wherever a test drives an + * in-process local reset instead of a subprocess/seam stub. Shared by the two `db schema + * declarative` callers (smart-target/sync recovery reset) so they don't each duplicate it. */ export interface SpawnRecord { diff --git a/apps/cli/tests/helpers/macos-signature.ts b/apps/cli/tests/helpers/macos-signature.ts index c306f4d512..8663ee076f 100644 --- a/apps/cli/tests/helpers/macos-signature.ts +++ b/apps/cli/tests/helpers/macos-signature.ts @@ -10,13 +10,12 @@ export type SignatureCheckResult = { }; /** - * Verify a macOS binary carries a valid signature. Runs on the macOS smoke-test - * runners via `codesign` / `spctl`. + * Verify a macOS binary carries a valid signature. Runs on the macOS smoke-test runners via + * `codesign`/`spctl`. * - * Phase 1 expects a full ad-hoc signature with our identifier and no - * `linker-signed` flag — the shape that fixes the macOS 26+ launch SIGKILL - * (CLI-1621 / GitHub #5556). When a Developer ID signature is present (Phase 2), - * it additionally checks the hardened-runtime flag and that Gatekeeper accepts a + * Phase 1 expects a full ad-hoc signature with our identifier and no `linker-signed` flag — the + * shape that avoids the macOS 26+ launch SIGKILL. When a Developer ID signature is present + * (Phase 2), it additionally checks the hardened-runtime flag and that Gatekeeper accepts a * quarantined copy, which validates the online notarization ticket. */ export async function verifyMacSignature(binPath: string): Promise<SignatureCheckResult> { From 04b5715501ba4d20099982259c9076dd44254c78 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:13:07 +0100 Subject: [PATCH 48/56] chore(repo): trim comments, shard 46 (776 -> 361 comment lines) --- .../config/src/project-config/registry.ts | 261 +++++------------- packages/config/src/project.ts | 64 ++--- packages/config/src/project.unit.test.ts | 24 -- .../src/promise-facade.stdin.unit.test.ts | 32 +-- packages/config/src/promise-facade.ts | 38 +-- .../config/src/promise-facade.unit.test.ts | 40 +-- packages/config/src/sparse.ts | 139 ++-------- packages/config/src/sparse.unit.test.ts | 39 +-- packages/config/src/storage.ts | 13 +- packages/config/src/workers.ts | 27 +- packages/config/src/workers.unit.test.ts | 19 -- packages/config/vitest.config.ts | 12 +- packages/stack/src/control/ControlServer.ts | 42 ++- .../stack/src/entrypoints/supervisor-node.ts | 7 +- .../src/functions/serve-main-resolver.ts | 12 +- packages/stack/src/gateway/HttpGateway.ts | 4 +- packages/stack/src/model/Compiler.ts | 7 +- packages/stack/src/model/DatabaseBootstrap.ts | 5 +- packages/stack/src/model/WorkloadCatalog.ts | 5 +- .../model/capabilities/auth-third-party.ts | 8 +- .../stack/src/preparation/ArtifactStore.ts | 19 +- .../stack/src/preparation/RuntimeArtifacts.ts | 2 +- .../src/preparation/SlimServicesSource.ts | 4 +- 23 files changed, 202 insertions(+), 621 deletions(-) diff --git a/packages/config/src/project-config/registry.ts b/packages/config/src/project-config/registry.ts index 8b9f2e462f..98b34dd077 100644 --- a/packages/config/src/project-config/registry.ts +++ b/packages/config/src/project-config/registry.ts @@ -17,39 +17,18 @@ import { } from "./registry-row.ts"; /** - * The non-auth half of the API↔`CliConfig` mapping table (CLI-2230). Rows are - * mined from the legacy push-direction sync mappers - * (`apps/cli/src/commands/config/push/config-sync/*.sync.ts`), which - * already encode which API fields correspond to which config fields for - * `config push`'s diff/apply flow — this registry repurposes that same - * correspondence for the pull direction. Every `configPath` below was - * verified against the live config schema (`../api.ts`, `../db.ts`, - * `../storage.ts`) before being written; see the per-section comments for - * fields that exist on the API side but have no config-side counterpart - * (deliberately unmapped, not an oversight). + * The non-auth half of the API↔`CliConfig` mapping table. See the per-section comments for + * fields that exist on the API side but have no config-side counterpart. */ -// === api ===================================================================== -// Sync precedent: config-sync/api.sync.ts:84-96 (`applyRemoteApiConfig`), -// :130-145 (`apiToUpdateBody`). - const apiDbSchemaPath = ["api", "db_schema"]; const apiExtraSearchPathPath = ["api", "db_extra_search_path"]; const apiMaxRowsPath = ["api", "max_rows"]; /** - * Whether the remote explicitly reports the Data API as disabled: - * api.sync.ts:84-87 (`applyRemoteApiConfig`) treats an empty remote - * `db_schema` as "Data API disabled" and early-returns without applying - * anything else from the section. The schemas/extra_search_path/max_rows rows - * below gate on this so a disabled remote maps to exactly - * `{ api: { enabled: false } }` — the other fields' remote values are - * meaningless while the service is off, and reporting them (e.g. - * `schemas: []`) would fabricate drift the legacy apply never saw. Only the - * explicit `""` sentinel disables: an *absent* `db_schema` means the (sparse) - * input didn't speak about it, so the sibling fields still map. (The legacy - * apply conflated missing with `""` via `valOrDefault`, but it only ever saw - * complete v1 responses, where the distinction cannot arise.) + * True when the remote reports the Data API disabled: an explicit `""` `db_schema`, not an + * absent one (which means the sparse input simply didn't mention it). The rows below gate on + * this so a disabled remote maps to exactly `{ api: { enabled: false } }`. */ function remoteDataApiDisabled(attributes: Record<string, unknown>): boolean { const api = attributes["api"]; @@ -57,39 +36,20 @@ function remoteDataApiDisabled(attributes: Record<string, unknown>): boolean { } /** - * DOCUMENT-side counterpart of `clampToUint` (same convergence rule as the - * auth `uintRow`s): the config schema accepts a negative number and the push - * mappers send it unchanged, but every pull-direction transform below clamps - * what the API reports — so a pushed negative projects back as `0`, and the - * document spelling must converge on that reading. Non-numbers stay verbatim. + * Document-side counterpart of `clampToUint`: the config schema accepts a negative number, + * but every pull-direction transform clamps what the API reports, so the document spelling + * must converge on the same reading. Non-numbers pass through unchanged. */ function clampDocumentUint(value: unknown): unknown { return typeof value === "number" ? clampToUint(value) : value; } /** - * DOCUMENT-side counterpart for `api.max_rows` specifically — NOT - * `clampDocumentUint` (human review round on PR #6339, thread 2): the push - * mapper only sends `max_rows` when it is strictly positive - * (`apiToUpdateBody`, api.sync.ts:141, `if (local.max_rows > 0)`), so a - * non-positive document value (`0` included, not just negative) is - * unmanaged — push never communicates it, and projecting it (even clamped to - * `0`) would assert a value that survives push as drift. Omit rather than - * clamp; a positive value stays verbatim (this row's `configPath` is the - * same one `clampToUint`/`expectInteger` narrow on the API arm, so a - * document-side fractional value is already an edge case neither arm - * canonicalizes further here). CLI-2266 item 3 tracks flipping this back to - * a plain clamp once push starts sending `max_rows` explicitly regardless of - * sign (e.g. as an explicit "unset" value). - * - * `!(value > 0)`, not `value <= 0` (engineer review round on PR #6339): the - * two are NOT equivalent for `NaN` — `NaN <= 0` is `false` (NaN keeps, push - * omits) while `!(NaN > 0)` is `true` (both omit) — and TOML can genuinely - * produce a NaN document value (`max_rows = nan`, which `smol-toml` parses - * to `Number.NaN`). An unfiltered NaN would ride into `ProjectConfig` and - * poison every downstream comparison (`NaN !== NaN`, permanent phantom - * drift). `!(value > 0)` is the exact negation of push's own `value > 0` - * gate, so it agrees with push on every float, NaN included. + * Omits (rather than clamps) `api.max_rows` when the document value is non-positive + * (including `0` and `NaN`): push only ever sends `max_rows` when it is strictly positive, + * so any other value never round-trips and asserting it would be drift. Uses `!(value > 0)` + * rather than `value <= 0` specifically because `NaN <= 0` is `false` — a TOML `nan` value + * would otherwise ride through and poison every downstream comparison. */ function normalizeDocumentMaxRows(value: unknown): unknown { return typeof value === "number" && !(value > 0) ? undefined : value; @@ -99,20 +59,15 @@ const apiSectionRows: ReadonlyArray<ProjectConfigMappingRow> = [ { configPath: ["api", "schemas"], apiPath: apiDbSchemaPath, - // Validation runs BEFORE the disabled gate: a malformed value alongside - // the disabled sentinel must still throw, not vanish behind the gate - // while its (consumed) path also disappears from unmappedApiFields. + // Validated before the disabled gate: a malformed value must still throw, even when the + // disabled sentinel would otherwise suppress this field. transform: (value, attributes) => { const schemas = splitCommaSeparated(expectString(value, apiDbSchemaPath)); return remoteDataApiDisabled(attributes) ? undefined : schemas; }, - // Beyond the comma round-trip: an explicitly EMPTY schemas array is - // unmanaged absence — the push only sends db_schema when the array is - // non-empty (api.sync.ts:137-139, with "" reserved for the disable - // path), and the pull side reads "" as the disabled sentinel, so the - // API arm can never project `[]`; keeping it would fabricate permanent - // drift. (extra_search_path differs: its push join is unconditional, - // so its empty array round-trips and stays.) + // An explicitly empty schemas array is unmanaged absence: push never sends `db_schema` + // for an empty array (`""` is reserved for the disabled sentinel), so the API side can + // never project `[]`. Unlike `extra_search_path`, whose empty array does round-trip. normalizeDocument: (value) => { const canonical = canonicalizeCommaJoinedArray(value); return Array.isArray(canonical) && canonical.length === 0 ? undefined : canonical; @@ -120,10 +75,8 @@ const apiSectionRows: ReadonlyArray<ProjectConfigMappingRow> = [ unit: "csv → string[]", }, { - // Derived, not a distinct API field: api.sync.ts:85 treats an empty - // remote `db_schema` as "Data API disabled" (`applyRemoteApiConfig`). - // Shares `apiDbSchemaPath` with the row above — multiple rows may read - // the same `apiPath` (registry-row.ts's docstring). + // Derived from the same `db_schema` field as the row above; multiple rows may share an + // `apiPath` (see registry-row.ts). configPath: ["api", "enabled"], apiPath: apiDbSchemaPath, transform: (value) => expectString(value, apiDbSchemaPath).length > 0, @@ -147,16 +100,10 @@ const apiSectionRows: ReadonlyArray<ProjectConfigMappingRow> = [ }, normalizeDocument: normalizeDocumentMaxRows, }, - // Deliberately unmapped (no config counterpart): api.db_pool, - // api.db_pool_acquisition_timeout. + // Unmapped (no config counterpart): api.db_pool, api.db_pool_acquisition_timeout. ]; -// === db ====================================================================== - -/** - * Settings fields whose remote value is a signed int clamped to uint - * (db.sync.ts:18 `SETTINGS_UINT_KEYS`, applied at :77-79). - */ +/** Settings fields whose remote value is a signed int, clamped to uint. */ const DB_SETTINGS_UINT_KEYS: ReadonlyArray<string> = [ "max_connections", "max_locks_per_transaction", @@ -169,9 +116,8 @@ const DB_SETTINGS_UINT_KEYS: ReadonlyArray<string> = [ ]; /** - * The remaining string-passthrough `db.settings` keys, verified against - * `../db.ts:40-67`. `session_replication_role` is excluded — see - * {@link sessionReplicationRoleRow}, below. + * The remaining string-passthrough `db.settings` keys. `session_replication_role` is + * excluded — see {@link sessionReplicationRoleRow}, below. */ const DB_SETTINGS_STRING_KEYS: ReadonlyArray<string> = [ "effective_cache_size", @@ -192,15 +138,10 @@ const DB_SETTINGS_STRING_KEYS: ReadonlyArray<string> = [ const sessionReplicationRolePath = ["database", "postgres_settings", "session_replication_role"]; /** - * `session_replication_role` is a closed enum on the config side - * (`"origin" | "replica" | "local"`, `../db.ts:55-60`), but the lenient API - * mirror (`./api-attributes.ts`) deliberately widens it to a plain string - * (ADR 0019 rule 2) so a new enum member the platform starts returning - * doesn't fail decode. Left in the generic `DB_SETTINGS_STRING_KEYS` loop, - * such a value would land in the typed output unguarded and be type-invalid - * against the config schema, so this row special-cases it out with the same - * enum guard as `poolerPoolModePath`, below: an unrecognized value omits the - * field rather than throwing — it stays reachable via `_apiResponse`. + * `session_replication_role` is a closed enum on the config side, but the lenient API + * mirror widens it to a plain string (see `docs/adr/0019-config-api-response-passthrough.md`) + * so a new enum value doesn't fail decode. This row guards it back to the enum, omitting an + * unrecognized value rather than throwing — it stays reachable via `_apiResponse`. */ const sessionReplicationRoleRow: ProjectConfigMappingRow = { configPath: ["db", "settings", "session_replication_role"], @@ -238,19 +179,13 @@ const dbSettingsRows: ReadonlyArray<ProjectConfigMappingRow> = [ const networkRestrictionsAllowedCidrsPath = ["database", "network_restrictions", "allowed_cidrs"]; /** - * v2 reports allowed CIDRs as one array with a `type` tag (`{address, type: - * "v4"|"v6"}[]`), where v1 (and this registry's config-side counterpart) - * split them into two pre-filtered arrays — `db.sync.ts:153-154` reads - * `remote.config.dbAllowedCidrs`/`dbAllowedCidrsV6` directly from a v1 - * response shaped that way already. Both `allowed_cidrs`/`allowed_cidrs_v6` - * rows below read this same `apiPath` and filter by `type` to reconstruct - * that split. + * v2 reports allowed CIDRs as one array with a `type` tag (`{address, type: "v4"|"v6"}[]`), + * where the config side splits them into two arrays; the `allowed_cidrs`/`allowed_cidrs_v6` + * rows below both read this `apiPath` and filter by `type` to reconstruct that split. * - * Throws rather than silently dropping a malformed entry: this field is a - * security allowlist, so a partially-filtered result (e.g. one malformed - * entry silently excluded from `allowed_cidrs`) would misreport "the remote - * removed your restrictions" — loud beats silent here, unlike the rest of - * this registry's lenient-toward-unknown-shapes default. + * Throws rather than silently dropping a malformed entry, since this is a security + * allowlist: a partially-filtered result would misreport "the remote removed your + * restrictions." */ function filterCidrAddresses( value: unknown, @@ -297,17 +232,14 @@ const poolerDefaultPoolSizePath = ["pooler", "default_pool_size"]; const poolerMaxClientConnPath = ["pooler", "max_client_conn"]; const dbSectionRows: ReadonlyArray<ProjectConfigMappingRow> = [ - // No sync precedent — exact name+type match against `../db.ts:88-94`. - // `null` has no counterpart row, so it's already omitted before this - // narrows; narrowing here guards a non-`null` non-number value. + // `null` already has no counterpart row and is omitted before this narrows. { configPath: ["db", "major_version"], apiPath: dbMajorVersionPath, transform: (value) => (value === null ? undefined : expectInteger(value, dbMajorVersionPath)), dualScope: true, }, - // v2 flattens what v1 nested under `currentConfig.database` - // (db.sync.ts:241 `applyRemoteSslEnforcement`). + // v2 flattens what v1 nested under `currentConfig.database`. { configPath: ["db", "ssl_enforcement", "enabled"], apiPath: ["database", "ssl_enforced"], @@ -329,21 +261,14 @@ const dbSectionRows: ReadonlyArray<ProjectConfigMappingRow> = [ arrayEquality: "sequence", unit: "type-tagged {address,type}[] → filtered string[] (v6)", }, - // Deliberately unmapped (no faithful counterpart): database. - // network_restrictions.{entitlement,status,updated_at,applied_at}. (There - // is no `network_restrictions.enabled` on the v2 contract at all — the - // config-side `db.network_restrictions.enabled` toggle, `../db.ts:167-172`, - // is a purely local management switch with no API-side counterpart to - // read from, not an unmapped API field.) - // - // Pooler — no sync precedent, name-matched to `../db.ts:95-126`. + // Unmapped (no faithful counterpart): database.network_restrictions.{entitlement,status, + // updated_at,applied_at}. `db.network_restrictions.enabled` has no API-side counterpart at + // all — it's a purely local management switch, not an unmapped API field. { configPath: ["db", "pooler", "pool_mode"], apiPath: poolerPoolModePath, - // The API also allows `"statement"` (`packages/api/src/generated/ - // contracts.ts:11056`); the config schema's `pool_mode` literal only - // accepts `"transaction"`/`"session"`, so that third value is omitted - // here — it stays reachable via `_apiResponse`. + // The API also allows `"statement"`; the config schema only accepts `"transaction"` or + // `"session"`, so that value is omitted here — it stays reachable via `_apiResponse`. transform: (value) => { const mode = expectString(value, poolerPoolModePath); return mode === "transaction" || mode === "session" ? mode : undefined; @@ -364,22 +289,15 @@ const dbSectionRows: ReadonlyArray<ProjectConfigMappingRow> = [ value === null ? undefined : expectInteger(value, poolerMaxClientConnPath), dualScope: true, }, - // Deliberately unmapped (no faithful counterpart): pooler. - // ignore_startup_parameters, server_idle_timeout, server_lifetime, - // query_wait_timeout, reserve_pool_size. + // Unmapped (no faithful counterpart): pooler.ignore_startup_parameters, + // server_idle_timeout, server_lifetime, query_wait_timeout, reserve_pool_size. ]; -// === storage ================================================================= -// Sync precedent: config-sync/storage.sync.ts:178-209 -// (`applyRemoteStorageConfig`), :282-306 (`storageToUpdateBody`). - const BINARY_ABBRS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"] as const; /** - * Port of Go's `fmt`-style `%.4g`: at most 4 significant digits, trailing - * zeros removed, no exponent for the magnitudes `bytesSize` below produces - * (scaled to `[0, 1024)`). Mirrors the CLI's `formatG4` - * (`apps/cli/src/command-internal/size-units.ts:109-119`). + * At most 4 significant digits with trailing zeros removed — printf's `%.4g` format. The + * magnitudes {@link bytesSize} produces (scaled to `[0, 1024)`) never need the exponent form. */ function formatSignificantDigits(value: number): string { if (value === 0) { @@ -396,24 +314,11 @@ function formatSignificantDigits(value: number): string { } /** - * Formats a byte count as a `"<n><unit>"` string — `docker/go-units`' - * `BytesSize`, ported at `apps/cli/src/command-internal/ - * size-units.ts:127-136` and used by the CLI's remote-apply - * (`storage.sync.ts:214,223` via `bytesSize()`, kept numeric internally and - * formatted only at TOML-render time — the legacy precedent for reproducing - * this formatting here rather than just stringifying the byte count) to - * re-serialise the API's int64 byte count into the human-readable form - * `storage.file_size_limit` holds in a config document. + * Formats a byte count as a `"<n><unit>"` string (e.g. `"50MiB"`). * - * This formatting round-trips textually against the *dominant* local - * spelling: a `BytesSize` string, including the schema default `"50MiB"` - * (`../storage.ts:13,42-47`'s `fileSizeLimit` union accepts either spelling - * on decode). It does NOT round-trip against a local document that spells - * the same limit as a bare number — `../storage.ts:42-47` normalizes a - * numeric local value to its *decimal* string (`52428800`, not `"50MiB"`) on - * decode, so the two spellings compare unequal textually even though they - * denote the same limit. Reconciling that comparison-granularity gap is the - * diff consumer's job (CLI-2156), not this mapping's. + * Round-trips textually against a document that already spells the limit as a `BytesSize` + * string, but not against one that spells it as a bare number (which normalizes to a decimal + * string on decode) — reconciling that comparison gap is the diff consumer's job. */ function bytesSize(size: number): string { let value = size; @@ -437,13 +342,9 @@ const BINARY_MAP: Readonly<Record<string, number>> = { const DIGIT_OR_DOT_OR_SPACE = "0123456789. "; /** - * Port of `units.RAMInBytes`, replicated verbatim from - * `apps/cli/src/command-internal/size-units.ts:32-102` — parses a - * human-readable RAM size (1024-based, case-insensitive, optional trailing - * `b`) OR a bare decimal byte count (both spellings `../storage.ts:35-46`'s - * `fileSizeLimit` schema accepts) into bytes. Throws on an unparseable - * string; used only by {@link canonicalizeFileSizeLimit}, which never lets - * this throw escape. + * Parses a human-readable RAM size (1024-based, case-insensitive, optional trailing `b`) or + * a bare decimal byte count into bytes. Throws on an unparseable string; used only by + * {@link canonicalizeFileSizeLimit}, which never lets this throw escape. */ function ramInBytes(sizeStr: string): number { let sep = -1; @@ -498,9 +399,8 @@ function ramInBytes(sizeStr: string): number { throw new Error(`invalid suffix: '${sfx}'`); } const bytes = size * mul; - // A finite numeric component can still overflow through the suffix - // multiplier (e.g. "1e308KiB") — bytesSize(Infinity) would render - // "InfinityYiB" instead of leaving the unrepresentable input verbatim. + // A finite numeric component can still overflow through the suffix multiplier (e.g. + // "1e308KiB"); without this check the result would render as "InfinityYiB". if (!Number.isFinite(bytes)) { throw new Error(`invalid size: '${sizeStr}'`); } @@ -508,26 +408,11 @@ function ramInBytes(sizeStr: string): number { } /** - * DOCUMENT-side byte-size canonicalization (CLI-2230's duration/byte-size - * finding): a document spells `storage.file_size_limit` as either a - * `BytesSize` string (`"50MiB"`) or a bare decimal byte count - * (`"52428800"`, `../storage.ts:35-46`), while {@link bytesSize} always - * emits the `BytesSize` spelling. Reparsing via `ramInBytes` and - * re-formatting via `bytesSize` makes both sides converge on one spelling - * for one logical limit. Never throws: a document value has already passed - * schema validation, so an unparsable value (which should not occur) is - * returned verbatim rather than failing `fromConfigDocument`. - * - * Deliberately quantized, not exact: both this function and the API-side row - * below format their byte count through {@link bytesSize}, which rounds to 4 - * significant digits ({@link formatSignificantDigits}). Two limits within - * ~0.1% of each other therefore compare equal as `BytesSize` strings even - * though their raw byte counts differ. This is accepted, not a bug to fix by - * comparing raw bytes instead: every value a user actually writes in - * `storage.file_size_limit` (`"50MiB"`, `"1GB"`, …) is exact at 4 significant - * digits, and the config schema models the field as a string - * (`../storage.ts`), so the comparison this canonicalization feeds is - * textual by construction either way. + * Canonicalizes a document's `storage.file_size_limit` to the `BytesSize` spelling + * {@link bytesSize} emits, so both sides of a diff converge on one spelling. Never throws — + * an unparsable value returns verbatim. Quantized to 4 significant digits like + * {@link bytesSize}, so two limits within ~0.1% of each other compare equal as strings; every + * value a user actually writes is already exact at that precision. */ function canonicalizeFileSizeLimit(value: unknown): unknown { if (typeof value !== "string") { @@ -546,9 +431,8 @@ const storageSectionRows: ReadonlyArray<ProjectConfigMappingRow> = [ { configPath: ["storage", "file_size_limit"], apiPath: storageFileSizeLimitPath, - // Non-negative: ramInBytes above rejects negative sizes, so formatting a - // negative API byte count (e.g. "-1B") would persist a value config - // loading cannot read back. + // Non-negative: ramInBytes rejects negative sizes, so a negative API byte count would + // persist a value config loading can't read back. transform: (value) => bytesSize( expectNumberBetween( @@ -614,27 +498,18 @@ const storageSectionRows: ReadonlyArray<ProjectConfigMappingRow> = [ clampToUint(expectInteger(value, ["storage", "features", "vector_buckets", "max_indexes"])), normalizeDocument: clampDocumentUint, }, - // Deliberately unmapped: storage.features.purge_cache.enabled, - // storage.capabilities.{list_v2,iceberg_catalog}, storage.upstream_target, - // storage.migration_version, storage.database_pool_mode. + // Unmapped: storage.features.purge_cache.enabled, storage.capabilities.{list_v2, + // iceberg_catalog}, storage.upstream_target, storage.migration_version, + // storage.database_pool_mode. ]; -// === realtime ================================================================ -// -// Zero rows, intentionally. `../realtime.ts`'s config section (`enabled`, -// `ip_version`, `max_header_length`) is entirely local dev-server tuning with -// no hosted-project counterpart; all 12 API `realtime.*` fields -// (`private_only`, `max_concurrent_users`, `max_events_per_second`, -// `max_bytes_per_second`, `max_channels_per_client`, `max_joins_per_second`, -// `max_presence_events_per_second`, `max_payload_size_in_kb`, -// `presence_enabled`, `suspend`, `connection_pool`, `postgres_changes_pool`) -// stay unmapped. Do not add rows here to "fix" `unmappedApiFields` reporting -// them — that report is correct. +// Zero rows here, intentionally: `../realtime.ts`'s config section is entirely local +// dev-server tuning with no hosted-project counterpart. Do not add rows to "fix" +// `unmappedApiFields` reporting the API's `realtime.*` fields — that report is correct. /** * The full API↔`CliConfig` mapping table: this file's non-auth rows plus - * `./registry-auth.ts`'s auth rows. `fromApiProjectConfig`/ - * `unmappedApiFields` (`./project-config.ts`) are the only consumers. + * `./registry-auth.ts`'s auth rows. */ export const projectConfigMappingRows: ReadonlyArray<ProjectConfigMappingRow> = [ ...apiSectionRows, diff --git a/packages/config/src/project.ts b/packages/config/src/project.ts index 6d37ce7424..7cbd593c21 100644 --- a/packages/config/src/project.ts +++ b/packages/config/src/project.ts @@ -31,12 +31,8 @@ function normalizeAmbientEnv( return values; } -// Detects a line of the form `KEY=<quote>...` (or `KEY: <quote>...`) whose -// quoted value does NOT close on that same physical line — the start of a -// godotenv-style multiline quoted value (e.g. a PEM block). Returns the quote -// character and the index of the opening quote within `line`, or `null` if -// the line doesn't open an unterminated quote (either no quote at all, or one -// that already closes on this line). +// Matches a `KEY=<quote>` (or `KEY: <quote>`) opener, used to detect the start of a +// multiline quoted value (e.g. a PEM block) that doesn't close on the same line. const dotEnvValueOpenerPattern = /^\s*(?:export\s+)?[\w.-]+(?:\s*=\s*?|:\s+?)(['"`])/; function findUnescapedQuoteIndex(text: string, quote: string, from: number): number { @@ -102,21 +98,10 @@ function parseDotEnv( let candidate = line; let consumedThrough = index; - // Check for an unterminated quote BEFORE attempting the single-line - // match: `dotEnvLinePattern`'s value alternatives fall back to an - // unquoted match (`[^#\r\n]+`) when none of the quoted alternatives - // close on this line, which would otherwise "succeed" with a truncated, - // still-quote-prefixed value instead of signaling a multiline value — - // masking the real bug rather than triggering accumulation. This is a - // godotenv-style quoted value spanning multiple physical lines (e.g. a - // PEM block); Go's `loadNestedEnv` parses this fine (`godotenv@v1.5.1`'s - // cursor-based scanner never splits into lines up front; see - // `dotenv.ts` for the Go-compatible reference implementation used - // elsewhere in this repo). Accumulate subsequent lines until the opened - // quote closes (or EOF), then match the same per-line pattern against - // the joined multiline chunk — its quoted-value alternatives use - // negated character classes (`[^"]` etc.), which already match embedded - // newlines once given the full span. + // Detect an unterminated quote before the single-line match: the pattern's unquoted + // fallback (`[^#\r\n]+`) would otherwise "succeed" with a truncated value instead of + // signaling a multiline value (e.g. a PEM block spanning several lines). Accumulate + // subsequent lines until the opened quote closes, then match the joined chunk. const opener = detectOpenQuoteStart(line); if (opener !== null) { for (let next = index + 1; next < lines.length; next += 1) { @@ -180,28 +165,18 @@ export interface LoadCliProjectEnvironmentOptions { /** See {@link FindCliProjectPathsOptions.search}. */ readonly search?: boolean; /** - * Skip reading/parsing `paths.envLocalPath` (`supabase/.env.local`) - * entirely. Mirrors Go's `loadDefaultEnv` (`apps/cli-go/pkg/config/ - * config.go:1243-1250`), which omits `.env.local` from its candidate - * filename list whenever `SUPABASE_ENV=test` — so a malformed or - * intentionally non-test `.env.local` is invisible to Go in that mode and - * must not fail config loading here either. Defaults to `false` so - * existing callers that don't have a `SUPABASE_ENV` gate of their own - * (`packages/stack`, `secrets set`) are unaffected. + * Skip reading/parsing `paths.envLocalPath` (`supabase/.env.local`) entirely, so a + * malformed or intentionally non-test `.env.local` cannot fail config loading. Defaults + * to `false`. */ readonly skipEnvLocal?: boolean; } -/** - * Not covered by semver — exported from `@supabase/config/internal` only. See - * that module's header for why. - */ +/** Not covered by semver — exported from `@supabase/config/internal` only. */ export interface InternalResolveCliConfigOptions { /** - * Opt into Go/viper-parity `env()` matching (case-agnostic - * `^env\((.*)\)$`). Defaults to `false`, which uses the pre-PR-#5765 strict - * SCREAMING_SNAKE_CASE matcher (`ENV_CAPTURE_REGEX_STRICT`). Only the - * Go-parity CLI sets this to `true`. + * Opt into viper-style case-agnostic `env()` matching (`^env\((.*)\)$`). Defaults to + * `false`, which requires SCREAMING_SNAKE_CASE. */ readonly goViperCompat?: boolean; } @@ -245,18 +220,11 @@ export const loadCliProjectEnvironment = Effect.fnUntraced(function* ( }); /** - * Effect-typed counterpart of `./lib/resolve.ts`'s plain sync - * `resolveCliConfigValue`, additionally accepting the internal-only - * `goViperCompat` option (see {@link InternalResolveCliConfigOptions}). - * `../effect.ts` re-exports this explicitly, which wins over the sync - * version's star re-export through `./index.ts` (see that module's doc - * comment on the deliberate shadowing) — `@supabase/config/internal` - * re-exports this same function typed to show `goViperCompat`. + * Effect-typed counterpart of the plain sync `resolveCliConfigValue` in + * `./lib/resolve.ts`, additionally accepting the internal-only `goViperCompat` option. * - * `cliProjectEnv` only needs `.values` (`Pick<CliProjectEnvironment, "values">`) — - * a caller that already has a project's env values but not the full - * `CliProjectEnvironment` shape (e.g. `paths`/`loadedPaths`/`sources`) can pass - * `{ values }` directly instead of threading through the whole loaded object. + * Accepts `Pick<CliProjectEnvironment, "values">` so a caller with only a project's env + * values, not the full loaded object, can pass `{ values }` directly. */ export function resolveCliConfigValue<T>( value: T, diff --git a/packages/config/src/project.unit.test.ts b/packages/config/src/project.unit.test.ts index 3659515d28..f57957c43c 100644 --- a/packages/config/src/project.unit.test.ts +++ b/packages/config/src/project.unit.test.ts @@ -55,11 +55,6 @@ describe("project discovery and lazy env resolution", () => { }); test("search: false only checks cwd itself, matching Go's exact-workdir resolution", async () => { - // Mirrors Go's `ChangeWorkDir` (`apps/cli-go/internal/utils/misc.go:238-257`): - // an explicit workdir is used exactly as given, with no ancestor climb — - // callers that already hold a Go-equivalent project root (e.g. the legacy - // `stop`/`status` ports' `cliSettings.workdir`) pass `search: false` to avoid - // picking up an unrelated ancestor project. const cwd = makeTempProject(); const repoRoot = join(cwd, "repo"); const packageRoot = join(repoRoot, "apps", "web"); @@ -92,9 +87,6 @@ describe("project discovery and lazy env resolution", () => { }); test("climbs past a FILE named `supabase` in the starting directory instead of failing with ENOTDIR", async () => { - // Go's getProjectRoot keeps climbing on any stat error - // (apps/cli-go/internal/utils/misc.go:216-231) — a stray FILE named - // `supabase` (not a directory) must read as "no config here", not crash. const cwd = makeTempProject(); const nestedCwd = join(cwd, "child"); @@ -257,10 +249,6 @@ describe("project discovery and lazy env resolution", () => { }); test("skipEnvLocal ignores .env.local entirely, matching Go's SUPABASE_ENV=test gate", async () => { - // Go's `loadDefaultEnv` (`apps/cli-go/pkg/config/config.go:1243-1250`) omits - // `.env.local` from its candidate filename list whenever `SUPABASE_ENV=test`, - // so a malformed `.env.local` is invisible to Go in that mode. Callers that - // reproduce this gate (`status`/`stop` handlers) pass `skipEnvLocal: true`. const cwd = makeTempProject(); try { @@ -278,7 +266,6 @@ describe("project discovery and lazy env resolution", () => { expect(projectEnv?.values.FROM_ENV).toBe("1"); expect(projectEnv?.loadedPaths).toEqual([join(cwd, "supabase", ".env")]); - // Without the flag, the same malformed file still fails as before. await expect(runConfigEffect(loadCliProjectEnvironment({ cwd }))).rejects.toBeInstanceOf( CliProjectEnvParseError, ); @@ -296,8 +283,6 @@ describe("project discovery and lazy env resolution", () => { await writeFile(join(projectRoot, "supabase", "config.toml"), `project_id = "ref_123"\n`); const defaultLoaded = await runConfigEffect(loadCliConfig(projectRoot)); - // The field stays optional so an explicit choice in either direction remains - // distinguishable from unset, which `config push` omits from the API payload. expect(defaultLoaded!.config.api.auto_expose_new_tables).toBeUndefined(); await writeFile( @@ -456,8 +441,6 @@ jwt_secret = "env(MISSING_SECRET)" resolveCliConfigValue(loaded!.config.auth.jwt_secret, projectEnv!, "auth.jwt_secret"), ); - // Secret paths are normally redacted, but unresolved env() literals pass - // through as plain strings so callers can see the missing reference. expect(Redacted.isRedacted(resolved)).toBe(false); expect(resolved).toBe("env(MISSING_SECRET)"); } finally { @@ -465,10 +448,6 @@ jwt_secret = "env(MISSING_SECRET)" } }); - // Go's `LoadEnvHook` (`apps/cli-go/pkg/config/decode_hooks.go:19-24`) only - // substitutes a non-empty env var (`len(env) > 0`) — a present-but-empty - // dotenv line (`EMPTY_SECRET=`) is treated the same as an unset var, so the - // literal `env(...)` reference is preserved rather than resolved to `""`. test("resolveCliConfigValue preserves env() literal when the env var is present but empty (Go parity)", async () => { const cwd = makeTempProject(); const projectRoot = join(cwd, "repo"); @@ -556,9 +535,6 @@ account_sid = "AC123" } }); - // Pins the pre-PR-#5765 strict SCREAMING_SNAKE_CASE `env()` matcher as the - // default for `resolveCliConfigValue`/`resolveCliConfigSubtree`, since - // `packages/stack` calls these without ever passing `goViperCompat`. test("resolveCliConfigValue does not resolve a lowercase-named env() reference by default", async () => { const cwd = makeTempProject(); const projectRoot = join(cwd, "repo"); diff --git a/packages/config/src/promise-facade.stdin.unit.test.ts b/packages/config/src/promise-facade.stdin.unit.test.ts index d7ffa70ecb..2ce9e0cb54 100644 --- a/packages/config/src/promise-facade.stdin.unit.test.ts +++ b/packages/config/src/promise-facade.stdin.unit.test.ts @@ -7,25 +7,11 @@ import { join } from "node:path"; import { Context, Effect, Layer, Option, Terminal } from "effect"; import { findCliProjectRoot } from "./bun.ts"; -// CLI-2231 regression guard: `BunServices.layer` (the full Bun platform -// services bundle) pulls in `Terminal`, which attaches a permanent -// `process.stdin` "end" listener the first time it's used. `bun.ts`'s -// facade only needs `FileSystem | Path` (see `bun.ts`), so a facade call -// must never grow that listener count. -// -// This is only meaningful on the FIRST facade call made anywhere in the -// process — `promise-facade.ts`'s `ManagedRuntime` is a lazily-built, -// module-level singleton, so a call made before this test's "before" -// snapshot would already have built the runtime (and attached any -// listener), making the before/after comparison vacuously pass. This file -// deliberately makes exactly ONE call into the bun facade — the call -// below — and no other test in this file may call into `bun.ts` (or any -// other module that shares its singleton) before or after it. Vitest's -// default per-file module isolation (`isolate: true`) is what guarantees -// this file's copy of `bun.ts` starts with its runtime unbuilt; putting -// this assertion in a file with other facade-calling tests (as the -// original version of this guard did) silently defeats it, which is -// exactly how this regression shipped and passed CI. +// `bun.ts`'s facade only needs `FileSystem | Path`, so a facade call must never attach the +// permanent `process.stdin` "end" listener that `Terminal` (pulled in by `BunServices.layer`) +// adds on first use. This is only observable on the very first facade call anywhere in the +// process, since the facade's `ManagedRuntime` is a lazy, module-level singleton — so this +// file must make exactly one facade call, and no other test here may call into `bun.ts` before or after it. describe("promise-facade stdin-leak regression (CLI-2231)", () => { test("the first facade call in this file does not attach a process.stdin 'end' listener", async () => { const cwd = mkdtempSync(join(tmpdir(), "supabase-promise-facade-stdin-")); @@ -42,11 +28,9 @@ describe("promise-facade stdin-leak regression (CLI-2231)", () => { }); describe("bun.ts's platform layer excludes Terminal", () => { - // A structural companion to the behavioral guard above: builds the exact - // layer `bun.ts` provides its `ManagedRuntime` — independent of `bun.ts` - // and the facade singleton, so it doesn't count as a second facade call — - // and asserts the resulting `Context` has no `Terminal` service at all, - // not just that using it happens not to touch stdin today. + // Builds the same layer `bun.ts` provides its `ManagedRuntime` independently, so it + // doesn't count as a second facade call, and asserts there's no `Terminal` service at all + // rather than just that using one doesn't touch stdin today. test("Layer.mergeAll(BunFileSystem.layer, BunPath.layer) builds a context with no Terminal service", async () => { const context = await Effect.runPromise( Layer.build(Layer.mergeAll(BunFileSystem.layer, BunPath.layer)).pipe(Effect.scoped), diff --git a/packages/config/src/promise-facade.ts b/packages/config/src/promise-facade.ts index 5c7bf888dc..2be88484ca 100644 --- a/packages/config/src/promise-facade.ts +++ b/packages/config/src/promise-facade.ts @@ -15,22 +15,11 @@ import { cliConfigStoreLayer } from "./cli-config.layer.ts"; import { CliConfigStore } from "./cli-config.service.ts"; /** - * Names deliberately mirror `@supabase/config/effect` one-to-one — the - * subpath itself (`/io` vs `/effect`) conveys Promise-vs-Effect, not the - * member names. - * - * A rejection from `loadCliConfig`, `loadCliConfigFile`, or `saveCliConfig` - * can carry any of five typed failures — this package's own - * `CliConfigParseError`, `DuplicateRemoteProjectIdError`, - * `InvalidRemoteProjectIdError`, `CliProjectEnvParseError`, or `PlatformError` - * (from `effect/PlatformError`) for a host/OS failure — distinguish via - * `instanceof`. One exception: `saveCliConfig`'s atomic-write step maps a - * rename failure to a defect rather than one of these typed failures (see - * `io.ts`'s `writeFileAtomic`) — the returned promise still rejects, but with - * the raw, un-mapped failure, not an instance of any class listed above. This - * is a deliberate design choice (a rename failure after a successful write - * indicates something is wrong with the filesystem itself, not a recoverable - * config condition), not an oversight. + * A rejection from `loadCliConfig`, `loadCliConfigFile`, or `saveCliConfig` carries one of + * `CliConfigParseError`, `DuplicateRemoteProjectIdError`, `InvalidRemoteProjectIdError`, + * `CliProjectEnvParseError`, or a `PlatformError` for a host/OS failure — check with + * `instanceof`. Exception: a `saveCliConfig` rename failure after a successful write + * rejects with the raw, unmapped error instead of one of these. */ export interface CliConfigIo { readonly loadCliConfig: ( @@ -48,10 +37,9 @@ export interface CliConfigIo { } /** - * Builds the Promise-based `@supabase/config/io` facade over a given platform - * layer. `Layer`'s `ROut` is declared contravariant (`in ROut`), so a - * platform layer providing a superset of `FileSystem | Path` (e.g. - * `BunServices.layer` / `NodeServices.layer`) is assignable here. + * Builds the Promise-based `@supabase/config/io` facade over a given platform layer. + * Accepts any layer providing a superset of `FileSystem | Path` (e.g. `BunServices.layer` + * or `NodeServices.layer`). */ export function makeCliConfigIo( platformLayer: Layer.Layer<FileSystem.FileSystem | Path.Path>, @@ -62,13 +50,9 @@ export function makeCliConfigIo( ); } - // Lazily built once per module and never disposed — this facade is a - // process-lifetime singleton, not a scoped resource, so there is no - // natural point at which to call `runtime.dispose()`. `ManagedRuntime.make` - // memoizes its build fiber, so a hypothetical failed build would replay its - // failure on every later call — unreachable today because the - // `FileSystem`/`Path` layers this facade is built from have `E = never`, - // but worth knowing if that ever changes. + // Lazily built once and never disposed: this facade is a process-lifetime singleton, not + // a scoped resource. `ManagedRuntime.make` memoizes its build fiber, so a failed build + // would replay that failure on every later call. let runtime: ReturnType<typeof buildRuntime> | undefined; function getRuntime() { diff --git a/packages/config/src/promise-facade.unit.test.ts b/packages/config/src/promise-facade.unit.test.ts index 920b1c7f28..f3648cf802 100644 --- a/packages/config/src/promise-facade.unit.test.ts +++ b/packages/config/src/promise-facade.unit.test.ts @@ -25,10 +25,8 @@ const { const decodeCliConfig = Schema.decodeUnknownSync(CliConfigSchema); -// mkdtemp against the OS temp dir (never a path under the repo) — a real -// project's ancestor-search would otherwise be able to walk up into this -// repo's own `apps/cli/docs/supabase` and resolve a project that isn't the -// one the test created. +// Under the OS temp dir, never a path under this repo — otherwise ancestor-search could +// walk up into this repo's own `apps/cli/docs/supabase` fixture. function makeTempProject(): string { return mkdtempSync(join(tmpdir(), "supabase-promise-facade-")); } @@ -54,9 +52,6 @@ describe("promise-facade via the Bun entrypoint", () => { const loaded = await loadCliConfig(cwd); expect(loaded?.config.project_id).toBe("facade-loaded-ref"); - // `db.major_version` is never set in the fixture above — asserting it - // resolves to the schema default proves the facade runs the real - // decode path, not just a raw TOML parse. expect(loaded?.config.db.major_version).toBe(17); expect(loaded?.format).toBe("toml"); expect(loaded?.path).toBe(join(cwd, "supabase", "config.toml")); @@ -130,9 +125,7 @@ describe("promise-facade via the Bun entrypoint", () => { await writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "env-ref"\n'); await writeFile(join(cwd, "supabase", ".env"), "GREETING=hello-from-dotenv\n"); - // `baseEnv` is passed explicitly (never the default `process.env`) so - // this assertion can't be satisfied by an unrelated variable leaking in - // from the real process environment. + // Explicit empty baseEnv, so no ambient process.env variable can satisfy this assertion. const projectEnv = await loadCliProjectEnvironment({ cwd, baseEnv: {} }); expect(projectEnv?.values.GREETING).toBe("hello-from-dotenv"); @@ -186,10 +179,6 @@ describe("promise-facade via the Bun entrypoint", () => { await mkdir(join(cwdB, "supabase"), { recursive: true }); await writeFile(join(cwdB, "supabase", "config.toml"), 'project_id = "second-ref"\n'); - // The runtime is built lazily on the first call below and cached for - // reuse — a second, independent call on the same module-level facade - // must still resolve correctly rather than reusing stale state from - // the first. const first = await loadCliConfig(cwdA); const second = await loadCliConfig(cwdB); @@ -213,9 +202,6 @@ const expectedFacadeFunctionNames = [ ]; describe("promise-facade parity between bun.ts, node.ts, and io-browser.ts", () => { - // io-browser.ts must export the same facade names as bun.ts/node.ts: a - // bundler resolving the "browser" condition needs every named import to - // exist at build time. test("io-browser.ts exports the same seven facade function names as bun.ts and node.ts", () => { for (const facade of [bunFacade, nodeFacade, ioBrowserFacade]) { for (const name of expectedFacadeFunctionNames) { @@ -224,10 +210,6 @@ describe("promise-facade parity between bun.ts, node.ts, and io-browser.ts", () } }); - // Each module also re-exports every pure symbol from `.` (see - // `describe("./io is a superset of src/index.ts", ...)` below), so this - // asserts the three modules' full export surfaces stay identical to each - // other, not just on the seven facade names above. test("bun.ts, node.ts, and io-browser.ts export the identical set of names", () => { expect(Object.keys(nodeFacade).sort()).toEqual(Object.keys(bunFacade).sort()); expect(Object.keys(ioBrowserFacade).sort()).toEqual(Object.keys(bunFacade).sort()); @@ -238,8 +220,6 @@ describe("./io is a superset of src/index.ts", () => { test("every runtime export key of index.ts is present, with an identical (not shadowed) binding, in bun.ts, node.ts, and io-browser.ts", () => { const defaultKeys = Object.keys(defaultEntrypoint); - // Guards against `defaultEntrypoint` being empty due to a broken - // import, which would otherwise make the loop below pass trivially. expect(defaultKeys.length).toBeGreaterThan(0); for (const [label, facade] of [ @@ -262,11 +242,8 @@ describe("./io is a superset of src/index.ts", () => { }); describe("io-browser.ts stays side-effect-free", () => { - // Uses a dynamic `import()` (rather than relying on the static import at - // the top of this file) so this assertion is meaningful on its own: a - // regression back to a bare top-level `throw` would fail this specific - // test with the rejection below, instead of crashing the whole file at - // module-load time before any test runs. + // Dynamic import so a regression back to a top-level throw fails this test + // specifically, instead of crashing the whole file at module-load time. test("importing the module does not throw", async () => { await expect(import("./io-browser.ts")).resolves.toBeDefined(); }); @@ -306,8 +283,6 @@ describe("promise-facade singleton runtime", () => { }); describe("promise-facade via the Node entrypoint", () => { - // The "default"-condition path (`@supabase/config/io` resolving to - // `./node.ts` outside Bun) otherwise has zero execution coverage. test("loadCliConfig loads and decodes a real supabase/config.toml", async () => { const cwd = makeTempProject(); @@ -351,6 +326,5 @@ describe("promise-facade rejection shapes", () => { }); }); -// The stdin-leak guard lives in `promise-facade.stdin.unit.test.ts`: it must -// observe the facade's FIRST call, which only a dedicated vitest-isolated -// file can guarantee. +// The stdin-leak guard lives in `promise-facade.stdin.unit.test.ts`, which needs its own +// vitest-isolated file to observe the facade's first call. diff --git a/packages/config/src/sparse.ts b/packages/config/src/sparse.ts index b6e9c4588a..538fa645a2 100644 --- a/packages/config/src/sparse.ts +++ b/packages/config/src/sparse.ts @@ -2,18 +2,8 @@ import { Schema } from "effect"; import { CliConfigSchema, type CliConfig } from "./base.ts"; /** - * Sparse config subtraction — see `docs/adr/0018-sparse-config-subtraction.md`. - * - * A sparse config is a partial overlay containing only the values that differ - * from some baseline. In the primary case — subtracting the default config - * ({@link omitDefaultValues}) — the result is itself a valid config document: - * re-decoding refills exactly what was removed, so it denotes the same - * effective config under the current schema's defaults. Subtracting any other - * baseline (e.g. a remote block against the merged base config) yields an - * overlay meaningful only relative to that baseline. Arrays are compared - * wholesale (order-sensitive) and never subtracted element-wise, so a sparse - * value is always either an entire array or an object subtree of kept leaves — - * hence arrays survive `DeepPartial` unchanged below. + * A deeply partial `T` used for sparse config overlays; arrays are kept as whole units + * rather than partialized element-wise. See `docs/adr/0018-sparse-config-subtraction.md`. */ export type DeepPartial<T> = T extends ReadonlyArray<unknown> @@ -25,18 +15,9 @@ export type DeepPartial<T> = export type SparseCliConfig = DeepPartial<CliConfig>; /** - * The family-neutral operand shape of the comparison core: a deeply partial - * root scope of {@link CliConfig}, without the nested `remotes` record. Every - * key an operand carries must hold its fully-resolved *effective* value; an - * absent key means the operand doesn't speak for that field — never that the - * field is at its default. Both config families fit: on the local side a full - * {@link CliConfig} document or a branch's merged effective config, and on - * the hosted side the sparse `ProjectConfig` subset produced by - * `toProjectConfig` — a Management API response never mentions local-only - * sections, so its operands are inherently partial. Keeping `remotes` out of - * the contract means neither operand has to fabricate one to type-check. - * (Replaces the former fully-materialized `BaseCliConfig` operand; see ADR - * 0018's addendum for the CLI-2230 ruling.) + * The comparison operand shape: a deeply partial {@link CliConfig} without `remotes`. + * Every key an operand carries must hold its fully-resolved effective value; an absent key + * means the operand doesn't speak for that field, not that the field is at its default. */ export type EffectiveConfig = DeepPartial<Omit<CliConfig, "remotes">>; @@ -45,18 +26,9 @@ const decodeCliConfig = Schema.decodeUnknownSync(CliConfigSchema); let defaultCliConfig: CliConfig | undefined; /** - * The default config: a {@link CliConfig} in which every value carries its - * schema-declared default. Derived by decoding `{}` through - * {@link CliConfigSchema} — the schema's `default` annotations and decoding - * defaults are the single source of truth, so there is no hand-maintained - * defaults table to drift. Fields declared `optionalKey` without a default - * (e.g. `project_id`, `api.external_url`) are absent. - * - * Memoized (and the memo shared with callers) rather than computed at module - * load, so importing the package doesn't pay for a full schema decode. The - * memo is deeply frozen before it is shared: it doubles as the module-wide - * subtraction baseline, so a caller mutation would silently corrupt every - * later {@link omitDefaultValues} result. + * The default config: a {@link CliConfig} decoded from `{}`, so every value carries its + * schema-declared default. Memoized and deeply frozen; the frozen result is reused as the + * baseline for every {@link omitDefaultValues} call, so callers must not mutate it. */ export function getDefaultCliConfig(): CliConfig { defaultCliConfig ??= deepFreeze(decodeCliConfig({})); @@ -64,21 +36,8 @@ export function getDefaultCliConfig(): CliConfig { } /** - * Recursively freezes `value` and returns it. Exported (not re-exported from - * `./index.ts` — this stays an internal cross-module helper, per CLI-2230's - * `_apiResponse` clone-and-freeze finding) so `./project-config/ - * project-config.ts` can freeze the cloned raw attributes it attaches, using - * the same freezing behavior {@link getDefaultCliConfig}'s memo relies on. - * - * Guarded against revisiting an already-frozen (or otherwise already-seen) - * object with a `WeakSet`, as defense in depth: {@link getDefaultCliConfig}'s - * memo is a decoded schema default, genuinely acyclic by construction, but - * `./project-config/project-config.ts`'s caller is a cloned Management API - * response — untrusted input — and that caller now bounds depth and cycles - * itself before ever calling this (`assertRawAttributesDepthWithinBound`). - * This guard exists so `deepFreeze` stays safe to call directly against - * arbitrary input even if that upstream bound is ever bypassed or forgotten, - * not because this function's own callers currently need it. + * Recursively freezes `value` and returns it. Guards against revisiting an already-seen + * object with a `WeakSet`, so it stays safe to call against cyclic or untrusted input. */ export function deepFreeze<T>(value: T): T { return deepFreezeVisiting(value, new WeakSet()); @@ -103,11 +62,9 @@ function isObject(value: unknown): value is Record<string, unknown> { } /** - * Defines `key` as an own data property. Record keys come from user config - * files, and both smol-toml and `JSON.parse` produce an own `__proto__` key - * (a valid function name or remote label) that a plain `target[key] = value` - * assignment would feed to the legacy prototype setter, silently dropping the - * entry — or, for object values, swapping the target's prototype. + * Defines `key` as an own data property. Guards against a `__proto__` key from user config + * (a valid function name or remote label) being treated as the prototype setter by a plain + * assignment, which would silently drop the entry or swap the target's prototype. */ export function setOwnProperty(target: Record<string, unknown>, key: string, value: unknown): void { Object.defineProperty(target, key, { @@ -154,21 +111,9 @@ function isEqualValue(left: unknown, right: unknown): boolean { } /** - * The untyped subtraction walk: returns `value − baseline`, or `undefined` - * when nothing survives. Values strictly deep-equal (order-sensitive) to the - * baseline's are removed; objects recurse per key and are dropped once empty; - * arrays are removed wholesale on equality, never subtracted element-wise. A - * key with no counterpart in the baseline is kept verbatim — which is exactly - * how `remotes` and other record entries pass through untouched when the - * baseline is the default config. Symmetrically, a baseline-only key is - * ignored by design: subtraction reports what `value` declares, and in - * overlay semantics absence means *inherit*, so a missing key is not a - * removal. - * - * Shared with `io.ts`, which subtracts *encoded* documents before writing - * minimal config files; the typed entry points below operate on decoded - * {@link CliConfig} values, the only shape where "equals the default" is - * well-defined. + * Returns `value − baseline`, or `undefined` when nothing survives. A key missing from the + * baseline is kept verbatim (so record entries like `remotes` pass through untouched); a + * key present only in the baseline is ignored, since overlay absence means "inherit". */ export function subtractValue(value: unknown, baseline: unknown): unknown { if (baseline === undefined) { @@ -201,58 +146,32 @@ export function subtractValue(value: unknown, baseline: unknown): unknown { } /** - * Returns the sparse config `config − baseline`. Directional: a value equal to - * the baseline's is removed even when it differs from the schema default, and - * a value differing from the baseline's is kept even when it equals the schema - * default. + * Returns the sparse config `config − baseline`. Directional: a value equal to the + * baseline is removed even if it differs from the schema default, and kept otherwise. * - * Operands must be *effective* wherever they speak: every key present must - * carry its fully-resolved value (a decode of a complete document, or of a - * raw-merged one — or the hosted values a Management API response reports), - * while an absent key is simply outside the comparison, per the absence rules - * above. A standalone-decoded `[remotes.*]` block is NOT a valid operand: - * decoding a sparse fragment materializes global defaults in every section it - * omitted, where the block meant to inherit from the base config, so the - * overlay would pin the branch to global defaults wherever the base overrides - * a field the block omits. To sparsify a branch's config (a `[remotes.*]` - * block declares overrides for a specific persistent Supabase branch, bound - * to it by `project_id`), subtract its merged effective config — the raw - * remote subtree merged over the raw base document *before* decoding, exactly - * as `io.ts`'s `mergeRemoteSubtree` does so remote schema defaults never leak - * in — against the base effective config, never the default config; see ADR - * 0018 for why the default-config baseline silently changes what the branch - * resolves to. + * Both operands must be effective — every present key holds its fully-resolved value. A + * standalone-decoded `[remotes.*]` block does not qualify: decoding it alone materializes + * defaults it meant to inherit from the base config. See + * `docs/adr/0018-sparse-config-subtraction.md`. */ export function subtractCliConfig( config: EffectiveConfig, baseline: EffectiveConfig, ): SparseCliConfig; -// The implementation signature stays untyped because TypeScript cannot verify -// that a structural walk over `unknown` reconstructs a `DeepPartial` of its -// input; the overload above is the contract, pinned by the unit tests. +// Untyped here because a structural walk over `unknown` can't be verified to reconstruct a +// `DeepPartial`; the overload above is the enforced contract. export function subtractCliConfig(config: EffectiveConfig, baseline: EffectiveConfig): unknown { const result = subtractValue(config, baseline); return isObject(result) ? result : {}; } /** - * Returns the sparse config `config − default config`: only the values that - * differ from their schema defaults, per {@link subtractCliConfig}'s - * semantics. The result is itself a valid config document — re-decoding - * refills the removed defaults, yielding the same effective config. `remotes` - * blocks (per-persistent-branch overrides) pass through untouched (the - * default config has none), and undefaulted `optionalKey` fields always - * survive when present. + * Returns the sparse config `config − default config`: only the values differing from + * their schema defaults. The result re-decodes to the same effective config. * - * The result is sparse at the root scope only: record-keyed entries - * (`functions.*`, `remotes.*`) survive whole, with every per-entry decoding - * default materialized — decoding fills them in, and the default config's - * empty records offer no per-entry baseline to subtract. This cancels out in - * a diff (both sides carry the same materialized defaults), but a consumer - * rendering the result directly must strip entry-level defaults itself. For a - * remote block that is necessarily the consumer's job — its correct baseline - * is the merged base config (ADR 0018); for function entries, `io.ts`'s - * `stripFunctionRecordDefaults` is the encoded-path precedent. + * Sparse only at the root: record-keyed entries (`functions.*`, `remotes.*`) survive + * whole, with their own per-entry defaults materialized, so a consumer rendering the + * result directly must strip those defaults itself. */ export function omitDefaultValues(config: EffectiveConfig): SparseCliConfig { return subtractCliConfig(config, getDefaultCliConfig()); diff --git a/packages/config/src/sparse.unit.test.ts b/packages/config/src/sparse.unit.test.ts index b60d4ffafe..71a80a1bc0 100644 --- a/packages/config/src/sparse.unit.test.ts +++ b/packages/config/src/sparse.unit.test.ts @@ -7,9 +7,6 @@ const decodeCliConfig = Schema.decodeUnknownSync(CliConfigSchema); describe("getDefaultCliConfig", () => { test("all schema defaults are mutually valid", () => { - // Decoding `{}` runs every business-rule check embedded in the schema, so - // a future default that conflicts with another fails here, loudly, rather - // than at import time in some consumer. expect(() => getDefaultCliConfig()).not.toThrow(); }); @@ -65,8 +62,6 @@ describe("omitDefaultValues", () => { }); test("sections emptied by subtraction disappear, cascading upward", () => { - // `api.tls.enabled` defaults to `false`: the leaf is pruned, leaving - // `tls: {}`, which is dropped, leaving `api: {}`, which is dropped. const config = decodeCliConfig({ api: { tls: { enabled: false } } }); expect(omitDefaultValues(config)).toEqual({}); }); @@ -89,11 +84,8 @@ describe("omitDefaultValues", () => { }); test("remotes pass through untouched, even when set to global defaults", () => { - // `api.max_rows = 1000` IS the global default, but inside a remote block - // it overrides whatever the base config resolves to — subtracting it - // against global defaults would silently change the branch's effective - // value. See ADR 0018: a remote block's baseline is the merged base - // config, never the default config. + // `max_rows: 1000` matches the global default but must still survive: it's set + // inside the remote block. const config = decodeCliConfig({ api: { max_rows: 500 }, remotes: { staging: { project_id: "abcdefghijklmnopqrst", api: { max_rows: 1000 } } }, @@ -104,18 +96,12 @@ describe("omitDefaultValues", () => { }); test("preserves a record entry named __proto__ as an own data property", () => { - // Both smol-toml (`[functions.__proto__]`) and JSON.parse produce an own - // `__proto__` key, and the schema decode preserves it — so the subtraction - // walk must define it as an own data property rather than let a plain - // `result[key] = value` assignment hit the legacy prototype setter and - // silently drop the function from the sparse output. const raw: unknown = JSON.parse('{"functions": {"__proto__": {"verify_jwt": false}}}'); const sparse = omitDefaultValues(decodeCliConfig(raw)); const functions = sparse.functions ?? {}; expect(Object.hasOwn(functions, "__proto__")).toBe(true); const entry = Object.getOwnPropertyDescriptor(functions, "__proto__")?.value; expect(entry).toMatchObject({ verify_jwt: false }); - // The walk must not have poisoned the container's prototype either. expect(Object.getPrototypeOf(functions)).toBe(Object.prototype); }); @@ -137,12 +123,6 @@ describe("subtractCliConfig", () => { }); test("sparsifies a branch via its merged effective config, not the decoded block", () => { - // The ADR 0018 call shape for CLI-2156/2064: both operands are *effective* - // configs, and the branch's is the raw remote subtree merged over the raw - // base document BEFORE decoding. Decoding the sparse `[remotes.*]` block - // on its own would materialize the global default `db.port = 54322` in - // place of the omitted-and-therefore-inherited base override `54399`, and - // the overlay would wrongly pin the branch to the global default. const rawBase = { api: { max_rows: 500 }, db: { port: 54399 } }; const rawRemote = { project_id: "abcdefghijklmnopqrst", api: { max_rows: 1000 } }; const base = decodeCliConfig(rawBase); @@ -156,15 +136,10 @@ describe("subtractCliConfig", () => { project_id: "abcdefghijklmnopqrst", api: { max_rows: 1000 }, }); - // The base-only `db.port` override the remote inherits must not surface. expect(overlay).not.toHaveProperty("db"); }); test("subtraction is directional against the baseline, not the defaults", () => { - // The CLI-2156 remote-block scenario: subtract a merged effective config - // against the merged BASE config. `api.max_rows: 1000` equals the global - // default but differs from the baseline's 500 — kept. `db.port: 54399` - // differs from the global default but equals the baseline's — removed. const baseline = decodeCliConfig({ api: { max_rows: 500 }, db: { port: 54399 } }); const config = decodeCliConfig({ api: { max_rows: 1000 }, db: { port: 54399 } }); expect(subtractCliConfig(config, baseline)).toEqual({ api: { max_rows: 1000 } }); @@ -172,13 +147,6 @@ describe("subtractCliConfig", () => { }); describe("EffectiveConfig operand widening (CLI-2230)", () => { - // `EffectiveConfig` covers any deeply-partial operand, not just a decoded - // `CliConfig` — the hosted-subset `ProjectConfig` `toProjectConfig` produces - // (`./project-config/project-config.ts`) is one such operand, and is never - // a fully-materialized document. These pin the runtime behavior both - // helpers already had against genuinely sparse operands, not just against - // full decodes. - test("subtractCliConfig: equal sparse operands cancel out entirely", () => { expect(subtractCliConfig({ api: { max_rows: 100 } }, { api: { max_rows: 100 } })).toEqual({}); }); @@ -201,9 +169,6 @@ describe("EffectiveConfig operand widening (CLI-2230)", () => { }); test("omitDefaultValues: does not flood in default-valued siblings the sparse operand never mentioned", () => { - // Only the two keys actually present on the operand may appear on the - // result — a flooding implementation would additionally materialize - // every other `api.*` default (`port`, `schemas`, `db_schema`, …). const sparse = omitDefaultValues({ api: { max_rows: 500, extra_search_path: [] } }); expect(sparse).toEqual({ api: { max_rows: 500, extra_search_path: [] } }); expect(Object.keys(sparse.api ?? {}).sort()).toEqual(["extra_search_path", "max_rows"]); diff --git a/packages/config/src/storage.ts b/packages/config/src/storage.ts index 67d6ec0c06..bab02e7ef3 100644 --- a/packages/config/src/storage.ts +++ b/packages/config/src/storage.ts @@ -24,21 +24,14 @@ const defaultMaxTables = 10; const defaultMaxCatalogs = 2; const defaultAnalyticsBuckets = {}; const defaultVector = {}; -// Go's embedded config template sets `[storage.vector] enabled = true`, which -// `config.Load` merges as the base layer, so an omitted key resolves to `true` -// (apps/cli-go/pkg/config/templates/config.toml + config_test.go:40). +// Vector buckets default to enabled, unlike most other storage subsections. const defaultVectorEnabled = true; const defaultMaxBuckets = 10; const defaultMaxIndexes = 5; const defaultVectorBuckets = {}; -/** - * `file_size_limit` accepts both a human-readable string (`"50MiB"`) and a bare - * byte count (`5000000`), matching Go's `sizeInBytes` decoder - * (apps/cli-go/pkg/config/config_test.go:TestFileSizeLimitConfigParsing). A - * numeric value is normalized to its decimal string so the decoded type stays a - * `string` for all consumers (`ramInBytes` parses either form identically). - */ +// Accepts a human-readable string (`"50MiB"`) or a bare byte count (`5000000`); a numeric +// value is normalized to its decimal string so the decoded type is always a `string`. const fileSizeLimit = Schema.Union([Schema.String, Schema.Number]).pipe( Schema.decodeTo(Schema.String, { decode: SchemaGetter.transform((value) => (typeof value === "number" ? String(value) : value)), diff --git a/packages/config/src/workers.ts b/packages/config/src/workers.ts index c3bc2a4178..1d20dd6e42 100644 --- a/packages/config/src/workers.ts +++ b/packages/config/src/workers.ts @@ -10,11 +10,8 @@ const links = [ }, ]; -/** - * Worker names end up in hostnames, so they are DNS labels — the same pattern - * the Management API validates `:name` against - * (`v2/projects/{ref}/workers/{name}`). - */ +// Worker names end up in hostnames, so they must be valid DNS labels, matching the +// Management API's own validation. const workerName = Schema.String.check(Schema.isPattern(/^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$/)); const worker = Schema.Struct({ @@ -42,10 +39,8 @@ const worker = Schema.Struct({ }), ), exposure: Schema.optionalKey( - // A plain string, like `runtime` and `size`: the Management API takes - // `spec.exposure` as an unconstrained string, and the CLI names the values it - // accepts when it reads one it does not know. Constraining it here would - // report a config that a newer CLI understands as unloadable. + // Left as an unconstrained string, matching the Management API: constraining it + // here would reject values a newer CLI understands. Schema.String.annotate({ description: dedent` How the worker is reached: \`public\` gives it an internet-facing URL, @@ -60,9 +55,8 @@ const worker = Schema.Struct({ }), ), instances: Schema.optionalKey( - // Bounded to match `spec.instances` in the Management API's input schema. A - // value that gets past here is dropped rather than sent, so leaving it - // unbounded deploys a different count than the config asked for. + // Bounded to match the Management API's input schema; an out-of-range value would + // be dropped rather than sent, deploying a different count than the config asked for. Schema.Number.check( Schema.isInt().annotate({ expected: "a whole number of instances" }), Schema.isGreaterThanOrEqualTo(0).annotate({ expected: "zero or more instances" }), @@ -90,14 +84,7 @@ const worker = Schema.Struct({ ), }); -/** - * `[workers]` — one `[workers.<name>]` table per worker, mirroring the - * `[functions.<slug>]` convention in the same file. - * - * Workers live at `supabase/workers/<name>/`; one whose code lives somewhere - * else entirely uses its own `source`, which is anchored to the project root and - * so can leave `supabase/`. - */ +/** `[workers]` — one `[workers.<name>]` table per worker, keyed by worker name. */ export const workers = Schema.Record(workerName, worker) .annotate({ default: {}, diff --git a/packages/config/src/workers.unit.test.ts b/packages/config/src/workers.unit.test.ts index 251f106de5..44374a857a 100644 --- a/packages/config/src/workers.unit.test.ts +++ b/packages/config/src/workers.unit.test.ts @@ -20,10 +20,6 @@ describe("workers schema", () => { expect(decode(every)).toEqual(every); }); - // Unconstrained, like `runtime` and `size`: the Management API takes - // `spec.exposure` as a plain string, and `push` is what names the values it - // accepts. Pinning an enum here would make a config a newer CLI understands - // fail to load at all. test("accepts an exposure it does not itself recognize", () => { expect(decode({ api: { exposure: "internal" } })).toEqual({ api: { exposure: "internal" } }); }); @@ -36,18 +32,12 @@ describe("workers schema", () => { expect(Schema.decodeUnknownSync(Schema.Struct({ workers }))({})).toEqual({ workers: {} }); }); - // Keys outside the DNS-label pattern fall outside the record's index - // signature and are dropped, the same way `[functions.<slug>]` treats a slug - // its own pattern does not match. `supabase experimental workers new` validates the name - // up front so the CLI never writes one that would vanish here. test("drops worker names that are not DNS labels", () => { expect(decode({ Not_A_Label: {}, api: { runtime: "node" } })).toEqual({ api: { runtime: "node" }, }); }); - // Every dial is optional: a worker scaffolded by `supabase experimental workers new` records - // only what it prompted for, and `push` resolves the rest from its own defaults. test("decodes a worker table with no dials set", () => { expect(decode({ api: {} })).toEqual({ api: {} }); }); @@ -56,9 +46,6 @@ describe("workers schema", () => { expect(() => decode({ api: { instances: "three" } })).toThrow(); }); - // `spec.instances` is an integer in the Management API's input schema, and a - // value that slips through here is dropped downstream and silently rescales - // the worker to 1 rather than failing. Named at load time instead. test.each([ ["a fraction", 1.5], ["a negative count", -1], @@ -74,10 +61,6 @@ describe("workers schema", () => { expect(() => decode({ api: "node" })).toThrow(); }); - // The published asset at `PROJECT_CONFIG_SCHEMA_URL` is what editors read, so - // the worker dials have to stay described and completable — and a worker value - // has to be a plain table, or an editor would accept a bare scalar the CLI - // refuses to load. test("includes worker properties in the generated JSON schema", () => { const json = JSON.parse(JSON.stringify(Schema.toJsonSchemaDocument(workers).schema)); const objectSchema = json.anyOf?.find((entry: { type?: string }) => entry?.type === "object"); @@ -90,8 +73,6 @@ describe("workers schema", () => { expect(workerSchema?.properties?.source).toBeDefined(); }); - // An integer bound the published schema carries, so an editor flags `1.5` - // before the CLI ever reads it. test("bounds instances as a non-negative integer in the generated JSON schema", () => { const json = JSON.parse(JSON.stringify(Schema.toJsonSchemaDocument(workers).schema)); const objectSchema = json.anyOf?.find((entry: { type?: string }) => entry?.type === "object"); diff --git a/packages/config/vitest.config.ts b/packages/config/vitest.config.ts index 14e1c86d76..2128f0a716 100644 --- a/packages/config/vitest.config.ts +++ b/packages/config/vitest.config.ts @@ -1,15 +1,9 @@ import { defaultClientConditions, defaultServerConditions } from "vite"; import { defineConfig } from "vitest/config"; -// This package publishes a `bun` export condition pointing at its -// TypeScript source (see package.json's `exports` map); without it, Vite's -// resolver falls through to the `default` condition and loads the built -// `dist/*.js` output instead — stale, or missing entirely on a fresh clone -// before the package has been built. Extending (not replacing) Vite's -// default condition lists keeps every other package's exports resolution -// unchanged. Required on every inline `test.projects` entry too: Vitest -// builds a separate Vite config per project and does not inherit these from -// the root config (see PR #6366 finding 0). +// Adds the `bun` export condition so Vite resolves this package's TypeScript source +// instead of a possibly stale or missing `dist/*.js` build. Must be repeated on every +// inline `test.projects` entry; Vitest builds a separate Vite config per project. const workspacePackageResolve = { conditions: [...defaultClientConditions, "bun"] }; const workspacePackageSsrResolve = { conditions: [...defaultServerConditions, "bun"] }; diff --git a/packages/stack/src/control/ControlServer.ts b/packages/stack/src/control/ControlServer.ts index f97ca07be1..ac4e4bccdd 100644 --- a/packages/stack/src/control/ControlServer.ts +++ b/packages/stack/src/control/ControlServer.ts @@ -207,9 +207,9 @@ const demuxSocket = ( const startCompletion = (completion: Effect.Effect<void>) => Effect.uninterruptible( FiberSet.run(completionFibers, completion, { startImmediately: true }).pipe( - // This witness is set only after the completion fiber has been - // handed to the owner-scoped FiberSet. The uninterruptible - // region keeps the fork and witness atomic to dispatch/onExit. + // Set only after the completion fiber is handed to the owner-scoped + // FiberSet; the uninterruptible region keeps fork and witness atomic + // with `onExit` below. Effect.tap(() => Effect.sync(() => { completionStarted = true; @@ -280,12 +280,10 @@ const demuxSocket = ( for (const frame of result.value) { if (closed) break; if (phase === "maintenance") { - // Probe and request validation are bounded by the maintenance admission - // deadline. A validated stop owns its full cleanup operation and may - // legitimately outlive that connection deadline; the caller joins the result. - // The preface deadline only governs admission until the first frame arrives; - // once a frame is present, dispatch owns the remaining validation/operation - // policy below. + // Probe/validation are bounded by the maintenance admission deadline; a + // validated stop owns its own cleanup and may outlive it. The preface deadline + // governs only admission until the first frame arrives — dispatch below owns + // policy after that. yield* markPrefaceReady; const operation = yield* Effect.exit( decodeFrame(frame).pipe( @@ -529,9 +527,9 @@ export const startControlServer = ( }) : protocol.send(clientId, response, transferables), }); - // Keep handler defects as keyed Exit responses so lifecycle-completion request state can be - // cleaned by the same send path as typed failures. Without this option RpcServer emits a - // client-level Defect frame that has no requestId for the terminal handoff. + // Keeps handler defects as keyed Exit responses so lifecycle-completion state can be + // cleaned by the same send path as typed failures; otherwise RpcServer emits a Defect + // frame with no requestId for the terminal handoff. const rpcProgram: Effect.Effect<never, never> = RpcServer.make(StackRpcGroup, { disableTracing: true, disableFatalDefects: true, @@ -626,19 +624,17 @@ export interface ControlClientOptions extends ControlIdentity { } export interface ControlClient { - // Each call allocates a fresh scoped socket, so these methods intentionally - // return per-call lazy Effects rather than shared Effect values. - // oxlint-disable-next-line effecttsgo/lazy-effect + // oxlint-disable-next-line effecttsgo/lazy-effect -- each call opens a fresh scoped socket. readonly probe: () => Effect.Effect< MaintenanceResponse, Socket.SocketError | MaintenanceProtocolError >; - // oxlint-disable-next-line effecttsgo/lazy-effect + // oxlint-disable-next-line effecttsgo/lazy-effect -- each call opens a fresh scoped socket. readonly stop: () => Effect.Effect< MaintenanceResponse, Socket.SocketError | MaintenanceProtocolError >; - // oxlint-disable-next-line effecttsgo/lazy-effect + // oxlint-disable-next-line effecttsgo/lazy-effect -- each call opens a fresh scoped socket. /** Connects with an RPC preface and completes when the owner closes the socket. */ readonly awaitClose: (onOpen?: Effect.Effect<void>) => Effect.Effect<void, Socket.SocketError>; readonly rpc: Effect.Effect<StackRpcClient, RpcClientError, Scope.Scope>; @@ -690,9 +686,9 @@ export const makeControlClient = ( }, ); const fiber = yield* Effect.forkChild(read); - // A connection can fail before NodeSocket runs the onOpen hook. Join the - // reader alongside writer readiness so that failure cannot strand this - // handshake on an unresolved Deferred. + // A connection can fail before NodeSocket runs the onOpen hook; join the reader + // alongside writer readiness so failure can't strand this handshake on an unresolved + // Deferred. const readerReady = Fiber.join(fiber).pipe( Effect.andThen( Effect.fail( @@ -718,9 +714,9 @@ export const makeControlClient = ( stackId: options.stackId, ownerSessionId: options.ownerSessionId, }).pipe(Effect.flatMap(write)); - // The server closes a successful maintenance connection immediately - // after flushing its response. Check the response witness after the - // reader exits so the close event cannot win that handoff race. + // The server closes a successful connection immediately after flushing its response; + // check the response witness after the reader exits so the close event can't win + // that race. const readerDone = Effect.exit(Fiber.join(fiber)).pipe( Effect.flatMap(() => Deferred.poll(response)), Effect.flatMap((completed) => diff --git a/packages/stack/src/entrypoints/supervisor-node.ts b/packages/stack/src/entrypoints/supervisor-node.ts index d5859f0d0d..7475a34057 100644 --- a/packages/stack/src/entrypoints/supervisor-node.ts +++ b/packages/stack/src/entrypoints/supervisor-node.ts @@ -1,7 +1,6 @@ import { NodeServices } from "@effect/platform-node"; import { Crypto, Data, Effect, FileSystem, Path, Schema } from "effect"; -// Node's fd3 readiness channel has no FileSystem abstraction; this is the -// process-entrypoint boundary where the descriptor is intentionally used. +// Node's fd3 readiness channel has no FileSystem abstraction, so it's used directly here. // oxlint-disable-next-line effecttsgo/node-builtin-import import * as NodeFs from "node:fs"; import { @@ -34,8 +33,8 @@ const reportSupervisorFailure = (error: unknown, readiness: ReadinessState): voi if (!readiness.written) try { const conflict = error instanceof StackOwnershipConflictError; - // The descriptor is owned by the parent launcher and intentionally - // written directly at this standalone process boundary. + // The descriptor is owned by the parent launcher, written directly at this process + // boundary. NodeFs.writeSync( 3, `${Schema.encodeSync(Schema.fromJsonString(SupervisorReadySchema))({ ok: false, code: conflict ? "ownership-conflict" : "failed", message: error instanceof Error ? error.message : "Supervisor failed" })}\n`, diff --git a/packages/stack/src/functions/serve-main-resolver.ts b/packages/stack/src/functions/serve-main-resolver.ts index 9de2608a2f..f01946e55f 100644 --- a/packages/stack/src/functions/serve-main-resolver.ts +++ b/packages/stack/src/functions/serve-main-resolver.ts @@ -118,10 +118,9 @@ export const resolveFunctionConfig = async (options: { if (canonicalInfo === undefined || !canonicalInfo.isDirectory) return undefined; const globalDefaults = overrides.$default; const functionOverride = overrides[slug]; - // `$default` cannot be a function slug (the slug schema only accepts letters, - // digits, `_`, and `-`) and therefore provides a collision-free global - // default for functions discovered after the stack started. A closed - // per-function override is spread last so it always wins. + // `$default` cannot be a function slug (the slug schema only accepts letters, digits, `_`, + // and `-`), so it's a collision-free global default; the per-function override is spread + // last so it always wins. const override = { ...globalDefaults, ...functionOverride, @@ -145,9 +144,8 @@ export const resolveFunctionConfig = async (options: { if (entrypointInfo === undefined || !entrypointInfo.isFile || entrypointInfo.isSymbolicLink) return undefined; - // Per-function import maps are relative to that function's directory. The - // reserved global default is explicitly root-relative, so one shared map is - // reused by every slug (including slugs created after serve starts). + // Per-function import maps are relative to that function's directory; the reserved global + // default is root-relative, so one shared map is reused by every slug. const functionImportMap = functionOverride?.importMapPath ?? functionOverride?.import_map; const globalImportMap = globalDefaults?.importMapRoot ?? globalDefaults?.import_map_root; let importMapPath = diff --git a/packages/stack/src/gateway/HttpGateway.ts b/packages/stack/src/gateway/HttpGateway.ts index d2e3f242da..c3fffeb5c0 100644 --- a/packages/stack/src/gateway/HttpGateway.ts +++ b/packages/stack/src/gateway/HttpGateway.ts @@ -444,8 +444,8 @@ const handleUpgrade = ( socket.destroy(); return; } - // Observe EOF on a paused upgrade without consuming any queued protocol bytes. The observer is - // removed before the backend tunnel starts so it cannot interfere with flowing-mode piping. + // Observes EOF on a paused upgrade without consuming queued protocol bytes; removed before + // the backend tunnel starts so it can't interfere with flowing-mode piping. const onSocketReadable = () => { socket.read(0); }; diff --git a/packages/stack/src/model/Compiler.ts b/packages/stack/src/model/Compiler.ts index 29be8ececa..9e2dcdd87a 100644 --- a/packages/stack/src/model/Compiler.ts +++ b/packages/stack/src/model/Compiler.ts @@ -447,10 +447,9 @@ const releaseFor = <T>( const selected = extract(raw, "version"); const selector = typeof selected === "string" ? selected : (previousVersion ?? module.defaultVersion); - // The CLI config exposes PostgreSQL as a major selector (for example `15` - // or `17`), while the stack catalog persists a concrete release. Resolve a - // major against the catalog here so the CLI never needs to know artifact - // patch IDs. Exact selectors keep their existing behavior for every module. + // The CLI config exposes PostgreSQL as a major selector (`15`, `17`), while the catalog + // persists a concrete release; resolve a major against the catalog here so the CLI never + // needs to know artifact patch IDs. const majorSelector = module.name === "database" && /^\d+$/.test(selector); const resolvedSelector = majorSelector ? (() => { diff --git a/packages/stack/src/model/DatabaseBootstrap.ts b/packages/stack/src/model/DatabaseBootstrap.ts index 036c641a46..8262d5a958 100644 --- a/packages/stack/src/model/DatabaseBootstrap.ts +++ b/packages/stack/src/model/DatabaseBootstrap.ts @@ -42,9 +42,8 @@ export class DatabaseBootstrapError extends Data.TaggedError("DatabaseBootstrapE }> {} /** - * The bootstrap intentionally knows nothing about a PostgreSQL client. A - * native or container runtime supplies this boundary from its already-ready - * database connection. + * The bootstrap knows nothing about a PostgreSQL client; a native or container runtime + * supplies this boundary from its already-ready database connection. */ export interface DatabaseTransaction { readonly execute: ( diff --git a/packages/stack/src/model/WorkloadCatalog.ts b/packages/stack/src/model/WorkloadCatalog.ts index 4a3e880524..d83a3cf2dc 100644 --- a/packages/stack/src/model/WorkloadCatalog.ts +++ b/packages/stack/src/model/WorkloadCatalog.ts @@ -55,8 +55,9 @@ const native = ( /** * The single authoritative private workload identity table. * - * Pins track the slim-services release feed (ADR 0017), not the Dockerfile, and - * deliberately diverge from it — do not "reconcile" the two. Maintained by + * Pins track the slim-services release feed (see + * `docs/adr/0017-simplified-managed-stack-architecture.md`), not the Dockerfile, and diverge + * from it on purpose — do not "reconcile" the two. Maintained by * `.github/workflows/sync-stack-workload-catalog.yml`. */ const workloadCatalog = { diff --git a/packages/stack/src/model/capabilities/auth-third-party.ts b/packages/stack/src/model/capabilities/auth-third-party.ts index 6291084f13..ade33678bc 100644 --- a/packages/stack/src/model/capabilities/auth-third-party.ts +++ b/packages/stack/src/model/capabilities/auth-third-party.ts @@ -1,10 +1,4 @@ -/** - * Closed mapping for the third-party issuer rules used by Auth.ResolveJWKS. - * - * This module is deliberately pure: network discovery and JWKS retrieval are - * owned by the runtime owner, while validation and issuer construction remain - * deterministic and testable during compilation. - */ +/** Closed mapping for the third-party issuer rules used by Auth.ResolveJWKS. */ type ThirdPartyProviderName = "firebase" | "auth0" | "aws_cognito" | "clerk" | "workos"; diff --git a/packages/stack/src/preparation/ArtifactStore.ts b/packages/stack/src/preparation/ArtifactStore.ts index 0e0412afc2..14c30f2b9e 100644 --- a/packages/stack/src/preparation/ArtifactStore.ts +++ b/packages/stack/src/preparation/ArtifactStore.ts @@ -3,12 +3,9 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { ArtifactIntegrityError, StackPreparationError } from "../public/Errors.ts"; import { validateRelativePath, validateSha256 } from "./Integrity.ts"; -/** - * A concrete artifact identity. `key` is deliberately private to preparation and may contain - * subdirectories, but never an absolute or traversing path. - */ +/** A concrete artifact identity. `key` may contain subdirectories but never an absolute or traversing path. */ export interface ArtifactRequest { - /** Immutable published identity; a reused key intentionally reuses its persisted digest. */ + /** Immutable published identity; a reused key reuses its persisted digest. */ readonly key: string; /** Relative paths that a runtime may use after installation. */ readonly requiredRuntimePaths: ReadonlyArray<string>; @@ -17,9 +14,9 @@ export interface ArtifactRequest { } /** - * The source is the only download/archive boundary. It writes an unpacked artifact tree below - * `destination` after verifying the downloaded archive digest. Production sources stream directly - * to disk and return void. The store itself remains independent of transport and archive formats. + * The source is the only download/archive boundary: it writes an unpacked artifact tree + * below `destination` after verifying the downloaded archive digest. The store stays + * independent of transport and archive formats. */ export interface ArtifactSource { /** Resolves the published digest only when the store has no valid cached artifact. */ @@ -616,9 +613,9 @@ const makeArtifactOperation = ( if (Option.isNone(cachedMetadata)) return Option.none(); const metadata = cachedMetadata.value; const sha256 = yield* verifyMetadata(request, metadata); - // Published content is intentionally not rehashed on cache hits. Metadata and cheap - // structural checks protect the cache boundary; content tampering may execute or fail - // later when the workload starts. + // Published content is not rehashed on cache hits: metadata and cheap structural checks + // protect the cache boundary; content tampering may execute or fail later when the + // workload starts. const safePaths = yield* ensureSafePaths( fs, path, diff --git a/packages/stack/src/preparation/RuntimeArtifacts.ts b/packages/stack/src/preparation/RuntimeArtifacts.ts index 4ddabe440d..eac0dee437 100644 --- a/packages/stack/src/preparation/RuntimeArtifacts.ts +++ b/packages/stack/src/preparation/RuntimeArtifacts.ts @@ -31,7 +31,7 @@ export interface PreparedWorkloadArtifact { readonly capability: PlannedWorkload["capability"]; readonly version: string; readonly outcome: "cached" | "downloaded" | "pulled"; - /** Native installation root. Container preparation deliberately has no root. */ + /** Native installation root; container preparation has no root. */ readonly artifactRoot?: string; readonly executablePath?: string; readonly image?: string; diff --git a/packages/stack/src/preparation/SlimServicesSource.ts b/packages/stack/src/preparation/SlimServicesSource.ts index df5fbf6adb..13496d9997 100644 --- a/packages/stack/src/preparation/SlimServicesSource.ts +++ b/packages/stack/src/preparation/SlimServicesSource.ts @@ -68,9 +68,7 @@ export const systemTarBoundary: TarBoundary = { ); }), }; -// The slim-services transport is a foreign HTTP boundary; production wiring -// may provide an Effect HttpClient-backed fetcher through RuntimeFactory. -// oxlint-disable-next-line effecttsgo/global-fetch +// oxlint-disable-next-line effecttsgo/global-fetch -- foreign HTTP boundary; production wiring may swap in an Effect HttpClient-backed fetcher. const fetcher: Fetcher = (input, init) => globalThis.fetch(input, init); const fetchBytes = ( From 73896b237395c87ba10961380c5a80f0fa71716e Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:13:56 +0100 Subject: [PATCH 49/56] chore(repo): trim comments, shard 02 (534 -> 272 comment lines) --- apps/cli/scripts/build-binary.ts | 8 +- apps/cli/scripts/build.ts | 41 +++----- apps/cli/scripts/detect-unmirrored-images.ts | 37 +++---- .../detect-unmirrored-images.unit.test.ts | 3 - apps/cli/scripts/generate-docs-spec.ts | 16 ++- apps/cli/scripts/generate-docs.ts | 11 +-- apps/cli/scripts/macos-signing.ts | 26 ++--- apps/cli/scripts/propose-release-notes.ts | 72 +++----------- apps/cli/scripts/publish-docs-spec.ts | 23 ++--- apps/cli/scripts/publish.ts | 40 +++----- apps/cli/scripts/update-homebrew.ts | 17 +--- apps/cli/scripts/update-scoop.ts | 14 +-- .../cli/src/auth/command-credentials.layer.ts | 99 ++++++------------- .../command-credentials.layer.unit.test.ts | 20 +--- .../src/auth/command-credentials.service.ts | 36 ++----- .../command-platform-api-factory.layer.ts | 11 +-- .../src/auth/command-platform-api.layer.ts | 14 ++- .../command-platform-api.layer.unit.test.ts | 23 +---- apps/cli/src/auth/errors.ts | 8 +- apps/cli/src/auth/http-debug.layer.ts | 26 ++--- apps/cli/src/auth/http-debug.unit.test.ts | 8 -- apps/cli/src/cli/agent-output.e2e.test.ts | 7 +- .../cli/boolean-flag-defaults.unit.test.ts | 40 +++----- apps/cli/src/cli/complete.integration.test.ts | 10 +- 24 files changed, 173 insertions(+), 437 deletions(-) diff --git a/apps/cli/scripts/build-binary.ts b/apps/cli/scripts/build-binary.ts index a7ce9cb8e8..e9326ed8ce 100644 --- a/apps/cli/scripts/build-binary.ts +++ b/apps/cli/scripts/build-binary.ts @@ -3,11 +3,9 @@ import { $ } from "bun"; import { bundleServeMainTemplate } from "../src/shared/functions/serve-main-bundler.ts"; /** - * Compile the CLI shell to a standalone binary, embedding the pre-bundled - * edge-runtime template via the `SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE` define so - * the binary serves Functions offline without bundling at runtime - * (supabase/supabase#45570). Used by the `build:binary` script; the multi-target - * release build in `build.ts` injects the same define. + * Compiles the CLI to a standalone binary, run via `pnpm build:binary`. Embeds the pre-bundled + * edge-runtime template through `SUPABASE_FUNCTIONS_SERVE_MAIN_TEMPLATE` so Functions serve + * offline without bundling at runtime (supabase/supabase#45570). */ const entrypoint = "src/main.ts"; const outfile = "dist/supabase"; diff --git a/apps/cli/scripts/build.ts b/apps/cli/scripts/build.ts index 73c262ea8f..8fa9fa2df9 100644 --- a/apps/cli/scripts/build.ts +++ b/apps/cli/scripts/build.ts @@ -181,13 +181,9 @@ async function buildGoTarget(target: (typeof TARGETS)[number]) { } /** - * Decide how to sign the macOS binaries. - * - * `rcodesign` (the apple-codesign project) signs Mach-O binaries from Linux, - * so signing happens inline on the existing build runner. Release CI installs - * it and sets SUPABASE_CLI_REQUIRE_SIGNING=1 so a missing tool fails the build; - * local builds without rcodesign degrade to "off" with a warning so - * contributors can still produce (unsigned) binaries. + * Decides how to sign macOS binaries. `rcodesign` signs Mach-O binaries from Linux, so signing + * runs inline on this build runner; falls back to "off" with a warning unless + * SUPABASE_CLI_REQUIRE_SIGNING=1, which fails the build when the tool is missing. */ function resolveSignMode(): SignMode { if (Bun.which("rcodesign")) { @@ -224,15 +220,12 @@ async function signDarwinBinaries(mode: SignMode) { const identifier = MACOS_IDENTIFIERS[binary]; console.log(`[${target.pkg}] Ad-hoc signing ${binary} (${identifier})...`); - // No key material => rcodesign emits a complete ad-hoc signature - // (CodeDirectory + RequirementSet + empty CMS), equivalent to - // `codesign --sign -`, replacing Bun/Go's linker-signed signature. + // No key material, so rcodesign produces an ad-hoc signature, equivalent to + // `codesign --sign -`, replacing Bun/Go's linker-signed one. await $`rcodesign sign --binary-identifier ${identifier} ${binPath}`; - // Linux-side verification that runs on every build: the signature must - // carry exactly our identifier (matched on the whole value, so the SFE's - // `com.supabase.cli` can't satisfy the sidecar's `com.supabase.cli-go`) - // and must no longer be linker-signed. + // Matches the identifier's whole value, so the SFE's `com.supabase.cli` can't satisfy + // the sidecar's `com.supabase.cli-go`, and confirms the signature is no longer linker-signed. const info = await $`rcodesign print-signature-info ${binPath}`.text(); const signedIdentifier = info.match(/^\s*identifier:\s*(\S+)\s*$/m)?.[1]; if (signedIdentifier !== identifier) { @@ -260,9 +253,8 @@ async function archiveTarget(target: (typeof TARGETS)[number]) { ]; await $`zip -j ${archivePath} ${files}`; - // setup-cli and other download clients always fetch a .tar.gz, including on - // Windows where tc.extractTar handles the archive. Publish a matching - // tar.gz alongside the .zip so those clients keep working. See #5257. + // setup-cli and other download clients always fetch a .tar.gz, even on Windows, so + // publish one alongside the .zip. See #5257. const tarArchive = target.archive.replace(/\.zip$/, ".tar.gz"); const tarArchivePath = path.join(distDir, tarArchive); const tarFiles = [`supabase${target.ext}`, `supabase-go${target.ext}`]; @@ -296,9 +288,8 @@ async function buildMuslBinaries() { `--outfile=${outfile}`, ]); - // Go binary is CGO_ENABLED=0 (fully static), so the glibc Linux build works on - // musl too. Copy it from the matching glibc package so the published musl npm - // package contains the supabase-go binary that GoProxy resolves to. + // The Go binary is fully static (CGO_ENABLED=0), so the glibc build works on musl too; + // copy it into the musl package so GoProxy finds supabase-go there. const glibcTarget = TARGETS.find( (candidate) => "nfpmArch" in candidate && candidate.nfpmArch === target.nfpmArch, ); @@ -329,9 +320,8 @@ async function buildLinuxPackages(version: string) { const outPath = path.join(distDir, outFile); const binDir = fmt === "apk" ? muslBinDir : glibcBinDir; - // Go binary is CGO_ENABLED=0 (fully static), so the glibc Linux build works on - // musl too. For apk (musl), binDir is muslBinDir for the TS binary but we still - // reference supabase-go from the glibc dir where it was built. + // The Go binary is fully static, so apk (musl) still references supabase-go + // from the glibc dir where it was built. const contents: Array<{ src: string; dst: string }> = [ { src: path.join(binDir, "supabase"), dst: "/usr/bin/supabase" }, { src: path.join(glibcBinDir, "supabase-go"), dst: "/usr/bin/supabase-go" }, @@ -408,9 +398,8 @@ await Promise.all(TARGETS.map(buildTarget)); console.log("\nCompiling Go CLI for all targets..."); await Promise.all(TARGETS.map(buildGoTarget)); -// Sign macOS binaries before archiving so every channel (npm platform -// packages, Homebrew, GitHub Release archives, checksums) ships the signed -// bytes. Must run before archiveTarget / buildLinuxPackages / generateChecksums. +// Must run before archiveTarget / buildLinuxPackages / generateChecksums so every +// distribution channel ships the signed bytes. const signMode = resolveSignMode(); console.log(`\nSigning macOS binaries (mode: ${signMode})...`); await signDarwinBinaries(signMode); diff --git a/apps/cli/scripts/detect-unmirrored-images.ts b/apps/cli/scripts/detect-unmirrored-images.ts index 44136f8caa..a172b2b76e 100644 --- a/apps/cli/scripts/detect-unmirrored-images.ts +++ b/apps/cli/scripts/detect-unmirrored-images.ts @@ -1,30 +1,22 @@ -// Detects which images pinned in apps/cli-go/pkg/config/templates/Dockerfile are -// not yet present on every mirror registry and emits the missing ones as JSON. -// Used by the mirror-template-images workflow to drive the backfill matrix. -// -// It checks every image and skips the ones already mirrored everywhere, so -// re-running after a successful mirror is a no-op. The exported helpers are -// unit-tested in detect-unmirrored-images.unit.test.ts; the entry block below -// (guarded by import.meta.main) performs the only side effects. +// Detects images pinned in apps/cli-go/pkg/config/templates/Dockerfile that are missing from +// any mirror registry and emits them as JSON, for the mirror-template-images workflow's backfill +// matrix. import { spawnSync } from "node:child_process"; import { appendFileSync } from "node:fs"; import process from "node:process"; import { dockerfileServiceImages } from "../src/shared/services/dockerfile-images.ts"; /** - * Registries the mirror publishes to and the CLI pulls from, mirroring Go's - * `utils.GetRegistryImageUrls` (`defaultRegistry` + `ghcrRegistry`). An image - * counts as mirrored only when it exists on EVERY one of these — the mirror - * pushes to all of them at once, so a tag present on one but not another is a - * partial mirror that must be re-pushed. + * Registries the mirror publishes to and the CLI pulls from. An image counts as mirrored only + * when it exists on every one of these — a tag present on one but not another is a partial + * mirror that must be re-pushed. */ export const MIRROR_REGISTRIES = ["public.ecr.aws", "ghcr.io"] as const; /** - * Mirror destination for an upstream image on a single registry, mirroring Go's - * `utils.GetRegistryImageUrl` (`registry + "/supabase/" + basename`). The - * upstream org is dropped — every image is mirrored under the `supabase/` - * namespace — e.g. `postgrest/postgrest:v14.14` -> `ghcr.io/supabase/postgrest:v14.14`. + * Mirror destination for an upstream image on a single registry. The upstream org is dropped — + * every image is mirrored under the `supabase/` namespace, e.g. + * `postgrest/postgrest:v14.14` -> `ghcr.io/supabase/postgrest:v14.14`. */ export function mirrorImageTarget(image: string, registry: string): string { const basename = image.slice(image.lastIndexOf("/") + 1); @@ -47,12 +39,9 @@ export interface MirrorPartition { } /** - * Split images by whether they are fully mirrored — present on EVERY registry in - * `registries`. An image missing from any one registry lands in `missing` so the - * backfill re-pushes it everywhere. Every (image, registry) pair is queried, each - * distinct image once. No image is skipped up front — a `supabase/*` image that is - * somehow absent is reported just like a third-party one. Idempotent: once an - * image is on all registries, a re-run skips it. + * Splits images by whether they exist on every registry in `registries`. An image missing from + * any one registry lands in `missing`, since the backfill must re-push it everywhere; images + * already mirrored everywhere land in `mirrored` and are skipped by a re-run. */ export async function partitionUnmirroredImages( images: Iterable<string>, @@ -75,8 +64,6 @@ export async function partitionUnmirroredImages( }; } -// An image counts as mirrored only when this returns true for every registry -// target; both are queried per image by the partition above. function imageExistsOnMirror(target: string): Promise<boolean> { const result = spawnSync("docker", ["buildx", "imagetools", "inspect", target], { stdio: "ignore", diff --git a/apps/cli/scripts/detect-unmirrored-images.unit.test.ts b/apps/cli/scripts/detect-unmirrored-images.unit.test.ts index f51420cf04..c4cad9e2c2 100644 --- a/apps/cli/scripts/detect-unmirrored-images.unit.test.ts +++ b/apps/cli/scripts/detect-unmirrored-images.unit.test.ts @@ -8,8 +8,6 @@ import { describe("detect unmirrored images", () => { test("mirrors an upstream image under the supabase namespace of a registry", () => { - // Third-party orgs are dropped; only the basename is kept, matching Go's - // utils.GetRegistryImageUrl. expect(mirrorImageTarget("postgrest/postgrest:v14.14", "ghcr.io")).toBe( "ghcr.io/supabase/postgrest:v14.14", ); @@ -48,7 +46,6 @@ describe("detect unmirrored images", () => { expect(mirrored).toEqual(["library/kong:2.8.1"]); expect(missing).toEqual(["postgrest/postgrest:v14.14"]); - // Two unique images x two registries = four checks. expect(queried).toHaveLength(4); }); diff --git a/apps/cli/scripts/generate-docs-spec.ts b/apps/cli/scripts/generate-docs-spec.ts index 319766b5d7..54ec3e8112 100644 --- a/apps/cli/scripts/generate-docs-spec.ts +++ b/apps/cli/scripts/generate-docs-spec.ts @@ -1,15 +1,11 @@ /** - * Emits the `clispec 001` CLI reference document for the supabase.com docs - * site (`supabase/supabase` `apps/docs/spec/cli_v1_commands.yaml`) from the - * Effect command tree plus the content under `apps/cli/docs/` - * (`supabase/` description overlays, `templates/examples.yaml`). + * Emits the `clispec 001` CLI reference document for the supabase.com docs site from the Effect + * command tree plus the content under `apps/cli/docs/`. Run as + * `bun scripts/generate-docs-spec.ts [version] > cli_v1_commands.yaml`; only the spec YAML goes + * to stdout, diagnostics go to stderr. * - * Contract (documented in `docs/README.md`): the spec YAML is the ONLY thing - * written to stdout, so `bun scripts/generate-docs-spec.ts > cli_v1_commands.yaml` - * yields a clean parseable file; diagnostics go to stderr. The spec version - * defaults to `latest` and can be overridden with an argument (a leading - * `v` is stripped) — the workspace package.json version is a semantic-release - * placeholder, so it is never used. + * The version defaults to `latest` (a leading `v` is stripped); the workspace `package.json` + * version is a semantic-release placeholder and is never used here. */ import path from "node:path"; import process from "node:process"; diff --git a/apps/cli/scripts/generate-docs.ts b/apps/cli/scripts/generate-docs.ts index eff52b6772..74e70b3cb1 100644 --- a/apps/cli/scripts/generate-docs.ts +++ b/apps/cli/scripts/generate-docs.ts @@ -74,13 +74,10 @@ function generateCommandDocs() { } /** - * Copies `@supabase/config`'s already post-processed (metadata + number- - * union-collapsed) `dist/*.json` schema artifact straight to its docs-site - * public path, rather than re-rendering `toCliConfigJsonSchema()`/ - * `toProjectConfigJsonSchema()` here (CLI-2234) — re-rendering would bypass - * `json-schema-postprocess.ts` and produce a document whose `$id` doesn't - * match what actually gets published. Requires `@supabase/config#build` to - * have already run (wired via `turbo.json`). + * Copies `@supabase/config`'s post-processed `dist/*.json` schema artifact to its docs-site + * public path rather than re-rendering it here, since re-rendering would bypass + * `json-schema-postprocess.ts` and produce a mismatched `$id`. Requires + * `@supabase/config#build` to have already run (wired via `turbo.json`). */ function copyConfigSchemaAsset(schemaUrl: string, distFileName: string) { const schemaPathname = new URL(schemaUrl).pathname.replace(/^\/docs/, ""); diff --git a/apps/cli/scripts/macos-signing.ts b/apps/cli/scripts/macos-signing.ts index a9279eae92..13b3037a63 100644 --- a/apps/cli/scripts/macos-signing.ts +++ b/apps/cli/scripts/macos-signing.ts @@ -1,16 +1,8 @@ /** - * macOS code-signing identifiers per shipped binary. Single source of truth - * shared by the signer (`build.ts`) and the verifier (the macOS smoke-test - * helper) so the identifier a binary is signed with can't drift from the one it - * is verified against. - * - * Why signing exists at all: `bun build --compile` and the Go linker emit a - * degenerate "linker-signed" ad-hoc signature (identifier `a.out`, no - * requirements blob) that macOS 26+ AMFI rejects, SIGKILLing the process at - * launch (CLI-1621 / GitHub #5556). Re-signing with a full ad-hoc signature — - * a complete CodeDirectory + RequirementSet + (empty) CMS, the same shape - * `codesign --sign -` produces — fixes it without any Apple credentials. Phase - * 2 will add Developer ID signing + notarization on top. + * macOS code-signing identifiers, shared by the signer (`build.ts`) and its verifier so both + * agree. `bun build --compile` and the Go linker emit an ad-hoc signature that macOS 26+ AMFI + * SIGKILLs at launch (GitHub #5556); re-signing with a full ad-hoc signature fixes it without + * Apple credentials. */ export type MacBinaryName = "supabase" | "supabase-go"; @@ -20,18 +12,14 @@ export const MACOS_IDENTIFIERS: Record<MacBinaryName, string> = { }; /** - * Look up the expected identifier for a binary basename. Returns `undefined` - * for anything that isn't a signed macOS binary, so callers verifying an - * arbitrary file path can fail closed. + * Looks up the expected identifier for a binary basename, or `undefined` if it isn't a signed + * macOS binary, so callers verifying an arbitrary path fail closed. */ export function macIdentifierFor(binary: string): string | undefined { return binary === "supabase" || binary === "supabase-go" ? MACOS_IDENTIFIERS[binary] : undefined; } -/** - * The macOS binaries shipped for the CLI: the CLI's Bun SFE alongside - * the Go sidecar it proxies a residual command surface to. - */ +/** The macOS binaries shipped for the CLI: the Bun binary and its Go sidecar. */ export function darwinBinaries(): MacBinaryName[] { return ["supabase", "supabase-go"]; } diff --git a/apps/cli/scripts/propose-release-notes.ts b/apps/cli/scripts/propose-release-notes.ts index 37abf57fbd..53a67338a0 100644 --- a/apps/cli/scripts/propose-release-notes.ts +++ b/apps/cli/scripts/propose-release-notes.ts @@ -1,42 +1,11 @@ #!/usr/bin/env bun -// Generate a user-centric GitHub Release body for a Supabase CLI tag -// by running the Claude Agent SDK against tools/release/release-notes-prompt.md -// with the raw semantic-release block substituted in. +// Generates a user-centric GitHub Release body for a Supabase CLI tag by running the Claude Agent +// SDK against tools/release/release-notes-prompt.md, then opens a PR proposing the notes for +// approval (unless --dry-run or --render-only). See the PR body template below for the approval +// workflow. // -// Pipeline shape: -// 1. `backfill-release-notes.ts --tag <tag>` produces the raw semantic-release -// markdown (without writing anything to the GH release). We always -// re-derive this so the proposer is decoupled from whatever happens to -// sit in the release body at the moment. -// 2. The raw block is inlined into tools/release/release-notes-prompt.md in -// place of the {{PASTE_SEMANTIC_RELEASE_BLOCK_HERE}} placeholder. -// 3. The Claude Agent SDK runs the rendered prompt with WebFetch + Bash so -// it can investigate PR bodies, linked issues, and changed files (the -// prompt's investigation step is real work, not boilerplate). -// 4. The agent's final assistant message is written to -// release-notes/v<VERSION>.md. -// 5. Unless --dry-run is passed, the script commits the file on a branch -// `release-notes/v<VERSION>` and opens a PR. Approving the PR (as a -// supabase/cli team member) triggers apply-release-notes.yml, which -// pushes the file's contents to the GH release body and closes the PR -// without merging. The PR targets `develop` (not `main`) so an -// accidental merge can never rewrite `main`'s history; in practice the -// file never lands on any branch. -// -// Usage: -// bun apps/cli/scripts/propose-release-notes.ts --tag v2.101.0 --dry-run -// bun apps/cli/scripts/propose-release-notes.ts --tag v2.101.0 --apply -// -// --tag Required. Release tag (e.g. v2.101.0 or v2.99.0-beta.1). -// --dry-run Print the proposed notes to stdout. Does not write any files, -// does not touch git. -// --apply Write release-notes/v<VERSION>.md, commit on a branch, push, -// and open a PR. Default behavior when neither flag is passed -// is `--dry-run`. -// --render-only Print the rendered prompt (template + raw notes block) -// and exit before any LLM call. Useful for prompt iteration -// and for verifying the pipeline shape without spending tokens. -// --model Optional. Override the Claude model (default: claude-haiku-4-5-20251001). +// Usage: bun apps/cli/scripts/propose-release-notes.ts --tag v2.101.0 [--dry-run] [--apply] +// [--render-only] [--model <name>] import { query, type Options } from "@anthropic-ai/claude-agent-sdk"; import { $ } from "bun"; import { mkdir, readFile, writeFile } from "node:fs/promises"; @@ -93,13 +62,11 @@ if (values["render-only"]) { console.error(`==> Running Claude Agent SDK (model=${values.model})`); const options: Options = { model: values.model, - // The agent needs WebFetch / WebSearch to investigate PR bodies and linked - // issues per the prompt's step 3, and Bash so it can use `gh` for - // authenticated GitHub queries instead of HTML scraping. Edit/Write are - // intentionally excluded — the script owns the final file output. + // WebFetch/WebSearch investigate PR bodies and linked issues; Bash uses `gh` for + // authenticated GitHub queries instead of HTML scraping. Edit/Write are excluded — the + // script owns the final file output. allowedTools: ["WebFetch", "WebSearch", "Bash"], - // Don't load the repo's CLAUDE.md or settings.json — the prompt is - // self-contained and we don't want unrelated agent context bleeding in. + // Skips the repo's CLAUDE.md and settings.json — the prompt is self-contained. settingSources: [], cwd: repoRoot, effort: "low", @@ -126,7 +93,6 @@ if (!finalText.trim()) { process.exit(1); } -// Append the raw notes to the final text to ensure the output is complete. const normalized = finalText.endsWith("\n") ? finalText : `${finalText}\n`; console.error(`==> Agent finished (cost ~$${cost.toFixed(4)})`); @@ -147,15 +113,9 @@ await writeFile(notesPath, normalized); console.error(`==> Wrote ${path.relative(repoRoot, notesPath)}`); const branch = `release-notes/v${version}`; -// Always cut the notes branch from origin/develop — the PR base. The workflow -// can be dispatched from an arbitrary feature branch that has diverged from -// the base by many commits; branching off the checked-out ref would drag -// every one of those commits into the PR (so the PR shows N changed files -// instead of just the proposed notes). The notes file is untracked at this -// point, so resetting HEAD to origin/develop leaves it untouched in the -// working tree. We target `develop` rather than `main` so that an accidental -// merge of this approval-only PR lands on the integration branch instead of -// rewriting `main`'s history. +// Branches from origin/develop, not the checked-out ref, so a feature branch that has diverged +// doesn't drag its commits into the PR; the notes file is untracked, so this leaves it in place. +// Targets develop rather than main so an accidental merge can't rewrite main's history. await $`git fetch --no-tags origin develop`.cwd(repoRoot).nothrow(); await $`git checkout -B ${branch} origin/develop`.cwd(repoRoot); await $`git add ${notesPath}`.cwd(repoRoot); @@ -179,10 +139,8 @@ if (!pushed) { process.exit(1); } -// Idempotently ensure the `do not merge` label exists on the repo, then attach -// it on PR creation. The label is a visual reminder for reviewers — the -// approval-based apply workflow never invokes the merge button — but the -// publish flow itself does not depend on it. +// Ensures the `do not merge` label exists before attaching it — a reminder for reviewers; the +// approval workflow never actually merges. const labelName = "do not merge"; await $`gh label create ${labelName} --color B60205 --description ${"Approve to apply; do not merge."} --force` .cwd(repoRoot) diff --git a/apps/cli/scripts/publish-docs-spec.ts b/apps/cli/scripts/publish-docs-spec.ts index b4b969500f..310f2be9ce 100644 --- a/apps/cli/scripts/publish-docs-spec.ts +++ b/apps/cli/scripts/publish-docs-spec.ts @@ -1,19 +1,11 @@ /** - * Publishes the generated CLI reference to the docs site by opening a PR - * against supabase/supabase, replacing the Go `tools/bumpdoc` that was deleted - * in the monorepo merge (71b543255). The reference has not been republished - * since, so the published spec is frozen at 2.98.2. - * - * Reads the spec on stdin so it composes with the generator exactly the way the - * Go release job did: + * Publishes the generated CLI reference to the docs site by opening a PR against + * supabase/supabase. Reads the spec on stdin: * * bun scripts/generate-docs-spec.ts <version> | bun scripts/publish-docs-spec.ts --version <version> * - * Like `bumpdoc`, this is a no-op when the spec is already published and no PR - * is missing: it prints "already up to date" and exits 0. A branch whose spec - * is ahead of base still gets its PR ensured, so a run that pushed but failed - * to open the PR is repaired by the next release. Any real failure exits - * non-zero. + * No-op (exit 0) when the spec is already published and no PR is missing; a branch already ahead + * of base still gets its PR ensured, so a failed prior run is repaired by the next release. */ import { $ } from "bun"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; @@ -27,7 +19,6 @@ const { values } = parseArgs({ options: { version: { type: "string" }, repo: { type: "string", default: "supabase/supabase" }, - // Path of the spec inside the docs repo, as the Go job passed it. "spec-path": { type: "string", default: "apps/docs/spec/cli_v1_commands.yaml" }, branch: { type: "string", default: "cli/ref-doc" }, base: { type: "string", default: "master" }, @@ -88,10 +79,8 @@ if (dryRun) { process.exit(0); } -// `--depth 1` is enough: when `cli/ref-doc` already exists its tip is fetched -// and the new spec lands as one commit on top, so commits pushed onto an open -// PR (sidebar fixes) survive later releases; otherwise the branch starts from -// base. +// `--depth 1` suffices: an existing `cli/ref-doc` branch is fetched at its tip and the new spec +// lands as one commit on top, so commits pushed onto an open PR survive later releases. const tmpDir = await mkdtemp(path.join(tmpdir(), "supabase-docs-")); try { await $`git clone --quiet --depth 1 --branch ${base} https://github.com/${repo}.git ${tmpDir}`; diff --git a/apps/cli/scripts/publish.ts b/apps/cli/scripts/publish.ts index c86c11139b..6f06469641 100644 --- a/apps/cli/scripts/publish.ts +++ b/apps/cli/scripts/publish.ts @@ -45,16 +45,12 @@ const tagFlag = ["--tag", tag]; const provenanceFlag = ["--provenance"]; const noGitChecksFlag = ["--no-git-checks"]; -// Reads the active npm registry once. We honour `npm config get registry` -// rather than hard-coding registry.npmjs.org so the existence probe and the -// publish target stay aligned — important for the local Verdaccio harness -// (`pnpm local-registry`), which rewrites the global npm/pnpm registry config. +// Uses `npm config get registry` instead of hard-coding registry.npmjs.org, so the probe and +// publish target agree — needed for the local Verdaccio harness (`pnpm local-registry`). const registryUrl = (await $`npm config get registry`.quiet().text()).trim().replace(/\/$/, ""); -// Probes the registry for `<name>@<version>`: -// 200 → already published, 404 → not, anything else throws. -// Used both as a pre-flight skip check and as a post-failure reconciliation -// when the actual publish errors with E403 (registry CDN cache may lag). +// Used as both a pre-flight skip check and post-failure reconciliation, since a publish can +// fail with E403 when the registry CDN cache lags behind an already-succeeded publish. async function isAlreadyPublished(name: string, version: string): Promise<boolean> { const encodedName = name.replace("/", "%2F"); const res = await fetch(`${registryUrl}/${encodedName}/${version}`, { method: "GET" }); @@ -65,9 +61,8 @@ async function isAlreadyPublished(name: string, version: string): Promise<boolea type PublishResult = "published" | "skipped"; -// Publishes one workspace package idempotently. If the version is already -// on the registry — either before we start or after a publish-time conflict -// — we skip and return "skipped". Any other failure propagates. +// Skips a version already on the registry — checked before publishing and again after a +// publish-time conflict — and lets any other failure propagate. async function publishPackage(opts: { name: string; version: string; @@ -106,10 +101,8 @@ console.log( : `Publishing to npm with tag "${tag}"...\n`, ); -// Defensive: every platform package must already be at the umbrella version. -// `sync-versions.ts` runs in the workflow before publish (`release-shared.yml`), -// so a mismatch here means the script was invoked out of order — fail loud -// rather than publishing an inconsistent set of packages. +// Every platform package must already be at the umbrella version; `sync-versions.ts` runs +// before this in the release workflow, so a mismatch means the script ran out of order. for (const pkg of PLATFORM_PACKAGES) { const pkgJson = await Bun.file(path.join(root, "packages", pkg, "package.json")).json(); if (pkgJson.version !== umbrellaVersion) { @@ -120,7 +113,6 @@ for (const pkg of PLATFORM_PACKAGES) { } } -// Publish all platform packages in parallel console.log("Publishing platform packages..."); const platformResults = await Promise.all( PLATFORM_PACKAGES.map((pkg) => @@ -133,14 +125,11 @@ const platformResults = await Promise.all( ), ); -// Build the umbrella package bin shim, then publish console.log("\nBuilding umbrella package shim..."); await $`pnpm build:shim`.cwd(cliDir); -// npm renders the README from the package directory on the package page. -// The workspace-internal apps/cli/README.md documents the source layout for -// contributors, which is not what we want users to see on npmjs.com. Copy -// the repo root README — the user-facing one — over it just before publish. +// npm renders the README from the package directory; apps/cli/README.md documents the source +// layout for contributors, not what npm users should see, so copy the root README over it. console.log("\nStaging root README for umbrella package..."); await copyFile(path.join(root, "README.md"), path.join(cliDir, "README.md")); @@ -157,12 +146,9 @@ const skippedCount = results.filter((r) => r === "skipped").length; console.log(`\nPublished: ${publishedCount}, Skipped: ${skippedCount}.`); -// All-skipped is ambiguous: it can mean "recovering from a downstream-only -// failure (GH release / brew / scoop) — bytes already on npm, just continue" -// OR "semantic-release re-computed a version whose bytes are already live, so -// today's commits silently did not ship". Since we cannot tell those apart -// here, log a loud warning so the human reviewing the workflow run can decide -// whether to re-cut as a fresh version via `workflow_dispatch`. +// All-skipped is ambiguous: it may mean a downstream-only retry (bytes already on npm) or that +// today's commits silently didn't ship a new version. Warn loudly so a human can decide whether +// to re-cut via workflow_dispatch. if (publishedCount === 0) { console.warn( `\n[warn] No packages were published — every package was already on the registry at ${umbrellaVersion}.\n` + diff --git a/apps/cli/scripts/update-homebrew.ts b/apps/cli/scripts/update-homebrew.ts index d2f0fe8c6e..6266fef0e7 100644 --- a/apps/cli/scripts/update-homebrew.ts +++ b/apps/cli/scripts/update-homebrew.ts @@ -32,22 +32,17 @@ const dryRun = values["dry-run"]!; const root = path.resolve(import.meta.dir, "../../.."); const distDir = path.join(root, "dist"); -// Convert name (e.g. "supabase-beta") to the Ruby class name Homebrew -// expects (e.g. "SupabaseBeta"). The class + filename differ by channel so -// `supabase` and `supabase-beta` can coexist as separate formulas in the -// same tap, but the installed binary is always `supabase` (matching the -// Go CLI's historical behaviour). +// Converts name (e.g. "supabase-beta") to the Ruby class Homebrew expects (e.g. "SupabaseBeta"). +// The class and filename vary by channel, but the installed binary is always `supabase`. const className = name .split(/[-_]/) .filter(Boolean) .map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase()) .join(""); -// `supabase-go` is the Go sidecar the CLI spawns via -// apps/cli/src/command-internal/go-proxy.layer.ts. It is looked up by exact -// filename colocated with process.execPath, so we MUST install it with its -// original name right next to the SFE. The `if File.exist?` guard keeps the -// formula working for a build that ships only the SFE. +// The Go sidecar is looked up by exact filename next to the running binary, so it must install +// under its original name; `if File.exist?` keeps the formula working when a build ships only +// the CLI binary. const installBlock = [ ` bin.install "supabase"`, ` bin.install "supabase-go" if File.exist?("supabase-go")`, @@ -55,7 +50,6 @@ const installBlock = [ const testInvocation = `#{bin}/supabase`; -// Parse checksums const checksums = new Map<string, string>(); const checksumsText = await readFile(path.join(distDir, "checksums.txt"), "utf-8"); for (const line of checksumsText.trim().split("\n")) { @@ -127,7 +121,6 @@ async function hasStagedChanges(repoDir: string, repoPath: string): Promise<bool throw new Error(`Failed to inspect staged changes for ${repoPath}`); } -// Clone tap repo, update formula, commit, push const tmpDir = await mkdtemp(path.join(tmpdir(), "homebrew-tap-")); try { const tapUrl = `https://github.com/${tap}.git`; diff --git a/apps/cli/scripts/update-scoop.ts b/apps/cli/scripts/update-scoop.ts index c93961d5c6..596f6405f7 100644 --- a/apps/cli/scripts/update-scoop.ts +++ b/apps/cli/scripts/update-scoop.ts @@ -30,16 +30,12 @@ const name = values.name!; const local = values.local!; const dryRun = values["dry-run"]!; -// The shipped binary is always `supabase.exe`, regardless of channel — only -// the manifest filename differs (e.g. `supabase-beta.json`) so stable and -// beta can coexist in the same bucket. Matches the Go CLI's historical -// scoop-bucket layout (`supabase.json` and `supabase-beta.json` both shim -// `supabase.exe`). +// The shipped binary is always `supabase.exe`; only the manifest filename differs (e.g. +// `supabase-beta.json`), so stable and beta can coexist in the same bucket. const binEntry = "supabase.exe"; const root = path.resolve(import.meta.dir, "../../.."); const distDir = path.join(root, "dist"); -// Parse checksums const checksums = new Map<string, string>(); const checksumsText = await readFile(path.join(distDir, "checksums.txt"), "utf-8"); for (const line of checksumsText.trim().split("\n")) { @@ -58,9 +54,8 @@ const baseUrl = local ? `file:///${distDir.replace(/\\/g, "/")}` : `https://github.com/${repo}/releases/download/v${version}`; -// Main-bucket layout uses unversioned Windows tarballs on GitHub Releases -// (release-shared.yml copies versioned builds to supabase_windows_*.tar.gz). -// Local builds only emit versioned archives, so --local keeps those names. +// Published releases use unversioned Windows tarball names (release-shared.yml copies the +// versioned build); local builds only emit versioned archives, so --local keeps those names. const amd64Tar = local ? `supabase_${version}_windows_amd64.tar.gz` : "supabase_windows_amd64.tar.gz"; @@ -121,7 +116,6 @@ async function hasStagedChanges(repoDir: string, repoPath: string): Promise<bool throw new Error(`Failed to inspect staged changes for ${repoPath}`); } -// Clone bucket repo, update manifest, commit, push const tmpDir = await mkdtemp(path.join(tmpdir(), "scoop-bucket-")); try { const bucketUrl = `https://github.com/${bucket}.git`; diff --git a/apps/cli/src/auth/command-credentials.layer.ts b/apps/cli/src/auth/command-credentials.layer.ts index 57924f79e8..06297151c0 100644 --- a/apps/cli/src/auth/command-credentials.layer.ts +++ b/apps/cli/src/auth/command-credentials.layer.ts @@ -118,9 +118,8 @@ function readGoWindowsTarget(module: KeyringModule, account: string): string | n } } -// `Entry.withTarget` is avoided as a probe — its constructor writes an empty -// placeholder. A `findCredentials` throw is ambiguous (an undecodable Go blob or -// a real enumeration failure), so reported as `"unknown"`, never assumed present. +// `Entry.withTarget` is avoided as a probe since its constructor writes an empty placeholder. A +// `findCredentials` throw is ambiguous, so it's reported as `"unknown"`, never assumed present. type WindowsTargetProbe = "present" | "absent" | "unknown"; function probeWindowsTarget(module: KeyringModule, account: string): WindowsTargetProbe { @@ -161,9 +160,8 @@ function normalizeGoWindowsPassword(value: string): string { const direct = normalizeKeyringToken(value); if (ACCESS_TOKEN_PATTERN.test(direct)) return direct; - // Go writes Windows CredentialBlob values as raw UTF-8 bytes. The TS keyring - // search API can surface those bytes packed into UTF-16 code units, so unpack - // each code unit back into the original byte sequence before validation. + // Go writes Windows CredentialBlob values as raw UTF-8 bytes, which the keyring search API + // can surface packed into UTF-16 code units; unpack each back into the original byte sequence. const bytes: number[] = []; for (let index = 0; index < value.length; index += 1) { const code = value.charCodeAt(index); @@ -200,21 +198,9 @@ function deleteGoWindowsTarget(module: KeyringModule, account: string): boolean return false; } } -// Delete the project database-password entry (keyed by project ref), surfacing a -// real failure while ignoring the "nothing to delete" cases — mirroring Go's -// unlink, which ignores both `keyring.ErrNotFound` AND `credentials.ErrNotSupported` -// (backend unavailable) and only surfaces other errors (`unlink.go:36-40`). -// -// The plain `Entry(service, projectRef)` is the macOS/Linux form and the Windows -// default. On Windows, Go also writes a separate target-shaped credential, -// deleted through the `withTarget` entry. The `withTarget` entry is only -// constructed on Windows — on macOS its first argument is an invalid keychain -// domain and throws. -// -// Each entry is probed before `deleteCredential()`: on macOS deleting an absent -// entry blocks on a Keychain authorization prompt, and an absent read means -// there is nothing to delete (ignorable, per Go). Only a real delete failure is -// surfaced as `CredentialDeleteError`. +// Deletes the project database-password keyring entry, ignoring "nothing to delete" and +// surfacing only a real failure. Each entry is probed first, since deleting an absent macOS +// entry blocks on a Keychain prompt; on Windows a separate `withTarget` entry is also probed. const deleteKeyringEntryStrict = ( module: KeyringModule, account: string, @@ -252,13 +238,8 @@ const deleteKeyringEntryStrict = ( return deleted; }); -// Delete the access-token profile entry, distinguishing three outcomes: -// - `"deleted"` — an entry existed and was removed (→ logged out, exit 0); -// - `"notFound"` — no entry existed (→ not logged in, exit 0); -// - `DeleteTokenError` — a real `deleteCredential()` failure (exit 1). -// Like `deleteKeyringEntryStrict`, the entry is probed first so deleting an -// absent macOS entry never blocks on a Keychain prompt, and the Windows -// target-shaped credential is handled separately. +// Deletes the profile's access-token keyring entry. Returns "notFound" when nothing existed +// (not an error) so the caller can treat that as already logged out. const deleteProfileKeyringEntry = ( module: KeyringModule, account: string, @@ -296,13 +277,9 @@ const deleteProfileKeyringEntry = ( return found ? "deleted" : "notFound"; }); -// Best-effort wipe of the `"Supabase CLI"` keyring namespace; errors are -// swallowed so one stuck credential can't abort logout. -// -// Windows credentials are also written under the target-shaped form -// (`Supabase CLI:<account>`), invisible to the plain enumeration below, so -// it's swept separately. `findCredentials` decodes every matched blob and -// aborts entirely on one undecodable entry. +// Best-effort wipe of the "Supabase CLI" keyring namespace; errors are swallowed so one stuck +// credential can't abort logout. Windows credentials are also written under a separate +// target-shaped form, invisible to the plain enumeration, so they're swept separately. const deleteAllKeyringEntries = ( module: KeyringModule, platform: RuntimePlatform, @@ -337,10 +314,8 @@ const deleteAllKeyringEntries = ( } }); -// `SUPABASE_NO_KEYRING=1` disables the OS keyring entirely (matches the shared -// credentials layer and the cli-e2e harness, which sets it). Without this, any -// unconditional keyring access — e.g. `unlink`'s credential delete — blocks on a -// Keychain authorization prompt in non-interactive / CI contexts. +// `SUPABASE_NO_KEYRING=1` disables the OS keyring entirely; without it, unconditional keyring +// access blocks on a Keychain authorization prompt in non-interactive/CI contexts. const loadKeyringModule = ( fs: FileSystem.FileSystem, ): Effect.Effect<Option.Option<KeyringModule>> => @@ -352,9 +327,8 @@ const loadKeyringModule = ( : yield* Effect.tryPromise(() => import("@napi-rs/keyring")).pipe(Effect.option); }); -// Keyring chain for a given profile account: profile key first, then the -// legacy `access-token` key. The account parameter matters because Go keys -// the read on the RECONCILED `CurrentProfile.Name` (`access_token.go:43`). +// Keyring chain for a given profile account: profile key first, then the legacy `access-token` +// key. Callers must pass the already-reconciled profile name. const readKeyringForAccount = ( keyringModule: Option.Option<KeyringModule>, profileAccount: string, @@ -392,24 +366,17 @@ const readFallbackFile = ( }); /** - * Token resolution for an explicit profile account, mirroring the service's - * `getAccessToken` chain exactly: env token → keyring (profile account, then - * legacy account) → fallback file. Go resolves credentials AFTER - * `LoadProfile`, so the keyring account is the reconciled - * `CurrentProfile.Name` (`access_token.go:43`) — but the `CommandCredentials` - * service captures the config layer's profile at construction. Commands that - * reconcile a pflag-effective profile (sso add/update, PR #5974 round 9) - * resolve their token through this instead, keyed on the reconciled name. - * Fails with the same validation error as the service; callers absorb it the - * same way `resolveAccessToken` does. + * Resolves an access token for an explicit profile account: env token → keyring (profile + * account, then legacy account) → fallback file. Used by commands that reconcile a + * pflag-effective profile after `CommandCredentials` already captured a different one at + * construction. Fails with the same validation error as `resolveAccessToken`. */ export const accessTokenForProfile = Effect.fnUntraced(function* (profileAccount: string) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runtimeInfo = yield* RuntimeInfo; const cliSettings = yield* CommandSettings; - // `serviceOption` keeps the logger optional (no-op outside the real CLI - // tree), same as the sso pflag-reconcile module's optional services. + // Keeps the logger optional — a no-op outside the real CLI tree. const debugLogger: DebugLoggerShape = Option.getOrElse( yield* Effect.serviceOption(DebugLogger), () => ({ debug: () => Effect.void, http: () => Effect.void }), @@ -469,21 +436,19 @@ const makeCommandCredentials = Effect.gen(function* () { return CommandCredentials.of({ getAccessToken: Effect.gen(function* () { - // Env takes precedence (matches access_token.go:38). if (Option.isSome(cliSettings.accessToken)) { yield* debugLogger.debug("Using access token from env var..."); yield* validateAccessToken(Redacted.value(cliSettings.accessToken.value), "env"); return Option.some(cliSettings.accessToken.value); } - // Keyring (profile key, then legacy key). Skipped on WSL. + // Skipped on WSL. const keyringValue = yield* readKeyring; if (Option.isSome(keyringValue)) { yield* validateAccessToken(keyringValue.value, "stored"); return Option.some(Redacted.make(keyringValue.value)); } - // Filesystem fallback in the Supabase home directory. const fileValue = yield* readFile; if (Option.isSome(fileValue)) { yield* debugLogger.debug(`Using access token from file: ${fallbackPath}`); @@ -506,16 +471,15 @@ const makeCommandCredentials = Effect.gen(function* () { ); if (ok) return; } - // The containing directory is world-readable (0755); only the token - // FILE itself needs to be private (0600). + // The containing directory is world-readable (0755); only the token file itself must + // be private (0600). yield* fs.makeDirectory(fallbackDir, { recursive: true, mode: 0o755 }).pipe(Effect.orDie); yield* fs.writeFileString(fallbackPath, token, { mode: 0o600 }).pipe(Effect.orDie); }), deleteAccessToken: Effect.gen(function* () { - // 1. Always remove the fallback token file first. A missing file is - // ignored; any other removal failure aborts before the keyring is - // touched. + // Removes the fallback token file first; a missing file is ignored, but any other + // failure aborts before the keyring is touched. const exists = yield* fs.exists(fallbackPath).pipe(Effect.orElseSucceed(() => false)); if (exists) { yield* fs.remove(fallbackPath).pipe( @@ -529,15 +493,14 @@ const makeCommandCredentials = Effect.gen(function* () { ); } - // 2. Best-effort delete of the legacy `access-token` keyring account. - // Any error here is ignored — never affects the result. + // Best-effort delete of the legacy `access-token` keyring account; errors here don't + // affect the result. if (Option.isSome(keyringModule)) { yield* tryKeyringDelete(keyringModule.value, LEGACY_KEYRING_ACCOUNT, runtimeInfo.platform); } - // 3. Delete the profile keyring account — this alone decides the outcome. - // No keyring backend (WSL / `SUPABASE_NO_KEYRING` / unsupported) maps - // to `NotLoggedInError`. + // Deleting the profile keyring account decides the outcome; no keyring backend (WSL, + // `SUPABASE_NO_KEYRING`, unsupported) maps to `NotLoggedInError`. if (Option.isNone(keyringModule)) { return yield* Effect.fail(new NotLoggedInError({ message: NOT_LOGGED_IN_MESSAGE })); } @@ -558,7 +521,7 @@ const makeCommandCredentials = Effect.gen(function* () { deleteProjectCredential: (projectRef: string) => Effect.gen(function* () { - // WSL / no keyring module: treated as `ErrNotSupported` — a no-op success. + // WSL or no keyring module: no-op success, nothing to delete. if (Option.isNone(keyringModule)) return false; return yield* deleteKeyringEntryStrict( keyringModule.value, diff --git a/apps/cli/src/auth/command-credentials.layer.unit.test.ts b/apps/cli/src/auth/command-credentials.layer.unit.test.ts index ba5c0806e4..b71c1caf22 100644 --- a/apps/cli/src/auth/command-credentials.layer.unit.test.ts +++ b/apps/cli/src/auth/command-credentials.layer.unit.test.ts @@ -23,8 +23,6 @@ import { commandCredentialsLayer } from "./command-credentials.layer.ts"; import { CommandCredentials } from "./command-credentials.service.ts"; import { DeleteTokenError, InvalidAccessTokenError, NotLoggedInError } from "./errors.ts"; -// Keyring mock - const passwords = new Map<string, string>(); let throwOnSetPassword = false; let throwOnSetSecret = false; @@ -99,8 +97,6 @@ vi.mock("@napi-rs/keyring", () => ({ }, })); -// Layer wiring - let tempHome: string; function makeLayer( @@ -400,10 +396,7 @@ describe("commandCredentialsLayer.saveAccessToken", () => { it.effect("falls back to the filesystem when the keyring write throws", () => { throwOnSetPassword = true; - // Deterministic mode assertions require a permissive umask: Go pins the - // fallback dir to 0755 (`access_token.go:91` → `MkdirIfNotExistFS`, - // `misc.go:273`, changed from the prior 0700) and the token file itself to - // 0600 (`access_token.go:94`). + // Deterministic mode assertions require a permissive umask. const prevUmask = process.umask(0); return Effect.gen(function* () { const { saveAccessToken } = yield* CommandCredentials; @@ -433,9 +426,6 @@ describe("commandCredentialsLayer.saveAccessToken", () => { }); }); -// `deleteAccessToken` collapses three outcomes — logged out / not-logged-in / -// real failure — into the file + legacy-keyring + profile-keyring sequence. -// These cases assert that ordering and tri-state exactly. describe("commandCredentialsLayer.deleteAccessToken", () => { const seedTokenFile = (home: string, token = VALID_TOKEN) => { const supaDir = join(home, ".supabase"); @@ -482,7 +472,6 @@ describe("commandCredentialsLayer.deleteAccessToken", () => { expect(JSON.stringify(exit.cause)).toContain("NotLoggedInError"); expect(JSON.stringify(exit.cause)).toContain("You were not logged in, nothing to do."); } - // File removal happens before the profile-keyring check (deliberate ordering). expect(tokenFileExists(tempHome)).toBe(false); }).pipe(Effect.provide(makeLayer())); }, @@ -533,8 +522,7 @@ describe("commandCredentialsLayer.deleteAccessToken", () => { const home = tempHome; const env = { HOME: home }; const tokenPath = join(home, ".supabase", "access-token"); - // Seed a profile keyring entry to prove the keyring is never touched once - // the file removal fails. + // Seeds a profile keyring entry to prove the keyring is never touched once the file removal fails. passwords.set("Supabase CLI/supabase", VALID_TOKEN); const runtimeInfoLayer = mockRuntimeInfo({ homeDir: home, cwd: home }); const fsLayer = Layer.succeed( @@ -724,7 +712,6 @@ describe("commandCredentialsLayer.deleteAllProjectCredentials", () => { const { deleteAllProjectCredentials } = yield* CommandCredentials; const exit = yield* Effect.exit(deleteAllProjectCredentials); expect(exit._tag).toBe("Success"); - // One undecodable entry aborts the whole findCredentials call. expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs1"))).toBe(true); expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs2"))).toBe(true); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); @@ -760,7 +747,8 @@ describe("commandCredentialsLayer.deleteProjectCredential", () => { ); }); -// Suppress unused-import nag — referenced in JSDoc / used in assertions above. +// Kept to avoid an unused-import lint error; the classes are only referenced by name in +// assertions above. void InvalidAccessTokenError; void DeleteTokenError; void NotLoggedInError; diff --git a/apps/cli/src/auth/command-credentials.service.ts b/apps/cli/src/auth/command-credentials.service.ts index 828aa4a542..a19d6f0c93 100644 --- a/apps/cli/src/auth/command-credentials.service.ts +++ b/apps/cli/src/auth/command-credentials.service.ts @@ -15,40 +15,20 @@ interface CommandCredentialsShape { >; readonly saveAccessToken: (token: string) => Effect.Effect<void, InvalidAccessTokenError>; /** - * Deletes the access token, in this exact order: - * - * 1. Remove `<SUPABASE_HOME or ~/.supabase>/access-token` first. A non-`ENOENT` removal error - * fails `DeleteTokenError`; a missing file is ignored. - * 2. Best-effort delete of the legacy `access-token` keyring account — any - * error other than not-found is swallowed and never affects the outcome. - * 3. Delete the profile keyring account (account = profile name). This - * **alone** decides the result: - * - keyring unavailable (no module / WSL / `SUPABASE_NO_KEYRING`) or the - * entry is absent → `NotLoggedInError`; - * - a real delete error → `DeleteTokenError`; - * - success → `void`. - * - * Deliberate quirk this preserves: on a no-keyring host the file is - * still removed, yet the call fails `NotLoggedInError` because the - * profile-keyring delete reports not-supported. + * Deletes the access token: removes the fallback file, best-effort deletes the legacy keyring + * account, then deletes the profile keyring account, which alone decides the result. The file + * is removed even on a no-keyring host, where the call still fails `NotLoggedInError`. */ readonly deleteAccessToken: Effect.Effect<void, NotLoggedInError | DeleteTokenError>; /** - * Deletes **every** entry in the `"Supabase CLI"` keyring namespace (project - * database passwords stored by `link`). Best-effort: never fails, and is a - * no-op when the keyring is unavailable. Used by `supabase logout` after - * the access token is removed. + * Deletes every entry in the `"Supabase CLI"` keyring namespace (project database passwords + * stored by `link`). Best-effort: never fails, and no-ops when the keyring is unavailable. */ readonly deleteAllProjectCredentials: Effect.Effect<void>; /** - * Deletes the stored database-password credential for a project from the OS - * keyring (keyring service `"Supabase CLI"`, account = the **project ref** — - * distinct from the access-token entry). Used by `supabase unlink`. - * - * Returns `true` when an entry was removed, `false` when none existed or the - * keyring is unavailable (WSL). Fails with `CredentialDeleteError` only - * for real keyring errors (e.g. permission denied); "not found" and - * "not supported" outcomes are not treated as failures. + * Deletes the stored database-password credential for a project from the OS keyring (account = + * the project ref). Returns `false` when none existed or the keyring is unavailable; fails + * with `CredentialDeleteError` only for a real keyring error. */ readonly deleteProjectCredential: ( projectRef: string, diff --git a/apps/cli/src/auth/command-platform-api-factory.layer.ts b/apps/cli/src/auth/command-platform-api-factory.layer.ts index 819cc7e99d..6c0adad60f 100644 --- a/apps/cli/src/auth/command-platform-api-factory.layer.ts +++ b/apps/cli/src/auth/command-platform-api-factory.layer.ts @@ -10,13 +10,10 @@ type CommandPlatformApiDeps = typeof makeCommandPlatformApi extends Effect.Effect<infer _A, infer _E, infer R> ? R : never; /** - * Captures the surrounding Management API context without resolving an access - * token. The raw fetch client is provided here so `makeCommandPlatformApi` owns - * the single typed-API debug wrapper. - * - * `dohFetchLayer` overrides `FetchHttpClient.Fetch` so that when the - * factory's `make` resolves on the `--linked` path, the typed API client - * honours `--dns-resolver https`. + * Captures the surrounding Management API context without resolving an access token; the raw + * fetch client is provided here so `makeCommandPlatformApi` owns the single typed-API debug + * wrapper. `dohFetchLayer` overrides `FetchHttpClient.Fetch` so a factory-resolved `--linked` + * client honours `--dns-resolver https`. */ export const commandPlatformApiFactoryLayer = Layer.effect( CommandPlatformApiFactory, diff --git a/apps/cli/src/auth/command-platform-api.layer.ts b/apps/cli/src/auth/command-platform-api.layer.ts index 7d3e8a97e4..9e4fdbbd53 100644 --- a/apps/cli/src/auth/command-platform-api.layer.ts +++ b/apps/cli/src/auth/command-platform-api.layer.ts @@ -18,11 +18,9 @@ export const makeCommandPlatformApi = Effect.gen(function* () { const cliSettings = yield* CommandSettings; const credentials = yield* CommandCredentials; const debugLogger = yield* DebugLogger; - // Every Management API response is passed through the per-command identity - // stitcher for session identity stitching. Consume the single per-command - // stitcher service rather than building one here, so the typed client shares - // the one `stitchAttempted` guard with the raw advisor GETs and the - // linked-project cache; otherwise each transport would re-alias/re-persist. + // Every Management API response goes through the per-command identity stitcher. Consuming + // the shared service, rather than building one here, keeps its `stitchAttempted` guard shared + // with the raw advisor GETs and the linked-project cache. const { stitch: stitchIdentityFromResponse } = yield* IdentityStitch; const transformClient = (client: HttpClient.HttpClient) => { @@ -41,9 +39,9 @@ export const makeCommandPlatformApi = Effect.gen(function* () { const resolveAccessToken = Effect.gen(function* () { if (Option.isSome(configuredToken)) { yield* debugLogger.debug("Using access token from env var..."); - // credentials.getAccessToken already validates the keyring/file paths; - // validate the env token here too so a malformed SUPABASE_ACCESS_TOKEN - // fails with the invalid-token error rather than being sent to the API. + // credentials.getAccessToken already validates the keyring/file paths; validate the env + // token here too so a malformed SUPABASE_ACCESS_TOKEN fails with the invalid-token error + // rather than being sent to the API. yield* validateAccessToken(Redacted.value(configuredToken.value), "env"); return configuredToken; } diff --git a/apps/cli/src/auth/command-platform-api.layer.unit.test.ts b/apps/cli/src/auth/command-platform-api.layer.unit.test.ts index cfa76c3bae..362c72b9a1 100644 --- a/apps/cli/src/auth/command-platform-api.layer.unit.test.ts +++ b/apps/cli/src/auth/command-platform-api.layer.unit.test.ts @@ -173,11 +173,8 @@ function withBaseDeps( } = {}, ) { const analytics = opts.analytics ?? mockAnalytics(); - // The typed client now consumes the single `IdentityStitch` service rather - // than building its own stitcher, so build that service from the test's - // Analytics / TelemetryRuntime / FileSystem / Path fakes and provide it. The - // underlying stitch behaviour is identical (the service wraps the same - // `makeIdentityStitcher`), so all alias/persist assertions still hold. + // Builds the shared `IdentityStitch` service from the test's Analytics/TelemetryRuntime/ + // FileSystem/Path fakes; alias/persist assertions rely on this real service instance. const identityStitch = identityStitchLayer.pipe( Layer.provide(analytics.layer), Layer.provide( @@ -281,9 +278,6 @@ describe("commandPlatformApiLayer", () => { it.effect( "fails with the invalid-token error when the env token is malformed (Go parity)", () => { - // The env token is validated against the sbp_ pattern before any API - // call; a malformed SUPABASE_ACCESS_TOKEN must fail with the - // invalid-token error, not be sent to the API. const http = captureRequests(); const layer = commandPlatformApiLayer.pipe( Layer.provide(mockCliSettings({ accessToken: "sbp_not_a_valid_token" })), @@ -530,11 +524,9 @@ describe("commandPlatformApiLayer", () => { }); }); -// The lazy factory underpins the `--linked` db-config resolver's auth-free -// `--password` path, which only loads a token when no password is supplied. -// Building the factory must therefore resolve NO token; the friendly auth -// error must still surface when a command branch actually reaches `make` -// (e.g. minting a temp role). +// The lazy factory backs the `--linked` db-config resolver's auth-free `--password` path, which +// must build without resolving a token but still surface the auth error once `make` is actually +// reached. describe("commandPlatformApiFactoryLayer (lazy token)", () => { it.effect("builds without resolving an access token even when none is configured", () => { const layer = commandPlatformApiFactoryLayer.pipe( @@ -542,9 +534,6 @@ describe("commandPlatformApiFactoryLayer (lazy token)", () => { Layer.provide(mockCredentials(Option.none())), withBaseDeps(), ); - // The eager `commandPlatformApiLayer` would fail to build here; obtaining the - // factory service without touching `make` must succeed — this is exactly the - // `--linked --password` path, which never mints a temp role. return Effect.gen(function* () { const factory = yield* CommandPlatformApiFactory; expect(typeof factory.make).toBe("object"); @@ -579,8 +568,6 @@ describe("commandPlatformApiFactoryLayer (lazy token)", () => { const factory = yield* CommandPlatformApiFactory; const first = yield* factory.make; const second = yield* factory.make; - // `Effect.cached` guarantees the token is resolved once and the same client - // instance is reused across repeated `make` calls within one command run. expect(first).toBe(second); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/auth/errors.ts b/apps/cli/src/auth/errors.ts index a997734314..01bb7d2a09 100644 --- a/apps/cli/src/auth/errors.ts +++ b/apps/cli/src/auth/errors.ts @@ -58,11 +58,9 @@ export class NotLoggedInError extends Data.TaggedError("NotLoggedInError")<{ } /** - * Raised by `deleteAccessToken` when removing the token fails for a real reason - * — a non-`ENOENT` failure removing `<SUPABASE_HOME or ~/.supabase>/access-token`, or a non - * not-found error deleting the profile keyring entry. Mirrors Go's - * `failed to remove access token file: …` / `failed to delete access token from - * keyring: …` errors (`access_token.go:100-119`), which exit 1. + * Raised by `deleteAccessToken` when removing the token fails for a real reason — a non-`ENOENT` + * failure removing `<SUPABASE_HOME or ~/.supabase>/access-token`, or a non not-found error + * deleting the profile keyring entry. */ export class DeleteTokenError extends Data.TaggedError("DeleteTokenError")<{ readonly message: string; diff --git a/apps/cli/src/auth/http-debug.layer.ts b/apps/cli/src/auth/http-debug.layer.ts index b8155e9db2..08239b0a80 100644 --- a/apps/cli/src/auth/http-debug.layer.ts +++ b/apps/cli/src/auth/http-debug.layer.ts @@ -30,19 +30,10 @@ const PRESIGNED_QUERY_KEYS = [ ]; /** - * The URL as it should appear in a debug log: unchanged, unless its query string - * carries a signature, in which case the query is replaced wholesale. - * - * Redacting the whole query rather than the matched parameters keeps the - * decision simple and cannot leak a sibling parameter that turns out to matter. - * The path survives, which is what makes the line useful for debugging in the - * first place. - * - * A denylist of known signature parameters, so it is by nature incomplete: a - * provider spelling its signature something new would log verbatim until the - * list learns about it. The alternative — redacting every query string — would - * cost the debug log its usefulness on the Management API calls that are the - * whole reason `--debug` exists. Add spellings here as they turn up. + * The URL as it should appear in a debug log: unchanged, unless its query string carries a + * signature, in which case the whole query is replaced — not just the matched parameter, so a + * sibling parameter can't leak — while the path survives for debugging. The signature denylist + * is inherently incomplete; add new spellings as they turn up. */ export function redactHttpUrl(url: string): string { let parsed: URL; @@ -65,12 +56,9 @@ export function redactHttpUrl(url: string): string { } /** - * Wraps `FetchHttpClient.layer` so every HTTP request goes through the CLI's - * debug side channel. The logger itself owns the `--debug` guard and the - * line formatting. - * - * `dohFetchLayer` overrides `FetchHttpClient.Fetch` with a - * DNS-over-HTTPS-aware fetch when `--dns-resolver https` is set. + * Wraps `FetchHttpClient.layer` so every HTTP request goes through the CLI's debug side channel; + * the logger owns the `--debug` guard and line formatting. `dohFetchLayer` overrides + * `FetchHttpClient.Fetch` with a DNS-over-HTTPS-aware fetch when `--dns-resolver https` is set. */ export const httpClientLayer = Layer.effect( HttpClient.HttpClient, diff --git a/apps/cli/src/auth/http-debug.unit.test.ts b/apps/cli/src/auth/http-debug.unit.test.ts index f5d120658b..f60d419269 100644 --- a/apps/cli/src/auth/http-debug.unit.test.ts +++ b/apps/cli/src/auth/http-debug.unit.test.ts @@ -1,12 +1,6 @@ import { describe, expect, test } from "vitest"; import { redactHttpUrl } from "./http-debug.layer.ts"; -/** - * `--debug` logs every request URL to stderr. For a presigned object-store URL - * the query string *is* the credential — for the Workers build-context upload, - * one that authorizes overwriting the archive a deploy is about to build from — - * so it must not survive into scrollback or a CI log. - */ describe("redactHttpUrl", () => { test.each([ [ @@ -34,8 +28,6 @@ describe("redactHttpUrl", () => { expect(redactHttpUrl(url)).not.toContain("deadbeef"); }); - // The debug log is only useful if ordinary requests still read normally, so - // redaction has to be the exception rather than the rule. test.each([ ["a Management API route", "https://api.supabase.com/v2/projects/abc/workers/api"], ["an ordinary query string", "https://api.supabase.com/v1/projects?limit=10"], diff --git a/apps/cli/src/cli/agent-output.e2e.test.ts b/apps/cli/src/cli/agent-output.e2e.test.ts index 0233746952..cc2c44de9f 100644 --- a/apps/cli/src/cli/agent-output.e2e.test.ts +++ b/apps/cli/src/cli/agent-output.e2e.test.ts @@ -16,11 +16,7 @@ describe("CLI agent output", () => { }); expect(exitCode).toBe(1); - // CLI-1901: the vendored effect CLI library's own duplicate JSON render - // (the old `{_tag:"Help"}` + `{_tag:"Error", error:{code:"ShowHelp"}}` - // pair on stdout, `{_tag:"Errors"}` on stderr) is gone. stdout carries - // exactly this repo's single Go-parity error line; the library's help - // doc is redirected to stderr instead of being dropped or duplicated. + // stdout carries exactly one JSON error line; the library's help doc goes to stderr. expect(parseJsonLines(stdout)).toEqual([ expect.objectContaining({ _tag: "Error", @@ -39,7 +35,6 @@ describe("CLI agent output", () => { ); expect(exitCode).toBe(1); - // CLI-1901: the help doc no longer prints to stdout at all. expect(stdout).toBe(""); expect(stderr).toContain("DESCRIPTION"); expect(stderr).toContain('Unknown subcommand "definitely-not-a-command"'); diff --git a/apps/cli/src/cli/boolean-flag-defaults.unit.test.ts b/apps/cli/src/cli/boolean-flag-defaults.unit.test.ts index c84cb843be..0b52cc503f 100644 --- a/apps/cli/src/cli/boolean-flag-defaults.unit.test.ts +++ b/apps/cli/src/cli/boolean-flag-defaults.unit.test.ts @@ -9,43 +9,25 @@ import { unwrapParam } from "../command-internal/param-introspection.ts"; import { rootCommand } from "./root.ts"; /** - * `Flag.boolean(name)` builds a bare `Single` param, and a bare `Single` is - * *required* — omitting it fails the whole command with a missing-flag error - * before the handler ever runs. Every boolean flag therefore has to be closed - * off with `Flag.withDefault(false)` or `Flag.optional`. - * - * Nothing else catches this: handler integration tests build their flags record - * directly, so they never touch the parser, and the required-ness is invisible - * to the type checker because a required boolean flag still infers as - * `boolean`. The flag only misbehaves when a real invocation omits it, which is - * precisely the invocation no handler test makes — so the guard walks the - * command tree instead of waiting for a command to be exercised end to end. - * - * `experimental workers push --no-wait` is the flag that prompted it: it first - * shipped with neither closer, which made a plain - * `supabase experimental workers push` fail to parse at all. + * `Flag.boolean(name)` alone builds a required param, so omitting it fails the whole command + * with a missing-flag error before the handler runs — every boolean flag must pair with + * `Flag.withDefault(false)` or `Flag.optional`. No other test catches this: handler integration + * tests build their flags record directly, bypassing the parser, and the type checker can't see + * the required-ness since it still infers as `boolean`. */ /** - * The published getter for a primitive's kind — `Primitive.getTypeName`, whose - * own doc example pins `Primitive.boolean` to `"boolean"`. Reading - * `primitiveType._tag` instead would couple this guard to effect's runtime - * representation, which this repo forbids in tests as well as in source. - * - * Derived from `Primitive.boolean` rather than written as the literal - * `"boolean"`: were that name to change upstream, a hardcoded literal would - * match nothing and leave the guard silently passing every command, which is - * the one failure mode a regression test must not have. + * Uses `Primitive.getTypeName(Primitive.boolean)` rather than the literal `"boolean"`, so a + * rename upstream breaks loudly instead of silently matching nothing and passing every command. + * Avoids `primitiveType._tag` to keep this guard off effect's runtime representation. */ const BOOLEAN_TYPE_NAME = Primitive.getTypeName(Primitive.boolean); function booleanFlagsRequiringAValue(command: Command.Command.Any): ReadonlyArray<string> { const internals = commandInternals(command); - // All three parameter sets a command can be parsed with, not just its own: - // `Command.withSharedFlags` puts inherited flags on `contextConfig`, and the - // root's persistent flags arrive as `globalFlags`. A bare boolean introduced - // through either would break every command that inherits it while a guard - // reading only `config.flags` stayed green. + // Checks all three parameter sets a command can be parsed with, not just its own: + // `Command.withSharedFlags` puts inherited flags on `contextConfig`, and the root's persistent + // flags arrive as `globalFlags`. const params = [ ...internals.config.flags, ...internals.contextConfig.flags, diff --git a/apps/cli/src/cli/complete.integration.test.ts b/apps/cli/src/cli/complete.integration.test.ts index 502f4b3fc4..d829e10260 100644 --- a/apps/cli/src/cli/complete.integration.test.ts +++ b/apps/cli/src/cli/complete.integration.test.ts @@ -6,13 +6,9 @@ import { EventCommandExecuted, PropExitCode } from "../shared/telemetry/event-ca import { captureCompleteTelemetryEffect, tryComplete, type CompleteDeps } from "./complete.ts"; import { rootCommand } from "./root.ts"; -// `mockAnalytics()` (`tests/helpers/mocks.ts`, the double `bash.integration.test.ts` -// uses for the same `cli_command_executed` assertion on the static completion -// leaves) records only the direct `capture(event, properties)` arguments — it -// never reads `CurrentAnalyticsContext`, so it can't see the `command` value -// `withAnalyticsContext` attaches. This local double mirrors the REAL -// `analyticsLayer`'s own capture implementation just enough to merge -// that context in, so this file can assert on `command` too. +// Mirrors the real `analyticsLayer`'s capture implementation just enough to merge +// `CurrentAnalyticsContext` into captured events, since the shared `mockAnalytics()` double +// doesn't read that context. function mockAnalyticsWithContext() { const captured: Array<{ event: string; From 6ea34fd470863d9a9b68585397c72ffd299c1d72 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:15:38 +0100 Subject: [PATCH 50/56] chore(repo): trim comments, shard 43 (1617 -> 679 comment lines) --- packages/config/src/config-diff.unit.test.ts | 160 +++------ packages/config/src/config-document.ts | 128 ++----- packages/config/src/config-edit.ts | 173 +++------ packages/config/src/config-edit.unit.test.ts | 128 ++----- packages/config/src/config-format.ts | 11 +- packages/config/src/effect.ts | 25 +- .../config/src/entrypoint-purity.unit.test.ts | 98 ++--- packages/config/src/errors.ts | 122 +------ packages/config/src/internal.ts | 19 +- packages/config/src/io-browser.ts | 29 +- packages/config/src/io.ts | 339 +++++------------- packages/config/src/io.unit.test.ts | 217 ++++------- packages/config/src/lib/env.ts | 118 ++---- packages/config/src/lib/resolve.ts | 36 +- packages/config/src/lib/secret-paths.ts | 32 +- .../src/monorepo-import-contract.unit.test.ts | 18 +- packages/config/src/paths.ts | 28 +- .../src/project-config/api-attributes.ts | 144 ++------ .../src/project-config/hosted-sections.ts | 10 +- 19 files changed, 445 insertions(+), 1390 deletions(-) diff --git a/packages/config/src/config-diff.unit.test.ts b/packages/config/src/config-diff.unit.test.ts index 980303cbab..51486286bf 100644 --- a/packages/config/src/config-diff.unit.test.ts +++ b/packages/config/src/config-diff.unit.test.ts @@ -14,9 +14,8 @@ import { getDefaultCliConfig } from "./sparse.ts"; const decodeCliConfig = Schema.decodeUnknownSync(CliConfigSchema); /** - * Builds the diff input the way the command layer does: the local operand is - * the loaded `{config, document}` pair (so raw-presence masking applies and - * the declared-key set comes from the same load), the remote operand is + * Builds the diff input the way the command layer does: the local operand is the loaded + * `{config, document}` pair (so raw-presence masking applies), the remote operand is * `fromApiProjectConfig` over bare v2 `data.attributes`. */ function diffWith( @@ -69,9 +68,8 @@ describe("diffProjectConfig classification", () => { }); test("remote-only drift keeps the materialized local default and declared: false", () => { - // The primary someone-changed-it-in-the-dashboard case: the file is - // silent, the local projection carries the schema default (1000), and a - // push would overwrite the remote 250 with it — the change must say so. + // The file is silent, so the local projection carries the schema default; a push would + // overwrite the remote value with it, so the change must say so. const result = diffWith({}, { api: { max_rows: 250 } }); const change = changeAt(result.changes, ["api", "max_rows"]); expect(change).toMatchObject({ @@ -83,9 +81,8 @@ describe("diffProjectConfig classification", () => { }); test("raw-presence-masked sections suppress zero-valued remotes", () => { - // db.ssl_enforcement is raw-presence-masked on the document arm (ADR - // 0021), so its local projection is silent when the file never declares - // it; the platform reporting the unconfigured state is not drift. + // db.ssl_enforcement is raw-presence-masked (ADR 0021): its local projection stays silent + // when the file never declares it, so the platform's unconfigured state is not drift. const clean = diffWith({}, { database: { ssl_enforced: false } }); expect(changeAt(clean.changes, ["db", "ssl_enforcement", "enabled"])).toBeUndefined(); @@ -97,10 +94,9 @@ describe("diffProjectConfig classification", () => { }); test("push-gated containers fall back to the raw schema default as baseline", () => { - // The registry maps network-restriction CIDRs unconditionally, but push - // gates them on the local `enabled` toggle, so the default projection is - // silent on them. The raw schema default (allow-all) IS the platform's - // unconfigured state — reporting it would flag every untouched project. + // Push gates network-restriction CIDRs on the local `enabled` toggle, so the default + // projection is silent on them; the raw schema default (allow-all) is the platform's + // unconfigured state, so reporting it would flag every untouched project. const clean = diffWith( {}, { @@ -133,10 +129,8 @@ describe("diffProjectConfig classification", () => { }); test("canonicalized zero durations suppress via the row's unconfiguredValue", () => { - // GoTrue reports 0 hours for unconfigured session bounds; the transform - // canonicalizes that to the STRING "0s", which no type-level zero check - // recognizes — the registry row's `unconfiguredValue` must. An untouched - // project reporting both bounds is clean; a real timebox is drift. + // GoTrue reports 0 hours for unconfigured session bounds; the transform canonicalizes that + // to the string "0s", which the registry row's `unconfiguredValue` recognizes as clean. const clean = diffWith({}, { auth: { sessions_timebox: 0, sessions_inactivity_timeout: 0 } }); expect(clean.changes).toEqual([]); @@ -148,11 +142,8 @@ describe("diffProjectConfig classification", () => { }); test("platform-rendered mailer subjects suppress regardless of the remote string", () => { - // Subject lines are rendered by the platform, so there is no fixed - // baseline string to pin — the row's `platformRendered` flag suppresses - // ANY remote string while the file stays silent, unlike - // `unconfiguredValue`'s exact-match baseline (still exercised here via - // the sibling notification `enabled` row). + // Subject lines are rendered by the platform, so there's no fixed baseline string to pin; + // `platformRendered` suppresses any remote string while the file stays silent. const clean = diffWith( {}, { @@ -183,8 +174,8 @@ describe("diffProjectConfig classification", () => { }); test("a declared mailer subject still classifies normally against the remote", () => { - // `platformRendered` only suppresses while the local projection is - // silent — a locally DECLARED subject compares like any other field. + // `platformRendered` only suppresses while the local projection is silent — a locally + // declared subject compares like any other field. const differs = diffWith( { auth: { email: { template: { confirmation: { subject: "Welcome to ACME" } } } } }, { auth: { mailer_subjects_confirmation: "Confirm your email address" } }, @@ -208,16 +199,11 @@ describe("diffProjectConfig classification", () => { }); test("every comparable path without a config-side baseline makes a deliberate choice", () => { - // Registry-driven guard for the remote_only suppression baseline: for - // each comparable path the default config's projection AND the raw - // default config are silent on, either its row declares the platform's - // `unconfiguredValue` (and a remote report equal to it classifies clean), - // or its row is `platformRendered` (and ANY remote report classifies - // clean, unconditionally), or the platform's unconfigured report is - // structural ABSENCE (sentinel-pruned SMTP/captcha/SMS/hook siblings, - // sparse postgres_settings) and a zero-form remote — which absence-class - // paths never receive — must REPORT rather than be silently swallowed by - // type-level zero inference. + // Registry-driven guard: for each comparable path the default config is silent on, its row + // must declare either `unconfiguredValue` (a matching remote report classifies clean), + // `platformRendered` (any remote report classifies clean), or rely on structural absence — + // in which case a zero-form remote value must still report as drift, not be silently + // swallowed. const defaults = fromConfigDocument(getDefaultCliConfig()); const raw = getDefaultCliConfig(); const valueAt = (root: unknown, path: ReadonlyArray<string>): unknown => { @@ -251,7 +237,7 @@ describe("diffProjectConfig classification", () => { const row = rowFor(path); expect(row, path.join(".")).toBeDefined(); if (row !== undefined && row.unconfiguredValue !== undefined) { - // The declared unconfigured value classifies clean... + // The declared unconfigured value classifies clean. const projected: Record<string, unknown> = {}; let cursor = projected; for (const segment of path.slice(0, -1)) { @@ -265,9 +251,8 @@ describe("diffProjectConfig classification", () => { }); expect(changeAt(result.changes, path), path.join(".")).toBeUndefined(); } else if (row !== undefined && row.platformRendered === true) { - // ...a platform-rendered row classifies clean for ANY remote value, - // since it has no local default to compare against — verify with an - // arbitrary string, not the one the platform happens to send today. + // A platform-rendered row classifies clean for any remote value, verified with an + // arbitrary string rather than the one the platform happens to send today. const projected: Record<string, unknown> = {}; let cursor = projected; for (const segment of path.slice(0, -1)) { @@ -281,11 +266,8 @@ describe("diffProjectConfig classification", () => { }); expect(changeAt(result.changes, path), path.join(".")).toBeUndefined(); } else { - // ...and a path relying on structural absence must not silently - // swallow a zero-form value if the platform ever starts reporting - // one: inject a zero-form leaf directly into the remote projection - // (bypassing the normalizer, which today omits these paths) and - // assert it REPORTS. + // A path relying on structural absence must still report a zero-form remote value: + // inject one directly into the projection, bypassing the normalizer that omits it today. const projected: Record<string, unknown> = {}; let cursor = projected; for (const segment of path.slice(0, -1)) { @@ -348,14 +330,9 @@ describe("diffProjectConfig classification", () => { }); test("a declared push-unmanaged sibling surfaces in unmanaged, never as a false clean", () => { - // A disabled `auth.oauth_server`'s siblings (`allow_dynamic_registration`, - // `authorization_url_path`) are retained-but-inert platform state that - // `config push` cannot communicate at all — pruned from the document - // projection by `DISABLED_SENTINEL_PRUNES` — so a declared value - // disagreeing with the remote's cannot be a `change` entry. It must - // surface in `unmanaged` so the clean changes list is visibly partial. - // `enabled` itself is an ordinary comparable path (CLI-2314) — see the - // next test. + // A disabled `auth.oauth_server`'s siblings are retained-but-inert platform state that + // `config push` cannot communicate — pruned from the document projection — so a disagreeing + // declared value must surface in `unmanaged`, not as a false clean `change`. const result = diffWith( { auth: { oauth_server: { enabled: false, authorization_url_path: "/consent" } } }, { auth: { oauth_server_enabled: false, oauth_server_authorization_path: "/other" } }, @@ -365,11 +342,8 @@ describe("diffProjectConfig classification", () => { }); test("a push-unmanaged sibling is excluded from classification even when the remote AGREES with it", () => { - // The disagreeing case above happened to pass even before the loop - // structurally excluded unmanaged paths, because the remote's value - // coincidentally matched. Matching values here proves the exclusion - // itself, not an accidental match — ADR 0022's "unmanaged paths can never - // classify either" applies even when local and remote agree. + // Matching values here proves the exclusion itself, not an accidental match (ADR 0022): + // unmanaged paths can never classify, even when local and remote agree. const result = diffWith( { auth: { oauth_server: { enabled: false, authorization_url_path: "/consent" } } }, { auth: { oauth_server_enabled: false, oauth_server_authorization_path: "/consent" } }, @@ -380,13 +354,9 @@ describe("diffProjectConfig classification", () => { }); test("push-unmanaged siblings are excluded from classification even when the remote DIFFERS from them", () => { - // Live repro: config.toml declares storage.analytics disabled with a - // max_namespaces value, the platform reports it enabled with a different - // value — `max_namespaces` is pruned from the document projection while - // the container is declared disabled, so it can never become a - // `remote_only` change, only `unmanaged`. `enabled` itself is an - // ordinary comparable path (CLI-2314) and correctly classifies as an - // `update`. + // Live repro: config.toml declares storage.analytics disabled with a max_namespaces value, + // the platform reports it enabled with a different value — `max_namespaces` is pruned from + // the document projection, so it can only be `unmanaged`, never `remote_only`. const result = diffWith( { storage: { analytics: { enabled: false, max_namespaces: 5 } } }, { storage: { features: { iceberg_catalog: { enabled: true, max_namespaces: 10 } } } }, @@ -402,9 +372,8 @@ describe("diffProjectConfig classification", () => { }); test("declared siblings of a disabled container surface in unmanaged", () => { - // Push writes only the disable sentinel for a disabled SMTP block, so a - // declared host is never communicated — the projection prunes it and the - // unmanaged list says so. + // Push writes only the disable sentinel for a disabled SMTP block, so a declared host is + // never communicated — the projection prunes it, and `unmanaged` says so. const result = diffWith( { auth: { email: { smtp: { enabled: false, host: "mail.example.com" } } } }, { auth: {} }, @@ -418,19 +387,8 @@ describe("diffProjectConfig classification", () => { }); test("db.major_version and db.pooler.* classify as normal update/remote_only, never unmanaged (PR #6451 correction)", () => { - // Both are `comparableProjectConfigPaths` members (`fromApiProjectConfig` - // maps them from real, `v2GetProjectConfig`-reported state) AND - // `fromConfigDocument` populates both normally (CLI-2316's package review - // round). The ORIGINAL version of this test asserted the opposite — - // excluding them from the document arm entirely, which made them - // permanently `unmanaged` for every stock project (the `supabase init` - // template declares all of these), silently blocking `config pull` from - // ever syncing the platform's real Postgres version or pooler settings: - // `unmanaged` paths never reach `changes` (`hasAncestorPathKey` above), - // and `config pull`'s planner (`planConfigPull`) only ever writes - // from `changes`. `config push` itself doesn't consult `ProjectConfig` at - // all today (still the legacy v1 `config-sync` mappers), so there was - // never a push-correctness reason to exclude them either. + // Both are `comparableProjectConfigPaths` members that `fromConfigDocument` populates + // normally, so they classify as ordinary update/remote_only rather than `unmanaged`. const result = diffWith( { db: { major_version: 15, pooler: { pool_mode: "session", default_pool_size: 15 } } }, { @@ -457,10 +415,8 @@ describe("diffProjectConfig classification", () => { }); test("db.pooler.enabled and db.pooler.port are never comparable — v2GetProjectConfig reports neither", () => { - // Unlike their 3 siblings above, these 2 have no registry row at all - // (`isComparableProjectConfigPath` is false for both), so they never - // reach the comparison loop regardless of local/remote presence — there - // is no remote value to ever compare or pull for either. + // Unlike their siblings above, these two have no registry row at all, so they never reach + // the comparison loop regardless of local/remote presence. const result = diffWith( { db: { pooler: { enabled: false, port: 12345 } } }, { pooler: { pool_mode: "session" } }, @@ -472,10 +428,8 @@ describe("diffProjectConfig classification", () => { }); test("sequence arrays register reordering as drift", () => { - // api.schemas is order-significant (the first entry is PostgREST's - // default schema), so local ["public","extensions"] vs the wire's - // "extensions,public" is a real difference — in both declared and - // undeclared classifications. + // api.schemas is order-significant (the first entry is PostgREST's default schema), so a + // reordering is real drift. const result = diffWith( { api: { schemas: ["public", "extensions"] } }, { api: { db_schema: "extensions,public" } }, @@ -585,11 +539,9 @@ describe("diffProjectConfig classification", () => { describe("absence policy", () => { test("absent-is-hands-off masks a fixed-list field (auth.captcha) out of local entirely, reporting remote_only with local: undefined", () => { - // `auth.captcha` is never declared in the document at all, and the - // remote is customized — `applyRawPresenceMask`'s fixed list removes the - // field from the local projection entirely rather than letting the - // schema default stand in, so a consumer can never mistake this for - // "push the default over the remote customization". + // `auth.captcha` is never declared; `applyRawPresenceMask`'s fixed list removes it from the + // local projection entirely rather than defaulting it, so this can't be mistaken for "push + // the default over the remote customization". const result = diffWith( {}, { auth: { security_captcha_enabled: true, security_captcha_provider: "hcaptcha" } }, @@ -603,14 +555,10 @@ describe("absence policy", () => { }); test("documents the hazardous cell: absent-is-default reports remote_only with the schema default masquerading as local", () => { - // No `document` at all — `diffProjectConfig({local: {config}, remote})`, - // the exact Studio-shaped call `ConfigAbsencePolicy` is named for. With - // no raw document to mask against, the local projection still carries - // the materialized schema default (`enabled: false`) as though it were a - // real declared value. This is deliberately NOT a "works correctly" - // assertion — it documents the one hazardous cell in the danger matrix: a - // consumer that treated `remote_only` as "safe to push" here would - // silently revert a genuine hosted customization to the schema default. + // No `document` supplied — the exact Studio-shaped call `ConfigAbsencePolicy` is named for. + // With no raw document to mask against, the local projection carries the schema default as + // though it were declared. This documents the one hazardous cell: a consumer that treated + // `remote_only` as "safe to push" here would silently revert a real customization. const result = diffProjectConfig({ local: { config: decodeCliConfig({}) }, remote: fromApiProjectConfig({ @@ -626,12 +574,8 @@ describe("absence policy", () => { }); test("a comparable path outside the raw-presence mask's fixed list classifies identically under both policies", () => { - // api.max_rows is not one of applyRawPresenceMask's fixed paths, so the - // extra masking `absent-is-hands-off` applies makes no difference to it - // either way — the generic `declared` mechanism alone is what keeps both - // call shapes from misclassifying an undeclared, remote-customized field - // as `update`, proving the fixed-list mask is additive, not the only - // thing standing between an undeclared field and a false `update`. + // api.max_rows isn't one of the fixed masked paths, so both call shapes rely on the + // generic `declared` mechanism alone to avoid misclassifying it as `update`. const attributes = { api: { max_rows: 250 } }; const withDocument = diffWith({}, attributes); const withoutDocument = diffProjectConfig({ @@ -668,8 +612,8 @@ describe("isEqualConfigValue", () => { test("set semantics on request are membership-only", () => { expect(isEqualConfigValue(["a", "b"], ["b", "a"], "set")).toBe(true); - // Duplicates carry no meaning for a set-mode field — identical - // membership with different duplicate counts is NOT drift. + // Duplicates carry no meaning for a set-mode field — identical membership with different + // duplicate counts is not drift. expect(isEqualConfigValue(["a", "a", "b"], ["a", "b", "b"], "set")).toBe(true); expect(isEqualConfigValue(["a", "a"], ["a", "b"], "set")).toBe(false); expect(isEqualConfigValue(["a", "b"], ["a"], "set")).toBe(false); diff --git a/packages/config/src/config-document.ts b/packages/config/src/config-document.ts index c6e63ee749..f7eecffb4e 100644 --- a/packages/config/src/config-document.ts +++ b/packages/config/src/config-document.ts @@ -5,7 +5,6 @@ import type { ConfigFormat } from "./config-format.ts"; import { getDefaultCliConfig, setOwnProperty, subtractValue } from "./sparse.ts"; import type { CliProjectEnvironment } from "./project.ts"; -/** Shared with `io.ts`'s `getSchemaRef`, which reads this key back off a raw document. */ export const cliConfigSchemaKey = "$schema"; export type CliConfigValueSource = "environment" | "local" | "remote"; @@ -28,45 +27,30 @@ export interface LoadedCliConfig { readonly schemaRef?: string; readonly ignoredPaths: ReadonlyArray<string>; /** - * The raw, post-`env()`-interpolation document the `config` was decoded from, - * with any matching `[remotes.*]` override already merged in (see - * {@link LoadCliConfigOptions.projectRef}). Lets callers inspect key - * presence — which the decoded `config` loses because the schema defaults - * optional sections — without re-reading the file. Present whenever the file - * parsed to an object. + * The raw, post-`env()`-interpolation document `config` was decoded from, with any matching + * `[remotes.*]` override merged in. Lets callers inspect key presence that the decoded + * `config` loses (the schema defaults optional sections). Present whenever the file parsed to + * an object. */ readonly document?: Record<string, unknown>; /** - * The raw document as parsed from disk: pre-`env()`-interpolation, pre- - * `[remotes.*]`-merge, but post-`[inbucket]`→`[local_smtp]` normalization - * (see `normalizeDeprecatedSMTPSections`). Unlike {@link document}, whose - * `remotes` key has already been merged/stripped by `applyRemoteOverride`, - * `remotes` (when present) is still intact here. A caller deciding WHERE to - * write a value — e.g. matching a `[remotes.*]` block by its literal - * `project_id` via `remoteNameForProjectRef` (`./io.ts`) — must use this, - * never {@link document}: the loader itself matches `[remotes.*]` against - * the raw literal, before `env(...)` resolution (see `applyRemoteOverride`'s - * doc comment). Present whenever the file parsed to an object. + * The raw document as parsed from disk: pre-`env()`-interpolation, pre-`[remotes.*]`-merge + * (unlike {@link document}, whose `remotes` key has already been merged/stripped). Callers + * matching a `[remotes.*]` block by its literal `project_id` must use this, not `document`. + * Present whenever the file parsed to an object. */ readonly rawDocument?: Record<string, unknown>; /** - * The exact file text {@link loadCliConfigFile} parsed `rawDocument`/`config` - * from — present whenever the file was read (both `.toml` and `.json`). - * Lets a caller that needs to edit the file (e.g. `config pull`'s surgical - * `applyConfigEdits`) use this as its write-baseline instead of re-reading - * the file a second time, so the plan it computed against and the bytes it - * edits can never diverge out from under it. `undefined` from - * `saveCliConfig`, which regenerates the file's content rather than parsing - * existing text. + * The exact file text `rawDocument`/`config` were parsed from, present whenever the file was + * read. Lets a caller that edits the file use this as its write-baseline instead of re-reading + * it, so the plan it computed against can't diverge from the bytes it edits. `undefined` from + * `saveCliConfig`, which regenerates content rather than parsing existing text. */ readonly rawText?: string; /** - * The already-`env()`-interpolated `remotes` subtree — the same map - * `checkRemoteProjectIdFormat` (`./io.ts`) validates a remote's resolved - * `project_id` against. Lets a caller read a remote's EFFECTIVE - * `project_id` (e.g. to display it) without re-running interpolation - * itself. Present whenever a `remotes` table exists in the document, - * regardless of whether any block matched `projectRef`. + * The already-`env()`-interpolated `remotes` subtree, letting a caller read a remote's + * effective `project_id` without re-running interpolation itself. Present whenever a + * `remotes` table exists, regardless of whether any block matched `projectRef`. */ readonly interpolatedRemotes?: Record<string, unknown>; /** @@ -76,15 +60,10 @@ export interface LoadedCliConfig { */ readonly appliedRemote?: string; /** - * The top-level `auth.external.{linkedin,slack}` sub-objects that were stripped from - * {@link document} before it was returned (provider id → the removed object), keyed by - * provider id. Empty when neither deprecated block was present. See - * `normalizeDeprecatedExternalProviders`'s doc comment for why a caller doing its own - * Go-parity scan over `document` (e.g. a decrypt-or-abort secret check) may need to fold - * this back in — Go's decode-time decrypt hook sees these blocks before its later - * validate-time deletion, so `document` alone under-reports what Go would have decrypted. - * Present (possibly `{}`) whenever {@link document} is; absent from `saveCliConfig`'s - * result, which has no document to strip from. + * The `auth.external.{linkedin,slack}` sub-objects stripped from {@link document} before it + * was returned, keyed by provider id (empty when neither was present). A caller scanning + * `document` on its own may need to fold this back in, since `document` alone under-reports + * what was originally there. Present whenever {@link document} is. */ readonly removedDeprecatedExternalProviders?: Readonly<Record<string, unknown>>; /** The source that supplied each explicitly configured effective leaf value. */ @@ -102,41 +81,23 @@ export const cliConfigValueSourceAt = ( )?.source; /** - * When `projectRef` is set, the matching `[remotes.<name>]` block (the one - * whose `project_id` equals it) is merged over the base config before decode, - * mirroring Go's `config.Load` with `Config.ProjectId` set - * (`apps/cli-go/pkg/config/config.go:503-562`). Omitting it loads the base - * config verbatim (no merge), so existing callers are unaffected. Go's - * duplicate-`project_id`/project-ref-format checks across every - * `[remotes.*]` block (`config.go:594-602,996-1001`) run unconditionally on - * every config load in Go, not only when a caller ends up selecting a - * remote — but here they only run when {@link InternalLoadCliConfigOptions.goViperCompat} - * is `true`, regardless of whether `projectRef` is set, so non-Go-parity - * callers that never select a remote (and never opt into Go parity) aren't - * broken by an unrelated duplicate/malformed `[remotes.*]` block. + * When `projectRef` is set, the matching `[remotes.<name>]` block is merged over the base + * config before decode; omitting it loads the base config verbatim. Duplicate-`project_id` and + * project-ref-format checks across every `[remotes.*]` block only run when + * {@link InternalLoadCliConfigOptions.goViperCompat} is `true`, so callers that never opt into + * that mode aren't broken by an unrelated malformed remote block. */ export interface LoadCliConfigOptions { readonly projectRef?: string; /** - * Pre-resolved project environment used to interpolate `env()` references. - * When omitted, the environment is resolved internally from `.env`/`.env.local` - * layered over `process.env` (the default for most callers). Callers that need - * Go-accurate, environment-specific resolution (e.g. `functions serve`, which - * also reads `.env.<SUPABASE_ENV>` files) resolve it themselves and pass it in - * so loading does not re-read those files or depend on `process.env` mutation. + * Pre-resolved project environment used to interpolate `env()` references. When omitted, + * it's resolved internally from `.env`/`.env.local` layered over `process.env`. Callers + * needing environment-specific resolution can resolve it themselves and pass it in instead. */ readonly cliProjectEnv?: CliProjectEnvironment; /** See {@link FindCliProjectPathsOptions.search}. */ readonly search?: boolean; - /** - * Skip the `config.json`-over-`config.toml` preference below and only ever - * load `config.toml`. Go's `Config.Load`/`NewPathBuilder` - * (`apps/cli-go/pkg/config/utils.go:43-48`) has no concept of a JSON project - * config file — it always resolves `supabase/config.toml` and treats a - * missing file as defaults — so Go-parity callers (the legacy `status`/`stop` - * ports) must set this to avoid picking up a stray `config.json` that Go - * would never see. - */ + /** Skip the `config.json`-over-`config.toml` preference and only ever load `config.toml`. */ readonly tomlOnly?: boolean; } @@ -146,22 +107,11 @@ export interface LoadCliConfigOptions { */ export interface InternalLoadCliConfigOptions extends LoadCliConfigOptions { /** - * Opt into the Go/viper-parity decode+validation semantics this loader - * otherwise omits, so only the Go-parity CLI (and shared modules - * invoked exclusively by it) pays for them. Defaults to `false` = pre-PR-#5765 - * behavior, which `packages/stack` and the functions manifest rely - * on. When `true`, mirrors Go's `config.Load` exactly: - * - runs the unconditional duplicate-`project_id` and project-ref-format - * checks across every `[remotes.*]` block (`config.go:594-602,996-1001`), - * even when no `projectRef` is requested; - * - warns on stderr for deprecated `auth.external.{linkedin,slack}` blocks - * (`config.go:1418-1423`) — the block is stripped from the decoded config - * either way, since the schema ignores excess properties; - * - matches `env(...)` references case-agnostically (`^env\((.*)\)$`) - * rather than the strict SCREAMING_SNAKE_CASE form; - * - splits a comma-separated string into a `[]string`-typed field (Go's - * `mapstructure.StringToSliceHookFunc(",")`, `config.go:775-784`), not - * just an `env()`-substituted one. + * Opts into Go/viper-parity decode and validation semantics: duplicate-`project_id` and + * project-ref-format checks run across every `[remotes.*]` block even without a `projectRef`, + * deprecated `auth.external.{linkedin,slack}` blocks warn on stderr, `env(...)` matching is + * case-agnostic, and comma-separated strings coerce into `[]string`-typed fields regardless of + * origin. Defaults to `false`, which `packages/stack` and the functions manifest rely on. */ readonly goViperCompat?: boolean; } @@ -173,11 +123,6 @@ export interface SaveCliConfigOptions { readonly schemaRef?: string; } -/** - * Shared with `io.ts`, which uses it to inspect raw (pre-decode) config - * documents while resolving `[remotes.*]` overrides and stripping deprecated - * sections. - */ export function isObject(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -186,10 +131,7 @@ const encodeCliConfig = Schema.encodeSync(CliConfigSchema); let defaultEncodedCliConfig: ReturnType<typeof encodeCliConfig> | undefined; -/** - * Memoized like `getDefaultCliConfig` — only the save path needs the - * encoded defaults, so importing the package pays for no schema decode. - */ +/** Memoized lazily, like `getDefaultCliConfig`, so importing the package doesn't pay for the encode. */ function getDefaultEncodedCliConfig(): ReturnType<typeof encodeCliConfig> { defaultEncodedCliConfig ??= encodeCliConfig(getDefaultCliConfig()); return defaultEncodedCliConfig; @@ -248,7 +190,6 @@ export function encodeCliConfigToToml(config: CliConfig): string { return encodeCliConfigToTomlDocument(config, undefined); } -/** Shared with `io.ts`'s `saveCliConfig`, which needs the `schemaRef`-carrying variant. */ export function encodeCliConfigToJsonDocument( config: CliConfig, schemaRef: string | undefined, @@ -256,7 +197,6 @@ export function encodeCliConfigToJsonDocument( return `${JSON.stringify(toConfigDocument(config, schemaRef), null, 2)}\n`; } -/** Shared with `io.ts`'s `saveCliConfig`, which needs the `schemaRef`-carrying variant. */ export function encodeCliConfigToTomlDocument( config: CliConfig, schemaRef: string | undefined, diff --git a/packages/config/src/config-edit.ts b/packages/config/src/config-edit.ts index bbc9b7cc23..5560e49f63 100644 --- a/packages/config/src/config-edit.ts +++ b/packages/config/src/config-edit.ts @@ -2,57 +2,13 @@ import * as SmolToml from "smol-toml"; import type { ConfigFormat } from "./config-format.ts"; /** - * Format-preserving surgical editor for `supabase/config.{toml,json}` (`supabase config - * pull`, CLI-2064). Pure and synchronous — no Effect, no filesystem: callers own reading the - * current file text and writing the returned text back out atomically. See ADR 0023 for the - * rejected alternatives (regenerating the file from the decoded `CliConfig`, adopting a full - * TOML-AST dependency) and the rationale for span-splicing plus mandatory re-parse - * verification instead. - * - * Design rules: - * 1. NEVER return unverified text. Every edited document is re-parsed and deep-compared - * against a `deepSet` of the original parse before it's returned; any mismatch — whether - * from a scanner misjudgment or a genuinely ambiguous document — surfaces as - * `verification_mismatch` rather than silently shipping wrong bytes. - * 2. Refusals are terminal, not best-effort (`ConfigEditRefusalReason`): a document this - * module can't safely edit (duplicate table headers, an array-of-tables or inline table - * sitting on the edit's path, an existing `env(...)` literal at the destination, a parse - * failure) is reported, never patched around. - * 3. Every edit either REPLACES an existing declared value's span in place, or INSERTS a new - * line/table. This module never deletes or reorders anything the caller didn't ask it to - * touch, so untouched comments, spacing, and quoting style survive byte-for-byte. An edit - * whose value already matches the destination is a no-op: the source is returned - * unchanged rather than reformatted. - * 4. TOML placement (see `planTomlSplices`/`placementOffsetForNewTable`'s doc comments): an existing `[a.b]` table - * gets the new key appended after its last declared key; a table only reachable through - * dotted-key assignment (no explicit header) gets a sibling dotted key next to the - * existing one — never a synthesized header over dotted keys; otherwise a brand new - * `[a.b]` header is inserted after whichever existing table shares the longest path - * prefix, or at EOF if none does. `[remotes.<label>]` is the one hardcoded exception: a - * newly created remote block always lands at EOF, preceded by one blank line, with - * `project_id` written first. - * 5. Multi-line arrays are rewritten single-line when their value is replaced — only the - * value span changes, never the surrounding key/comment text. - * 6. Keys/table labels are written bare when they match `/^[A-Za-z0-9_-]+$/`, basic-quoted - * otherwise (`remotes."feature/login"`, `"+15551234"`); new string values are always - * basic-quoted with proper escaping. This module never introduces literal-quote syntax of - * its own — an existing literal string elsewhere in the file is preserved verbatim because - * it's simply never touched. - * - * `ConfigEditValue` intentionally allows nested-object values (e.g. rewriting an entire - * `auth.sms.test_otp` map in one call): such an edit is flattened into one leaf edit per - * scalar/array field before it's applied, in BOTH formats, so it composes with every rule - * above without a special "replace a whole table" code path. The WRITTEN text only ever - * touches the leaves the object actually mentions — it never deletes a sibling key the object - * left out, matching this module's "never delete" invariant, in TOML and JSON alike. Mandatory - * verification (rule 1) holds that written result to a stricter standard, though: it's compared - * against `deepSet`, which — unlike the flattened write — REPLACES the whole destination - * subtree with the given object. So an object-valued edit that omits an existing sibling key - * `verification_mismatch`-refuses (the sibling the write left untouched disagrees with the - * replaced-away expectation) unless the object already covers every key the destination table - * currently declares, or the destination doesn't exist yet. This contract — merge mentioned - * leaves, refuse rather than silently drop an unmentioned sibling — is identical across both - * formats. + * Format-preserving surgical editor for `supabase/config.{toml,json}`. Pure and synchronous: + * callers own reading the current file text and writing the returned text back out atomically. + * Every edited document is re-parsed and deep-compared against the original before being + * returned; any mismatch refuses rather than shipping wrong bytes. An edit either replaces an + * existing value's span in place or inserts a new line/table — nothing else is touched, so + * untouched comments, spacing, and quoting survive byte-for-byte. See ADR 0023 for the design + * rationale and rejected alternatives. */ export type ConfigEditValue = @@ -96,11 +52,8 @@ export type ConfigEditOutcome = | { readonly kind: "refused"; readonly refusal: ConfigEditRefusal }; /** - * The `[remotes.<label>]` placement exception's two literals (this file's header comment, - * rule 4): a newly created remote block always lands at EOF, preceded by one blank line, - * with `project_id` written first. Named here rather than spelled inline at the sites that - * test them (`renderMissingTableCluster`, `planTomlSplices`) so the exception reads as one - * rule instead of four bare strings. + * The `[remotes.<label>]` placement exception: a newly created remote block always lands at + * EOF, preceded by one blank line, with `project_id` written first. */ const REMOTES_TABLE_NAME = "remotes"; const REMOTE_PROJECT_ID_KEY = "project_id"; @@ -110,14 +63,9 @@ function isRemotesLabelRoot(path: ReadonlyArray<string>): boolean { return path.length === 2 && path[0] === REMOTES_TABLE_NAME; } -// --------------------------------------------------------------------------- -// Small generic helpers shared by both format arms. -// --------------------------------------------------------------------------- - -// Duplicated from `lib/env.ts`'s `ENV_CAPTURE_REGEX` rather than imported: this module is -// restricted to `smol-toml` as its only import (see this file's header comment / ADR 0023), -// so it stays independently embeddable wherever a surgical text edit is needed without -// pulling in this package's wider env-resolution graph. +// Duplicated from `lib/env.ts` rather than imported: this module is restricted to `smol-toml` +// as its only import, so it stays independently embeddable without pulling in this package's +// wider env-resolution graph. const ENV_CAPTURE_REGEX = /^env\((.*)\)$/; function isPlainRecord(value: unknown): value is Readonly<Record<string, unknown>> { @@ -233,10 +181,10 @@ function deepSetOne(root: unknown, path: ReadonlyArray<string>, value: unknown): } /** Applies one `path`/`value` pair per entry on top of `root`, immutably. Shared by `deepSet` - * (the verification ORACLE, built from the original un-flattened edits) and JSON's actual - * per-leaf MUTATION (built from `flattenEdits`'s output instead) — the two callers intentionally - * pass different entry sets; see this file's header comment for why they must differ for an - * object-valued edit. */ + * (the verification oracle, built from the original un-flattened edits) and the JSON arm's + * actual per-leaf mutation (built from `flattenEdits`'s output) — the two intentionally use + * different entry sets so an object-valued edit's write only touches leaves it mentions, while + * verification still catches a dropped sibling. */ function applyPathEntries( root: unknown, entries: ReadonlyArray<{ readonly path: ReadonlyArray<string>; readonly value: unknown }>, @@ -247,10 +195,9 @@ function applyPathEntries( ); } -/** Applies every edit's `path`/`value` on top of `root`, immutably — REPLACING the whole +/** Applies every edit's `path`/`value` on top of `root`, immutably, replacing the whole * destination subtree for an object-valued edit. This is the expected-value side of mandatory - * verification for both formats; it is never the mutation itself (see `applyPathEntries` for - * that, and this file's header comment for why the two must differ). */ + * verification for both formats — see `applyPathEntries` for the actual per-leaf mutation. */ function deepSet(root: unknown, edits: ReadonlyArray<ConfigEdit>): unknown { return applyPathEntries(root, edits); } @@ -282,10 +229,6 @@ function unchangedOutcome(source: string, edits: ReadonlyArray<ConfigEdit>): Con }; } -// --------------------------------------------------------------------------- -// Leaf flattening: an object-valued edit becomes one leaf edit per scalar/array field. -// --------------------------------------------------------------------------- - type ConfigEditLeafValue = string | number | boolean | ReadonlyArray<string | number | boolean>; interface LeafEdit { @@ -313,11 +256,6 @@ function flattenEdits(edits: ReadonlyArray<ConfigEdit>): ReadonlyArray<LeafEdit> return leaves; } -// --------------------------------------------------------------------------- -// Value/key rendering (shared rules: bare-if-safe keys, always-basic-quoted strings, -// always-single-line arrays). -// --------------------------------------------------------------------------- - const BARE_KEY_PATTERN = /^[A-Za-z0-9_-]+$/; function renderBasicString(value: string): string { @@ -372,15 +310,9 @@ function renderLeafValue(value: ConfigEditLeafValue): string { return isLeafArrayValue(value) ? `[${value.map(renderScalar).join(", ")}]` : renderScalar(value); } -// --------------------------------------------------------------------------- -// TOML character-level scanner. -// -// Produces top-level tokens in document order: blank lines, whole-line comments, table -// headers (`[a.b]` / `[[a.b]]`), and key-value lines (bare/dotted/quoted key, any value type, -// including multi-line arrays and multi-line strings — both scanned as single opaque spans). -// Never throws: a construct it can't make sense of yields an `{ error }` result instead, which -// the caller reports as `parse_error`. -// --------------------------------------------------------------------------- +// Character-level TOML scanner. Produces top-level tokens in document order: blank lines, +// comments, table headers, and key-value lines (multi-line arrays/strings scanned as single +// opaque spans). Never throws — an unrecognized construct yields an `{ error }` result instead. interface TomlHeaderToken { readonly kind: "header"; @@ -542,10 +474,9 @@ function scanBareValue(source: string, pos: number): number { } cursor++; } - // Trailing inline whitespace before a `#` comment (or EOL) belongs to the - // comment/line-ending, not the value: without this, replacing `port = 54321 - // # comment` would fold that space into the replaced span and yield - // `port = 54322# comment`, silently eating it (CLI-2064 review finding 1). + // Trailing inline whitespace before a `#` comment (or EOL) belongs to the comment/line-ending, + // not the value: without this, replacing a value would fold that space into the replaced span + // and eat it. while (cursor > pos && (source[cursor - 1] === " " || source[cursor - 1] === "\t")) { cursor--; } @@ -817,20 +748,12 @@ function scanTomlDocument(source: string): TomlScanResult | TomlScanError { } const newline = source.includes("\r\n") ? "\r\n" : "\n"; - // Checking `source.endsWith(newline)` breaks on a mixed-EOL file whose - // detected flavor is CRLF (because SOME earlier line uses it) but whose - // final line ends in a bare `\n` — that string doesn't end with `"\r\n"`, - // so this used to read as "no trailing newline" and doubled the EOF - // terminator on an EOF-inserted block (CLI-2064 review finding 3). A - // string ending in `"\r\n"` always also ends in `"\n"`, so checking for - // `"\n"` alone covers both terminators. + // A mixed-EOL file may be detected as CRLF overall (because some earlier line uses it) while + // its final line ends in a bare `\n`; checking for `"\n"` alone (rather than the detected + // flavor) covers both terminators, since a `"\r\n"`-ending string always also ends in `"\n"`. return { source, tokens, headers, newline, endsWithNewline: source.endsWith("\n") }; } -// --------------------------------------------------------------------------- -// TOML placement + splicing. -// --------------------------------------------------------------------------- - interface Splice { readonly start: number; readonly end: number; @@ -923,7 +846,7 @@ function indentOfLineAt(source: string, lineStart: number): string { /** * Where a brand new table (one with no existing header at all) is inserted: right after - * whichever EXISTING table shares the longest path prefix with it (ties broken by file order — + * whichever existing table shares the longest path prefix with it (ties broken by file order — * the later one wins), or at EOF when no existing table shares any prefix at all. * `[remotes.<label>]` blocks bypass this entirely (see the caller) — they always go to EOF. */ @@ -1007,9 +930,9 @@ interface TomlPlanResult { } /** - * Resolves every leaf edit's placement against the scanned document (see this file's header - * comment, rule 4, for the placement priority) and returns the full splice list plus which new - * tables each leaf caused to be created (for `AppliedConfigEdit.createdTables`). + * Resolves every leaf edit's placement against the scanned document (see + * {@link placementOffsetForNewTable} for the priority rule for brand-new tables) and returns the + * full splice list plus which new tables each leaf caused to be created. */ function planTomlSplices(scan: TomlScanResult, leaves: ReadonlyArray<LeafEdit>): TomlPlanResult { const keyTokens = scan.tokens.filter((token): token is TomlKeyToken => token.kind === "kv"); @@ -1053,14 +976,10 @@ function planTomlSplices(scan: TomlScanResult, leaves: ReadonlyArray<LeafEdit>): if (key.path.length <= parent.length || !startsWithPath(key.path, parent)) { continue; } - // A dotted-sibling candidate must be declared under an ANCESTOR of `parent` (a table + // A dotted-sibling candidate must be declared under an ancestor of `parent` (a table // header whose path is a prefix of `parent`, root included) via dotted-key assignment — - // never a key living inside a genuine DESCENDANT table (e.g. `host` in - // `[auth.email.smtp]` when inserting `auth.email.enable_confirmations`). That descendant - // case used to match too (its path is also below `parent`), computing an enclosing length - // longer than `leaf.path` itself and splicing in an empty, keyless dotted key — refused - // only by mandatory re-parse verification, never actually inserted (CLI-2064 review - // finding: dotted-sibling search over-matches descendant tables). + // never a key living inside a genuine descendant table (e.g. `host` in + // `[auth.email.smtp]` when inserting `auth.email.enable_confirmations`). const enclosingTablePath = key.tableIndex === -1 ? [] : (scan.headers[key.tableIndex]?.path ?? []); if (!startsWithPath(parent, enclosingTablePath)) { @@ -1270,10 +1189,6 @@ function applyTomlEdits(source: string, edits: ReadonlyArray<ConfigEdit>): Confi return { kind: "applied", text: nextText, applied }; } -// --------------------------------------------------------------------------- -// JSON arm. -// --------------------------------------------------------------------------- - function detectJsonNewline(source: string): "\n" | "\r\n" { return source.includes("\r\n") ? "\r\n" : "\n"; } @@ -1299,11 +1214,9 @@ function applyJsonEdits(source: string, edits: ReadonlyArray<ConfigEdit>): Confi return unchangedOutcome(source, edits); } - // Flattened to one leaf edit per scalar/array field (same as the TOML arm's own - // `flattenEdits` call) so an object-valued edit only ever merges the leaves it mentions — - // never deletes an unmentioned sibling. The env-reference check runs per LEAF for the same - // reason: an object edit's own top-level path is never itself a string, so checking there - // (the previous behavior) could never catch a leaf that shadows an existing `env(...)` value. + // Flattened to one leaf edit per scalar/array field so an object-valued edit only ever merges + // the leaves it mentions, never deletes an unmentioned sibling. The env-reference check runs + // per leaf for the same reason: an object edit's own top-level path is never itself a string. const leaves = flattenEdits(edits); for (const leaf of leaves) { @@ -1331,11 +1244,9 @@ function applyJsonEdits(source: string, edits: ReadonlyArray<ConfigEdit>): Confi } catch { return refused("verification_mismatch", [], "edited document failed to re-parse"); } - // Verified against `deepSet` — built from the ORIGINAL, un-flattened `edits` — not against - // `nextValue`, which is the flattened per-leaf mutation. Comparing against the mutation would - // make this verification tautological: it could never disagree with itself, so a sibling an - // object-valued edit silently drops would never surface as a mismatch (see this file's header - // comment). + // Verified against `deepSet` (the original, un-flattened `edits`), not `nextValue` (the + // flattened per-leaf mutation) — comparing against the mutation would be tautological, so a + // sibling an object-valued edit silently drops would never surface as a mismatch. const expected = deepSet(parsed, edits); if (!deepEqualValue(reparsed, expected)) { return refused( @@ -1354,10 +1265,6 @@ function applyJsonEdits(source: string, edits: ReadonlyArray<ConfigEdit>): Confi return { kind: "applied", text: nextText, applied }; } -// --------------------------------------------------------------------------- -// Public entry point. -// --------------------------------------------------------------------------- - export function applyConfigEdits( source: string, format: ConfigFormat, diff --git a/packages/config/src/config-edit.unit.test.ts b/packages/config/src/config-edit.unit.test.ts index 1595da7e0c..13fef19d00 100644 --- a/packages/config/src/config-edit.unit.test.ts +++ b/packages/config/src/config-edit.unit.test.ts @@ -97,10 +97,7 @@ allowed_cidrs = ["1.2.3.4/32"] expect(SmolToml.parse(text)).toEqual({ a: { x: 1, y: 'back\\slash and "quote"' } }); }); - // Regression coverage for CLI-2064 review finding 1: `scanBareValue` used to include - // trailing inline whitespace before a `#` comment in the replaced value span, so replacing - // `port = 54321 # comment` silently ate the space and produced `port = 54322# comment`. The - // mandatory re-parse can't catch this — TOML parses the value identically either way. + // Re-parsing can't catch this: TOML parses the value identically either way. test("replacing a bare number keeps exactly the original single space before a trailing comment", () => { const source = "[db]\nport = 54321 # default port\n"; const outcome = applyConfigEdits(source, "toml", [{ path: ["db", "port"], value: 54322 }]); @@ -125,17 +122,14 @@ allowed_cidrs = ["1.2.3.4/32"] expect(applied(outcome).text).toBe("[db]\nport = 54322# no space\n"); }); - // Pin test: a string value is already terminated by its closing quote, so the trailing - // whitespace before `#` was never part of `scanBasicString`'s span — unaffected by this bug - // or its fix either way. + // Pin test: a quote-terminated value's span already excludes trailing whitespace before `#`. test("replacing a string value with a trailing comment is unaffected (already quote-terminated)", () => { const source = '[a]\nname = "old" # a name\n'; const outcome = applyConfigEdits(source, "toml", [{ path: ["a", "name"], value: "new" }]); expect(applied(outcome).text).toBe('[a]\nname = "new" # a name\n'); }); - // Pin test: replacing an EXISTING root-level (single-segment) key must keep working — - // finding 6's new refusal only targets an INSERT at that same shape (no exact key match). + // Pin test: only an insert at this shape is refused, not a replace of an existing key. test("replaces an existing root-level single-segment key in place", () => { const source = 'project_id = "demo"\n\n[api]\nenabled = true\n'; const outcome = applyConfigEdits(source, "toml", [{ path: ["project_id"], value: "updated" }]); @@ -175,17 +169,7 @@ b.d = 2 `); }); - // Regression coverage for CLI-2064 review finding: the dotted-sibling search used to accept - // ANY key-value declared below the insert's parent, including one living inside a genuine - // DESCENDANT table header (not a dotted-key-only parent) — here `host` inside - // `[auth.email.smtp]` when inserting under `auth.email`. That produced an enclosing length - // longer than the leaf path itself and spliced in an empty, keyless ` = true` line, refused - // only by mandatory re-parse verification. The fix requires the candidate's ENCLOSING TABLE to - // be a prefix of the insert's parent (an ancestor, reached via dotted-key assignment) — a - // descendant table like `[auth.email.smtp]` no longer qualifies, so this falls through to - // ordinary missing-table placement instead, creating `[auth.email]` right after the table it - // shares the longest path prefix with (TOML permits declaring a super-table after its - // sub-table). + // TOML permits declaring a super-table after its sub-table, as this fixture does. test("creates the missing parent table instead of a bogus dotted sibling when only a descendant table exists", () => { const source = `[auth.email.smtp] host = "smtp.example.com" @@ -354,8 +338,6 @@ max_rows = 900 expect(applied(otpOutcome).text).toContain('"+15551234" = "123456"'); }); - // Regression coverage for CLI-2064 review finding 5: inserting a new key into a table whose - // existing keys are indented used to write the new line flush-left instead of matching. test("inserts a new key into an indented table matching the indentation of the key it follows", () => { const source = `[a] x = 1 @@ -434,21 +416,14 @@ auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" expect(refusalReason(outcome)).toBe("parse_error"); }); - // Regression coverage for CLI-2064 review finding 6: a root-level single-segment path with - // no existing exact key (an INSERT, not a replace) used to fall into the "dotted sibling" - // placement branch — trivially true for every key in the document, since an empty path is a - // prefix of everything — and splice in a keyless ` = value` line that only the mandatory - // re-parse caught. This is now refused up front instead. test("refuses inserting a brand-new root-level (single-segment) key", () => { const source = 'project_id = "demo"\n\n[api]\nenabled = true\n'; const outcome = applyConfigEdits(source, "toml", [{ path: ["new_top_level_key"], value: 1 }]); expect(refusalReason(outcome)).toBe("verification_mismatch"); }); - // Regression/documentation coverage for CLI-2064 review finding 7: `deepSet` (the mandatory - // verification oracle) REPLACES a destination table wholesale with an object-valued edit's - // value, while the actual written text only ever touches the leaves the object mentions — - // so an object edit that omits an existing sibling key verify-mismatches instead of merging. + // `deepSet` (the verification oracle) replaces the destination table wholesale, while the + // actual write only touches the leaves the edit mentions — so an omitted sibling mismatches. test("refuses an object-valued edit that omits an existing sibling key of the destination table", () => { const source = `[a] x = 1 @@ -458,11 +433,8 @@ y = 2 expect(refusalReason(outcome)).toBe("verification_mismatch"); }); - // Regression pin: the TOML arm already refused this shape before the JSON arm was brought in - // line with it (config-edit.ts's JSON-arm consistency fix) — this test just confirms TOML's - // own behavior didn't move. Same shape as the JSON refusal test below, with the omitted - // sibling spelled as an `env(...)` reference (an exfiltration-adjacent flavor: the omitted - // sibling is exactly the kind of value that must never be silently dropped from the file). + // Same shape as the JSON refusal test below; the omitted sibling is an `env(...)` reference, + // exactly the kind of value that must never be silently dropped from the file. test("refuses a TOML object-valued edit that would delete an existing env() sibling", () => { const source = `[auth.sms.test_otp] "+15551234" = "111111" @@ -486,11 +458,6 @@ describe("applyConfigEdits (toml): newline and idempotence", () => { ); }); - // Regression coverage for CLI-2064 review finding 3: `endsWithNewline` used to check - // `source.endsWith(newline)`, where `newline` is CRLF as soon as the file contains one - // anywhere. A mixed-EOL file whose LAST line ends in a bare `\n` (not preceded by `\r`) - // doesn't end with `"\r\n"`, so this read as "no trailing newline" and doubled the EOF - // terminator ahead of an EOF-inserted block. test("a mixed-EOL file whose last line ends in a bare LF gets exactly one blank line before EOF-inserted content, not two", () => { const source = "[api]\r\nmax_rows = 1000\n"; const outcome = applyConfigEdits(source, "toml", [ @@ -680,8 +647,7 @@ describe("applyConfigEdits (json)", () => { ]); }); - // Regression coverage for CLI-2064 review finding 4: `JSON.stringify` always renders `\n`, so - // this used to silently flip a CRLF-flavored JSON config to LF on every edit. + // `JSON.stringify` always renders `\n`, so this guards against silently flipping CRLF to LF. test("preserves CRLF line endings and a trailing newline", () => { const source = '{\r\n "api": {\r\n "max_rows": 1000\r\n }\r\n}\r\n'; const outcome = applyConfigEdits(source, "json", [{ path: ["api", "max_rows"], value: 2 }]); @@ -707,11 +673,8 @@ describe("applyConfigEdits (json)", () => { expect(refusalReason(outcome)).toBe("parse_error"); }); - // Regression coverage for the JSON-arm consistency fix: before it, an object-valued edit was - // mutated AND verified with the same whole-subtree-replacing `deepSet`, so it could never - // catch itself deleting an unmentioned sibling — here, an `env(...)` reference, the exact kind - // of value this module must never silently drop. The TOML arm already refused this shape (see - // the pinned regression test in the `toml` describe block above); this is the JSON equivalent. + // Same mechanism as the object-edit refusal above: an omitted `env(...)` sibling must never + // be silently dropped from the file. test("refuses a JSON object-valued edit that would delete an existing env() sibling, leaving the source untouched", () => { const source = `{ "auth": { @@ -731,9 +694,8 @@ describe("applyConfigEdits (json)", () => { expect(outcome.kind).toBe("refused"); }); - // Companion case: an object-valued edit that mentions EVERY existing sibling key has nothing - // left to delete, so it merges cleanly — writing only the mentioned leaves and preserving the - // destination's OWN key order (not the edit value's own key order, which lists them reversed). + // Companion case: an edit that mentions every existing sibling key merges cleanly, preserving + // the destination's own key order rather than the edit value's. test("merges a JSON object-valued edit that omits no existing sibling, writing mentioned leaves and preserving key order", () => { const source = `{ "auth": { @@ -779,29 +741,11 @@ max_rows = 1000 }); }); -// --------------------------------------------------------------------------- -// Randomized property test. For every random edit set that `applyConfigEdits` accepts, asserts: -// -// (a) BYTE PRESERVATION — every source line whose span doesn't intersect an edited key's own -// line appears verbatim, in order, in the output; a REPLACED key's line is held to a -// narrower standard (its `key = ` prefix and any trailing `#comment` — including the -// whitespace right before it — must survive unchanged, only the value between them may -// differ), since that's the one span the editor is actually allowed to touch. This is the -// check that would have caught finding 1 (comment-eating): the ORIGINAL, tautological -// version of this test only compared PARSED values, which are identical either way. -// (b) IDEMPOTENCE — re-applying the same edits to the already-edited text is a no-op -// (byte-identical to the first result). -// (c) ZERO REFUSALS — these fixtures/edits never hit a refusal-triggering construct (no -// duplicate headers, arrays-of-tables, inline tables, or existing `env()` values), so an -// "applied" outcome is required, not merely accepted; a refusal here means either a fixture -// accidentally exercises one of those constructs, or a real regression. -// (d) the original PARSE-EQUIVALENCE oracle — `SmolToml.parse(apply(source, edits).text)` must -// deep-equal an independently computed `deepSet(SmolToml.parse(source), edits)`. Still a -// valid oracle (it does catch structural mistakes), just not a SUFFICIENT one on its own — -// see (a) above. -// +// Randomized property test. For every random edit set `applyConfigEdits` accepts, asserts byte +// preservation (untouched lines survive verbatim; a replaced line keeps its `key = ` prefix and +// trailing comment, only the value differs), idempotence (re-applying is a no-op), zero +// refusals, and parse-equivalence against an independently computed whole-subtree replacement. // Seeded PRNG so a failure reproduces: the seed is printed in every thrown error. -// --------------------------------------------------------------------------- function mulberry32(seed: number): () => number { let state = seed; @@ -918,9 +862,7 @@ backend = "postgres" [experimental.pgdelta] enabled = true `, - // Rich in trailing inline comments on bare (non-string) values, deliberately varying the - // whitespace before `#` (single space / multiple spaces / none) — this is the fixture shape - // that would have caught finding 1, via the byte-preservation check below. + // Rich in trailing inline comments on bare values, varying the whitespace before `#`. `[db] port = 54322 # default port, don't change carelessly major_version = 17 # postgres version @@ -932,10 +874,9 @@ max_client_conn = 100# no space before this comment ]; /** A line, as read off one of `PROPERTY_FIXTURES` verbatim, and — for a key-value line — the - * fully qualified path it declares. Deliberately INDEPENDENT of `config-edit.ts`'s own scanner: - * these fixtures never contain indented keys, dotted-key assignments, quoted keys/headers, or - * multi-line values, so a full TOML scanner isn't needed to know which physical line each - * key-value pair lives on — only used to build the byte-preservation oracle below. */ + * fully qualified path it declares. Independent of `config-edit.ts`'s own scanner: these + * fixtures never contain indented keys, dotted-key assignments, or multi-line values, so a + * simple line-based reader is enough. */ interface FixtureLine { readonly text: string; readonly path?: ReadonlyArray<string>; @@ -957,9 +898,8 @@ function describeFixtureLines(source: string): ReadonlyArray<FixtureLine> { }); } -/** Splits a key-value line into its `key = ` prefix and, if present, its trailing comment — - * INCLUDING whichever run of spaces/tabs immediately precedes the `#`, since that's exactly the - * span finding 1's bug used to eat. */ +/** Splits a key-value line into its `key = ` prefix and, if present, its trailing comment, + * including whichever run of spaces/tabs immediately precedes the `#`. */ function splitKvLineSuffix(line: string): { prefix: string; commentSuffix: string | undefined } { const prefixMatch = /^([A-Za-z0-9_-]+\s*=\s*)/.exec(line); const prefix = prefixMatch?.[1] ?? ""; @@ -977,8 +917,8 @@ function splitKvLineSuffix(line: string): { prefix: string; commentSuffix: strin /** Asserts property (a): every `sourceLines` entry appears, in order, in `outputText` — exactly * (for a line whose path isn't in `replacedPathKeys`) or matching just its `key = ` prefix and - * trailing comment (for a line whose path IS in `replacedPathKeys`, since only its value may - * differ). Throws with a descriptive message on the first violation. */ + * trailing comment (for a line whose path is in `replacedPathKeys`, since only its value may + * differ). */ function assertLinePreservation( sourceLines: ReadonlyArray<FixtureLine>, replacedPathKeys: ReadonlySet<string>, @@ -1046,13 +986,9 @@ describe("applyConfigEdits (toml): randomized property test", () => { if (leaf === undefined) { continue; } - // A root-level (single-segment) leaf has no enclosing table to insert a new sibling - // key into (see finding 6's dedicated refusal test) — only ever replace it here. - // Nesting the new key one level UNDER the leaf itself (rather than alongside it, in - // its own enclosing table) is deliberately avoided too: a scalar leaf isn't a table, - // so `SmolToml.parse` rejects the resulting document as redeclaring the same key as - // both a value and a table — an unrelated, out-of-scope corner case this test isn't - // meant to exercise. + // A root-level (single-segment) leaf has no enclosing table for a new sibling key, so + // only ever replace it here. Nesting a new key under the leaf itself is avoided too: a + // scalar leaf isn't a table, so `SmolToml.parse` would reject the redeclaration. const canInsert = leaf.path.length > 1; const isInsert = canInsert && rand() > 0.7; const path = isInsert @@ -1067,11 +1003,9 @@ describe("applyConfigEdits (toml): randomized property test", () => { continue; } - // De-duplicate by path, last edit wins (matching `deepSet`'s own reduce semantics): two - // edits at the exact same path in one call hits an unrelated, out-of-scope corner case - // (`applySplices` applying two same-span replacements sequentially, the second using a - // now-stale offset) that isn't one of this review's findings — avoided here so the zero- - // refusals property stays meaningful. + // De-duplicate by path, last edit wins: two edits at the same path would hit an + // unrelated corner case (`applySplices` using a stale offset for the second), avoided + // here so the zero-refusals property stays meaningful. const dedupedByPath = new Map<string, ConfigEdit>(); for (const edit of rawEdits) { dedupedByPath.set(pathKey(edit.path), edit); diff --git a/packages/config/src/config-format.ts b/packages/config/src/config-format.ts index 4fe89b4fa0..1c58d3c8cc 100644 --- a/packages/config/src/config-format.ts +++ b/packages/config/src/config-format.ts @@ -1,11 +1,2 @@ -/** - * Leaf module with zero imports of its own. `errors.ts` — this package's - * most primitive module — needs `ConfigFormat` for {@link CliConfigParseError}, - * so this type lives here rather than in `config-document.ts`, which itself - * imports from `project.ts`, which imports from `errors.ts`. Defining - * `ConfigFormat` in `config-document.ts` would create an - * `errors.ts` → `config-document.ts` → `project.ts` → `errors.ts` import - * cycle (benign at runtime today, but a live constraint for declaration - * emit). - */ +/** The on-disk formats a CLI config document can be written or parsed as. */ export type ConfigFormat = "json" | "toml"; diff --git a/packages/config/src/effect.ts b/packages/config/src/effect.ts index 9cd30d0a61..36e0e79c42 100644 --- a/packages/config/src/effect.ts +++ b/packages/config/src/effect.ts @@ -10,21 +10,13 @@ import * as project from "./project.ts"; export { configJsonPath, configTomlPath, saveCliConfig } from "./io.ts"; export { validateCliConfig } from "./validate.ts"; -/** - * Narrowed to the public `LoadCliConfigOptions` (no `goViperCompat`). The - * underlying implementation in `./io.ts` is typed against the wider - * `InternalLoadCliConfigOptions` (a strict superset — one additional optional - * field), so assigning it here is a safe, cast-free narrowing: a function - * accepting the wider options type is assignable to a variable typed to - * accept only the narrower one. `@supabase/config/internal` re-exports this - * same runtime function typed to additionally show `goViperCompat`. - */ +/** Loads a CLI config document; the internal `goViperCompat` option is exposed only via `@supabase/config/internal`. */ export const loadCliConfig: ( cwd: string, options?: LoadCliConfigOptions, ) => ReturnType<typeof io.loadCliConfig> = io.loadCliConfig; -/** See {@link loadCliConfig}'s doc comment for the narrowing rationale. */ +/** Loads a CLI config document from an explicit file path; see {@link loadCliConfig}. */ export const loadCliConfigFile: ( filePath: string, options?: LoadCliConfigOptions, @@ -34,15 +26,8 @@ export { inferFunctionsManifest } from "./functions-manifest.ts"; export { loadDotEnvFile, loadCliProjectEnvironment } from "./project.ts"; /** - * Explicit named exports take precedence over `export * from "./index.ts"` - * above for a shared name (ESM re-export resolution), so these Effect-typed - * variants deliberately shadow `./index.ts`'s plain sync - * `resolveCliConfigValue`/`resolveCliConfigSubtree` on this subpath — the - * Effect-typed variant wins on `./effect`; the sync variant lives on `.`. - * - * Narrowed to no options parameter (no `goViperCompat`) for the same reason - * as {@link loadCliConfig} above; `@supabase/config/internal` re-exports - * these same runtime functions typed to additionally show `goViperCompat`. + * Effect-typed counterpart to the sync `resolveCliConfigValue` exported from `.`; the internal + * `goViperCompat` option is exposed only via `@supabase/config/internal`. */ export const resolveCliConfigValue: <T>( value: T, @@ -50,7 +35,7 @@ export const resolveCliConfigValue: <T>( configPath: string, ) => Effect.Effect<ResolvedCliConfigValue<T>> = project.resolveCliConfigValue; -/** See {@link resolveCliConfigValue}'s doc comment for the shadowing and narrowing rationale. */ +/** See {@link resolveCliConfigValue}. */ export const resolveCliConfigSubtree: <T>( value: T, cliProjectEnv: Pick<CliProjectEnvironment, "values">, diff --git a/packages/config/src/entrypoint-purity.unit.test.ts b/packages/config/src/entrypoint-purity.unit.test.ts index a242748fbf..70b4469384 100644 --- a/packages/config/src/entrypoint-purity.unit.test.ts +++ b/packages/config/src/entrypoint-purity.unit.test.ts @@ -7,15 +7,11 @@ import * as defaultEntrypoint from "./index.ts"; import * as effectEntrypoint from "./effect.ts"; import * as internalEntrypoint from "./internal.ts"; -// `src/index.ts` is the entrypoint Studio (a browser bundle) imports -// directly. It must stay bundlable with no Node/Bun runtime underneath it — -// no `@effect/platform-*` package, no `node:`/`bun:` builtin. Bare `effect` -// itself is fine to import: its `FileSystem`/`Path` Context.Tag references -// are inert data until a platform `Layer` actually provides them, which only -// `src/bun.ts`/`src/node.ts` (via `@supabase/config/io`) and `src/effect.ts` -// do. This test statically walks the entrypoint's real relative import graph -// so a future edit that reintroduces a platform/IO dependency into that graph -// fails here instead of silently breaking browser bundling downstream. +// `src/index.ts` is the entrypoint Studio (a browser bundle) imports directly. It must stay +// bundlable with no Node/Bun runtime underneath it — no `@effect/platform-*` package, no +// `node:`/`bun:` builtin. Bare `effect` itself is fine: its `FileSystem`/`Path` tags are inert +// until a platform `Layer` provides them. This test walks the entrypoint's real import graph so +// a future edit reintroducing a platform/IO dependency fails here instead of breaking bundling. const srcDir = dirname(fileURLToPath(import.meta.url)); const packageRoot = join(srcDir, ".."); @@ -53,42 +49,27 @@ const allowedBareSpecifier = (specifier: string): boolean => specifier === "smol-toml" || specifier === "dedent"; -// This package's tests always run under Bun (`bun --bun vitest`, per -// `AGENTS.md`) — fail loudly here rather than letting a plain-node vitest +// This package's tests always run under Bun — fail loudly rather than let a plain-node vitest // run silently pass with an empty/broken walk. if (typeof Bun === "undefined") { throw new Error("this test requires the Bun runtime"); } -// `Bun.Transpiler`'s import scanner replaces this file's previous hand-rolled -// comment-stripping/type-blanking/regex extraction, which had two reproduced -// false negatives: a template-literal dynamic import (``import(`./io.ts`)``) -// — the old specifier regex only matched quote characters — and a -// semicolon-less `export type Foo = number` statement swallowing the very -// next (real) import into its blanked-out range. `scanImports` is Bun's own -// TS/JSX-aware parser: it excludes type-only import/export statements, -// keeps a mixed inline-`type` specifier alive, and reports dynamic -// `import()`/`require()` calls (as `"dynamic-import"`/`"require-call"`) -// alongside ordinary `import ... from` statements — all verified against -// the fixtures in the `describe("extractSpecifiers", ...)` block below. +// Uses Bun's own TS/JSX-aware import scanner rather than a hand-rolled comment-stripping regex, +// which had false negatives on a template-literal dynamic import and a semicolon-less type +// alias swallowing the next real import. `scanImports` excludes type-only imports, keeps a +// mixed inline-`type` specifier alive, and reports dynamic `import()`/`require()` calls — all +// verified by the `extractSpecifiers` fixtures below. const transpiler = new Bun.Transpiler({ loader: "ts" }); function extractSpecifiers(rawSource: string): string[] { return transpiler.scanImports(rawSource).map((entry) => entry.path); } -// `scanImports` only reports a dynamic `import()`/`require()` call when its -// argument is a literal string or a template literal with no `${...}` -// interpolation (verified below) — a non-literal argument (a bare -// identifier, or an interpolated template literal) is silently omitted from -// its result instead of erroring. The walker can't know what such a call -// might resolve to at runtime, so rather than silently under-reporting the -// graph, this scans the same (comment-stripped, type-erased) transpiled -// source for any `import(`/`require(` call whose argument isn't a static -// string/template literal, and treats a match as a hard failure. Consulting -// the pre-transpiled comment-free/type-erased source (rather than re-running -// our own comment stripper) keeps this check honest about only ever seeing -// what the transpiler itself considers live code. +// `scanImports` silently omits a dynamic `import()`/`require()` call whose argument isn't a +// static string/template literal, since it can't know what such a call might resolve to. Rather +// than under-reporting the graph, this scans the same transpiled source for any such call and +// treats a match as a hard failure. const dynamicImportOrRequireCall = /\b(?:import|require)\s*\(\s*([\s\S]*?)\)/g; function isStaticSpecifierArgument(argument: string): boolean { @@ -288,10 +269,8 @@ describe("collectImportGraph", () => { const { visitedFiles, bareSpecifiers } = collectImportGraph(join(srcDir, "index.ts")); -// The real, post-partition pure runtime graph (CLI-2231): computed by running -// the walker above and hardcoded here so both additions AND removals are a -// deliberate, loud review event rather than silently passing or silently -// failing on an unrelated assertion. +// The real, post-partition pure runtime graph: computed by running the walker above and +// hardcoded here so both additions and removals are a deliberate, loud review event. const expectedPureGraphFiles = [ "index.ts", "base.ts", @@ -357,13 +336,9 @@ describe("src/index.ts stays browser-safe", () => { }); }); -// CLI-2234 group 8c: `src/io-browser.ts` (the `browser` condition target for -// `@supabase/config/io`) must stay just as bundler-safe as `index.ts` itself -// — it only adds inert, throw-when-invoked stubs plus a type-only import from -// `promise-facade.ts` (erased at the specifier-scan level, same as every -// other `import type`/`export type` statement this walker already ignores) -// on top of `export * from "./index.ts"`. Reuses the exact same walker and -// browser-safe bare-specifier allowlist as the `index.ts` suite above. +// `src/io-browser.ts` (the `browser` condition target for `@supabase/config/io`) must stay just +// as bundler-safe as `index.ts` itself — it only adds inert, throw-when-invoked stubs on top of +// `export * from "./index.ts"`. Reuses the same walker and allowlist as the `index.ts` suite. const ioBrowserGraph = collectImportGraph(join(srcDir, "io-browser.ts")); const expectedIoBrowserGraphFiles = [ join(srcDir, "io-browser.ts"), @@ -478,9 +453,8 @@ describe("src/effect.ts is a superset of src/index.ts", () => { `); }); - // `resolveCliConfigValue`/`resolveCliConfigSubtree` are the one deliberate - // exception (see `effect.ts`'s doc comment): `./effect`'s Effect-typed - // variant intentionally shadows `./index.ts`'s plain sync variant, since + // `resolveCliConfigValue`/`resolveCliConfigSubtree` are the one exception (see `effect.ts`'s + // doc comment): `./effect`'s Effect-typed variant shadows `./index.ts`'s plain sync one, since // explicit named exports win over a star re-export of the same name. const deliberatelyShadowedKeys = new Set(["resolveCliConfigValue", "resolveCliConfigSubtree"]); @@ -533,11 +507,9 @@ describe("src/internal.ts export surface", () => { `); }); - // `./internal`'s `resolveCliConfigValue`/`resolveCliConfigSubtree`/ - // `loadCliConfig` are the SAME runtime functions `./effect` exports - // (only the accepted options TYPE differs — internal.ts's is the wider, - // `goViperCompat`-capable one), so unlike the deliberate shadowing between - // `.` and `./effect` above, there is no shadowing to assert here. + // `./internal`'s `resolveCliConfigValue`/`resolveCliConfigSubtree`/`loadCliConfig` are the + // same runtime functions `./effect` exports (only the accepted options type differs), so + // unlike the shadowing between `.` and `./effect` above, there is no shadowing to assert here. test("resolveCliConfigValue, resolveCliConfigSubtree, and loadCliConfig are identical to effect.ts's bindings", () => { for (const key of [ "resolveCliConfigValue", @@ -557,14 +529,10 @@ describe("package.json exports map", () => { expect(Object.keys(ioExports)).toEqual(["bun", "node", "browser", "default"]); }); - // `.`/`./effect`/`./internal` lead with `bun` (CLI-2234): `tsc` under this - // repo's `customConditions: ["bun"]` must resolve straight to `src/*.ts` - // (self-typed, no separate `.d.ts` needed) instead of `dist/*.d.ts`, which - // requires `bun` to win the exports-map lookup ahead of `types` — see - // `apps/cli/tsconfig.json`'s `customConditions`. `types` only needs to - // precede `default` (the dist JS the `types` `.d.ts` describes), not be - // first outright, so a plain `nodenext` consumer (no `bun` condition - // requested) still resolves `types` -> `dist/*.d.ts` correctly. + // `.`/`./effect`/`./internal` lead with `bun`: `tsc` under this repo's `customConditions: + // ["bun"]` must resolve straight to `src/*.ts` instead of `dist/*.d.ts`, which requires `bun` + // to win the exports-map lookup ahead of `types`. `types` only needs to precede `default`, not + // be first outright, so a plain `nodenext` consumer still resolves `types` -> `dist/*.d.ts`. test("'types' precedes 'default' in every conditional export object (CLI-2234)", () => { const conditionObjects = [ packageJson.exports["."], @@ -592,11 +560,9 @@ describe("package.json exports map", () => { ); }); - // The `types`/`default` conditions of `.`/`./effect`/`./internal`/`./io` - // (node, browser, default) all point at `dist/` build outputs, which only - // exist after `pnpm run build` — intentionally NOT checked here so this - // test stays build-independent. `scripts/build.ts`'s tree-shake/Node-consumer - // smoke test owns dist correctness instead (CLI-2232). + // The `types`/`default` conditions all point at `dist/` build outputs, which only exist after + // `pnpm run build` — intentionally not checked here so this test stays build-independent. + // `scripts/build.ts`'s tree-shake/Node-consumer smoke test owns dist correctness instead. test("the ./io bun condition target exists on disk (its only src target)", () => { expect(() => readFileSync(join(packageRoot, packageJson.exports["./io"].bun))).not.toThrow(); }); diff --git a/packages/config/src/errors.ts b/packages/config/src/errors.ts index 9855e75b39..95b0903806 100644 --- a/packages/config/src/errors.ts +++ b/packages/config/src/errors.ts @@ -6,65 +6,22 @@ export class CliConfigParseError extends Data.TaggedError("CliConfigParseError") readonly format: ConfigFormat; readonly cause: unknown; /** - * The pre-schema-decode `edge_runtime` subtree (post env-interpolation and - * `[remotes.*]` merge) — present only when the failure happened during - * *schema* decode (`Schema.decodeUnknownSync`), not during raw TOML/JSON - * parsing. `Schema.decodeUnknownSync` is all-or-nothing: a single invalid - * field anywhere in the document discards the entire decode, unlike Go's - * `viper`+`mapstructure` decode (`apps/cli-go/pkg/config/config.go:749`), - * which mutates the target struct field-by-field and keeps whatever - * independently decoded before hitting an unrelated error. Callers that - * need Go's tolerance for a single subtree (e.g. `secrets set` recovering - * `edge_runtime.secrets` when an unrelated field like `analytics.port` is - * malformed) can re-decode this subtree against the full schema themselves. - * Only `edge_runtime` is retained, not the whole document — several callers - * of `loadCliConfig` don't catch `CliConfigParseError` at all, so - * this error can propagate with whatever is attached here, and no caller - * needs anything outside `edge_runtime` today. Every `edge_runtime.secrets` - * value is wrapped in `Redacted` (mirroring `secret()`'s `x-secret` - * treatment elsewhere in this package) so an uncaught error can't - * accidentally leak a resolved secret into a log or trace; callers must - * unwrap via `Redacted.value` before re-decoding. `undefined` when the - * document never parsed at all — that class has no recoverable structure in - * either implementation. + * The pre-decode `edge_runtime` subtree, present only when a schema decode failure discarded + * the rest of the document (schema decode is all-or-nothing). `edge_runtime.secrets` values + * stay wrapped in `Redacted` so this can't leak a resolved secret if left uncaught. */ readonly document?: { readonly edge_runtime?: unknown }; /** - * Name of the `[remotes.<name>]` block whose subtree was merged over the - * base document before the decode that produced this error, when a - * `projectRef` was supplied and one matched. Mirrors `appliedRemote` on - * {@link LoadedCliConfig} for the success path. Go's `loadFromFile` - * prints `Loading config override: [remotes.<name>]` to stderr - * unconditionally, *before* `mapstructure` decode ever runs - * (`apps/cli-go/pkg/config/config.go:604-609`) — so the notice is still due - * even when the subsequent decode fails. Callers that tolerate a - * schema-decode failure and keep going (e.g. `secrets set`) must surface - * this themselves; callers that let the error propagate get no such - * notice from Go either, since `c.load(v)` fails before `Run` prints - * anything else. `undefined` when no `projectRef` was requested or none - * matched — same as the raw-parse-failure case, where remote merging never - * runs at all. + * Name of the matched `[remotes.<name>]` block; present even when the decode itself fails, + * since remote selection happens first. Mirrors {@link LoadedCliConfig}'s `appliedRemote`. */ readonly appliedRemote?: string; }> {} -/** - * Shared human-message prefix for every {@link ProjectConfigParseError} - * construction site (`./project-config/*.ts`), so the class always reads as - * one coherent failure kind rather than a grab-bag of ad hoc wording. - */ const PROJECT_CONFIG_PARSE_ERROR_MESSAGE_PREFIX = "Could not read the project config from the Management API response"; -/** - * Renders `detail` under the shared {@link ProjectConfigParseError} message - * convention: `"<prefix>: <detail>"`, or `"<prefix>: at data.attributes.<apiPath>: - * <detail>"` when `apiPath` is given and non-empty. Every construction site - * (`./project-config/project-config.ts`, `./project-config/registry-row.ts`, - * `./project-config/registry.ts`) builds its message through this helper so - * the "at data.attributes...." rendering stays identical everywhere an - * `apiPath` is known. - */ +/** Formats a {@link ProjectConfigParseError} message, appending the API-attributes path when given. */ export function formatProjectConfigParseErrorMessage( detail: string, apiPath?: ReadonlyArray<string>, @@ -75,47 +32,21 @@ export function formatProjectConfigParseErrorMessage( return `${PROJECT_CONFIG_PARSE_ERROR_MESSAGE_PREFIX}: at ${["data", "attributes", ...apiPath].join(".")}: ${detail}`; } -/** - * {@link ProjectConfigParseError} is, by construction, always the same - * underlying situation: this package's mirrored schema/registry - * (`./project-config/api-attributes.ts`, `./project-config/registry*.ts`) is - * behind what the Management API actually sent. There is therefore exactly - * one remediation, attached as `suggestion` at every construction site: - * upgrade first (a newer package version may already map or leniently accept - * the offending shape), then report if it persists. - */ +/** Suggested remediation attached to every {@link ProjectConfigParseError}. */ export const PROJECT_CONFIG_PARSE_ERROR_SUGGESTION = "Try upgrading the Supabase CLI to the latest version. If the error persists on the latest version, report it at https://github.com/supabase/cli/issues."; /** - * A Management API v2 project-config response failed to map into a - * `ProjectConfig`: the envelope/attributes shape didn't decode, or a - * registry-mapped field carried a value of the wrong type. `message` is a - * human-readable summary built via {@link formatProjectConfigParseErrorMessage} - * at every construction site; `detail` optionally carries a fuller, - * multi-issue rendering (currently only populated for a schema decode - * failure, via `SchemaIssue.makeFormatterDefault()`); `suggestion` is always - * {@link PROJECT_CONFIG_PARSE_ERROR_SUGGESTION}. Unknown keys never cause - * this on their own — the mapping decode is lenient toward - * API-ahead-of-package skew by design (ADR 0019, rule 2) — with one - * documented trade: an own `data` or `attributes` key found on what was - * actually meant to be a bare-attributes payload is indistinguishable from a - * real envelope and is treated as one (`unwrapApiResponse`'s docstring in - * `./project-config/project-config.ts`), so a section genuinely named either - * of those two words would trigger envelope validation instead of being - * tolerated as an unmapped key. + * A Management API v2 project-config response failed to map into a {@link ProjectConfig}. + * Unmapped keys never trigger this on their own; a payload with an own `data` or `attributes` + * key is treated as an envelope even when it was meant as bare attributes. */ export class ProjectConfigParseError extends Data.TaggedError("ProjectConfigParseError")<{ readonly message: string; /** - * What actually went wrong, as a closed union telemetry can branch on: - * `"api_response"` (the default when absent) — the Management API payload - * itself failed to decode or map; `"caller_misuse"` — the CALLER handed - * this package's own API an invalid argument (a `toProjectConfig` source - * carrying neither/both keys or not an object at all, a non-object - * `attachApiResponse` operand). Misuse is a programming error in the - * consumer: the upgrade `suggestion` does not apply to it, and it must not - * be reported as an external platform failure. + * `"api_response"` (default) — the platform payload itself failed to decode or map. + * `"caller_misuse"` — the caller passed this package's own API an invalid argument; the + * upgrade suggestion does not apply and this should not be reported as a platform failure. */ readonly reason?: "api_response" | "caller_misuse"; /** @@ -134,40 +65,21 @@ export class CliProjectEnvParseError extends Data.TaggedError("CliProjectEnvPars readonly line: number; }> {} -/** - * Two `[remotes.*]` blocks declare the same `project_id` as the requested - * `projectRef`. Mirrors Go's `loadFromFile` guard - * (`apps/cli-go/pkg/config/config.go:508-509`); `message` matches the Go string - * verbatim so callers can surface it without rewrapping. - */ +/** Two `[remotes.*]` blocks declare the same `project_id` as the requested `projectRef`. */ export class DuplicateRemoteProjectIdError extends Data.TaggedError( "DuplicateRemoteProjectIdError", )<{ readonly message: string; }> {} -/** - * A `[remotes.<name>]` block's `project_id` is not a valid 20-lowercase-letter - * project ref. Mirrors Go's `Config.Validate` (`apps/cli-go/pkg/config/config.go: - * 558,996-1001`), which checks every remote's `project_id` against `refPattern` - * on every config load — regardless of whether that remote ends up selected — - * so this fails before Docker/API access, same as Go. `message` matches the Go - * string verbatim so callers can surface it without rewrapping. - */ +/** A `[remotes.<name>]` block's `project_id` is not a valid 20-lowercase-letter project ref. */ export class InvalidRemoteProjectIdError extends Data.TaggedError("InvalidRemoteProjectIdError")<{ readonly message: string; }> {} /** - * Replacing a config document's on-disk text failed inside - * `writeCliConfigDocumentText` (`./io.ts`) — the temp-file write, the mode - * copy, or the final rename onto {@link path}. `cause` carries the underlying - * `PlatformError`; `message` is a ready-to-surface human summary built at the - * construction site. `config pull`'s surgical edit - * (`applyConfigEdits`/`config-edit.ts`) is this error's primary producer — - * unlike `saveCliConfig`'s full-document regeneration (`writeFileAtomic`, - * which dies on failure), a caller replacing hand-authored text with a - * targeted edit needs a typed failure it can react to. + * Replacing a config document's on-disk text failed: the temp-file write, mode copy, or final + * rename onto {@link path}. `cause` carries the underlying `PlatformError`. */ export class CliConfigWriteError extends Data.TaggedError("CliConfigWriteError")<{ readonly path: string; diff --git a/packages/config/src/internal.ts b/packages/config/src/internal.ts index abcec7250f..42964d68c7 100644 --- a/packages/config/src/internal.ts +++ b/packages/config/src/internal.ts @@ -1,19 +1,10 @@ /** - * NOT covered by semver. This subpath exists solely for `apps/cli`'s own use - * and its contract-guard tests — every export here (its existence, its shape, - * its behavior) can change or vanish in any release without notice. External - * consumers must use `.`, `./effect`, or `./io` instead; only `apps/cli` may - * import `@supabase/config/internal` (enforced by - * `src/monorepo-import-contract.unit.test.ts`). + * Not covered by semver: exists solely for `apps/cli`'s own use, and can change or vanish in + * any release without notice. External consumers must use `.`, `./effect`, or `./io` instead. * - * `loadCliConfig`/`resolveCliConfigValue`/`resolveCliConfigSubtree` below are - * the SAME runtime functions `./effect` exports, just re-typed here to widen - * their options parameter to the internal-only, Go-parity `goViperCompat` - * knob (`InternalLoadCliConfigOptions` for `loadCliConfig`; - * `resolveCliConfigValue`/`resolveCliConfigSubtree`'s own widened options - * type is package-internal and not itself re-exported here) — this module - * otherwise only re-exports types and registry data, not independent - * implementations. + * `loadCliConfig`/`resolveCliConfigValue`/`resolveCliConfigSubtree` below are the same runtime + * functions `./effect` exports, re-typed here to widen their options parameter to the + * internal-only `goViperCompat` knob. */ export { ENV_CAPTURE_REGEX } from "./lib/env.ts"; export { diff --git a/packages/config/src/io-browser.ts b/packages/config/src/io-browser.ts index 66ea9bf9b0..514e5c1e1f 100644 --- a/packages/config/src/io-browser.ts +++ b/packages/config/src/io-browser.ts @@ -1,26 +1,19 @@ import type { CliConfigIo } from "./promise-facade.ts"; -// Resolved by bundlers targeting `browser` for the `@supabase/config/io` -// subpath. There is no browser-safe file-IO implementation — browser -// consumers must use the pure `@supabase/config` entrypoint instead. +// Resolved by bundlers targeting `browser` for the `@supabase/config/io` subpath; there is no +// browser-safe file-IO implementation, so browser consumers must use the pure `@supabase/config` +// entrypoint instead. // -// This module must stay side-effect-free: it exports the same seven names as -// `bun.ts`/`node.ts` so a bundler resolves named imports successfully at -// BUILD time (a bare top-level `throw` here previously made every named -// import fail with a generic "No matching export", never reaching this -// curated message). Each export only throws once actually INVOKED, so an -// isomorphic/edge bundle that imports this module (directly, transitively, -// or as a namespace) but never calls into it — a real risk given Next's edge -// condition set includes "browser" — never crashes at module evaluation. +// Exports the same seven names as `bun.ts`/`node.ts` so bundlers resolve named imports, and each +// export only throws once invoked — so a bundle that imports this module without calling into it +// doesn't crash at module evaluation. async function unavailableInBrowser(): Promise<never> { throw new Error( '@supabase/config/io is not available in browser bundles; import the pure surface from "@supabase/config" instead.', ); } -// Typed against `CliConfigIo` (mirroring how `bun.ts`/`node.ts` type -// their own facade object) so this module cannot structurally diverge from -// the real facades' export shape. +// Typed against `CliConfigIo` so this can't structurally diverge from the real facades' shape. const cliConfigIo: CliConfigIo = { loadCliConfig: unavailableInBrowser, findCliProjectRoot: unavailableInBrowser, @@ -39,10 +32,6 @@ export const loadCliProjectEnvironment = cliConfigIo.loadCliProjectEnvironment; export const saveCliConfig = cliConfigIo.saveCliConfig; export const inferFunctionsManifest = cliConfigIo.inferFunctionsManifest; export type { CliConfigIo } from "./promise-facade.ts"; -// Re-exports every pure symbol from `.` (types, schema, errors, etc.) so -// `./io` consumers can name `LoadedCliConfig`/`CliProjectPaths`/etc. without -// a second import from `@supabase/config` — `index.ts`'s own graph is pure, -// so this doesn't drag anything platform-specific into it, and this module -// stays platform-free. No name collisions with the seven facade functions -// above (verified against `index.ts`'s export surface). +// Re-exports every pure symbol from `.` so `./io` consumers don't need a second import; this +// stays platform-free since `index.ts`'s own graph is pure. export * from "./index.ts"; diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index 2299e6b0df..e9c509fb68 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -38,11 +38,9 @@ function siblingConfigPathWith(path: Path.Path, cwd: string, format: ConfigForma } /** - * Deep-merges a `[remotes.*]` subtree over the base document, reproducing Go's - * `mergeRemoteConfig` (`apps/cli-go/pkg/config/config.go:550`): nested objects - * merge recursively; arrays and scalars replace wholesale (viper sets each leaf - * key). Operates on the raw, pre-decode document so only keys the remote block - * actually declares override the base — the remote section's schema defaults + * Deep-merges a `[remotes.*]` subtree over the base document: nested objects merge recursively, + * arrays and scalars replace wholesale. Operates on the raw, pre-decode document so only keys + * the remote block actually declares override the base — the remote section's schema defaults * never leak in. */ function mergeRemoteSubtree( @@ -68,7 +66,7 @@ function remoteSetsDbSeedEnabled(remote: Record<string, unknown>): boolean { return isObject(seed) && "enabled" in seed; } -/** Forces `db.seed.enabled = false`, immutably, matching Go's mergeRemoteConfig. */ +/** Forces `db.seed.enabled = false`, immutably. */ function withDbSeedDisabled(document: Record<string, unknown>): Record<string, unknown> { const db = isObject(document["db"]) ? document["db"] : {}; const seed = isObject(db["seed"]) ? db["seed"] : {}; @@ -88,15 +86,10 @@ function pathKey(path: ReadonlyArray<string>): string { } /** - * Builds a `project_id -> "[remotes.<name>]"` map across every `[remotes.*]` - * block, failing on the first duplicate. Mirrors Go's `loadFromFile` - * (`config.go:594-602`): that loop runs unconditionally on every config load, - * regardless of whether any remote's `project_id` ends up matching - * `Config.ProjectId`. Here, {@link applyRemoteOverride} only invokes this when - * `goViperCompat` is set, so it still runs even for callers that don't - * request a specific `projectRef` — but only under Go-parity mode. A missing - * `project_id` reads as `""` (Go's `viper.GetString`), so two remotes that - * both omit it collide on the empty key and fail just as in Go. + * Builds a `project_id -> "[remotes.<name>]"` map across every `[remotes.*]` block, failing on + * the first duplicate. {@link applyRemoteOverride} only invokes this when `goViperCompat` is + * set, so it runs even for callers that don't request a specific `projectRef`. A missing + * `project_id` reads as `""`, so two remotes that both omit it collide on the empty key. */ const checkDuplicateRemoteProjectIds = Effect.fnUntraced(function* ( remotes: Record<string, unknown>, @@ -116,13 +109,8 @@ const checkDuplicateRemoteProjectIds = Effect.fnUntraced(function* ( }); /** - * Extracts `project_id` for every `[remotes.<name>]` block, in document - * order, reading a missing field as `""` (Go's `viper.GetString`). Shared by - * every reader of raw `remotes.*.project_id` values so there is exactly one - * place that defines what counts as a remote's `project_id` — this backs - * {@link remoteNameForProjectRef} below and, at the config-load call site, - * `applyRemoteOverride`'s match. Returns `[]` for anything that isn't a - * `remotes` table (including `undefined`). + * Extracts `project_id` for every `[remotes.<name>]` block, in document order, reading a + * missing field as `""`. Returns `[]` for anything that isn't a `remotes` table. */ export function remoteProjectIdEntries( remotes: unknown, @@ -138,19 +126,11 @@ export function remoteProjectIdEntries( } /** - * The name of the `[remotes.<name>]` block whose `project_id` equals - * `projectRef`, or `undefined` when none matches — including when - * `projectRef` itself is `undefined`, which never matches a remote, even one - * that itself omits `project_id` (which reads as `""`). This is the exact - * raw-literal match {@link applyRemoteOverride} runs during config load: Go's - * `loadFromFile` selection loop reads viper's raw string values, before - * `LoadEnvHook` ever resolves `env(...)` (`apps/cli-go/pkg/config/config.go: - * 596-611`, `decode_hooks.go:13-26`). A caller deciding WHERE to write a value - * for a given `projectRef` (e.g. `config pull`'s scope resolution) must call - * this against `LoadedCliConfig.rawDocument?.["remotes"]`, never - * `LoadedCliConfig.document`, for the same reason — an - * `[remotes.x] project_id = "env(REF)"` that resolves to `REF` must not match - * a caller-supplied, already-resolved `REF`. + * The name of the `[remotes.<name>]` block whose `project_id` equals `projectRef`, or + * `undefined` when none matches (including when `projectRef` itself is `undefined`). Matches + * against the raw, pre-`env()` literal — callers must pass `LoadedCliConfig.rawDocument`'s + * `remotes`, never `LoadedCliConfig.document`, so a `project_id = "env(REF)"` that resolves to + * `REF` doesn't match a caller-supplied, already-resolved `REF`. */ export function remoteNameForProjectRef( remotes: unknown, @@ -162,25 +142,14 @@ export function remoteNameForProjectRef( return remoteProjectIdEntries(remotes).find((entry) => entry.projectId === projectRef)?.name; } -/** Go's project-ref pattern (`apps/cli-go/pkg/config/config.go:558`): exactly 20 - * lowercase ASCII letters. */ +/** Valid project ref format: exactly 20 lowercase ASCII letters. */ const REMOTE_PROJECT_ID_PATTERN = /^[a-z]{20}$/; /** - * Rejects the first `[remotes.*]` block whose `project_id` is not a valid - * project ref, mirroring Go's `Config.Validate` (`config.go:996-1001`) — that - * loop runs unconditionally over every remote on every config load, not only - * the one that ends up selected/merged. Here, {@link applyRemoteOverride} only - * invokes this when `goViperCompat` is set. - * - * Unlike {@link checkDuplicateRemoteProjectIds}/the match below (which read - * viper's raw, pre-`LoadEnvHook` values — see {@link applyRemoteOverride}'s - * doc comment), `Config.Validate` runs entirely AFTER the struct decode - * (`config.go:882`), by which point `LoadEnvHook` has already resolved every - * `env(...)` reference (`config.go:749-753`). So this check must see the - * already-interpolated `project_id`, not the literal `env(REF)` form — an - * `[remotes.x] project_id = "env(REF)"` that resolves to a valid 20-letter ref - * passes here even though the raw string doesn't match the pattern itself. + * Rejects the first `[remotes.*]` block whose `project_id` is not a valid project ref, across + * every remote regardless of selection. Unlike {@link checkDuplicateRemoteProjectIds}, this must + * see the already-interpolated `project_id`: a `project_id = "env(REF)"` that resolves to a + * valid ref passes here even though the raw literal doesn't match the pattern. */ const checkRemoteProjectIdFormat = Effect.fnUntraced(function* (remotes: Record<string, unknown>) { for (const [remoteName, remote] of Object.entries(remotes)) { @@ -195,32 +164,11 @@ const checkRemoteProjectIdFormat = Effect.fnUntraced(function* (remotes: Record< }); /** - * Applies the `[remotes.<name>]` override whose `project_id` matches `projectRef` - * to `rawDocument`, mirroring Go's `loadFromFile` remote resolution - * (`config.go:503-518`). Returns the merged document (with `remotes` stripped, - * still pre-`env()`-interpolation — the caller re-interpolates the result) and - * the matched remote name. `projectRef` of `undefined` never matches any remote - * (including one that itself omits `project_id`, which reads as `""`) — callers - * that don't request a specific remote get the duplicate/format checks below - * without the merge, so the base document loads verbatim as before. - * - * `rawDocument`'s `remotes` block is the PRE-interpolation document: Go's - * duplicate-check/selection loop in `loadFromFile` reads directly off viper's - * raw config values (`v.GetString(fmt.Sprintf("remotes.%s.project_id", name))`, - * `config.go:596-610`) and only calls `c.load(v)` — which resolves `env(...)` - * via `LoadEnvHook` during the struct decode (`config.go:749-753`, - * `decode_hooks.go:13-26`) — afterward (`config.go:611`). So a - * `[remotes.prod] project_id = "env(REF)"` is matched/deduped against the - * LITERAL `env(REF)` string in Go, never against `REF`'s resolved value; this - * mirrors that exactly rather than matching post-interpolation, which would - * merge a remote Go itself would never select. `interpolatedRemotes` (Go's - * post-decode `c.Remotes`, mirrored here as the already-interpolated - * `remotes` subtree) is used only for {@link checkRemoteProjectIdFormat} — see - * its doc comment for why that check needs the resolved value instead. - * - * The match itself is {@link remoteNameForProjectRef}, exported for callers - * that need the same raw-literal rule against a previously loaded - * `LoadedCliConfig.rawDocument` without reloading the file. + * Applies the `[remotes.<name>]` override whose `project_id` matches `projectRef` to + * `rawDocument`, matching against the raw, pre-`env()` literal so an unresolved `env(...)` + * reference is matched by its literal form, not its resolved value. Returns the merged, still + * pre-interpolation document (`remotes` stripped) and the matched name; an absent or unmatched + * `projectRef` returns the base document verbatim. */ const applyRemoteOverride = Effect.fnUntraced(function* ( rawDocument: Record<string, unknown>, @@ -271,11 +219,8 @@ interface NormalizedSMTPDocument { } /** - * Rewrites the deprecated `[inbucket]` config section (top-level and per - * `[remotes.*]`) to its preferred `[local_smtp]` name, mirroring Go's - * `normalizeDeprecatedSMTPConfig`. When both keys are present the explicit - * `local_smtp` wins and `inbucket` is dropped. The returned `deprecatedSections` - * drive the user-facing deprecation warnings emitted by the caller. + * Rewrites the deprecated `[inbucket]` config section (top-level and per `[remotes.*]`) to its + * preferred `[local_smtp]` name; when both are present, the explicit `local_smtp` wins. */ function normalizeDeprecatedSMTPSections(document: unknown): NormalizedSMTPDocument { if (!isObject(document)) { @@ -311,19 +256,13 @@ function normalizeDeprecatedSMTPSections(document: unknown): NormalizedSMTPDocum interface NormalizedExternalProvidersDocument { readonly document: unknown; - /** Provider ids (`"linkedin"` | `"slack"`) whose deprecated top-level block was `enabled` — drives the WARN. */ + /** Provider ids (`"linkedin"` | `"slack"`) whose deprecated top-level block was `enabled`. */ readonly deprecatedProviders: ReadonlyArray<string>; /** * The removed top-level `auth.external.{linkedin,slack}` sub-objects (provider id → the - * object that was deleted), regardless of `enabled`. Go's global `DecryptSecretHookFunc` - * runs during decode — strictly BEFORE `Config.Validate()` → `external.validate()` (which - * this function mirrors) ever deletes these blocks (`config.go:753,775-783` decode vs. - * `config.go:882,1148,1419-1425` validate) — so an `encrypted:` secret hiding in one of - * these blocks still gets decrypted-or-aborted in Go. A caller that needs to reproduce that - * decrypt-or-abort check against the returned (already-stripped) {@link LoadedCliConfig.document} - * (e.g. `config push`'s pre-check) can fold this back in. Only the top-level blocks are - * captured, not any surviving `remotes.*.auth.external.{linkedin,slack}` — that's the - * separate, already-documented "non-matching remote" gap. + * removed object), regardless of `enabled`. A caller checking these for an `encrypted:` + * secret against the already-stripped {@link LoadedCliConfig.document} can fold this back in. + * Only the top-level blocks are captured, not any surviving `remotes.*.auth.external.*`. */ readonly removedProviders: Readonly<Record<string, unknown>>; } @@ -331,31 +270,11 @@ interface NormalizedExternalProvidersDocument { const DEPRECATED_EXTERNAL_PROVIDERS = ["linkedin", "slack"] as const; /** - * Go's `(e external) validate()` deprecated-provider handling - * (`apps/cli-go/pkg/config/config.go:1418-1423`): `linkedin`/`slack` are - * unconditionally deleted from `auth.external` before the required-field loop - * runs, so a bare `[auth.external.slack] enabled = true` with no - * `client_id`/`secret` loads fine in Go — a warning prints to stderr only - * when the deleted provider was `enabled`, never a hard failure. - * - * Unlike {@link normalizeDeprecatedSMTPSections}'s `[inbucket]` rename — which - * Go's own `normalizeDeprecatedSMTPConfig` runs BEFORE remote selection, over - * every `[remotes.*]` entry unconditionally (`config.go:594,614-640`) — Go's - * `external.validate()` runs from `Config.Validate()`, exactly ONCE on the - * final post-remote-merge struct (`config.go:882,1148`). A non-selected - * remote's own `auth.external.slack` block is never even looked at by Go. So - * this must run on the POST-merge document (`documentForDecode`, after - * `applyRemoteOverride`), not the pre-merge one: - * - the top-level `auth.external.{linkedin,slack}` is always stripped, and - * reported (for the caller to warn on) only when it was `enabled`, - * matching Go's single `external.validate()` call. - * - any `remotes.*.auth.external.{linkedin,slack}` still present (only - * possible when no remote matched `projectRef`, so `applyRemoteOverride` - * left `remotes` in place) is also stripped, but never reported — purely - * so `remoteCliConfigBlock`'s eager, whole-map schema decode - * (`packages/config/src/base.ts`) doesn't reject an unselected remote's - * deprecated block over a field Go itself never struct-decodes at all for - * a remote that isn't in effect. + * Strips the deprecated `auth.external.{linkedin,slack}` providers, unconditionally, reporting + * one only when it was `enabled`. Runs on the post-remote-merge document, since only the final + * merged config's `auth.external` matters. Also strips (without reporting) any surviving + * `remotes.*.auth.external.{linkedin,slack}`, purely so an unselected remote's deprecated block + * doesn't get rejected by this package's eager, whole-map schema decode. */ function normalizeDeprecatedExternalProviders( document: unknown, @@ -397,32 +316,18 @@ function normalizeDeprecatedExternalProviders( } /** - * Wraps every `edge_runtime.secrets` value in `Redacted` before it's attached - * to `CliConfigParseError.document`. By this point `secrets` values are - * real, resolved secrets (post `env()` interpolation, see - * `interpolateEnvReferencesAgainstSchema` in `loadCliConfigFile`) — the - * same values `secret()` (`lib/env.ts`) annotates `x-secret` for elsewhere in - * this package (`resolveCliConfigValue`'s `redactValue`). Several callers of - * `loadCliConfig` (`gen types`, `next start`, `functions dev/serve/deploy`) - * don't catch `CliConfigParseError` at all, so this keeps the same - * accidental-leak protection `Redacted` already gives every other secret path - * in this package, in case an uncaught error's `document` ever reaches a log - * or trace. `secrets set`'s `recoverEdgeRuntimeConfig`/`filterDecodableSecrets` - * unwrap via `Redacted.isRedacted`/`Redacted.value` before re-decoding. + * Wraps every `edge_runtime.secrets` value in `Redacted` before it's attached to + * `CliConfigParseError.document`, so an uncaught parse error can't leak a resolved secret into + * a log or trace. Callers must unwrap via `Redacted.value` before re-decoding. */ function redactEdgeRuntimeSecrets(edgeRuntime: unknown): unknown { if (!isObject(edgeRuntime) || !("secrets" in edgeRuntime)) { return edgeRuntime; } if (!isObject(edgeRuntime.secrets)) { - // The whole `secrets` field is malformed — e.g. `secrets = ["actual-secret"]` - // (a TOML array instead of a table) — rather than a single bad entry - // inside an otherwise-valid table. Still carries a secret in its - // structure, so wrap the field as one unit with the same rationale as - // the per-entry case below. Guarded by `"secrets" in edgeRuntime` - // (not just falling through on `undefined`) so `edge_runtime` documents - // that legitimately omit `secrets` don't gain a spurious - // `Redacted.make(undefined)` field. + // A malformed `secrets` field (e.g. a TOML array instead of a table) still carries a + // secret in its structure, so wrap it as one unit. Guarded by `"secrets" in edgeRuntime` so + // a document that legitimately omits `secrets` doesn't gain a spurious `Redacted` field. return { ...edgeRuntime, secrets: Redacted.make(edgeRuntime.secrets, { label: "edge_runtime.secrets" }), @@ -430,12 +335,8 @@ function redactEdgeRuntimeSecrets(edgeRuntime: unknown): unknown { } return { ...edgeRuntime, - // Wrap the whole entry, not just string values: a malformed - // `[edge_runtime.secrets]` entry (e.g. a TOML array `FOO = ["actual-secret"]` - // or inline table) still carries the secret in its structure, and - // `Redacted.make` accepts any value — `toString`/`toJSON` always render - // `<redacted:...>` regardless of the wrapped type, so this can't leak a - // non-string entry either. + // Wraps the whole entry, not just string values: a malformed entry (e.g. a TOML array) + // still carries a secret in its structure, and `Redacted.make` accepts any value. secrets: Object.fromEntries( Object.entries(edgeRuntime.secrets).map(([name, value]) => [ name, @@ -460,9 +361,6 @@ function parseCliConfig( path: string, appliedRemote: string | undefined, ): Effect.Effect<CliConfig, CliConfigParseError> { - // `validateCliConfig` preserves the loader's checked base/structural remote - // decode split. Keep the loader-specific path, format, and narrow redacted - // document attached to its existing parse error contract. return validateCliConfig(document).pipe( Effect.mapError( (cause) => @@ -481,40 +379,26 @@ function parseCliConfig( export interface DecodeCliConfigDocumentForValidationEffectOptions { /** - * The config file path `document` would be written to (or was read from) — - * the same `<projectRoot>/supabase/config.{toml,json}` shape - * {@link loadCliConfigFile} takes as its own `filePath`. Used for two - * things, exactly like that function: locating the project's - * `.env`/`.env.local` files (two directories up from `path`) to resolve - * `env(VAR)` references, and attaching to a decode failure's - * `CliConfigParseError.path`/`.format`. This function never reads or - * writes `path` itself. + * The config file path `document` would be written to (or was read from); used only to locate + * the project's `.env`/`.env.local` files and to attach to a decode failure's + * `CliConfigParseError.path`/`.format`. Never read or written itself. */ readonly path: string; readonly format: ConfigFormat; readonly goViperCompat?: boolean; /** - * When set, merges the `[remotes.<remoteName>]` block of `document`'s own - * `remotes` map over the root — the same `mergeRemoteSubtree`/ - * `withDbSeedDisabled` merge {@link applyRemoteOverride} runs for a matched - * remote — before decoding, so the merged subtree is checked against the - * root's business rules (`decodeCliConfig`, checks ENABLED) rather than the - * `disableChecks: true` treatment an unselected `[remotes.*]` block gets - * from {@link parseCliConfig}. A name that doesn't match any block under - * `document.remotes` is a no-op: `document` decodes exactly as it would - * with this option omitted. + * When set, merges the `[remotes.<remoteName>]` block over the root before decoding, so it's + * checked against the root's full business rules instead of the relaxed treatment an + * unselected remote block gets. A name matching no block under `document.remotes` is a no-op. */ readonly remoteName?: string; } /** - * Merges the `[remotes.<remoteName>]` block of `document`'s own `remotes` map - * over `document` itself, mirroring {@link applyRemoteOverride}'s merge for a - * MATCHED remote (same `mergeRemoteSubtree`/`withDbSeedDisabled` helpers) — - * except the caller already knows which remote it wants merged (no - * `project_id` match against a `projectRef` needed). Returns `document` - * unchanged, with `appliedRemote: undefined`, when `remoteName` is omitted or - * doesn't match any block under `document.remotes`. + * Merges the `[remotes.<remoteName>]` block of `document`'s own `remotes` map over `document` + * itself, using the same merge {@link applyRemoteOverride} runs for a matched remote — except + * the caller already knows which remote it wants. Returns `document` unchanged, with + * `appliedRemote: undefined`, when `remoteName` is omitted or unmatched. */ function mergeSelectedRemoteForValidation( document: Record<string, unknown>, @@ -536,30 +420,11 @@ function mergeSelectedRemoteForValidation( } /** - * Decodes `document` — a full, raw `CliConfig` document shape (`remotes` - * intact), the same shape {@link loadCliConfigFile} parses off disk — through - * exactly the env-resolution + `[remotes.*]`-merge + schema-decode pipeline - * that function runs, without touching the filesystem for a raw TOML/JSON - * parse (the caller already holds a parsed/assembled document). Unlike a - * caller-supplied env map, `env(VAR)` references are resolved the SAME way - * {@link loadCliConfigFile} resolves them for a real load: `.env`/`.env.local` - * under `options.path`'s project directory, layered under the current - * process's own environment (`loadCliProjectEnvironment`). `options.remoteName` - * (see {@link mergeSelectedRemoteForValidation}) additionally lets a caller - * validate the document as it would decode with one `[remotes.*]` block - * selected, rather than only ever as the unselected base document. - * - * Written for `config pull` (CLI-2064): before writing, it projects its own - * planned edits onto the base document and decodes the result here to - * confirm the file it is about to write still loads — a written file - * `loadCliConfig` cannot parse would brick every subsequent command until - * hand-edited. No new decode logic: this composes the same private - * `loadCliProjectEnvironment` → `interpolateEnvReferencesAgainstSchema` → - * `normalizeDeprecatedExternalProviders` → `parseCliConfig` steps - * {@link loadCliConfigFile} itself runs (plus the remote-merge helper above, - * itself built from {@link loadCliConfigFile}'s own `applyRemoteOverride` - * building blocks), minus the raw parse that doesn't apply to an - * already-in-memory document. + * Decodes `document` — a full, raw `CliConfig` document shape, `remotes` intact — through the + * same env-resolution + `[remotes.*]`-merge + schema-decode pipeline {@link loadCliConfigFile} + * runs, without touching the filesystem for a raw parse. `env(VAR)` references resolve against + * `.env`/`.env.local` under `options.path`'s project directory. `options.remoteName` lets a + * caller validate the document as it would decode with one `[remotes.*]` block selected. */ export const decodeCliConfigDocumentForValidationEffect = Effect.fnUntraced(function* ( document: Record<string, unknown>, @@ -613,15 +478,9 @@ export const loadCliConfigFile = Effect.fnUntraced(function* ( catch: (cause) => new CliConfigParseError({ path: filePath, format, cause }), }); const { document: normalized, deprecatedSections } = normalizeDeprecatedSMTPSections(document); - // Warn on stderr (matching Go's normalizeDeprecatedSMTPConfig) so the notice - // never pollutes machine-readable stdout payloads. Pinned to the real - // console (bypassing whatever `Console.Console` is ambient) so this always - // writes immediately, matching Go's synchronous `fmt.Fprintln(os.Stderr, ...)` - // (config.go:618,630) — a caller wrapping this in a deferred/buffered - // `Console.Console` (e.g. `apps/cli/src/shared/cli/run.ts`'s - // `withoutParseErrorHelpDump`, which only buffers the CLI parser's own - // duplicate-render writes and must never delay a handler's real output; see - // CLI-1901) must not silently swallow or delay it. + // Warn on stderr, writing directly to the real console (bypassing whatever `Console.Console` + // is ambient) so a caller wrapping this in a deferred/buffered console can't delay or + // swallow it. for (const section of deprecatedSections) { const replacement = section.replace(/inbucket$/, "local_smtp"); yield* Console.error( @@ -629,12 +488,9 @@ export const loadCliConfigFile = Effect.fnUntraced(function* ( ).pipe(Effect.provideService(Console.Console, globalThis.console)); } - // Substitute `env(VAR)` references against `.env`/`.env.local`/ambient env - // before schema decode. Required for numeric/boolean fields, which would - // otherwise crash the strict decoder with `Expected number` (CLI-1489). - // The config file lives at `<projectRoot>/supabase/config.{toml,json}`, so - // walking two directories up gives us the project root that - // `loadCliProjectEnvironment` expects. + // Substitute `env(VAR)` references against `.env`/`.env.local`/ambient env before schema + // decode, since a numeric/boolean field would otherwise crash the strict decoder on a string. + // The config file lives two directories under the project root `loadCliProjectEnvironment` expects. const projectRoot = path.dirname(path.dirname(filePath)); const cliProjectEnv = options?.cliProjectEnv ?? @@ -653,23 +509,17 @@ export const loadCliConfigFile = Effect.fnUntraced(function* ( onResolvedEnv, }); - // Interpolated once here purely to give `applyRemoteOverride`'s FORMAT check - // (not its match/merge — see that function's doc comment) the resolved - // `remotes.*.project_id`, matching Go's post-decode `Config.Validate`. + // Interpolated once here purely to give `applyRemoteOverride`'s format check (not its + // match/merge) the resolved `remotes.*.project_id`. const interpolatedForValidation = interpolateDocument(normalized); const interpolatedRemotes = isObject(interpolatedForValidation) && isObject(interpolatedForValidation["remotes"]) ? interpolatedForValidation["remotes"] : undefined; - // Merge the matching `[remotes.*]` override over the RAW (pre-`env()`- - // interpolation) document — Go's `loadFromFile` duplicate-check/selection - // loop runs on viper's raw string values, before `LoadEnvHook` ever resolves - // `env(...)` (`config.go:594-611`, `decode_hooks.go:13-26`); see - // `applyRemoteOverride`'s doc comment. The match/merge itself always runs - // (callers that don't request a `projectRef` just never match a remote, so - // the base document loads verbatim), but the duplicate-`project_id`/format - // checks only run when `goViperCompat` is set — see `applyRemoteOverride`. + // Merge the matching `[remotes.*]` override over the raw, pre-`env()` document (see + // `applyRemoteOverride`). The match/merge always runs; the duplicate-`project_id`/format + // checks only run when `goViperCompat` is set. let documentForDecode: unknown = normalized; let appliedRemote: string | undefined; let remoteLeafPaths: Array<string[]> = []; @@ -685,14 +535,10 @@ export const loadCliConfigFile = Effect.fnUntraced(function* ( remoteLeafPaths = resolved.remoteLeafPaths; } - // The merge above ran on the raw document, so any `env(...)` reference in - // the winning remote's subtree (or elsewhere in the base) still needs - // resolving before decode — mirrors Go's `LoadEnvHook` running on the - // post-merge viper store inside `c.load(v)`. When no remote matched, this - // recomputes the same substitutions `interpolatedForValidation` already - // made (documentForDecode is just `normalized` again) — a redundant walk on - // that path, but correctness on the match+`env()` path matters more than - // avoiding it. + // The merge above ran on the raw document, so any `env(...)` reference in the winning + // remote's subtree (or elsewhere in the base) still needs resolving before decode. When no + // remote matched this redundantly recomputes `interpolatedForValidation`'s substitutions, but + // correctness on the match+`env()` path matters more than avoiding that. const resolvedEnvironmentPaths: Array<string[]> = []; const resolvedEnvironmentNames = new Map<string, ReadonlyArray<string>>(); documentForDecode = isObject(documentForDecode) @@ -702,24 +548,14 @@ export const loadCliConfigFile = Effect.fnUntraced(function* ( }) : documentForDecode; - // Strip Go's deprecated `auth.external.{linkedin,slack}` provider ids from - // the POST-remote-merge document, matching `external.validate()` running - // once on the final effective config (see `normalizeDeprecatedExternalProviders`). + // Strip the deprecated `auth.external.{linkedin,slack}` provider ids from the post-remote-merge + // document (see `normalizeDeprecatedExternalProviders`). const { document: normalizedForDecode, deprecatedProviders, removedProviders, } = normalizeDeprecatedExternalProviders(documentForDecode); - // Warn on stderr, matching Go's `external.validate()` (`config.go:1418-1423`). - // Go's own format string is a raw string literal ending in a literal - // backslash-n (raw string literals never process escapes, and `Fprintf` - // doesn't append a newline the way `Fprintln` does), so Go's actual stderr - // bytes have no real line break after this message — a library-internal - // artifact, not the parity-relevant part, same call already made for - // `InvalidPortEnvOverrideError` in the CLI. Not reproduced - // byte-for-byte; `Console.error` supplies a normal trailing newline instead. - // Pinned to the real console for the same reason as the `[inbucket]` - // warning above — see that comment. + // Pinned to the real console, same as the `[inbucket]` warning above. if (goViperCompat) { for (const ext of deprecatedProviders) { yield* Console.error( @@ -878,22 +714,11 @@ export const saveCliConfig = Effect.fnUntraced(function* (options: SaveCliConfig const DEFAULT_CLI_CONFIG_FILE_MODE = 0o644; /** - * Atomically replaces `filePath`'s content with `content`: writes a fresh - * temp file in the SAME directory, CREATED with the target's current mode - * already applied (falling back to {@link DEFAULT_CLI_CONFIG_FILE_MODE} when - * the target doesn't exist yet), then `rename`s over the target. The mode is - * passed straight to `writeFileString`'s own `open()` rather than applied via - * a separate `chmod` afterward, so a restrictively-permissioned file (e.g. - * `0600`) is never briefly world/group-visible at whatever the process umask - * would otherwise leave behind between the write and the chmod. Unlike - * {@link writeFileAtomic} (used by `saveCliConfig`'s full-document - * regeneration, which dies on any platform failure), this surfaces a typed - * {@link CliConfigWriteError} — `config pull`'s surgical edit - * (`applyConfigEdits`/`config-edit.ts`) needs to react to a write failure - * (e.g. render it in a machine payload) rather than crash. The temp file is - * removed on any failure — and, harmlessly, on success too, since by then it - * no longer exists at that path (the `remove` failure there is ignored - * either way). + * Atomically replaces `filePath`'s content: writes a temp file in the same directory with the + * target's current mode (or {@link DEFAULT_CLI_CONFIG_FILE_MODE} if none exists) applied at + * creation — not via a later `chmod` — so a restrictive file is never briefly more permissive, + * then renames over the target. Unlike {@link writeFileAtomic}, this surfaces a typed + * {@link CliConfigWriteError} instead of dying on failure. */ export const writeCliConfigDocumentText = Effect.fnUntraced(function* ( filePath: string, diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index 5034eeb148..2f17566f99 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -241,10 +241,8 @@ describe("config io", () => { }); test("only validates the highest-priority enabled sms provider during decode (Go switch parity)", () => { - // Go's `(s *sms) validate()` (`apps/cli-go/pkg/config/config.go:1348-1410`) is a boolean - // `switch` that inspects providers in a fixed priority order (twilio, twilio_verify, - // messagebird, textlocal, vonage) and validates ONLY the first enabled one — a later - // enabled-but-incomplete provider is never even looked at. A complete, higher-priority + // Providers are validated in a fixed priority order (twilio, twilio_verify, messagebird, + // textlocal, vonage); only the first enabled one is checked, so a complete, higher-priority // `twilio` block plus an incomplete, lower-priority `messagebird` block must decode fine. const config = decodeCliConfig({ auth: { @@ -294,11 +292,9 @@ describe("config io", () => { }); test("decodes an unmodeled email template/notification name (Go map[string] parity)", () => { - // Go's `Auth.Email.Template`/`Notification` are genuine `map[string]emailTemplate`/ - // `map[string]notification` (`apps/cli-go/pkg/config/auth.go:247-248`) — open maps with no - // key restriction; `(e *email) validate(fsys)` (`pkg/config/config.go:1293-1313`) iterates - // every entry regardless of name. An unrecognized key like `[auth.email.template.custom]` - // is a legitimate config shape Go accepts, not a decode error. + // `auth.email.template`/`notification` are open maps with no key restriction, and every + // entry is validated regardless of name — an unrecognized key like + // `[auth.email.template.custom]` is a legitimate config shape, not a decode error. const config = decodeCliConfig({ auth: { email: { @@ -450,10 +446,8 @@ stack = true } }); - // Go's `NewPathBuilder`/`Config.Load` (`apps/cli-go/pkg/config/utils.go: - // 43-48`) only ever resolves `supabase/config.toml` — it has no concept of a - // JSON project config file. Go-parity callers (legacy `status`/`stop`) pass - // `tomlOnly: true` so a stray `config.json` never wins over `config.toml`. + // Go-parity callers (legacy `status`/`stop`) pass `tomlOnly: true` so a stray `config.json` + // never wins over `config.toml`. test("loads TOML instead of JSON when tomlOnly is set, even if JSON exists", async () => { const cwd = makeTempProject(); const jsonPath = await runConfigEffect(configJsonPath(cwd)); @@ -614,12 +608,9 @@ major_version = 16 try { await mkdir(join(cwd, "supabase"), { recursive: true }); - // `analytics.port` fails schema decode (expects a number), which is - // enough to fail the whole `Schema.decodeUnknownSync` call while - // `edge_runtime.secrets` parses fine on its own — the scenario - // `recoverEdgeRuntimeConfig` (apps/cli's `secrets set`) exists to - // recover from. `MY_SUPER_SECRET_VALUE` stands in for a real secret so - // the assertion below can confirm it never appears in plaintext. + // `analytics.port` fails schema decode, which fails the whole `Schema.decodeUnknownSync` + // call while `edge_runtime.secrets` parses fine on its own. `MY_SUPER_SECRET_VALUE` stands + // in for a real secret so the assertion below can confirm it never appears in plaintext. await writeFile( tomlPath, `[analytics] @@ -653,8 +644,7 @@ FOO = "MY_SUPER_SECRET_VALUE" const foo = (secrets as Record<string, unknown>).FOO; expect(Redacted.isRedacted(foo)).toBe(true); expect(Redacted.value(foo as Redacted.Redacted<string>)).toBe("MY_SUPER_SECRET_VALUE"); - // The whole point: a caller that doesn't know to unwrap `Redacted` - // (e.g. an uncaught error serialized into a log) never sees the raw + // The whole point: a caller that doesn't know to unwrap `Redacted` never sees the raw // secret, even via JSON.stringify. expect(JSON.stringify(error.value.document)).not.toContain("MY_SUPER_SECRET_VALUE"); } finally { @@ -668,10 +658,9 @@ FOO = "MY_SUPER_SECRET_VALUE" try { await mkdir(join(cwd, "supabase"), { recursive: true }); - // `FOO` is a TOML array, not a string — the schema decode for this - // entry fails, but the raw pre-decode value still carries - // `MY_SUPER_SECRET_VALUE` in plaintext. `redactEdgeRuntimeSecrets` must - // wrap the entry regardless of its shape, not just string entries. + // `FOO` is a TOML array, not a string, so its own schema decode fails, but the raw + // pre-decode value still carries `MY_SUPER_SECRET_VALUE` in plaintext — + // `redactEdgeRuntimeSecrets` must wrap it regardless of shape. await writeFile( tomlPath, `[analytics] @@ -717,10 +706,9 @@ FOO = ["MY_SUPER_SECRET_VALUE"] try { await mkdir(join(cwd, "supabase"), { recursive: true }); - // `secrets` itself is a TOML array here, not a table — the whole field - // is malformed rather than a single entry inside it. `isObject` rejects - // arrays, so `redactEdgeRuntimeSecrets` must wrap the field as one unit - // instead of falling through its early-return and leaving it raw. + // `secrets` itself is a TOML array here, not a table, so the whole field is malformed + // rather than a single bad entry — `redactEdgeRuntimeSecrets` must wrap the field as one + // unit instead of leaving it raw. await writeFile( tomlPath, `[analytics] @@ -1101,10 +1089,8 @@ enabled = "env(SUPABASE_ANALYTICS_ENABLED)" ); test("splits a comma-separated string literal into a slice (Go's StringToSliceHookFunc)", async () => { - // Go's `newDecodeHook` (`apps/cli-go/pkg/config/config.go:775-784`) wires - // `mapstructure.StringToSliceHookFunc(",")` unconditionally, so a plain - // string value for a `[]string` field like `additional_redirect_urls` - // decodes fine in Go — not just via `env(...)`. + // A plain string value for a `[]string` field like `additional_redirect_urls` decodes fine + // when `goViperCompat` is set, not just via `env(...)`. const cwd = makeTempProject(); try { @@ -1291,10 +1277,8 @@ port = "env(SUPABASE_DB_PORT_TEST)" } }); - // Regression coverage for the default-off (`goViperCompat` omitted) path — - // these pin pre-PR-#5765 behavior so `packages/stack` and the - // functions manifest (none of which pass `goViperCompat`) don't inherit the - // Go-parity CLI's stricter/wider semantics. + // Pins pre-Go-parity default behavior, so `packages/stack` and the functions manifest (which + // don't pass `goViperCompat`) don't inherit the Go-parity CLI's stricter/wider semantics. test("loads successfully with a duplicate [remotes.*] project_id when goViperCompat is omitted", async () => { const cwd = makeTempProject(); @@ -1485,11 +1469,9 @@ describe("config io [remotes.*] merge", () => { return cwd; } - // Remote `project_id`s below are valid 20-lowercase-letter refs (Go's - // `refPattern`, `config.go:558`) — `Config.Validate` rejects every - // `[remotes.*].project_id` against that pattern unconditionally on every - // config load (`config.go:996-1001`), so test fixtures must satisfy it too, - // even for scenarios that don't care about the ref's specific value. + // Remote `project_id`s below are valid 20-lowercase-letter refs, since `Config.Validate` + // rejects every `[remotes.*].project_id` against that pattern on every config load, so test + // fixtures must satisfy it too, even when a scenario doesn't care about the ref's value. const PREVIEW_REF = "previewrefaaaaaaaaaa"; const STAGING_REF = "stagingrefaaaaaaaaaa"; @@ -1635,17 +1617,12 @@ schemas = ["env(REMOTE_SCHEMA)"] try { const loaded = await runConfigEffect(loadCliConfig(cwd, { projectRef: PREVIEW_REF })); expect(loaded!.appliedRemote).toBe("preview"); - // remote block's project_id overrides the base expect(loaded!.config.project_id).toBe(PREVIEW_REF); - // remote scalar wins expect(loaded!.config.api.max_rows).toBe(999); - // array replaced wholesale (not element-merged) + // Array replaced wholesale, not element-merged. expect(loaded!.config.api.schemas).toEqual(["remote_only"]); - // base-only sibling under the same table survives expect(loaded!.config.api.enabled).toBe(true); - // a non-matching remote ([remotes.staging]) is not applied expect(loaded!.config.db.major_version).toBe(15); - // remotes are stripped from the merged document before decode expect(loaded!.document?.remotes).toBeUndefined(); } finally { await rm(cwd, { recursive: true, force: true }); @@ -1668,11 +1645,9 @@ max_rows = 999 try { const loaded = await runConfigEffect(loadCliConfig(cwd, { projectRef: PREVIEW_REF })); - // document: env() resolved, the matching remote merged in, remotes stripped. expect(loaded!.config.api.max_rows).toBe(999); expect(loaded!.document?.remotes).toBeUndefined(); - // rawDocument: still the literal env() string, remotes table intact. const rawApi = loaded!.rawDocument?.api; expect(isObject(rawApi) ? rawApi.max_rows : undefined).toBe( "env(SUPABASE_RAW_DOCUMENT_MAX_ROWS_TEST)", @@ -1691,13 +1666,8 @@ max_rows = 999 } }); - // Unlike the matched case above (where `applyRemoteOverride` strips - // `remotes` from `document`), an UNSELECTED `remotes` table survives on - // `document` — interpolated, since interpolation runs over the whole - // post-merge document regardless of whether anything was selected. Callers - // that need to inspect the remaining (non-target) remotes' effective values - // can read `document.remotes`; callers deciding WHERE to write must still - // use `rawDocument` (see `remoteNameForProjectRef`'s doc comment). + // An unselected `remotes` table survives on `document` (interpolated), unlike the matched case + // above where it's stripped; callers deciding where to write must still use `rawDocument`. test("keeps an interpolated remotes table on document when no remote matches", async () => { const previous = process.env.SUPABASE_UNMATCHED_REMOTE_REF_TEST; process.env.SUPABASE_UNMATCHED_REMOTE_REF_TEST = STAGING_REF; @@ -1761,11 +1731,8 @@ project_id = "env(SUPABASE_INTERPOLATED_REMOTES_TEST)" }); test("carries appliedRemote on CliConfigParseError when the matched remote's decode fails", async () => { - // Go prints `Loading config override: [remotes.<name>]` unconditionally - // as soon as the `project_id` match is found, *before* `mapstructure` - // decode runs (`apps/cli-go/pkg/config/config.go:604-609`) — so the notice - // is still owed even when the decode that follows fails. `db.major_version` - // is an unrelated schema-decode error; the remote merge must still have + // The remote match/merge notice is owed even when the subsequent decode fails. + // `db.major_version` is an unrelated schema-decode error; the merge must still have // happened (and be reported) ahead of it. const cwd = await writeTomlProject( `${BASE_WITH_REMOTES} @@ -1806,9 +1773,8 @@ major_version = "not-a-number" }); test("does not merge remotes when no projectRef is requested and none has an empty project_id", async () => { - // `projectRef` defaults to "" (Go's own `Config.ProjectId` default for - // commands with no `--project-ref` flag), so this only stays unmerged - // because neither remote's `project_id` is empty. + // `projectRef` defaults to `""`, so this only stays unmerged because neither remote's + // `project_id` is empty. const cwd = await writeTomlProject(BASE_WITH_REMOTES); try { const loaded = await runConfigEffect(loadCliConfig(cwd)); @@ -1821,12 +1787,9 @@ major_version = "not-a-number" }); test("rejects duplicate project_id across remotes even when no projectRef is requested", async () => { - // Go's duplicate-project_id check (config.go:594-602) runs unconditionally - // on every config load, inside the same loop that resolves the [remotes.*] - // override — it is not gated on a caller actually selecting a remote. - // status/stop (internal/utils/flags/config_path.go:11) never bind a - // `--project-ref` flag, so they hit this check with `Config.ProjectId == ""`, - // and it must still fail on a config-wide duplicate. + // The duplicate-project_id check runs unconditionally on every config load, in the same + // loop that resolves the `[remotes.*]` override — it is not gated on a caller actually + // selecting a remote. const cwd = await writeTomlProject(`project_id = "baseref" [remotes.a] @@ -1850,12 +1813,8 @@ project_id = "dupref" } }); - // `goViperCompat` is required even though a `projectRef` is passed: the - // duplicate/format checks in `applyRemoteOverride` are gated solely on - // `goViperCompat`, not on whether a remote is being selected — the remote - // match/merge itself stays unconditional, but pre-PR-#5765 callers that - // pass a `projectRef` without opting into Go parity no longer get these - // checks for free. + // `goViperCompat` is required even though `projectRef` is passed: the duplicate/format checks + // are gated solely on `goViperCompat`, not on whether a remote is being selected. test("rejects duplicate project_id across remotes with Go's message", async () => { const cwd = await writeTomlProject(`project_id = "baseref" @@ -1881,9 +1840,8 @@ project_id = "dupref" }); test("rejects duplicate project_id among remotes that do not match projectRef", async () => { - // Go builds the duplicate map across all [remotes.*] blocks before applying the - // matching override, so a clash between two non-target remotes still fails even - // though neither shares projectRef (config.go:503-518). + // The duplicate map is built across all `[remotes.*]` blocks before applying the matching + // override, so a clash between two non-target remotes still fails. const cwd = await writeTomlProject(`project_id = "baseref" [remotes.target] @@ -1911,8 +1869,8 @@ project_id = "dupref" }); test("rejects two remotes that both omit project_id", async () => { - // A missing project_id reads as "" (Go's viper.GetString), so two remotes that - // both omit it collide on the empty key. + // A missing project_id reads as "", so two remotes that both omit it collide on the empty + // key. const cwd = await writeTomlProject(`project_id = "baseref" [remotes.a] @@ -1939,10 +1897,9 @@ max_rows = 2 }); test("rejects a remote project_id that is not a valid 20-letter ref, even with no projectRef requested", async () => { - // Go's Config.Validate (config.go:996-1001) checks every [remotes.*].project_id - // against refPattern unconditionally on every config load — not only the one - // that ends up selected — so this must fail closed before status/stop reach - // Docker, exactly like Go, even when the caller never selects a remote. + // Every `[remotes.*].project_id` is checked against the ref pattern on every config load, + // not only the one that ends up selected, so this must fail closed even when the caller + // never selects a remote. const cwd = await writeTomlProject(`project_id = "baseref" [remotes.bad] @@ -2018,14 +1975,8 @@ enabled = true }); test("resolves env() on a lowercase-named variable, matching Go's case-agnostic matcher", async () => { - // Go's `LoadEnvHook` (`apps/cli-go/pkg/config/decode_hooks.go:11`) is - // `^env\((.*)\)$` — it doesn't restrict the captured name's case, so - // `project_id = "env(project_id)"` resolves against a same-case env var - // in the Go CLI. This isn't specific to `project_id`; any string field - // goes through the same pre-decode walk. This case-agnostic matching is - // itself one of the four Go-viper-parity behaviors gated by - // `goViperCompat` — without it, the strict SCREAMING_SNAKE_CASE matcher - // wouldn't match this lowercase name at all. + // Env-var matching is case-agnostic when `goViperCompat` is set; without it, the strict + // SCREAMING_SNAKE_CASE matcher wouldn't match this lowercase name at all. const previous = process.env.project_id; process.env.project_id = "lowercase-ref"; const cwd = await writeTomlProject(`project_id = "env(project_id)"\n`); @@ -2043,12 +1994,8 @@ enabled = true }); test("does not match a remote whose project_id is env(REF) against the resolved ref (Go parity)", async () => { - // Go's `loadFromFile` duplicate-check/selection loop reads viper's RAW - // string values (`config.go:596-610`) and only calls `c.load(v)` — which - // resolves `env(...)` via `LoadEnvHook` — afterward (`config.go:611`, - // `decode_hooks.go:13-26`). So a `[remotes.x] project_id = "env(REF)"` - // never matches a caller-supplied, already-resolved `REF`: Go compares the - // literal `env(REF)` string, not what it resolves to. + // A `[remotes.x] project_id = "env(REF)"` never matches a caller-supplied, already-resolved + // `REF`: matching compares the literal `env(REF)` string, not what it resolves to. const previous = process.env.SUPABASE_REMOTE_ENV_REF_TEST; process.env.SUPABASE_REMOTE_ENV_REF_TEST = PREVIEW_REF; const cwd = await writeTomlProject(`project_id = "baseref" @@ -2076,11 +2023,8 @@ max_rows = 999 }); test("validates a remote's env(REF) project_id format against its resolved value, not the literal", async () => { - // Go's `Config.Validate` (`config.go:989-1001`) runs entirely after the - // struct decode, by which point `LoadEnvHook` has already resolved - // `env(...)` — so it validates the RESOLVED project_id against the - // 20-lowercase-letter pattern, not the literal `env(REF)` string (which - // would never match the pattern itself). + // Format validation runs after `env(...)` resolution, so it validates the resolved + // project_id against the pattern, not the literal `env(REF)` string. const previous = process.env.SUPABASE_REMOTE_ENV_REF_FORMAT_TEST; process.env.SUPABASE_REMOTE_ENV_REF_FORMAT_TEST = PREVIEW_REF; const cwd = await writeTomlProject(`project_id = "baseref" @@ -2128,13 +2072,9 @@ max_rows = "env(SUPABASE_REMOTE_MAX_ROWS_TEST)" } }); - // Go's `Config.Validate` only checks `remotes.*.project_id` format for - // every remote (`config.go:996-1001`, "Since remote config is merged to - // base, we only need to validate the project_id field") — every other - // business-rule check (`Auth.External.validate()`, etc.) runs exactly once, - // against the merged effective config (`config.go:1136-1152`), never - // iterated over `c.Remotes[*]`. A non-selected `[remotes.*]` block's own - // business-rule violations must not fail the whole config load. + // Business-rule checks (`Auth.External.validate()`, etc.) run exactly once, against the + // merged effective config, never iterated over every remote — a non-selected `[remotes.*]` + // block's own business-rule violations must not fail the whole config load. test("loads an unselected remote whose external provider is enabled without a secret", async () => { const cwd = await writeTomlProject( `project_id = "baseref" @@ -2147,9 +2087,8 @@ enabled = true `, ); try { - // No projectRef requested, so [remotes.staging] is never selected/merged — - // Go would never business-rule-validate it, even though it decodes fine - // structurally. + // [remotes.staging] is never selected/merged, so it's never business-rule-validated, + // even though it decodes fine structurally. const loaded = await runConfigEffect(loadCliConfig(cwd)); expect(loaded!.appliedRemote).toBeUndefined(); expect(loaded!.config.remotes.staging?.auth.external.github.enabled).toBe(true); @@ -2170,9 +2109,9 @@ enabled = true `, ); try { - // Selecting [remotes.staging] merges it into the effective config, which - // Go DOES business-rule-validate (config.go:1136-1152) — a required - // `client_id`/`secret` is missing, so this must still fail. + // Selecting [remotes.staging] merges it into the effective config, which is + // business-rule-validated — a required `client_id`/`secret` is missing, so this must + // still fail. const exit = await Effect.runPromiseExit( loadCliConfig(cwd, { projectRef: STAGING_REF }).pipe(Effect.provide(BunServices.layer)), ); @@ -2183,10 +2122,9 @@ enabled = true }); test("still fails on a structurally malformed value inside an unselected remote", async () => { - // Go's `UnmarshalExact` always structurally decodes every remote - // (`config.go:246,749-756`) regardless of selection — only the - // merged-config-only business rules are skipped for a non-selected - // remote, not type/shape decoding. + // Every remote is structurally decoded regardless of selection — only the + // merged-config-only business rules are skipped for a non-selected remote, not type/shape + // decoding. const cwd = await writeTomlProject( `${BASE_WITH_REMOTES} [remotes.staging.db] @@ -2469,11 +2407,8 @@ describe("remoteNameForProjectRef / remoteProjectIdEntries", () => { }); test("does not match a block whose project_id is still the literal env(REF) form, even when REF resolves to the queried ref", () => { - // No interpolation happens inside this function — it compares the RAW - // string, mirroring Go's raw-viper selection loop (see - // `applyRemoteOverride`'s doc comment in `io.ts`). A block whose - // `project_id` is the literal `env(...)` form must never match an - // already-resolved ref, even when that's exactly what it would resolve to. + // Compares the raw string, not the interpolated value: a block whose `project_id` is the + // literal `env(...)` form must never match an already-resolved ref. const remotes = { staging: { project_id: "env(SUPABASE_REMOTE_REF)" }, }; @@ -2607,11 +2542,9 @@ describe("writeCliConfigDocumentText", () => { } }); - // The directory-missing case above fails at the `writeFileString` step, before any temp file - // ever exists — so it never actually exercises `Effect.ensuring`'s cleanup removing a real - // survivor. Making the destination an existing directory instead lets the temp file get - // created successfully, so the RENAME step is what fails (`fs.rename` refuses to replace a - // directory with a file), which is the one path that does exercise that cleanup. + // The directory-missing case above fails before any temp file exists, so it never exercises + // `Effect.ensuring`'s cleanup removing a real survivor. An existing directory as the + // destination lets the temp file get created, so the rename step is what fails instead. test("fails with a typed CliConfigWriteError when rename fails, and still cleans up the temp file it already created", async () => { const { cwd, filePath } = await tempFileTargets(); @@ -2678,10 +2611,9 @@ describe("decodeCliConfigDocumentForValidationEffect", () => { }); test("does not apply business-rule checks to a [remotes.*] block when no remoteName is given (checks disabled)", async () => { - // The SAME `enabled: true` + empty `account_sid`/`message_service_sid` shape that fails at - // the root above decodes fine inside a `[remotes.*]` block — `RemotesSchema` decodes with - // `disableChecks: true` (mirrors Go, which only applies these business rules to the merged - // effective config, never an unselected remote override). + // The same `enabled: true` + empty `account_sid`/`message_service_sid` shape that fails at + // the root above decodes fine inside a `[remotes.*]` block, which decodes with + // `disableChecks: true` since business rules only apply to the merged effective config. const document = { project_id: "abc123", remotes: { @@ -2702,10 +2634,8 @@ describe("decodeCliConfigDocumentForValidationEffect", () => { }); test("resolves env(VAR) from the project's own .env file, not just the ambient process environment", async () => { - // Unlike the deleted pure `decodeCliConfigDocumentForValidation` (which only ever resolved - // against a caller-supplied env map, never `.env`/`.env.local`), this Effect variant resolves - // `env(VAR)` exactly like `loadCliConfigFile` — including a var that ONLY exists in the - // project's own `.env` file, never in `process.env`. + // Resolves `env(VAR)` exactly like `loadCliConfigFile` — including a var that only exists + // in the project's own `.env` file, never in `process.env`. const cwd = makeTempProject(); const path = await runConfigEffect(configTomlPath(cwd)); @@ -2730,10 +2660,9 @@ describe("decodeCliConfigDocumentForValidationEffect", () => { }); test("leaves an unresolved env(VAR) literal in place rather than failing the field's own required-non-empty check", async () => { - // A required STRING field spelled as `env(VAR)` with `VAR` unset decodes as the literal - // `env(VAR)` string, matching `loadCliConfigFile`'s own tolerance for an unset var — non-empty, - // so it still satisfies a bare `!== undefined && !== ""` required-field check even though it - // never actually resolved. + // A required string field spelled as `env(VAR)` with `VAR` unset decodes as the literal + // `env(VAR)` string, which is non-empty, so it still satisfies a required-field check even + // though it never actually resolved. const document = { project_id: "abc123", auth: { diff --git a/packages/config/src/lib/env.ts b/packages/config/src/lib/env.ts index 3be14204ad..81ee02a363 100644 --- a/packages/config/src/lib/env.ts +++ b/packages/config/src/lib/env.ts @@ -1,17 +1,10 @@ import { Schema, SchemaAST } from "effect"; -// Go's `LoadEnvHook` matcher (`apps/cli-go/pkg/config/decode_hooks.go:11`) is -// `^env\((.*)\)$` — permissive on the captured name's case/content, and -// reused verbatim for secrets (`secret.go:99`) and the unset-var warning -// (`config.go:1195`). Matching that exactly (not an uppercase-only -// restriction) so e.g. `project_id = "env(project_id)"` substitutes the same -// way it does in the Go CLI. +// Matches `env(...)` references case-insensitively on the captured name — e.g. +// `env(project_id)` is a valid reference, not just SCREAMING_SNAKE_CASE names. export const ENV_PATTERN = "^env\\((.*)\\)$"; export const ENV_CAPTURE_REGEX = /^env\((.*)\)$/; -// Pre-PR-#5765 strict matcher: SCREAMING_SNAKE_CASE names only. Selected when -// `goViperCompat` is off so non-Go-parity surfaces (next/, packages/stack, the -// functions manifest) keep the narrower matching they had before PR #5765 -// widened env() resolution to Go's case-agnostic `^env\((.*)\)$`. +// Stricter matcher used when `goViperCompat` is off: only SCREAMING_SNAKE_CASE names match. export const ENV_CAPTURE_REGEX_STRICT = /^env\(([A-Z_][A-Z0-9_]*)\)$/; const envRegex = new RegExp(ENV_PATTERN); @@ -45,45 +38,10 @@ export const secret = (annotations?: SecretAnnotations) => "x-secret": true, }); -// --------------------------------------------------------------------------- -// Pre-decode env() interpolation with schema-aware type coercion -// --------------------------------------------------------------------------- -// -// TOML/JSON parsers turn `port = "env(SUPABASE_ANALYTICS_PORT)"` into a string -// at `analytics.port`, but the schema declares `port: Schema.Number`. Without -// pre-decode handling the strict decoder rejects the string and crashes -// `supabase db start` (CLI-1489). -// -// `interpolateEnvReferencesAgainstSchema` walks the parsed document and the -// schema AST in parallel: -// - For string leaves matching `env(VAR)`: substitute `env[VAR]` if set, or -// preserve the literal verbatim if unset (matches Go's -// `apps/cli-go/pkg/config/decode_hooks.go:14-21`). -// - After substitution, if the schema at that path expects Number or Boolean -// and the value is still a string, coerce it. This mirrors Go's -// mapstructure chain where `LoadEnvHook` returns a string and subsequent -// hooks convert it to the target type. -// - Number/boolean coercion is only attempted on strings produced by env() -// substitution. Pre-existing string literals at non-string paths are left -// untouched — they'll surface as schema errors at decode time with their -// original value, preserving error clarity. -// - Array coercion is the one exception: if the schema at that path expects -// a homogeneous string array, ANY string leaf (substituted or a plain -// literal) is split on `,` — mirroring Go's `StringToSliceHookFunc(",")` -// (`apps/cli-go/pkg/config/config.go:775-784`), which is wired -// unconditionally into the decode hook chain regardless of where the -// string came from (e.g. `additional_redirect_urls = "http://a,http://b"` -// decodes fine in Go today, not just via `env(...)`). - type ExpectedType = "number" | "boolean" | "string" | "array" | "unknown"; -// Go decodes an env()-substituted boolean via mapstructure's weakly-typed -// `decodeBool`, which runs `strconv.ParseBool` on the string — a wider -// acceptance set than the literal `"true"`/`"false"` this module used to -// require. Mirrors `parseGoBool`'s `GO_BOOL_TRUE`/`GO_BOOL_FALSE` -// (`apps/cli/src/command-internal/db-config.toml-read.ts:615-616`); -// duplicated here (not imported) so `packages/config` doesn't depend on -// `apps/cli`. +// Accepted boolean string forms, matching Go's `strconv.ParseBool`; duplicated rather than +// imported so `packages/config` has no dependency on `apps/cli`. const GO_BOOL_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); const GO_BOOL_FALSE = new Set(["0", "f", "F", "FALSE", "false", "False", ""]); @@ -97,12 +55,9 @@ function unwrapAst(ast: SchemaAST.AST): SchemaAST.AST { return ast; } -// A homogeneous `Schema.Array(Schema.String)` compiles to an `Arrays` AST -// node with no fixed tuple `elements` and a single `rest` spread type. Only -// this shape (not a fixed string tuple, and not a mixed-type array) is -// eligible for Go's `StringToSliceHookFunc(",")` coercion below — mirroring -// that Go itself only wires the hook for `[]string`-kind targets -// (`apps/cli-go/pkg/config/config.go:775-784`), not fixed-arity tuples. +// A homogeneous `Schema.Array(Schema.String)` compiles to an `Arrays` AST node with no fixed +// tuple `elements` and a single `rest` spread type; only this shape (not a fixed tuple or a +// mixed-type array) is eligible for the comma-split coercion below. function isHomogeneousStringArray(node: SchemaAST.AST): boolean { if (node._tag !== "Arrays" || node.elements.length !== 0 || node.rest.length !== 1) { return false; @@ -123,11 +78,8 @@ function leafExpectedType(ast: SchemaAST.AST): ExpectedType { case "Arrays": return isHomogeneousStringArray(node) ? "array" : "unknown"; case "Union": { - // Walk Union branches in declared order; first concrete primitive wins. - // For unions like `Schema.Union(Schema.Number, Schema.Null)` this picks - // the meaningful side. If the union mixes Number and String we err on - // the side of the first match — the schema decode will still validate - // membership after coercion. + // Walks branches in declared order; the first concrete primitive wins (e.g. `Number` in + // `Schema.Union(Schema.Number, Schema.Null)`). Schema decode still validates afterward. for (const variant of node.types) { const t = leafExpectedType(variant); if (t !== "unknown") { @@ -205,10 +157,7 @@ function coerceLeaf(value: unknown, expected: ExpectedType): unknown { return value; } if (expected === "array") { - // Go's `mapstructure.StringToSliceHookFunc(",")` (wired in - // `apps/cli-go/pkg/config/config.go:775-784`): an empty string decodes to - // an empty slice, otherwise the string is split on the separator with no - // further trimming of the resulting elements. + // An empty string decodes to an empty array; otherwise split on `,` with no trimming. return value === "" ? [] : value.split(","); } return value; @@ -225,10 +174,8 @@ function substituteEnvLeaf( } const envName = match[1]; const resolved = envName === undefined ? undefined : env[envName]; - // Go's LoadEnvHook only substitutes when the env var is non-empty - // (`apps/cli-go/pkg/config/decode_hooks.go:19-24`: `len(env) > 0`), so a - // key that's present but empty (e.g. a dotenv `KEY=` line) preserves the - // `env(KEY)` literal exactly like an unset key, rather than substituting "". + // A present-but-empty var (e.g. a dotenv `KEY=` line) preserves the `env(KEY)` literal, same + // as an unset key, instead of substituting an empty string. if (envName === undefined || resolved === undefined || resolved === "") { return { value, resolved: false }; } @@ -310,26 +257,15 @@ function walk( } const expected = ast === null ? "unknown" : leafExpectedType(ast); - // Go's `StringToSliceHookFunc(",")` (`apps/cli-go/pkg/config/config.go: - // 775-784`) is wired unconditionally into `v.UnmarshalExact`'s decode - // hook chain, so it splits ANY string being decoded into a `[]string` - // field — a plain TOML literal (`additional_redirect_urls = "a,b"`) just - // as much as an `env()`-substituted one. Unlike the number/boolean - // coercion below (scoped to substituted values only, since TOML already - // decodes literal numbers/booleans to their native type), array coercion - // must also apply to literal strings that never went through - // `substituteEnvLeaf`. Gated by `goViperCompat`: when off, the string is - // left unsplit — literal and substituted alike — so an array-typed field - // fed a string fails decode instead of silently coercing, matching - // pre-PR-#5765 behavior. + // Unlike number/boolean coercion, array coercion also applies to literal strings that never + // went through env() substitution (e.g. plain TOML `"a,b"`). Gated by `goViperCompat`: off + // leaves strings unsplit, so an array-typed field fed a string fails decode instead of coercing. if (expected === "array") { return goViperCompat ? coerceLeaf(substituted, expected) : substituted; } - // Substitute env() then coerce based on the schema's expected type at this - // path. Only the substituted form is fed to coercion — literal strings at - // non-string paths are left untouched so the decoder can report them with - // their original value. + // Only the substituted form is fed to coercion; literal strings at non-string paths are + // left untouched so the decoder reports them with their original value. if (substituted === document) { return document; } @@ -343,21 +279,9 @@ function walk( } /** - * Pre-decode env() substitution + schema-aware coercion. - * - * Walks the raw parsed document and the schema AST in parallel. For every - * string leaf matching `env(VAR)`: - * 1. Substitutes `env[VAR]` if set AND non-empty, else preserves the - * literal verbatim (Go-parity with - * `apps/cli-go/pkg/config/decode_hooks.go:14-21`, which gates on - * `len(env) > 0` — a set-but-empty var, e.g. a dotenv `KEY=` line, - * leaves the `env(KEY)` literal untouched just like an unset one). - * 2. If the schema at that path expects Number or Boolean, coerces the - * substituted string to the expected primitive — mirroring Go's - * mapstructure chain where `LoadEnvHook` returns a string that the next - * hook converts to the target type. - * - * Returns a new structure; does not mutate the input. + * Substitutes `env(VAR)` references against `env` and coerces the result to the schema's + * expected primitive type at each path. A set-but-empty variable leaves the literal untouched, + * same as an unset one. Returns a new structure; does not mutate the input. */ export function interpolateEnvReferencesAgainstSchema( document: unknown, diff --git a/packages/config/src/lib/resolve.ts b/packages/config/src/lib/resolve.ts index fac62b90f9..ca640f217a 100644 --- a/packages/config/src/lib/resolve.ts +++ b/packages/config/src/lib/resolve.ts @@ -105,18 +105,9 @@ function redactValue(value: unknown, path: ReadonlyArray<string>, goViperCompat: } /** - * Shared by the plain sync resolvers below and `../project.ts`'s - * Effect-typed `resolveCliConfigValue`/`resolveCliConfigSubtree` (which wrap - * this in `Effect.sync` and additionally accept the internal-only - * `goViperCompat` option). - * - * Declared as an overload pair rather than a single generic signature: the - * body's `unknown`-typed implementation signature is what lets - * `interpolateValue`/`redactValue` (both genuinely `unknown -> unknown`, - * since the recursion branches on runtime shape, not on `T`) flow straight - * through to the return without an `as` cast — callers only ever see the - * generic overload above, which resolves `T` from the argument and returns - * `ResolvedCliConfigValue<T>` directly. + * Shared by the plain sync resolvers below and the Effect-typed variants in `../project.ts`. + * Declared as an overload pair so the `unknown`-typed implementation flows through to + * `interpolateValue`/`redactValue` without an `as` cast. */ export function resolveCliConfigValueAtPath<T>( value: T, @@ -135,22 +126,9 @@ export function resolveCliConfigValueAtPath( } /** - * Plain synchronous counterpart of `../project.ts`'s Effect-typed - * `resolveCliConfigValue`, exported from `.` under the same name — `./effect` - * re-exports the Effect-typed variant explicitly, which wins over this one's - * star re-export through `./index.ts` (see `../effect.ts`'s doc comment). - * - * `cliProjectEnv` only needs `.values` (`Pick<CliProjectEnvironment, "values">`) — - * a caller that already has a project's env values but not the full - * `CliProjectEnvironment` shape (e.g. `paths`/`loadedPaths`/`sources`) can pass - * `{ values }` directly instead of threading through the whole loaded object. - * - * Has no options parameter: this package's one resolver knob (`goViperCompat`) - * is internal-only — see `InternalResolveCliConfigOptions` in `../project.ts`. - * That type is package-internal (not itself re-exported from - * `@supabase/config/internal`); only the `resolveCliConfigValue`/ - * `resolveCliConfigSubtree` functions widened to accept it are exported from - * there. Adding a public knob later is a non-breaking, additive change. + * Plain synchronous counterpart to the Effect-typed `resolveCliConfigValue` in `../project.ts`; + * has no options parameter, since this package's only resolver knob (`goViperCompat`) is + * internal-only. */ export function resolveCliConfigValue<T>( value: T, @@ -160,7 +138,7 @@ export function resolveCliConfigValue<T>( return resolveCliConfigValueAtPath(value, cliProjectEnv, toPathSegments(configPath), false); } -/** See {@link resolveCliConfigValue}'s doc comment for why `cliProjectEnv` only needs `.values`. */ +/** See {@link resolveCliConfigValue}. */ export function resolveCliConfigSubtree<T>( value: T, cliProjectEnv: Pick<CliProjectEnvironment, "values">, diff --git a/packages/config/src/lib/secret-paths.ts b/packages/config/src/lib/secret-paths.ts index 572b9fbc56..d86610b009 100644 --- a/packages/config/src/lib/secret-paths.ts +++ b/packages/config/src/lib/secret-paths.ts @@ -1,26 +1,14 @@ import { CliConfigSchema } from "../base.ts"; -/** - * Schema-derived `x-secret` leaf paths under {@link CliConfigSchema}, and the - * predicate built on top of them. Extracted from `../project.ts` (CLI-2230's - * secret-omission finding): `../project.ts` sits outside the pure browser-safe - * graph (`../entrypoint-purity.unit.test.ts`'s `expectedPureGraphFiles`) — it - * imports `effect`'s `FileSystem`/`Redacted` platform surface — while - * `../project-config/project-config.ts` (which needs this same predicate to - * omit secret leaves from a document-sourced `ProjectConfig`) is itself part - * of that pure graph. Moving the collector here, rather than duplicating it, - * gives both callers one source of truth for "which `CliConfig` paths are - * `x-secret`", per this repo's policy of moving code to its correct owner - * over duplicating it. - */ +// Lives here (not `../project.ts`) so `../project-config/project-config.ts`, which needs the +// same secret predicate, doesn't have to import `../project.ts`'s platform-specific graph. function collectSecretPathPatterns( node: unknown, prefix: ReadonlyArray<string> = [], ): Array<ReadonlyArray<string>> { - // The walker narrows each AST piece structurally instead of asserting a - // node shape: an AST change then makes the walk find nothing (which the - // exhaustive secret-strip test catches as a vanished pattern set) rather - // than silently reading through a stale asserted shape. + // Narrows each AST piece structurally rather than asserting a shape, so an AST change makes + // the walk find nothing (caught by the exhaustive secret-strip test) instead of silently + // reading through a stale assertion. const patterns: Array<ReadonlyArray<string>> = []; if (!isAstNodeLike(node)) { return patterns; @@ -64,14 +52,8 @@ function isAstNodeLike(value: unknown): value is Record<string, unknown> { } /** - * Derived from `CliConfigSchema` once, at module load — the schema's - * annotations are the single source of truth for which paths are secret; no - * hand-maintained list exists alongside it. A pattern segment is either a - * literal key or `"*"` (a dynamic `Schema.Record` key, e.g. `db.vault.*`, - * `edge_runtime.secrets.*`, `remotes.*.auth.jwt_secret`). Exported (beyond - * {@link isSecretPath}) so `../project-config/project-config.unit.test.ts` - * can build an exhaustive secret-strip probe from the same source of truth, - * rather than a second hand-picked field list. + * Every `x-secret` leaf path in {@link CliConfigSchema}, derived once at module load. A pattern + * segment is either a literal key or `"*"` for a dynamic `Schema.Record` key (e.g. `db.vault.*`). */ export const secretPathPatterns = collectSecretPathPatterns(CliConfigSchema.ast); diff --git a/packages/config/src/monorepo-import-contract.unit.test.ts b/packages/config/src/monorepo-import-contract.unit.test.ts index 204bc5ba7b..17702c00a1 100644 --- a/packages/config/src/monorepo-import-contract.unit.test.ts +++ b/packages/config/src/monorepo-import-contract.unit.test.ts @@ -3,22 +3,8 @@ import { readdirSync, readFileSync } from "node:fs"; import { dirname, join, sep } from "node:path"; import { fileURLToPath } from "node:url"; -// Enforces the three monorepo-wide import rules from `packages/config/AGENTS.md` -// ("Monorepo import rule"): `@supabase/config/io` has zero internal -// consumers by design (it exists only for external, non-Effect-native -// Node/Bun code), this package's internals must never be deep-imported (only -// the `.`/`./io`/`./effect`/`./internal` entrypoints are supported import -// paths), and `@supabase/config/internal` — unlike `./io` — IS an expected -// consumer, but only from `apps/cli`. -// -// A plain substring scan (no parsing) is enough for this — it's fast and the -// forbidden specifiers can't appear by accident. The forbidden strings below -// are built by concatenation so this file's own source can never self-match -// (on top of the directory exclusion below, which already keeps this whole -// package — where those specifier strings legitimately appear in doc -// comments and the build script's own smoke-test source string — out of the -// walk). -// +// Enforces the import rules in `packages/config/AGENTS.md`'s "Monorepo import rule". Forbidden +// specifiers are built by concatenation so this file's own source can't self-match the scan. const srcDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(srcDir, "..", "..", ".."); diff --git a/packages/config/src/paths.ts b/packages/config/src/paths.ts index fb28f7fe7d..0e04e8905c 100644 --- a/packages/config/src/paths.ts +++ b/packages/config/src/paths.ts @@ -9,12 +9,8 @@ export interface CliProjectPaths { readonly envLocalPath: string; } -// A stat failure (e.g. ENOTDIR when this root has a FILE named `supabase`) -// means "no config here" — Go's getProjectRoot keeps climbing on any stat -// error (apps/cli-go/internal/utils/misc.go:216-231). The failed probe is -// logged at Debug as a structured hook for future diagnostics — the CLI does -// not currently lower its minimum log level for `--debug`, so this is not -// yet Go-parity debug visibility. +// Any stat failure (e.g. ENOTDIR when this root has a file named `supabase`) means "no config +// here", not a fatal error — log it at Debug and keep searching. const probeExists = (self: Effect.Effect<boolean, PlatformError>) => self.pipe( Effect.tapError((error) => Effect.logDebug("config probe failed", error)), @@ -46,23 +42,9 @@ const findConfigInRoot = Effect.fnUntraced(function* (root: string) { export interface FindCliProjectPathsOptions { /** - * When `false`, only `cwd` itself is checked for `supabase/config.{json,toml}` — - * no ancestor climb. Go's own resolution never searches twice: an explicit - * `--workdir`/`SUPABASE_WORKDIR` is used exactly as given (`ChangeWorkDir`, - * `apps/cli-go/internal/utils/misc.go:238-257`), and once `os.Chdir`'d there, - * `config.toml` is read as a plain relative path with no further ancestor - * search (`NewPathBuilder`, `pkg/config/utils.go:43-48`). Ancestor climbing in - * Go only ever happens once, as the *default* when workdir is unset - * (`getProjectRoot`, `internal/utils/misc.go:216-231`). - * - * Callers that already hold an authoritative, Go-equivalent project root - * (e.g. the legacy `stop`/`status` ports' `cliSettings.workdir`, which mirrors - * `ChangeWorkDir`'s own explicit-vs-default resolution) should pass `false` - * here to avoid a second, un-Go-like ancestor search that could otherwise - * pick up an unrelated ancestor project's config. - * - * Defaults to `true` (the original ancestor-search behavior), so existing - * callers are unaffected. + * When `false`, only `cwd` itself is checked — no ancestor climb. Pass `false` when the + * caller already holds an authoritative project root, to avoid picking up an unrelated + * ancestor's config. Defaults to `true`. */ readonly search?: boolean; } diff --git a/packages/config/src/project-config/api-attributes.ts b/packages/config/src/project-config/api-attributes.ts index 2f448ce122..7dec4bb868 100644 --- a/packages/config/src/project-config/api-attributes.ts +++ b/packages/config/src/project-config/api-attributes.ts @@ -1,94 +1,14 @@ import { Schema } from "effect"; /** - * A deliberately lenient mirror of the Management API v2 project-config - * resource's `data.attributes` shape (`packages/api/src/generated/ - * contracts.ts:10809-11402`, `V2GetProjectConfigOutput`). Hand-mirrored - * rather than imported from `packages/api`: `@supabase/config` must not - * depend on `packages/api` (CLI-2230's decoupling requirement — the config - * package ships to npm on its own, independent of the generated API client). - * - * Leniency, per ADR 0019 rule 2 ("lenient decode, applied before the strict - * generated schema sees the body"): - * - Every section and every field is `Schema.optionalKey`, all the way down — - * an API-ahead-of-package field the package doesn't know about yet is - * simply absent from the decoded value, never a decode failure. - * - No range/pattern checks (`isInt`, `isGreaterThanOrEqualTo`, `isPattern`, - * …) even where the real contract has them: an out-of-range or - * differently-shaped value must still decode and reach the mapping layer, - * which is the thing actually responsible for narrowing it. - * - Closed string unions in the real contract (`Schema.Literals`) are widened - * to plain `Schema.String` here — `pooler.pool_mode`, - * `database.network_restrictions.allowed_cidrs[].type`, - * `database.postgres_settings.session_replication_role` — so a new enum - * member the platform starts returning never fails THIS decode. `pool_mode` - * and `session_replication_role` carry that leniency all the way through: - * their rows (`./registry.ts`) silently omit an unrecognized value rather - * than throwing. `allowed_cidrs[].type` does not — it decodes fine here, - * but its row (`filterCidrAddresses`, `./registry.ts`) deliberately - * hard-fails the mapping on an unrecognized type, since this field is a - * security allowlist where loud beats silent (that file's own docstring). - * So a new CIDR `type` member still surfaces as a `ProjectConfigParseError` - * overall — just one this schema's decode step isn't the one that throws. - * - `auth` is the real contract's own `Schema.Record(Schema.String, - * Schema.Json)` (a flat record keyed by lowercased GoTrue setting name) — - * already maximally lenient in the real contract, so no widening needed. - * - **Registry validates, decode only carries the key-set guard.** Every - * field this file's sibling registries (`./registry.ts`, - * `./registry-auth.ts`) actually map keeps a concrete leaf type — decode - * is this package's one chance to reject a genuinely malformed value for a - * field it cares about, before the registry's `transform`s run. Every field - * with NO registry row is `Schema.optionalKey(Schema.Unknown)` instead: a - * platform-side type change on a field this package doesn't map (e.g. - * `storage.capabilities.list_v2` growing a third state, `api.db_pool` - * changing shape) must never fail this decode, since `fromApiProjectConfig` - * is contractually lenient toward exactly that kind of API-ahead-of-package - * skew (ADR 0019 rule 2) — a concretely-typed unmapped field would - * contradict that contract by turning an irrelevant platform change into a - * decode failure for every consumer. `Schema.Unknown` keeps the key - * present (rather than dropping the field, which `Schema.Struct`'s default - * `onExcessProperty: "ignore"` would do for a field not declared at all) so - * the key-set drift guard - * (`apps/cli/src/shared/config/project-config-api-drift.unit.test.ts`'s - * `AssertNever<Exclude<keyof …, keyof …>>` pairs) still catches the real - * contract adding, removing, or renaming that key, and so the raw value is - * still reachable at that path for `unmappedApiFields`/`_apiResponse` - * passthrough — only its *type* is no longer load-bearing at decode time. - * - * Excess top-level/nested keys beyond what's declared below are silently - * dropped, not rejected: Effect v4's `Schema.Struct` decode defaults - * `onExcessProperty` to `"ignore"` (`.repos/effect/packages/effect/src/ - * SchemaAST.ts:446,477-483`), so a plain `Schema.decodeUnknownSync` call - * needs no extra options to get this behavior. Tolerated keys are not lost, - * though — `fromApiProjectConfig` (`./project-config.ts`) attaches the raw, - * pre-decode attributes object verbatim as `_apiResponse` (ADR 0019 rule 1), - * so `unmappedApiFields` can still see them. - * - * Every field from the real contract's six sections is mirrored below - * (including every never-mapped field, now `Schema.Unknown`) so this - * schema's key set matches the real contract's field-for-field, rather than - * being pieced together from a hand-maintained comment. - * - * This shape is also the operand of the type-level assignability guard in - * `apps/cli/src/shared/config/project-config-api-drift.unit.test.ts` - * (`_typeDriftGuard`), but that guard only catches the real contract - * *widening* a field's type out from under this deliberately narrower mirror - * — a field the real contract adds, removes, or renames passes an - * assignability check silently, since TypeScript structural assignability - * doesn't require the source type to have no extra/differently-named - * properties. That same test file's type-level key-set assertions - * (`AssertNever<Exclude<keyof …, keyof …>>`, one pair per nesting level) are - * the guard against additions, removals, and renames; this schema's job is - * only to stay a faithful, maximally-lenient mirror of whatever shape those - * two guards jointly pin down. + * A lenient, hand-mirrored copy of the v2 project-config API's `data.attributes` shape (not + * imported from `packages/api`, so this package has no dependency on the generated client). + * Every field is optional, unranged, and never a closed union, so an API-ahead-of-package value + * always decodes; `./registry.ts`/`./registry-auth.ts` narrow and map it. Unmapped fields stay + * `Schema.Unknown`, not omitted, so drift detection and passthrough can still see them. */ -// Mapped (23 of 38): the STRING-passthrough keys (`DB_SETTINGS_STRING_KEYS`), -// `session_replication_role` (widened to `String`, `sessionReplicationRoleRow`), -// `track_commit_timestamp` (`Boolean`), and the UINT-clamped keys -// (`DB_SETTINGS_UINT_KEYS`) — see `./registry.ts`. Every other key below is -// `Schema.Unknown`: unmapped, per that file's own "Deliberately unmapped" -// comment. +// See `./registry.ts` for which of these fields are mapped; the rest are `Schema.Unknown`. const postgresSettingsAttributes = Schema.Struct({ effective_cache_size: Schema.optionalKey(Schema.String), logical_decoding_work_mem: Schema.optionalKey(Schema.String), @@ -130,9 +50,7 @@ const postgresSettingsAttributes = Schema.Struct({ cron_log_statement: Schema.optionalKey(Schema.Unknown), }); -// `allowed_cidrs` stays typed — it's mapped (`filterCidrAddresses`, -// `./registry.ts`). `entitlement`/`status`/`updated_at`/`applied_at` are -// unmapped (that file's "Deliberately unmapped" comment). +// `allowed_cidrs` is mapped (`filterCidrAddresses`, `./registry.ts`); the rest are unmapped. const networkRestrictionsAttributes = Schema.Struct({ entitlement: Schema.optionalKey(Schema.Unknown), status: Schema.optionalKey(Schema.Unknown), @@ -155,9 +73,7 @@ const databaseAttributes = Schema.Struct({ postgres_settings: Schema.optionalKey(postgresSettingsAttributes), }); -// `pool_mode`/`default_pool_size`/`max_client_conn` stay typed — all three -// are mapped (`./registry.ts`). The other five are unmapped (that file's -// "Deliberately unmapped" comment). +// `pool_mode`/`default_pool_size`/`max_client_conn` are mapped (`./registry.ts`); the rest aren't. const poolerAttributes = Schema.Struct({ pool_mode: Schema.optionalKey(Schema.String), ignore_startup_parameters: Schema.optionalKey(Schema.Unknown), @@ -169,15 +85,9 @@ const poolerAttributes = Schema.Struct({ max_client_conn: Schema.optionalKey(Schema.Number), }); -// `db_schema`/`db_extra_search_path`/`max_rows` stay typed — all three are -// mapped (`./registry.ts`). `db_pool`/`db_pool_acquisition_timeout` are -// unmapped (that file's "Deliberately unmapped" comment) — including -// `db_pool`, whose real-contract shape is a nullable number: preserving that -// as `Schema.Union([Schema.Number, Schema.Null])` here would still fail -// decode the moment the platform widens it to anything else, exactly the -// hazard this file's unmapped-fields rule exists to avoid, so it is -// `Schema.Unknown` like every other unmapped field rather than a special -// case. +// `db_schema`/`db_extra_search_path`/`max_rows` are mapped (`./registry.ts`); the rest aren't. +// `db_pool`'s real shape is a nullable number, but it stays `Schema.Unknown` like every other +// unmapped field rather than a more precise type that could fail decode if the platform widens it. const apiAttributes = Schema.Struct({ db_schema: Schema.optionalKey(Schema.String), db_extra_search_path: Schema.optionalKey(Schema.String), @@ -186,11 +96,8 @@ const apiAttributes = Schema.Struct({ db_pool: Schema.optionalKey(Schema.Unknown), }); -// Zero rows map any `realtime.*` field (`./registry.ts`'s "=== realtime -// ===" comment) — every field is `Schema.Unknown`. The keys themselves stay -// declared (rather than dropping the whole section) so the key-set drift -// guard still catches the real contract adding/removing/renaming one of -// them. +// No `realtime.*` field is mapped; keys stay declared as `Schema.Unknown` (rather than dropping +// the section) so the drift guard still catches the API adding, removing, or renaming one. const realtimeAttributes = Schema.Struct({ private_only: Schema.optionalKey(Schema.Unknown), max_concurrent_users: Schema.optionalKey(Schema.Unknown), @@ -206,11 +113,8 @@ const realtimeAttributes = Schema.Struct({ postgres_changes_pool: Schema.optionalKey(Schema.Unknown), }); -// `purge_cache` is unmapped (`./registry.ts`'s "Deliberately unmapped" -// comment) — collapsed to `Schema.Unknown` rather than kept as a nested -// `{enabled}` struct, same rule as every other unmapped field. -// `image_transformation`/`s3_protocol`/`iceberg_catalog`/`vector_buckets` all -// stay typed — every field inside them is mapped. +// `image_transformation`/`s3_protocol`/`iceberg_catalog`/`vector_buckets` are fully mapped; +// `purge_cache` is unmapped and collapsed to `Schema.Unknown` like every other unmapped field. const storageFeaturesAttributes = Schema.Struct({ image_transformation: Schema.optionalKey( Schema.Struct({ enabled: Schema.optionalKey(Schema.Boolean) }), @@ -234,14 +138,12 @@ const storageFeaturesAttributes = Schema.Struct({ ), }); -// Unmapped in full (`./registry.ts`'s "Deliberately unmapped" comment) — -// collapsed to `Schema.Unknown` rather than kept as a `{list_v2, -// iceberg_catalog}` struct, same rule as every other unmapped field. +// Unmapped in full; collapsed to `Schema.Unknown` rather than kept as a `{list_v2, +// iceberg_catalog}` struct, like every other unmapped field. const storageCapabilitiesAttributes = Schema.Unknown; -// `file_size_limit` stays typed — mapped. `upstream_target`/ -// `migration_version`/`database_pool_mode`/`capabilities` are unmapped -// (`./registry.ts`'s "Deliberately unmapped" comment). +// `file_size_limit` is mapped; `capabilities`/`upstream_target`/`migration_version`/ +// `database_pool_mode` aren't. const storageAttributes = Schema.Struct({ file_size_limit: Schema.optionalKey(Schema.Number), features: Schema.optionalKey(storageFeaturesAttributes), @@ -263,12 +165,8 @@ export const ProjectConfigApiAttributesSchema = Schema.Struct({ export type ProjectConfigApiAttributes = typeof ProjectConfigApiAttributesSchema.Type; /** - * The per-service block keys of the v2 project-config resource's - * `data.attributes`, in alphabetical order — derived from the mirror schema's - * own key set so consumers never hand-copy the block list (a hand-copied list - * reports a newly-learned block "not returned" forever, test-green). The - * package owns the response shape; a consumer rendering comparison scope - * (CLI-2156's scope line) reads it from here. + * The per-service block keys of the v2 project-config `data.attributes`, in alphabetical order — + * derived from this schema's own key set so consumers never hand-copy a list that could go stale. */ export const projectConfigApiBlockKeys: ReadonlyArray<string> = Object.keys( ProjectConfigApiAttributesSchema.fields, diff --git a/packages/config/src/project-config/hosted-sections.ts b/packages/config/src/project-config/hosted-sections.ts index f83d228b2c..cd5c415ee3 100644 --- a/packages/config/src/project-config/hosted-sections.ts +++ b/packages/config/src/project-config/hosted-sections.ts @@ -1,12 +1,4 @@ -/** - * The seven {@link CliConfig} (`../base.ts`) section keys a hosted - * project-config API response can speak for — the vocabulary ceiling for - * {@link ProjectConfig} (`./project-config.ts`)'s compile-time type and - * {@link ProjectConfigSchema} (`./project-schema.ts`)'s runtime derivation. - * Owned here rather than duplicated in either consumer, per this repo's - * policy of moving a shared constant to its correct owner instead of - * hand-keeping two copies in sync. - */ +/** The seven {@link CliConfig} section keys a hosted project-config API response can speak for. */ export const HOSTED_SECTION_KEYS = [ "api", "auth", From b0e532b62dc938027dd04ad862facc60c50a4894 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:16:42 +0100 Subject: [PATCH 51/56] ci(repo): trim workflow and JSONC config comments (803 -> 507 comment lines) --- .github/workflows/ai-review.yml | 254 +++++++----------- .github/workflows/apply-release-notes.yml | 31 +-- .github/workflows/cli-go-codeql.yml | 39 +-- .github/workflows/dispatch-cli-e2e-ci.yml | 23 +- .github/workflows/mirror-slim-image.yml | 42 +-- .github/workflows/release-config.yml | 185 +++++-------- .github/workflows/release-shared.yml | 151 ++++------- .github/workflows/release.yml | 72 ++--- .github/workflows/setup-cli-smoke-test.yml | 34 +-- .github/workflows/verify-install-channels.yml | 59 ++-- .oxlintrc.json | 17 +- apps/cli/tsconfig.json | 23 +- packages/config/tsconfig.build.json | 15 +- packages/config/tsconfig.declarations.json | 10 +- 14 files changed, 329 insertions(+), 626 deletions(-) diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index b7dc5fa18c..a386875f9d 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -1,17 +1,12 @@ name: AI Review -# One-shot AI code review: replaces the Codex GitHub App's automatic -# per-push reviews (which churned 30-40 short rounds per PR) with a single -# exhaustive pass that runs at most once per PR. See -# .github/ai-review/README.md for the full design and security model. +# One-shot AI code review: replaces the Codex GitHub App's automatic per-push reviews with a +# single exhaustive pass that runs at most once per PR. See .github/ai-review/README.md for the +# full design and security model. # -# Three ways to trigger a run: -# - workflow_dispatch, for testing / ad-hoc runs against any PR number. -# - an internal maintainer commenting `/ai-review` on a PR. -# - automatically, when a PR opens or leaves draft. resolve.ts gates the -# automatic path to PR authors with repository write access; external -# contributors' PRs are skipped and go through the manual `/ai-review` -# maintainer path instead. +# Triggered by workflow_dispatch (testing/ad-hoc), an internal maintainer commenting +# `/ai-review`, or automatically when a PR opens or leaves draft — resolve.ts gates the automatic +# path to PR authors with write access; external contributors go through the manual path instead. on: workflow_dispatch: inputs: @@ -29,30 +24,21 @@ on: permissions: {} -# One source of truth for the two model names — `resolve`/`claude-review`/ -# `codex-review` all read these instead of hardcoding them a second and -# third time, and `post-review`'s footer reads them too (see the "Post -# review" step below). +# One source of truth for the model names; resolve, claude-review, codex-review, and +# post-review's footer all read these instead of hardcoding them separately. env: CLAUDE_MODEL: claude-opus-5 CODEX_MODEL: gpt-5.6-sol -# Ordinary (non-command) issue_comment events fire this workflow for EVERY -# comment on EVERY PR; with only the PR number in the group, any comment -# (even one that isn't `/ai-review`) would cancel an in-flight review via -# `cancel-in-progress`. Give those runs their own per-run group so they can -# never cancel a real review. The command test is exact equality -# (`!= '/ai-review'`), mirroring resolve.ts's first-line check — `startsWith` -# would let a near-miss like `/ai-reviewers` (which resolve.ts rejects) land -# in a shared group and cancel a running review anyway. +# Non-command issue_comment events fire for every comment on every PR, so grouping by PR number +# alone would let any comment cancel an in-flight review; give those runs their own per-run +# group instead. The command check is exact equality (mirroring resolve.ts), so a near-miss like +# `/ai-reviewers` can't land in the shared group and cancel a real review. # -# `pull_request` events get their own per-PR `auto` group, separate from the -# manual (`/ai-review` / dispatch) `review` group: an auto event may well -# resolve to a SKIP (dedup, no write access), and letting it share the manual -# group would let e.g. a ready_for_review event cancel an in-flight -# maintainer-requested review and then not replace it. The cost is that an -# auto and a manual run can overlap on the same PR — rare, and self-healing, -# since the later post supersedes the earlier review. +# `pull_request` events get a separate per-PR `auto` group from the manual `review` group, since +# an auto event may resolve to a skip and would otherwise cancel an in-flight maintainer-requested +# review without replacing it. An auto and manual run can overlap on the same PR as a result — +# rare, and self-healing since the later post supersedes the earlier one. concurrency: group: >- ai-review-${{ github.event.pull_request.number || github.event.issue.number || inputs.pr }}-${{ @@ -66,21 +52,17 @@ jobs: name: Resolve runs-on: ubuntu-latest timeout-minutes: 5 - # For issue_comment events, only PR comments starting with /ai-review - # AND carrying an association that could plausibly be a maintainer reach - # this job at all. This is a cheap, non-authoritative pre-filter - # (defense-in-depth only): it can't see a private org member's real - # permission, so it can under-admit. The authoritative checks — the - # EXACT command match and the effective-permission lookup — happen in - # resolve.ts, which is the actual gate. + # Only PR comments starting with /ai-review, from an association that could plausibly be a + # maintainer, reach this job — a cheap, non-authoritative pre-filter (it can't see a private + # org member's real permission, so it can under-admit). The authoritative checks — exact + # command match and effective-permission lookup — happen in resolve.ts. if: > github.event_name != 'issue_comment' || (github.event.issue.pull_request != null && startsWith(github.event.comment.body, '/ai-review') && contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.comment.author_association)) - # `resolve` reacts 👀 to the triggering comment (pull-requests: write) but - # runs ONLY trusted, default-branch code (see the pinned checkout ref - # below) — never a PR's own code — so granting it write is safe. + # Reacts 👀 to the triggering comment (pull-requests: write) but runs only trusted, + # default-branch code (see the pinned checkout ref below), never a PR's own — safe to grant write. permissions: pull-requests: write contents: read @@ -90,9 +72,8 @@ jobs: head_ref: ${{ steps.resolve.outputs.head_ref }} trigger: ${{ steps.resolve.outputs.trigger }} steps: - # Base repo, default ref, pinned explicitly — this job runs trusted - # repository code exclusively, and must keep doing so even though the - # `pull_request` trigger above hands it PR-authored event payloads. + # Pinned to the default ref explicitly — this job must keep running only trusted repo code, + # even though the pull_request trigger above hands it PR-authored event payloads. - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: ${{ github.event.repository.default_branch }} @@ -119,14 +100,11 @@ jobs: if: needs.resolve.outputs.should_run == 'true' runs-on: ubuntu-latest timeout-minutes: 60 - # SECURITY-CRITICAL: this job checks out the PR's own head commit, which - # is untrusted review subject matter, not something this job trusts with - # more access. Nothing this job EXECUTES may come from that checkout: - # prompts, the findings schema, and the validation script are all read - # from a SEPARATE trusted checkout of the default branch (`path: trusted` - # below). The job holds no write permissions, a read-only Claude tool - # allowlist (no write/edit tools, no Bash), and no secrets beyond - # ANTHROPIC_API_KEY. + # Security-critical: this job checks out the PR's own head commit as untrusted review subject + # matter only — nothing it executes may come from that checkout. Prompts, the findings schema, + # and the validation script are all read from a separate trusted default-branch checkout + # (`path: trusted` below). The job holds no write permissions, a read-only Claude tool + # allowlist (no write/edit tools, no Bash), and no secrets beyond ANTHROPIC_API_KEY. permissions: contents: read pull-requests: read @@ -169,11 +147,10 @@ jobs: base_ref=$(jq -r '.baseRefName' /tmp/ai-review/pr.json) bun .github/scripts/ai-review/generate-pr-diff.ts "$PR" "$base_ref" - # Pin the exact published version so a new Claude Code release can't - # silently change review behavior mid-rollout; bump deliberately. - # Install from the TRUSTED checkout with npm config isolation so a - # PR-supplied `.npmrc`/`.npmrc`-adjacent config in the untrusted `pr` - # checkout can never redirect this install to a hostile registry. + # Pin the exact published version so a Claude Code release can't silently change review + # behavior mid-rollout. Installed from the trusted checkout with npm config isolation so a + # PR-supplied `.npmrc` in the untrusted `pr` checkout can never redirect this install to a + # hostile registry. - name: Install Claude Code CLI working-directory: trusted run: | @@ -187,18 +164,13 @@ jobs: --globalconfig "${RUNNER_TEMP}/ai-review-npmrc-global" \ --registry=https://registry.npmjs.org/ @anthropic-ai/claude-code@2.1.247 - # SECURITY-CRITICAL invariant: PR code is only ever READ by `claude`, - # via the `( cd .../pr && claude ... )` subshell below — nothing else in - # this step, and no `bun` process anywhere in this job, ever runs with - # a cwd inside `pr`. `bun` auto-loads `bunfig.toml` (`preload` runs - # arbitrary code) and `.env` from its cwd; a `pr`-cwd `bun` invocation - # would let a PR-authored `pr/bunfig.toml` execute attacker code in a - # step that holds `ANTHROPIC_API_KEY`. `claude` is a standalone binary - # (not run via `bun`), so `bunfig.toml` never applies to it; `--bare` - # already disables hooks/MCP/CLAUDE.md, and `--strict-mcp-config` is - # belt-and-suspenders against a future CLI regression. The step's own - # `working-directory: trusted` keeps `jq` and `bun` on the trusted - # checkout for everything outside that one subshell. + # Security-critical invariant: PR code is only ever read by `claude`, in the `pr`-cwd + # subshell below — no other command in this job, including any `bun` call, runs with a cwd + # inside `pr`. `bun` auto-loads `bunfig.toml` (arbitrary `preload` code) and `.env` from its + # cwd, so a `pr`-cwd `bun` call would let a PR-authored `bunfig.toml` execute in a step + # holding `ANTHROPIC_API_KEY`. `claude` is a standalone binary unaffected by `bunfig.toml`; + # `--bare` disables hooks/MCP/CLAUDE.md, and `--strict-mcp-config` guards against a future + # CLI regression. - name: Run Claude review working-directory: trusted env: @@ -249,15 +221,10 @@ jobs: exit 1 fi - # Scrubs any secret-shaped substring a prompt-injected model might have - # echoed back (e.g. from `Read`-ing a secret-bearing path) out of the - # raw JSON before it's uploaded as a (public-repo) artifact; the posted - # review is scrubbed separately at render time. `if: always()` so a - # partial `claude-raw.json` from a failed attempt is still scrubbed - # before the always-on upload step below; guarded because - # `claude-findings.json` may not exist if every attempt failed before - # the extraction step. Runs from the trusted cwd, same as every other - # `bun` invocation in this job. + # Scrubs any secret-shaped substring a prompt-injected model might have echoed back before + # the raw JSON is uploaded as a public-repo artifact; the posted review is scrubbed + # separately at render time. Runs with `if: always()` so a partial `claude-raw.json` from a + # failed attempt is still scrubbed before the always-on upload step. - name: Redact secrets from Claude findings if: always() working-directory: trusted @@ -284,11 +251,10 @@ jobs: name: Codex review needs: resolve if: needs.resolve.outputs.should_run == 'true' - # Codex's INDEPENDENT review. It no longer depends on claude-review, so it - # runs IN PARALLEL with it. It works purely from /tmp/ai-review/pr.diff - # (absolute path in its prompt), so it needs no PR-head checkout — its ONLY - # checkout is the trusted default branch. The verify-by-reading step (which - # does need the PR's files) is the separate `adjudicate` job below. + # Codex's independent review doesn't depend on claude-review, so it runs in parallel. It + # works purely from /tmp/ai-review/pr.diff, so it needs only the trusted default-branch + # checkout — no PR-head checkout. The verify-by-reading step (which needs the PR's files) is + # the separate `adjudicate` job below. permissions: contents: read pull-requests: read @@ -321,29 +287,18 @@ jobs: mkdir -p /tmp/ai-review jq 'del(.["$schema"])' .github/ai-review/findings.schema.json > /tmp/ai-review/findings.schema.json - # Safety strategy (drop-sudo + read-only), verified against the pinned - # openai/codex-action@52fe01ec…'s action.yml + src/runCodexExec.ts — see - # the adjudicate job below for the full rationale. In short: Codex runs as - # a non-sudo-capable user, in a sandbox with no filesystem writes and no - # network, with no `codex-args`/`--sandbox` duplication. + # Safety strategy (drop-sudo + read-only) — see the "Run Codex adjudication" step below for + # the full rationale (verified against the pinned openai/codex-action's action.yml + + # src/runCodexExec.ts). In short: non-sudo user, no filesystem writes, no network. - name: Run Codex independent review - # Pinned to v1.11 (52fe01ec…), NOT v1.12 (86365089…): v1.12 has two - # confirmed, still-open upstream regressions that hang or kill the job - # on exactly this workflow's config (safety-strategy: drop-sudo, - # sandbox: read-only, output-schema-file) — openai/codex-action#151 - # (the wrapper waits on the child process's `close` event with - # inherited stdio, so a lingering descendant keeps the step alive - # forever after Codex has already written its output) and - # openai/codex-action#160 (drop-sudo's v1.12 rewrite chmods - # root-owned /run service sockets, breaking systemd-resolved on the - # GitHub-hosted runner itself, which kills the job ~52-65 minutes in - # regardless of job timeout-minutes). Both are independent of the - # pinned Codex CLI version. This pin was silently regressed to v1.12 - # once already by a grouped Dependabot bump (#6484) — see the - # dependabot.yml ignore entry for openai/codex-action, which exists - # specifically to stop that from happening again. There is no - # released fix above v1.12 as of 2026-09-09; re-verify both issues - # are closed before ever re-bumping this pin. + # Pinned to v1.11 (52fe01ec…), not v1.12 (86365089…): v1.12 has two open upstream + # regressions on this workflow's config (safety-strategy: drop-sudo, sandbox: read-only, + # output-schema-file) — openai/codex-action#151 (a lingering descendant process keeps the + # step alive after Codex already wrote its output) and openai/codex-action#160 + # (drop-sudo's rewrite chmods root-owned /run sockets, breaking systemd-resolved and + # killing the job regardless of timeout-minutes). A dependabot.yml ignore entry for + # openai/codex-action stops an automated bump from silently regressing this pin; + # re-verify both issues are closed before ever re-bumping it. uses: &codex-action-pin openai/codex-action@52fe01ec70a42f454c9d2ebd47598f9fd6893d56 # v1.11 with: openai-api-key: ${{ secrets.OPENAI_API_KEY }} @@ -352,8 +307,6 @@ jobs: effort: high output-schema-file: /tmp/ai-review/findings.schema.json output-file: /tmp/ai-review/codex-findings.json - # Pinned explicitly (verified via `npm view @openai/codex version`); - # never left floating. codex-version: "0.150.1" working-directory: ${{ github.workspace }} safety-strategy: drop-sudo @@ -389,21 +342,17 @@ jobs: - resolve - claude-review - codex-review - # Runs when AT LEAST ONE independent review succeeded — a single flaky model - # job must not sink the whole review. Each findings download below is guarded - # by its job's result, and the stage step substitutes an empty findings set - # for any review that didn't complete, so the adjudicator reconciles 1 or 2. + # Runs when at least one independent review succeeded — a single flaky model job must not + # sink the whole review. Findings download steps are guarded per job result, and staging + # substitutes an empty findings set for any review that didn't complete. if: ${{ !cancelled() && needs.resolve.outputs.should_run == 'true' && (needs.claude-review.result == 'success' || needs.codex-review.result == 'success') }} - # SECURITY-CRITICAL: this job checks out the PR head (untrusted subject - # matter) so Codex can VERIFY findings by reading the real files. Codex runs - # with its working directory at the workspace ROOT, which holds only the - # `pr/` and `trusted/` checkouts (no AGENTS.md/config of its own), and reads - # `pr/` read-only; the adjudicate prompt's injection guard treats every file - # under `pr/` (including any AGENTS.md/CLAUDE.md) as untrusted data. Every - # `bun` invocation runs from `trusted/`. Blast radius of a prompt-injected - # Codex here is bounded to review CONTENT: read-only sandbox, no network, - # key proxied by the action, and the output is secret-scrubbed before it - # leaves this job. + # Security-critical: this job checks out the PR head (untrusted subject matter) so Codex can + # verify findings by reading the real files. Codex's working directory is the workspace root, + # which holds only `pr/` and `trusted/` (no AGENTS.md/config of its own); it reads `pr/` + # read-only, and the adjudicate prompt's injection guard treats every file under `pr/` + # (including any AGENTS.md/CLAUDE.md) as untrusted data. Every `bun` invocation runs from + # `trusted/`. A prompt-injected Codex here is bounded to review content: read-only sandbox, + # no network, key proxied by the action, and secret-scrubbed output. permissions: contents: read pull-requests: read @@ -485,31 +434,22 @@ jobs: mkdir -p /tmp/ai-review jq 'del(.["$schema"])' .github/ai-review/merged-review.schema.json > /tmp/ai-review/merged-review.schema.json - # Safety strategy, verified against the pinned - # openai/codex-action@52fe01ec…'s action.yml + src/runCodexExec.ts: - # - `safety-strategy: read-only` forces codex-exec's legacy sandbox to - # read-only, but Codex still runs as the action's default, - # sudo-capable user — the action's own docs/security.md calls this - # combination out as unsafe, since a sudo-capable process can read - # secrets like OPENAI_API_KEY out of memory (e.g. via procfs) even - # under a read-only filesystem sandbox with no network. - # - `safety-strategy: drop-sudo` (the action's default) removes sudo - # from the user running Codex, closing that hole, but says nothing - # on its own about Codex's filesystem/network sandbox. - # - `determinePermissionSelection()` only forces the legacy read-only - # sandbox when `safety-strategy === "read-only"`; otherwise it honors - # a separately-set `sandbox` input as-is. So setting BOTH - # `safety-strategy: drop-sudo` and `sandbox: read-only` composes them - # safely: non-sudo user, no filesystem writes, no network — with no - # `codex-args`/`--sandbox` duplication. - # `working-directory` is the workspace root so Codex's cwd holds no - # untrusted AGENTS.md/config; it reads the PR from `pr/` and executes - # nothing from it. + # Safety strategy, per the pinned openai/codex-action's action.yml + src/runCodexExec.ts: + # - `safety-strategy: read-only` forces the legacy sandbox read-only, but Codex still + # runs as the action's default sudo-capable user — the action's own docs call this + # unsafe, since a sudo-capable process can read secrets like OPENAI_API_KEY out of + # memory even under a read-only, no-network sandbox. + # - `safety-strategy: drop-sudo` (the action's default) removes sudo from the user + # running Codex but says nothing on its own about the filesystem/network sandbox. + # - The action only forces its legacy read-only sandbox when `safety-strategy === + # "read-only"`; otherwise it honors a separately-set `sandbox` input as-is. Setting + # `safety-strategy: drop-sudo` and `sandbox: read-only` together composes them safely: + # non-sudo user, no filesystem writes, no network. + # `working-directory` is the workspace root so Codex's cwd holds no untrusted AGENTS.md/ + # config; it reads the PR from `pr/` and executes nothing from it. - name: Run Codex adjudication - # Same v1.11 pin as codex-review's "Run Codex independent review" - # step above — see that step's comment for the full v1.11-vs-v1.12 - # rationale (openai/codex-action#151, #160). YAML-aliased so the SHA - # only needs to change in one place. + # Same v1.11 pin as codex-review's step above (see its comment for the full rationale); + # YAML-aliased so the SHA only needs to change in one place. uses: *codex-action-pin with: openai-api-key: ${{ secrets.OPENAI_API_KEY }} @@ -551,23 +491,22 @@ jobs: needs: - resolve - adjudicate - # Runs only when adjudication succeeded (it produced the merged review this - # job posts). `!cancelled()` is required here because an explicit `if` - # replaces the default "all needed jobs succeeded" check. + # Runs only when adjudication succeeded (it produced the merged review this job posts). + # `!cancelled()` is required since an explicit `if` replaces the default "all needed jobs + # succeeded" check. if: ${{ !cancelled() && needs.resolve.outputs.should_run == 'true' && needs.adjudicate.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 10 permissions: pull-requests: write steps: - # SECURITY-CRITICAL: this is the only job with write permission, so it - # must only ever execute trusted base-branch code — never the PR head. - # Checking out `develop` explicitly (never `needs.resolve.outputs.head_ref`) - # keeps a malicious PR from smuggling a script change into the one job - # that can write back to the PR. (For `pull_request` events GitHub runs - # the workflow FILE from the PR's own ref; acceptable because the auto - # path only admits same-repo PRs, whose authors hold write access - # anyway, and fork PRs run with a read-only token and no secrets.) + # Security-critical: this is the only job with write permission, so it must only ever + # execute trusted base-branch code — never the PR head. Checking out `develop` explicitly + # (never `needs.resolve.outputs.head_ref`) keeps a malicious PR from smuggling a script + # change into the one job that can write back to the PR. (For `pull_request` events GitHub + # runs the workflow file from the PR's own ref; acceptable since the auto path only admits + # same-repo PRs, whose authors hold write access anyway, and fork PRs run with a read-only + # token and no secrets.) - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: ref: develop @@ -590,7 +529,6 @@ jobs: MERGED_REVIEW_PATH: /tmp/ai-review/merged-review.json TRIGGER: ${{ needs.resolve.outputs.trigger }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} - # CLAUDE_MODEL / CODEX_MODEL are inherited from the workflow-level - # `env:` block above — the same values passed to `claude`/ - # `codex-action` — so the footer never drifts from what actually ran. + # CLAUDE_MODEL / CODEX_MODEL are inherited from the workflow-level `env:` block above, + # so the footer never drifts from what actually ran. run: bun .github/scripts/ai-review/post-review.ts post diff --git a/.github/workflows/apply-release-notes.yml b/.github/workflows/apply-release-notes.yml index 054fc20d26..0a0b7e60e0 100644 --- a/.github/workflows/apply-release-notes.yml +++ b/.github/workflows/apply-release-notes.yml @@ -1,14 +1,9 @@ name: Apply release notes -# Approval-based publish. When a member of the supabase/cli team approves a -# release-notes PR (head ref `release-notes/v<VERSION>`), this workflow pushes -# the proposed notes to the GitHub Release body for the corresponding tag, -# comments the release URL on the PR, and closes the PR without merging. The -# release-notes PR targets `develop` (not `main`) so an accidental merge can -# never rewrite `main`'s history; the file is not meant to land on any branch. -# -# Mirrors the fast-forward job in release.yml, which already gates on a -# `pull_request_review` + `approved` event. +# Approval-based publish: when a supabase/cli team member approves a release-notes PR (head ref +# `release-notes/v<VERSION>`), this pushes the notes to the GitHub Release body for that tag, +# comments the release URL on the PR, and closes it without merging. The PR targets `develop` +# (not `main`) so an accidental merge can never rewrite `main`'s history. on: pull_request_review: @@ -32,9 +27,8 @@ jobs: outputs: authorized: ${{ steps.check.outputs.authorized }} steps: - # App token: needs `orgs/.../teams/.../memberships` read (the org-installed - # App has it), repo write to edit the release, and PR write to comment - # and close. Matches release.yml's fast-forward step. + # App token needs org membership read, repo write to edit the release, and PR write to + # comment and close. - id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: @@ -47,9 +41,8 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} APPROVER: ${{ github.event.review.user.login }} PR_NUMBER: ${{ github.event.pull_request.number }} - # Fail closed: any response other than an active membership means the - # approval is ignored. We post a comment so the reviewer sees why their - # approval didn't apply, then exit 0 so the workflow isn't flagged red. + # Fail closed: any state other than active membership is ignored. Exit 0 (after + # commenting why) so the workflow isn't flagged red. run: | set -euo pipefail status=$(gh api \ @@ -79,9 +72,8 @@ jobs: client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} - # Checkout the PR head so any reviewer edits made in the GitHub UI before - # approval are captured. apply-release-notes.ts reads from the working - # tree. + # Checkout the PR head so reviewer edits made before approval are captured; + # apply-release-notes.ts reads from the working tree. - uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1 with: ref: ${{ github.event.pull_request.head.sha }} @@ -98,8 +90,7 @@ jobs: HEAD_REF: ${{ github.event.pull_request.head.ref }} PR_NUMBER: ${{ github.event.pull_request.number }} APPROVER: ${{ github.event.review.user.login }} - # The branch is named `release-notes/v<VERSION>`, so the tag is just - # the basename. apply-release-notes.ts validates the file's existence. + # The branch is named `release-notes/v<VERSION>`, so the tag is just the basename. run: | set -euo pipefail tag="${HEAD_REF##release-notes/}" diff --git a/.github/workflows/cli-go-codeql.yml b/.github/workflows/cli-go-codeql.yml index e079be038c..3f19507312 100644 --- a/.github/workflows/cli-go-codeql.yml +++ b/.github/workflows/cli-go-codeql.yml @@ -1,14 +1,3 @@ -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# name: "CodeQL" on: @@ -25,11 +14,7 @@ on: jobs: analyze: name: Analyze (${{ matrix.language }}) - # Runner size impacts CodeQL analysis time. To learn more, please see: - # - https://gh.io/recommended-hardware-resources-for-running-codeql - # - https://gh.io/supported-runners-and-hardware-resources - # - https://gh.io/using-larger-runners (GitHub.com only) - # Consider using larger runners or machines with greater resources for possible analysis time improvements. + # Swift requires a macOS runner; every other language in the matrix uses blacksmith. runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'blacksmith-8vcpu-ubuntu-2404' }} timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }} permissions: @@ -51,39 +36,19 @@ jobs: build-mode: autobuild - language: javascript-typescript build-mode: none - # CodeQL supports the following values keywords for 'language': 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' - # Use `c-cpp` to analyze code written in C, C++ or both - # Use 'java-kotlin' to analyze code written in Java, Kotlin or both - # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both - # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, - # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. - # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how - # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9 with: languages: ${{ matrix.language }} build-mode: ${{ matrix.build-mode }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - # If the analyze step fails for one of the languages you are analyzing with - # "We were unable to automatically build your code", modify the matrix above - # to set the build mode to "manual" for that language. Then modify this step - # to build your code. - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + # Placeholder manual-build step; only runs if a language's build-mode above is set to "manual". - if: matrix.build-mode == 'manual' shell: bash run: | diff --git a/.github/workflows/dispatch-cli-e2e-ci.yml b/.github/workflows/dispatch-cli-e2e-ci.yml index 109b85cf49..ed5aa0f22a 100644 --- a/.github/workflows/dispatch-cli-e2e-ci.yml +++ b/.github/workflows/dispatch-cli-e2e-ci.yml @@ -1,20 +1,9 @@ name: Dispatch cli-e2e-ci -# Asks the supabase/cli-e2e-ci harness to run the cli `test:live` suite against -# a full supabox stack, built from THIS PR's head commit (CLI-1825 / CLI-1831). -# -# This is distinct from `live-e2e.yml`, which runs the collocated live suite -# against managed staging (api.supabase.green). Here the same `apps/cli` suite -# runs against a local Supabox stack stood up inside the private cli-e2e-ci repo; -# we only fire the trigger and pass our head SHA — cli-e2e-ci checks that SHA out -# into its `cli` submodule. -# -# Opt-in by label to keep the expensive full-stack run off every PR: add the -# `run-live-e2e-ci` label (re-dispatches on each subsequent push while labeled). -# cli-e2e-ci reports a `cli-e2e-ci / live` commit status back onto the head SHA. -# -# Fork PRs cannot dispatch (no access to the App secret); run cli-e2e-ci's own -# workflow_dispatch with `cli_ref` for those. +# Triggers supabase/cli-e2e-ci to run the cli `test:live` suite against a full Supabox stack +# built from this PR's head commit — distinct from live-e2e.yml, which targets managed staging. +# Opt in with the `run-live-e2e-ci` label; fork PRs cannot dispatch (no access to the App +# secret) and use cli-e2e-ci's own workflow_dispatch with `cli_ref` instead. on: pull_request: types: [labeled, synchronize, reopened] @@ -24,9 +13,7 @@ permissions: jobs: dispatch: - # Same-repo PRs only: fork PRs don't receive secrets (GH_APP_PRIVATE_KEY), so - # the App-token step would fail and leave a red check. Skip them cleanly — - # fork PRs use cli-e2e-ci's workflow_dispatch with `cli_ref` instead. + # Fork PRs don't receive secrets (GH_APP_PRIVATE_KEY), so the App-token step would fail; skip them. if: >- contains(github.event.pull_request.labels.*.name, 'run-live-e2e-ci') && github.event.pull_request.head.repo.full_name == github.repository diff --git a/.github/workflows/mirror-slim-image.yml b/.github/workflows/mirror-slim-image.yml index 454dc49c71..7f48630a15 100644 --- a/.github/workflows/mirror-slim-image.yml +++ b/.github/workflows/mirror-slim-image.yml @@ -1,23 +1,14 @@ name: Mirror Slim Image -# Mirrors slim service images published by supabase/slim-services from -# ghcr.io/supabase/cli/<service>:<version> to -# public.ecr.aws/supabase/cli/<service>:<version>. +# Mirrors slim service images published by supabase/slim-services from ghcr.io/supabase/cli to +# public.ecr.aws/supabase/cli. The sender polls the ECR Public destination and fails its release +# unless it resolves to the exact index digest published, so the copy must be digest-preserving +# via `regctl image copy` (byte-for-byte); do not switch to `docker buildx imagetools create`, +# which can rewrite the index and change its digest. # -# The slim-services release pipeline sends a `mirror-slim-image` -# repository_dispatch to this repo, then anonymously polls the ECR Public -# destination (15-minute timeout) and fails its release unless the destination -# resolves to the exact index digest it published. The copy must therefore be -# digest-preserving: we use `regctl image copy`, which moves the whole OCI -# index (all platform manifests and referrers) byte-for-byte. Do NOT switch -# this to `docker buildx imagetools create` — it can rewrite the index and -# change its digest, breaking the sender's verification. -# -# The payload arrives with whatever authority holds the dispatch token, so it -# is validated as untrusted input: names are pattern-checked, source and -# destination are derived here rather than trusted from the payload, and the -# source must resolve to the digest claimed by the sender before anything is -# copied. +# The payload arrives with whatever authority holds the dispatch token, so it is treated as +# untrusted: names are pattern-checked, source/destination are derived here rather than trusted +# from the payload, and the source must resolve to the claimed digest before anything is copied. # # Full contract: docs/design/ecr-mirror-dispatch.md in supabase/slim-services. @@ -103,8 +94,7 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Install regctl - # Installed under $RUNNER_TEMP (always writable by the job user) and - # exposed to later steps via $GITHUB_PATH. + # $RUNNER_TEMP is always writable by the job user. run: | set -euo pipefail install -d "${RUNNER_TEMP}/regctl-bin" @@ -174,16 +164,10 @@ jobs: SOURCE: ${{ steps.validate.outputs.source }} DESTINATION: ${{ steps.validate.outputs.destination }} DIGEST: ${{ steps.validate.outputs.digest }} - # Copy by digest so the copy cannot race a tag move on the source; the - # whole index, all child manifests, and any referrers move as-is. - # - # The copy runs unconditionally, with no early exit when the - # destination tag already resolves to the digest: regctl's copy is - # incremental, so a re-dispatch after a complete copy is a cheap - # verification pass, while re-running after a partial failure (root - # index pushed but referrers or digest-tags missing) completes the - # copy instead of skipping it. Re-dispatches therefore still exit - # successfully with the destination digest unchanged. + # Copies by digest (not tag) so it can't race a tag move on the source, and runs + # unconditionally with no early exit: regctl's copy is incremental, so re-dispatching + # after a complete copy is a cheap no-op, and after a partial failure it finishes the + # copy instead of skipping it. run: | set -euo pipefail regctl image copy --referrers --digest-tags \ diff --git a/.github/workflows/release-config.yml b/.github/workflows/release-config.yml index c002f39c8f..d3d2bc24ff 100644 --- a/.github/workflows/release-config.yml +++ b/.github/workflows/release-config.yml @@ -7,16 +7,12 @@ on: paths: - "packages/config/**" - ".github/workflows/release-config.yml" - # workflow_dispatch is the manual re-cut path, mirroring the CLI's Release - # workflow. Defaults to `true` so a stray "Run workflow" click can't - # accidentally publish — operators must consciously untick this. + # workflow_dispatch is the manual re-cut path, mirroring the CLI's Release workflow. Defaults + # to `true` so a stray "Run workflow" click can't accidentally publish. # - # There is deliberately no `version` input: the publish job's registry probe - # skips versions that already exist on npm (after verifying the registry - # bytes match the reviewed artifact), so recovery from stale published - # bytes is "land a new (releasable) commit" — with no binary artifacts and a - # human approval in the loop, the CLI's cut-forward escape hatch isn't worth - # a second code path here. + # There is no `version` input: the publish job's registry probe already skips versions that + # exist on npm with matching bytes, so recovery from stale published bytes is just landing a + # new releasable commit — not worth a second cut-forward code path here. workflow_dispatch: inputs: dry_run: @@ -45,11 +41,9 @@ jobs: npm_tag: ${{ steps.plan.outputs.npm_tag }} dry_run: ${{ steps.plan.outputs.dry_run }} steps: - # semantic-release runs `git push --dry-run HEAD:<branch>` as part of - # verifyAuth even in `dry_run: true` mode, so the token must have push - # access to the protected `develop` branch. The default GITHUB_TOKEN - # doesn't, so we mint an App-installation token from the same App used - # by the CLI's release pipeline. + # semantic-release runs `git push --dry-run HEAD:<branch>` as part of verifyAuth even in + # `dry_run: true` mode, so the token needs push access to the protected develop branch — + # the default GITHUB_TOKEN doesn't have it. - id: app-token uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: @@ -57,11 +51,9 @@ jobs: private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} permission-contents: write - # `persist-credentials: false` is required: otherwise checkout caches the - # default GITHUB_TOKEN as an `http.extraheader` in git config, and that - # Authorization header overrides the App token semantic-release puts in - # the push URL — making the dry-push identify as `github-actions[bot]` - # and get rejected by branch protection. + # `persist-credentials: false` is required: otherwise checkout caches the default + # GITHUB_TOKEN in git config, and its Authorization header overrides the App token + # semantic-release puts in the push URL, making the dry-push get rejected by branch protection. - uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1 with: fetch-depth: 0 @@ -101,20 +93,16 @@ jobs: trap 'echo "::${resume_token}::"' EXIT pnpm exec bun packages/config/scripts/release-plan.ts --notes-out "$RUNNER_TEMP/config-release-notes.md" - # The build, gate, and pack steps also run on private-blocked pushes - # (should_release=false, version set): if `private` were ever flipped - # back on, every config push would still rehearse the plan half of the - # release train while the publish half stays parked. + # Build/gate/pack also run on private-blocked pushes (should_release=false, version set): + # if `private` is ever flipped back on, the plan half of the release train is already + # rehearsed on every config push. - name: Build @supabase/config if: steps.plan.outputs.version != '' run: pnpm exec turbo run @supabase/config#build - # Pack the exact tarball the approver's evidence (the gate summary - # below) describes. The publish job publishes THIS artifact rather than - # rebuilding: builds are not byte-reproducible across jobs (see - # release-shared.yml's brew/scoop cache-key comments for how that bit - # once before), and a rebuild would mean the approved bytes and the - # published bytes can differ. + # Packs the exact tarball the approver's evidence (the gate summary below) describes, + # rather than rebuilding: builds aren't byte-reproducible across jobs, so a rebuild could + # let the approved bytes and published bytes differ. - name: Pack the release tarball if: steps.plan.outputs.version != '' env: @@ -127,11 +115,9 @@ jobs: pnpm pack --pack-destination "$RUNNER_TEMP/config-release-artifact" cp "$RUNNER_TEMP/config-release-notes.md" "$RUNNER_TEMP/config-release-artifact/" - # The gate diffs the declarations INSIDE the packed tarball (not the - # dist/ build directory it was packed from), so the approver's evidence - # is generated from the same bytes the publish job ships — a packlist - # regression that drops .d.ts files from the tarball fails here instead - # of shipping a surface the approver never saw. + # Diffs the declarations inside the packed tarball (not the dist/ directory it was packed + # from), so a packlist regression that drops .d.ts files fails here instead of shipping a + # surface the approver never saw. - name: Run type-surface release gate against the packed tarball if: steps.plan.outputs.version != '' env: @@ -153,13 +139,10 @@ jobs: if-no-files-found: error retention-days: 7 - # The `config-release` environment's required-reviewers rule is repo - # configuration, not code: an environment referenced by a workflow is - # auto-created WITHOUT protection rules, in which case the publish job - # would run straight through unreviewed. Fail closed here — before a - # real (non-dry) release can reach the publish job — if the rule is - # missing or unreadable. Private-blocked rehearsals (should_release - # false) are unaffected — this only gates real releases. + # An environment referenced by a workflow is auto-created without protection rules, so if + # `config-release`'s required-reviewers rule (repo config, not code) goes missing, the + # publish job would run straight through unreviewed. Fail closed here before a real + # (non-dry) release can reach it; private-blocked rehearsals are unaffected. - name: Assert the release approval gate is armed if: steps.plan.outputs.should_release == 'true' && steps.plan.outputs.dry_run != 'true' env: @@ -194,26 +177,21 @@ jobs: name: Publish needs: plan if: needs.plan.outputs.should_release == 'true' && needs.plan.outputs.dry_run != 'true' - # npm provenance verification rejects non-GitHub-hosted runners with - # E422 ("Unsupported GitHub Actions runner environment: self-hosted"). - # Blacksmith runners count as self-hosted from sigstore's POV, so the - # publish job must stay on a github-hosted runner. The job is short and - # not compute-bound, so the wall-clock cost is negligible. + # npm provenance verification rejects non-GitHub-hosted runners with E422; Blacksmith runners + # count as self-hosted from sigstore's POV, so this job must stay on a github-hosted runner + # (it's short and not compute-bound, so the cost is negligible). runs-on: ubuntu-latest timeout-minutes: 30 environment: name: config-release - # This environment must be configured with required reviewers in repo - # settings (asserted by the plan job above). The approver reviews the - # plan job's step summary (release notes + type-surface gate diff) - # before approving — that approval IS the hard semver gate, and the - # tarball published below is byte-identical to the one that evidence - # was generated from. + # Configured with required reviewers in repo settings (asserted by the plan job above). The + # approver reviews the plan job's step summary (release notes + type-surface gate diff) + # before approving — that approval is the hard semver gate, and the tarball published below + # is byte-identical to what that evidence was generated from. url: https://www.npmjs.com/package/@supabase/config/v/${{ needs.plan.outputs.version }} - # OIDC trusted publishing + provenance — same as release-shared.yml; no - # NPM_TOKEN anywhere. This job deliberately runs NO dependency install and - # NO build: the only repo code it executes is this workflow file, keeping - # arbitrary package code away from the job that holds id-token: write. + # OIDC trusted publishing + provenance — same as release-shared.yml; no NPM_TOKEN anywhere. + # This job runs no dependency install and no build: the only repo code it executes is this + # workflow file, keeping arbitrary package code away from the job that holds id-token: write. permissions: contents: write id-token: write @@ -237,9 +215,8 @@ jobs: persist-credentials: true token: ${{ steps.app-token.outputs.token }} - # npm only — no `pnpm install`, no workspace toolchain. npm ≥ 11.5.1 is - # required for OIDC trusted publishing, newer than the runner image's - # system npm, and node 24 bundles it. + # npm only — no `pnpm install`, no workspace toolchain. npm ≥ 11.5.1 (required for OIDC + # trusted publishing) is newer than the runner image's system npm; node 24 bundles it. - name: Install node uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: @@ -267,12 +244,9 @@ jobs: exit 1 fi - # Idempotent, mirroring publish.ts's registry-probe intent: a re-run - # after a post-publish failure must not die on EPUBLISHCONFLICT — but - # the skip is only safe when the registry's bytes ARE the reviewed - # artifact, since the tag push below would otherwise bless foreign - # bytes (e.g. a previous run's tag push failed, new commits landed, - # and a fresh plan recomputed the same version from a newer tree). + # Idempotent, mirroring publish.ts's registry-probe intent: a re-run after a post-publish + # failure must not die on EPUBLISHCONFLICT. The skip is only safe when the registry's bytes + # match the reviewed artifact, since the tag push below would otherwise bless foreign bytes. - name: Publish to npm working-directory: ${{ runner.temp }}/config-release run: | @@ -298,18 +272,12 @@ jobs: npm publish "./${tarball}" --ignore-scripts --provenance --tag "${NPM_TAG}" fi - # "Published successfully" for the notification jobs below means - # REGISTRY-VISIBLE with the reviewed bytes: probe npm until the version - # resolves (the registry can lag a publish by a few seconds) and its - # integrity matches the reviewed tarball. Runs before the tag push so a - # tag is never blessed for bytes this run couldn't confirm on the - # registry; the dist-tag assertion deliberately lives AFTER the tag push - # so a dist-tag propagation hiccup can't strand the release live on npm - # with origin untagged. A transient failure here is safe to re-run — the - # publish step's registry probe skips the republish and this check - # repeats — though a re-run of this job re-arms the config-release - # approval gate and costs a second human approval — hence the generous - # (~2 minute) visibility budget. + # "Published successfully" for the notification jobs means registry-visible with the + # reviewed bytes: probe npm until the version resolves and its integrity matches. Runs + # before the tag push so a tag is never blessed for unconfirmed bytes; the dist-tag + # assertion instead lives after the tag push so a propagation hiccup can't strand the + # release published but origin-untagged. A re-run of this job re-arms the approval gate and + # costs a second human approval, hence the generous (~2 minute) visibility budget. - name: Verify the release is live on npm working-directory: ${{ runner.temp }}/config-release run: | @@ -347,12 +315,10 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - # Push the tag to origin as soon as npm has the bytes, before any - # downstream step that can fail. Without this, a failure in the GH - # release step leaves origin with no tag for the version that is now - # live on npm — and a subsequent plan would recompute the same version - # against stale bytes. Idempotent: skips push if the tag is already on - # origin (e.g. a re-run of a job that previously got past this step). + # Pushes the tag as soon as npm has the bytes, before any downstream step that can fail — + # otherwise a later failure leaves origin untagged for a version already live on npm, and a + # subsequent plan would recompute the same version against stale bytes. Idempotent: skips + # if the tag is already on origin. - name: Push version tag run: | set -euo pipefail @@ -364,11 +330,10 @@ jobs: git push origin "${tag}" fi - # Asserted after the tag push on purpose: npm already holds the reviewed - # bytes (verified above), so a dist-tag propagation hiccup must not - # strand the release npm-published but origin-untagged. Retries until - # the tag points at THIS version — a stale packument still echoing the - # previous version is a retryable state, not a terminal mismatch. + # Asserted after the tag push on purpose: npm already holds the reviewed bytes (verified + # above), so a dist-tag propagation hiccup must not strand the release npm-published but + # origin-untagged. Retries until the tag points at this version, since a stale packument is + # a retryable state, not a terminal mismatch. - name: Verify the npm dist-tag run: | set -euo pipefail @@ -406,13 +371,10 @@ jobs: # become the repo's "latest" release. make_latest: "false" - # Pages the release Slack channel the moment a real run arms the - # config-release approval gate. This job only needs `plan`, so it runs in - # parallel with the publish job's `waiting` state — the ping and the pending - # deployment appear together. The approval itself stays on GitHub: the run - # page hosts the Approve button and the plan job's evidence summary; the - # webhook is one-way and cannot host an interactive approval. Nothing - # depends on this job, so a Slack/webhook failure can't block the release. + # Pages Slack the moment a real run arms the config-release approval gate. Runs in parallel + # with the publish job's `waiting` state (needs only `plan`), so the ping and the pending + # deployment appear together — the approval itself stays on GitHub, since the webhook is + # one-way and can't host an interactive approval. notify-slack-approval: name: Notify Slack (approval needed) needs: plan @@ -426,12 +388,9 @@ jobs: secrets: SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} - # Posts once the publish job has verified the release is registry-visible - # with the reviewed bytes. No `if:` needed: the implicit success() gate - # means this only runs when plan and publish both succeeded, and publish - # itself only runs for real (non-dry) releases — dry runs and no-release - # pushes skip publish, which skips this too. Nothing depends on this job, - # so a Slack/webhook failure can't affect the already-completed release. + # Posts once publish has verified the release is registry-visible with the reviewed bytes. No + # `if:` needed: the implicit success() gate means this only runs when plan and publish both + # succeeded, and publish itself only runs for real releases. notify-slack: name: Notify Slack needs: [plan, publish] @@ -445,14 +404,10 @@ jobs: secrets: SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} - # Distinguishes "a reviewer rejected the pending deployment" from a real - # pipeline failure before paging the channel: a rejection marks the publish - # job failed, and announcing that as a broken release would page people - # about a deliberate decision. The run's approvals record is the only place - # the distinction is visible from inside the workflow. The dry-run guard - # reads the dispatch input directly rather than plan's output, so a plan - # job that dies before recording dry_run still can't page for an - # operator-watched dry run. + # Distinguishes a reviewer's rejection from a real pipeline failure before paging the channel — + # a rejection marks the publish job failed, and announcing that as a broken release would page + # people about an intentional call. The dry-run guard reads the dispatch input directly, so a + # plan job that dies before recording `dry_run` still can't page for an operator-watched run. classify-failure: name: Classify failure needs: [plan, publish] @@ -493,14 +448,10 @@ jobs: echo "status=failure" >> "$GITHUB_OUTPUT" fi - # Reports a failed (or reviewer-declined) release. `failure()` on the - # classify job evaluates against its `needs` chain, so this pair fires - # whenever `plan` or `publish` fails — including an approval rejection — - # but stays quiet for dry runs and no-release pushes (skipped needs don't - # count as failures). When `plan` fails its outputs are empty, so the - # message falls back to the workflow run link as the actionable detail. - # Fails open: if the classifier itself breaks, the page still goes out as a - # plain failure (its status output is empty, so the expression falls back). + # Reports a failed (or reviewer-declined) release. `failure()` on the classify job evaluates + # against its `needs` chain, so this pair fires whenever `plan` or `publish` fails — including + # an approval rejection — but stays quiet for dry runs and no-release pushes. Fails open: if + # the classifier itself breaks, the page still goes out as a plain failure. notify-slack-failure: name: Notify Slack (failure) needs: [plan, publish, classify-failure] diff --git a/.github/workflows/release-shared.yml b/.github/workflows/release-shared.yml index 5db584e874..57b1d3e8a5 100644 --- a/.github/workflows/release-shared.yml +++ b/.github/workflows/release-shared.yml @@ -119,12 +119,10 @@ jobs: enableCrossOsArchive: true fail-on-cache-miss: true - # Docker's classic image store keeps a single platform manifest per - # tag, so pulling `alpine:3.21` for amd64 and again for arm64 leaves - # only the most recent one in the local store — and `docker save` - # captures only that one. Enabling the containerd snapshotter lets - # the store hold multi-platform manifests under a single tag, which - # is what makes the cache step below work for both arches. + # Docker's classic image store keeps a single platform manifest per tag, so pulling + # `alpine:3.21` for amd64 then arm64 leaves only the most recent in the local store, and + # `docker save` captures only that one. The containerd snapshotter lets the store hold + # multi-platform manifests under a single tag instead. - name: Enable containerd image store if: runner.os == 'Linux' run: | @@ -133,17 +131,12 @@ jobs: sudo systemctl restart docker docker info --format '{{.DriverStatus}}' - # The host's binfmt_misc must be mounted BEFORE installing QEMU. On cold - # Blacksmith VMs it is not mounted by default; the privileged - # tonistiigi/binfmt installer that setup-qemu-action runs then registers - # the qemu interpreters inside its own mount namespace — it prints - # "installing: arm64 OK" but the handlers vanish when the container exits, - # so the host kernel never gains arm64 emulation and every - # `docker run --platform linux/arm64` dies with "exec format error". - # Warm/reused VMs already had it mounted, which is why this only failed - # intermittently (on cache-miss, i.e. cold-VM, runs). Mount it on the host - # after the docker restart so the registration lands in the host kernel - # and persists through to the smoke tests. + # The host's binfmt_misc must be mounted before installing QEMU. On cold Blacksmith VMs it + # isn't mounted by default, so setup-qemu-action's privileged installer registers the qemu + # interpreters inside its own mount namespace — they vanish when the container exits, and + # `docker run --platform linux/arm64` fails with "exec format error" (only on cold-VM runs, + # since warm VMs already have it mounted). Mount it on the host after the docker restart so + # the registration lands in the host kernel. - name: Ensure binfmt_misc is mounted on the host if: runner.os == 'Linux' run: | @@ -167,11 +160,10 @@ jobs: # the host kernel. Reads binfmt_misc directly — no image pull, no network. grep -q enabled /proc/sys/fs/binfmt_misc/qemu-aarch64 - # Cache the smoke-test base images across runs. Without this, eight - # parallel `docker run` calls in smoke-test-linux.ts race on first-time - # pulls and surface as docker exit 125 ("daemon could not start the - # container") on a subset of jobs. Bump the `-v<N>` suffix to invalidate - # the cache when the image list or tags change. + # Caches the smoke-test base images across runs — without it, eight parallel `docker run` + # calls in smoke-test-linux.ts race on first-time pulls and fail with docker exit 125 + # ("daemon could not start the container"). Bump the `-v<N>` suffix to invalidate when the + # image list or tags change. - name: Cache smoke-test docker images if: runner.os == 'Linux' id: smoke-docker-cache @@ -219,9 +211,8 @@ jobs: run: chmod +x packages/cli-*/bin/supabase || true - name: Run smoke tests - # Force bash so ${VERSION}/${NPM_TAG} expand identically across the - # ubuntu/macos/windows matrix — windows-latest defaults to pwsh, which - # treats those as empty PowerShell variables (env vars are `$env:VAR`). + # Force bash so ${VERSION}/${NPM_TAG} expand identically across the ubuntu/macos/windows + # matrix — windows-latest defaults to pwsh, which treats those as empty PowerShell variables. shell: bash run: pnpm run test:smoke -- --version "${VERSION}" --tag "${NPM_TAG}" working-directory: apps/cli @@ -269,7 +260,6 @@ jobs: uses: ./.github/workflows/live-e2e-gate.yml with: sha: ${{ github.sha }} - # Forward the one secret required by live-e2e-gate.yml. secrets: SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN: ${{ secrets.SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN }} @@ -280,11 +270,9 @@ jobs: - smoke-test-macos - live-e2e-gate if: ${{ !cancelled() && !inputs.dry_run && needs.build-github.result == 'success' && needs.smoke-test.result == 'success' && needs.smoke-test-macos.result == 'success' && (inputs.channel != 'stable' || needs.live-e2e-gate.result == 'success') }} - # npm provenance verification rejects non-GitHub-hosted runners with - # E422 ("Unsupported GitHub Actions runner environment: self-hosted"). - # Blacksmith runners count as self-hosted from sigstore's POV, so the - # publish job must stay on a github-hosted runner. The job is short - # and not compute-bound, so the wall-clock cost is negligible. + # npm provenance verification rejects non-GitHub-hosted runners with E422; Blacksmith runners + # count as self-hosted from sigstore's POV, so this job must stay on a github-hosted runner + # (it's short and not compute-bound, so the cost is negligible). runs-on: ubuntu-latest timeout-minutes: 45 env: @@ -339,13 +327,10 @@ jobs: git config user.name "github-actions[bot]" git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - # Push the version tag to origin as soon as npm has the bytes, before any - # downstream step that can fail. Without this, a failure in the GH-release - # step (or anything after) leaves origin with no tag for the version that - # is now live on npm — and `semantic-release --dry-run` on the next push - # will keep recomputing the same version, causing the publish job to no-op - # against stale bytes. Idempotent: skips push if the tag is already on - # origin (e.g. a re-run of a job that previously got past this step). + # Pushes the tag as soon as npm has the bytes, before any downstream step that can fail — + # otherwise a later failure leaves origin untagged for a version already live on npm, and + # `semantic-release --dry-run` on the next push keeps recomputing the same version. + # Idempotent: skips if the tag is already on origin. - name: Push version tag run: | set -euo pipefail @@ -357,19 +342,16 @@ jobs: git push origin "${tag}" fi - # semantic-release matches tags against branch.channel using git-notes - # under refs/notes/semantic-release-<tag>. Without this note, the develop - # plan job recomputes the same prerelease version every push (see - # docs/ci-beta-tags-investigation.md). Stable releases on main don't - # need this — main's branch config has no explicit channel. + # semantic-release matches tags against branch.channel using git-notes under + # refs/notes/semantic-release-<tag>; without this note, the develop plan job recomputes + # the same prerelease version every push (see docs/ci-beta-tags-investigation.md). Stable + # releases on main don't need this, since main's branch config has no explicit channel. # - # The note must be attached to the underlying commit, not the annotated - # tag object created by "Push version tag" above. semantic-release reads - # notes via `git log --no-walk --tags=* --notes=...`, which looks for - # notes on commits; a note on the tag object is invisible to it and - # leaves the tag with channels:[null], so the prerelease arm of - # getLastRelease's filter rejects it and the plan falls back to the - # last stable. Hence the `${tag}^{commit}` deref. + # The note must attach to the underlying commit, not the annotated tag object created by + # "Push version tag" above — semantic-release reads notes via `git log --no-walk + # --tags=* --notes=...`, which looks at commits, so a note on the tag object leaves the tag + # with channels:[null] and the plan falls back to the last stable. Hence the + # `${tag}^{commit}` deref. - name: Push channel note for prerelease tags if: ${{ inputs.prerelease }} run: | @@ -383,10 +365,9 @@ jobs: git push origin "${note_ref}" fi - # goreleaser used to publish both supabase_<version>_<os>_<arch>.tar.gz and - # supabase_<os>_<arch>.tar.gz; setup-cli, install scripts, docs and various - # third-party tools point at releases/latest/download/<unversioned>.tar.gz. - # Re-publish the alias so those callers keep working. See supabase/cli#5257. + # setup-cli, install scripts, docs, and other tools reference + # releases/latest/download/<unversioned>.tar.gz, so republish that alias alongside the + # versioned tarball. - name: Create unversioned tarball aliases run: | set -euo pipefail @@ -465,11 +446,10 @@ jobs: secrets: DF_FIREWALL_TOKEN: ${{ secrets.DF_FIREWALL_TOKEN }} - # Once the raw semantic-release block is in the release body, ask Claude to - # rewrite it into user-centric notes and open a PR for human approval. Stable - # releases only on this path — prereleases keep the raw body. Non-blocking so - # an LLM hiccup never gates a published release; reviewers can propose beta - # notes manually from the Actions tab (workflow_dispatch). + # Once the raw semantic-release block is in the release body, asks Claude to rewrite it into + # user-centric notes and open a PR for human approval. Stable only — prereleases keep the raw + # body. Non-blocking so an LLM hiccup never gates a published release; reviewers can propose + # beta notes manually via workflow_dispatch. propose-release-notes: uses: ./.github/workflows/propose-release-notes.yml needs: backfill-release-notes @@ -503,12 +483,10 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - # Must restore the github-hosted build (-github-v1), the same artifacts - # the publish job uploads to the GitHub Release. The Bun-compiled binaries - # are not byte-for-byte reproducible across the blacksmith and github - # builds, so the blacksmith dist/checksums.txt does not match the released - # tarballs. Reading it here produced a formula whose sha256 rejected the - # downloaded archive ("Formula reports different checksum"). + # Restores the github-hosted build (-github-v1) — the same artifacts the publish job + # uploads to the GitHub Release. Bun-compiled binaries aren't byte-for-byte reproducible + # across the blacksmith and github builds, so the blacksmith checksums.txt doesn't match + # the released tarballs, and a formula built from it rejects the downloaded archive. - name: Restore build artifacts cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: @@ -566,12 +544,10 @@ jobs: with: dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} - # Must restore the github-hosted build (-github-v1), the same artifacts - # the publish job uploads to the GitHub Release. The Bun-compiled binaries - # are not byte-for-byte reproducible across the blacksmith and github - # builds, so the blacksmith dist/checksums.txt does not match the released - # tarballs. Reading it here would produce a manifest whose hash rejects the - # downloaded archive. + # Restores the github-hosted build (-github-v1) — the same artifacts the publish job + # uploads to the GitHub Release. Bun-compiled binaries aren't byte-for-byte reproducible + # across the blacksmith and github builds, so the blacksmith checksums.txt doesn't match + # the released tarballs, and a manifest built from it rejects the downloaded archive. - name: Restore build artifacts cache uses: actions/cache/restore@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 with: @@ -608,18 +584,12 @@ jobs: GH_TOKEN: ${{ steps.app-token.outputs.token }} GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} - # Post-publish smoke test for the `supabase/setup-cli` GitHub Action against - # the just-released CLI. Runs last and intentionally does not gate - # publish-homebrew / publish-scoop — by the time the smoke runs, the npm - # package and GitHub release are already live and the brew/scoop pushes - # have either succeeded or skipped, so a setup-cli regression surfaces as - # a red post-release signal without holding back the rest of the channel. - # - # Depends on the publish jobs only via `needs` for ordering; the `if` - # uses `always() && needs.publish.result == 'success'` so the smoke still - # runs when publish-homebrew / publish-scoop are skipped or fail. - # The reusable workflow can also be dispatched manually against any - # already-published version when debugging setup-cli regressions. + # Post-publish smoke test for the `supabase/setup-cli` GitHub Action against the just-released + # CLI. Runs last and intentionally doesn't gate publish-homebrew/publish-scoop — by the time it + # runs, the npm package and GitHub release are already live, so a setup-cli regression surfaces + # as a red post-release signal without holding back the rest of the channel. `needs` is for + # ordering only; `always() && needs.publish.result == 'success'` lets it run even when + # publish-homebrew/publish-scoop are skipped or fail. setup-cli-smoke: needs: [publish, publish-homebrew, publish-scoop] if: ${{ always() && !inputs.dry_run && needs.publish.result == 'success' }} @@ -627,16 +597,11 @@ jobs: with: version: ${{ inputs.version }} - # Post-publish end-to-end check that the Homebrew tap, Scoop bucket, and the - # curl|bash install script actually install the just-released CLI. brew/scoop - # verify the published checksum against the downloaded tarball, so this is the - # signal that would have caught CLI v2.107.0 (mismatched brew/scoop sha256s). - # - # Only runs when brew/scoop were published (beta/stable) and both pushes - # succeeded. A channel that skipped brew/scoop entirely is covered by the GitHub - # Release download path in setup-cli-smoke. Like setup-cli-smoke, it runs last - # and does not gate the rest of the channel: by the time it runs the manifests - # are already live, so a failure surfaces as a red post-release signal. + # Post-publish check that the Homebrew tap, Scoop bucket, and curl|bash install script + # actually install the just-released CLI — brew/scoop verify the published checksum against + # the downloaded tarball, catching a mismatch like CLI v2.107.0. Only runs when brew/scoop were + # published and both pushes succeeded; a skipped channel is covered by the GitHub Release + # download path in setup-cli-smoke. Runs last and doesn't gate the rest of the channel. verify-install-channels: needs: [publish, publish-homebrew, publish-scoop] if: ${{ always() && !inputs.dry_run && inputs.publish_brew_scoop && needs.publish-homebrew.result == 'success' && needs.publish-scoop.result == 'success' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5dd0b55a10..7f043e20c4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -25,18 +25,16 @@ on: description: npm package version to publish (must be unique on npm; pick the next unused version) required: true type: string - # Defaults to `true` so a stray "Run workflow" click can't accidentally - # publish — operators recovering from a stale-bytes run must consciously - # untick this when dispatching. + # Defaults to `true` so a stray "Run workflow" click can't accidentally publish; operators + # must consciously untick this when dispatching. dry_run: description: Dry run (skip actual publishing) required: false type: boolean default: true -# The release pipeline mutates external package registries, git tags, GitHub -# Releases, Homebrew, and Scoop. Keep one release per ref active at a time so -# duplicate push events cannot race the same computed version. +# The release pipeline mutates external registries, git tags, GitHub Releases, Homebrew, and +# Scoop, so keep one release per ref active at a time to avoid duplicate push events racing. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false @@ -90,11 +88,9 @@ jobs: dry_run: ${{ steps.compute.outputs.dry_run }} channel: ${{ steps.compute.outputs.channel }} steps: - # semantic-release runs `git push --dry-run HEAD:<branch>` as part of - # verifyAuth even in `dry_run: true` mode, so the token must have push - # access to the protected `develop`/`main` branches. The default - # GITHUB_TOKEN doesn't, so we mint an App-installation token from the - # same App used for fast-forward + brew/scoop pushes. + # semantic-release runs `git push --dry-run HEAD:<branch>` as part of verifyAuth even in + # `dry_run: true` mode, so the token needs push access to the protected develop/main + # branches — the default GITHUB_TOKEN doesn't have it. - id: app-token if: github.event_name == 'push' uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 @@ -102,11 +98,9 @@ jobs: client-id: ${{ vars.GH_APP_CLIENT_ID }} private-key: ${{ secrets.GH_APP_PRIVATE_KEY }} permission-contents: write - # `persist-credentials: false` is required: otherwise checkout caches the - # default GITHUB_TOKEN as an `http.extraheader` in git config, and that - # Authorization header overrides the App token semantic-release puts in - # the push URL — making the dry-push identify as `github-actions[bot]` - # and get rejected by branch protection. + # `persist-credentials: false` is required: otherwise checkout caches the default + # GITHUB_TOKEN in git config, and its Authorization header overrides the App token + # semantic-release puts in the push URL, making the dry-push get rejected by branch protection. - uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1 with: fetch-depth: 0 @@ -185,11 +179,9 @@ jobs: name: Release needs: plan if: needs.plan.outputs.should_release == 'true' - # pull-requests: write is required by the nested propose-release-notes - # workflow (release-shared.yml -> propose-release-notes.yml). For nested - # reusable workflows, a called job's permissions can't exceed those granted - # to the calling job, so this must be declared here even though the propose - # job uses an App token for its actual PR creation. + # pull-requests: write is required by the nested propose-release-notes workflow + # (release-shared.yml -> propose-release-notes.yml): a called job's permissions can't exceed + # those granted to the calling job, so this must be declared here too. permissions: actions: read contents: write @@ -216,12 +208,9 @@ jobs: LINEAR_CLI_BETA_RELEASE_ACCESS_KEY: ${{ secrets.LINEAR_CLI_BETA_RELEASE_ACCESS_KEY }} SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN: ${{ secrets.SUPABASE_E2E_CLI_LIVE_STAGING_ACCESS_TOKEN }} - # Republishes the supabase.com CLI reference, restoring the job the Go - # `tools/bumpdoc` ran before the monorepo merge (71b543255) deleted it — the - # published spec has been frozen at 2.98.2 since. Stable only: the reference - # documents the CLI users actually install, so pre-releases are skipped. - # Nothing depends on this job, so a docs-site failure cannot affect the - # already-completed release. + # Republishes the supabase.com CLI reference. Stable only: the reference documents the CLI + # users actually install, so pre-releases are skipped. Nothing depends on this job, so a + # docs-site failure cannot affect the already-completed release. docs: name: Publish reference docs needs: [plan, release] @@ -255,9 +244,8 @@ jobs: run: gh auth setup-git - name: Publish the CLI reference working-directory: apps/cli - # `shell: bash` for `-o pipefail`: the default `run:` shell would take - # the exit status of the publisher alone, so a generator that died - # part-way would look like a successful run of a truncated spec. + # `shell: bash` for `-o pipefail`: the default `run:` shell takes only the publisher's + # exit status, so a generator that died part-way would look like a successful run. shell: bash env: GH_TOKEN: ${{ steps.app-token.outputs.token }} @@ -271,12 +259,10 @@ jobs: bun scripts/generate-docs-spec.ts "$VERSION" \ | bun scripts/publish-docs-spec.ts --version "$VERSION" "${args[@]}" - # Posts to the release Slack channel once the pipeline succeeds. Listing - # `release` in `needs` without a status function in `if:` keeps the implicit - # success() gate, so this only runs when both plan and release succeeded. - # The `if:` then filters to real (non-dry-run) stable cuts; beta and dry - # runs stay silent. Nothing depends on this job, so a Slack/webhook - # failure can't affect the already-completed release. + # Listing `release` in `needs` without a status function in `if:` keeps the implicit success() + # gate, so this only runs when both plan and release succeeded; the `if:` then filters to real + # (non-dry-run) stable cuts. Nothing depends on this job, so a Slack failure can't affect the + # already-completed release. notify-slack: name: Notify Slack needs: [plan, release] @@ -292,15 +278,11 @@ jobs: secrets: SLACK_RELEASE_WEBHOOK: ${{ secrets.SLACK_RELEASE_WEBHOOK }} - # Reports a broken release on every channel. `failure()` evaluates against the - # `needs` chain, so this fires whenever `plan` or `release` (and anything in - # the reusable release-shared workflow) fails. Skipped jobs — e.g. the - # fast-forward path or a release that never started — don't count as failures, - # so this stays quiet there. Dry runs are excluded — the guard reads the - # dispatch input directly, so even a plan job that dies before recording its - # dry_run output stays quiet; an operator running one is already watching it - # live. When `plan` fails its outputs are empty, so the message falls back to - # the workflow run link as the actionable detail. + # Reports a broken release on every channel. `failure()` evaluates against the full `needs` + # chain, so this fires whenever `plan` or `release` (including the reusable release-shared + # workflow) fails. Skipped jobs — e.g. the fast-forward path, or a release that never started — + # don't count as failures. Dry runs are excluded via the dispatch input directly, so even a + # plan job that dies before recording its `dry_run` output stays quiet. notify-slack-failure: name: Notify Slack (failure) needs: [plan, release] diff --git a/.github/workflows/setup-cli-smoke-test.yml b/.github/workflows/setup-cli-smoke-test.yml index 1725e4ada2..b0b17fcd30 100644 --- a/.github/workflows/setup-cli-smoke-test.yml +++ b/.github/workflows/setup-cli-smoke-test.yml @@ -1,13 +1,9 @@ name: setup-cli Smoke Test -# Smoke test for the `supabase/setup-cli` GitHub Action against a specific -# published CLI version. Run automatically after every beta release (called -# from release-shared.yml's `setup-cli-smoke` job) and also manually via -# workflow_dispatch when debugging setup-cli regressions or verifying that -# a previously broken environment (e.g. Alpine) is back to green. -# -# Exists primarily to catch regressions like supabase/setup-cli#427 where -# musl libc + archive layout changes broke the action on Alpine silently. +# Smoke test for the `supabase/setup-cli` GitHub Action against a specific published CLI +# version. Runs automatically after every beta release and also manually via workflow_dispatch +# when debugging setup-cli regressions. Catches regressions like supabase/setup-cli#427, where +# musl libc + archive layout changes silently broke the action on Alpine. on: workflow_call: @@ -36,9 +32,8 @@ jobs: - ubuntu-latest - macos-latest - windows-latest - # GitHub Actions doesn't allow expressions in `uses:`, so the action - # ref is selected via two `if:`-gated install steps below rather than - # interpolating `matrix.major-version` into a single `uses:` line. + # GitHub Actions doesn't allow expressions in `uses:`, so the action ref is selected via + # two `if:`-gated install steps below instead of interpolating `matrix.major-version`. major-version: - v1 - v2 @@ -72,14 +67,10 @@ jobs: exit 1 fi - # Alpine leg of the setup-cli smoke test. Kept as a separate job because - # GitHub Actions only honours the `container:` field on Linux runners, and - # mixing container + non-container entries in a single matrix via - # `container: ${{ matrix.container }}` is fragile when the value is empty. - # - # The musl-vs-glibc + archive layout regressions tracked in - # supabase/setup-cli#427 only reproduce inside a real Alpine container, so - # this job is the actual signal the workflow was created for. + # A separate job because GitHub Actions only honours `container:` on Linux runners, and mixing + # container + non-container entries in one matrix is fragile when the value is empty. The + # musl-vs-glibc + archive layout regressions from supabase/setup-cli#427 only reproduce inside + # a real Alpine container. setup-cli-smoke-test-alpine: name: setup-cli ${{ matrix.major-version }} (alpine) strategy: @@ -90,9 +81,8 @@ jobs: - v2 runs-on: ubuntu-latest timeout-minutes: 10 - # `node:20-alpine` ships a musl-linked Node, which is required for - # JavaScript-based actions (setup-cli included) to launch inside an - # Alpine container — the runner's mounted glibc Node won't execute here. + # `node:20-alpine` ships a musl-linked Node, required for JavaScript-based actions to launch + # here — the runner's mounted glibc Node won't execute inside an Alpine container. container: image: node:20-alpine env: diff --git a/.github/workflows/verify-install-channels.yml b/.github/workflows/verify-install-channels.yml index cf5a6983bb..a3a94bc4fa 100644 --- a/.github/workflows/verify-install-channels.yml +++ b/.github/workflows/verify-install-channels.yml @@ -1,35 +1,14 @@ name: Verify Install Channels -# Post-publish end-to-end verification that the *published* install channels -# (Homebrew, Scoop, and the curl|bash install script) actually install the -# just-released CLI and serve artifacts whose checksums match what the channel -# manifests declare. Runs automatically after every brew/scoop publish (called -# from release-shared.yml's `verify-install-channels` job) and can also be -# dispatched manually against any already-published version when debugging an -# install regression. +# Post-publish verification that the published install channels (Homebrew, Scoop, curl|bash) +# install the just-released CLI and serve artifacts matching the channel manifests' checksums. +# Runs automatically after every brew/scoop publish, and can also be dispatched manually against +# an already-published version. # -# Exists primarily to catch regressions like CLI v2.107.0, where the Homebrew -# formula and Scoop manifest shipped sha256 checksums that did not match the -# tarballs on the GitHub Release, so `brew install` / `scoop install` failed -# for every user with "Formula reports different checksum". brew, scoop, and -# the install script all verify the declared checksum against the downloaded -# bytes before installing, so a real install reproduces that failure exactly -# instead of trusting the manifest the publish step wrote. -# -# Each leg goes beyond `supabase --version` (handled by the Bun wrapper without -# touching the sidecar) and directly checks that the `supabase-go` sidecar -# binary is present and executable in the channel's install directory, so a -# package that omits or misplaces it fails here instead of silently shipping -# a CLI whose still-Go-proxied commands (see docs/go-cli-porting-status.md) -# would fail for every user. -# -# This used to run `supabase completion bash`, since that command was -# Go-proxied. It no longer is (CLI-1965 ported shell completion to native -# TypeScript, and the Go CLI's own completion command was subsequently -# removed too), so that probe silently stopped testing the sidecar at all. -# Checking for the sidecar file directly instead of routing through some -# still-proxied command avoids repeating that mistake as more commands get -# natively ported. +# Performs a real install so a manifest/tarball checksum mismatch (as in CLI v2.107.0) reproduces +# here instead of being trusted from the publish step. Each leg also directly checks that the +# `supabase-go` sidecar binary is present and executable in the install directory, checking the +# file rather than a CLI command, since a command could later be natively ported off the sidecar. on: workflow_call: @@ -68,10 +47,8 @@ permissions: jobs: homebrew: - # macOS and Linux exercise different stanzas of the formula - # (`on_macos` vs `on_linux`), each with its own URL + sha256, so both must - # install for the tap to be considered verified. Homebrew is preinstalled - # on GitHub-hosted Ubuntu runners. + # macOS and Linux exercise different formula stanzas (`on_macos` vs `on_linux`), each with + # its own URL + sha256, so both must install for the tap to be considered verified. name: Homebrew ${{ inputs.brew_name }} (${{ matrix.runner }}) strategy: fail-fast: false @@ -84,14 +61,11 @@ jobs: env: VERSION: ${{ inputs.version }} BREW_NAME: ${{ inputs.brew_name }} - # Don't auto-update Homebrew itself before installing; `brew install - # <user>/<tap>/<formula>` taps supabase/homebrew-tap from its git HEAD - # regardless, so the freshly-pushed formula is picked up either way. + # Skips Homebrew's own auto-update; `brew install <user>/<tap>/<formula>` taps + # supabase/homebrew-tap from git HEAD regardless, so the fresh formula is picked up anyway. # - # NB: do NOT set HOMEBREW_NO_INSTALL_FROM_API here. It only affects the - # homebrew/core + cask taps (third-party taps are always read from git), - # and on Linux runners it forces a large, slow local checkout of - # homebrew/core that makes this job hang for 10+ minutes. + # Do not also set HOMEBREW_NO_INSTALL_FROM_API: it only affects homebrew/core + cask taps, + # and on Linux it forces a slow homebrew/core checkout that hangs this job for 10+ minutes. HOMEBREW_NO_AUTO_UPDATE: "1" steps: - name: Set up Homebrew on PATH @@ -205,9 +179,8 @@ jobs: scoop bucket add supabase https://github.com/supabase/scoop-bucket scoop install "supabase/$env:SCOOP_NAME" - name: Verify supabase --version - # Force bash so ${VERSION} expands the same way it does on the other - # legs — windows-latest defaults to pwsh, which treats it as an empty - # PowerShell variable (env vars are `$env:VAR`). + # Force bash so ${VERSION} expands the same way as the other legs — windows-latest + # defaults to pwsh, which treats it as an empty PowerShell variable. shell: bash run: | set -euo pipefail diff --git a/.oxlintrc.json b/.oxlintrc.json index 42deffc4f2..1a78e4aec9 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -15,11 +15,10 @@ "**/.source" ], "rules": { - // Spreading an Effect Data.Class instance into its constructor is the - // idiomatic copy-with-update pattern (`new State({ ...state, ... })`). + // `new State({ ...state, ... })` is the idiomatic Data.Class copy-with-update pattern. "typescript/no-misused-spread": "off", - // Effect combinators are passed as references by design, e.g. - // `.pipe(semaphore.withPermit)`; they do not rely on `this`. + // Effect combinators like `.pipe(semaphore.withPermit)` are passed as references by + // design and don't rely on `this`. "typescript/unbound-method": "off", // Too strict for CLI string building; `no-base-to-string` still guards // against `[object Object]` interpolation bugs. @@ -35,13 +34,9 @@ } }, { - // `.github/scripts` is the only `bun:test` consumer in the repo (every - // package test suite uses vitest). `@types/bun`'s test matcher types - // reuse the same sync `Matchers<T>` interface for `expect(x).rejects`, - // so `.rejects.toThrow(...)` types as returning `void` even though it - // must be awaited at runtime — a `@types/bun` typing gap, not a real - // `await`-of-non-Promise bug. Verified: `bun test` and `tsc --noEmit` - // both pass; removing the `await` would make the assertion racy. + // `.github/scripts` is the only `bun:test` consumer; `@types/bun`'s shared `Matchers<T>` + // types `.rejects.toThrow(...)` as returning `void`, even though it must be awaited at + // runtime. Removing the `await` would make the assertion racy. "files": [".github/scripts/**"], "rules": { "typescript/await-thenable": "off" diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index fe40faa8e6..18012018f9 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -1,22 +1,15 @@ { "extends": "@tsconfig/bun/tsconfig.json", "compilerOptions": { - // Lets `tsc` resolve `@supabase/config`'s exports-map `bun` condition - // straight to its `src/*.ts` sources (self-typed, no `.d.ts` needed) - // instead of `dist/*.d.ts`, which requires that package to be built - // first — see `packages/config/package.json`'s exports map and - // `packages/config/AGENTS.md`'s "Build" section (CLI-2234). This also - // affects any OTHER dependency whose own exports map declares a `bun` - // condition (e.g. `@supabase/pg-topo`) — see this package's AGENTS.md/PR - // notes for a known collision that surfaces there. + // Resolves `@supabase/config`'s exports-map `bun` condition straight to its `src/*.ts` + // sources instead of `dist/*.d.ts`, which requires that package to be built first — see + // `packages/config/package.json`'s exports map and `packages/config/AGENTS.md`'s "Build" + // section. Any other dependency with its own `bun` exports condition can hit the same issue. "customConditions": ["bun"], - // `@supabase/pg-topo` (external, from supabase/pg-toolbelt) also declares - // a `bun` exports condition, pointing at its UNBUILT `src/*.ts`, which - // carries type errors at 1.0.0-alpha.5 that its shipped `dist/*.d.ts` - // does not surface. `paths` wins over exports-condition resolution, so - // pin its types to the published declarations. Drop this once a fixed - // pg-topo release (>= 1.0.0-alpha.6) clears the pnpm minimumReleaseAge - // window and is bumped in this package. + // `@supabase/pg-topo`'s `bun` exports condition points at unbuilt `src/*.ts` with type + // errors at 1.0.0-alpha.5 that its published `dist/*.d.ts` doesn't have. `paths` wins over + // exports-condition resolution, so pin to the published types until a fixed release is + // bumped here. "paths": { "@supabase/pg-topo": ["./node_modules/@supabase/pg-topo/dist/index.d.ts"] } diff --git a/packages/config/tsconfig.build.json b/packages/config/tsconfig.build.json index 73b1cfd0ae..d3a9029d7f 100644 --- a/packages/config/tsconfig.build.json +++ b/packages/config/tsconfig.build.json @@ -1,15 +1,8 @@ { - // Compiles the published `dist/` output for `pnpm build`. Extends the same - // `@tsconfig/bun` strictness baseline as `tsconfig.json` (dev/tests, run - // under Bun) but swaps its bundler-mode settings for a real Node-compatible - // ESM emit, so the two configs share strictness without fighting over - // `module`/`moduleResolution`/`types`/`noEmit`, which this file overrides. - // - // `"types": []` drops the ambient `bun` global types from `tsconfig.json` - // and doubles as the @types/bun leak audit CLI-2234 requires: if any - // compiled module reaches for a Bun-only global (`Bun.*`, `Bun.env`, ...), - // this compile fails on a missing name instead of silently type-checking - // against `bun-types`. + // Compiles the published `dist/` output for `pnpm build`, swapping the `@tsconfig/bun` + // baseline's bundler-mode settings for a real Node-compatible ESM emit. `"types": []` drops + // the ambient `bun` globals, so a compiled module reaching for a Bun-only global (`Bun.*`) + // fails to compile instead of silently type-checking against `bun-types`. "extends": "@tsconfig/bun/tsconfig.json", "compilerOptions": { "lib": ["ESNext"], diff --git a/packages/config/tsconfig.declarations.json b/packages/config/tsconfig.declarations.json index be7328b4d5..6676ca6d92 100644 --- a/packages/config/tsconfig.declarations.json +++ b/packages/config/tsconfig.declarations.json @@ -1,11 +1,7 @@ { - // Declaration-only companion to `tsconfig.build.json`, used by - // `tools/config-api-compare.ts` (repo root) to emit both the base and head - // `.d.ts` trees it diffs (CLI-2234). A second minimal config — rather than - // overriding `declarationMap` on the CLI — because `declarationMap` is a - // boolean compiler option with no dedicated CLI negation flag; `--outDir` - // is a plain string override and stays safe to pass on the command line at - // each call site. + // Declaration-only companion to `tsconfig.build.json`, used by `tools/config-api-compare.ts` + // to emit both the base and head `.d.ts` trees it diffs. A separate config rather than a CLI + // override because `declarationMap` is a boolean compiler option with no CLI negation flag. "extends": "./tsconfig.build.json", "compilerOptions": { "emitDeclarationOnly": true, From 2f90e3f9b5c8197e4b8fa482504be70e92c121b1 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:18:50 +0100 Subject: [PATCH 52/56] chore(repo): trim comments, shard 01 (1037 -> 700 comment lines) --- .github/scripts/ai-review/post-review.test.ts | 38 +--- .github/scripts/ai-review/post-review.ts | 192 ++++++------------ .github/scripts/ai-review/resolve.test.ts | 2 - .github/scripts/ai-review/resolve.ts | 96 +++------ .github/scripts/contribution-gate.test.ts | 9 - .github/scripts/contribution-gate.ts | 70 +++---- .github/scripts/sync-workload-catalog.test.ts | 6 +- apps/cli-e2e/src/server/pg-mock.ts | 36 +--- apps/cli-e2e/src/server/placeholder.ts | 16 +- apps/cli-e2e/src/server/replay-server.ts | 36 ++-- apps/cli-e2e/src/server/request-matcher.ts | 11 +- apps/cli-e2e/src/tests/auth.e2e.test.ts | 26 +-- .../cli-e2e/src/tests/config-push.e2e.test.ts | 11 +- .../cli-e2e/src/tests/config-toml.e2e.test.ts | 14 +- .../src/tests/database-core.e2e.test.ts | 54 +---- apps/cli-e2e/src/tests/env.ts | 7 +- .../src/tests/go-binary-surface.e2e.test.ts | 86 +++----- apps/cli-e2e/src/tests/inspect.e2e.test.ts | 12 -- apps/cli-e2e/src/tests/migrations.e2e.test.ts | 9 +- .../src/tests/project-lifecycle.e2e.test.ts | 24 +-- apps/cli-e2e/src/tests/stack.e2e.test.ts | 39 +--- apps/cli-e2e/src/tests/storage.e2e.test.ts | 16 -- apps/cli/scripts/backfill-release-notes.ts | 77 +++---- 23 files changed, 258 insertions(+), 629 deletions(-) diff --git a/.github/scripts/ai-review/post-review.test.ts b/.github/scripts/ai-review/post-review.test.ts index cd1db8890a..0f884b196c 100644 --- a/.github/scripts/ai-review/post-review.test.ts +++ b/.github/scripts/ai-review/post-review.test.ts @@ -25,9 +25,7 @@ import { truncateReviewBody, } from "./post-review.ts"; -// A single hunk touching file.ts lines 10-14 on the new side: line 10 is -// context, line 11 replaces a removed line, 12 is a pure addition, 13-14 are -// trailing context. Hand-computed RIGHT-side anchors: {10, 11, 12, 13, 14}. +// One hunk on file.ts; RIGHT-side anchors: {10, 11, 12, 13, 14}. const SINGLE_HUNK_DIFF = `diff --git a/file.ts b/file.ts index 111..222 100644 --- a/file.ts @@ -41,8 +39,7 @@ index 111..222 100644 context line 14 `; -// Two hunks in the same file: {1,2,3} from the first hunk, {20,21,22} from -// the second (the RIGHT counter resets to each hunk's own header). +// Two hunks in one file; each resets the RIGHT counter to its own header. const MULTI_HUNK_DIFF = `diff --git a/multi.ts b/multi.ts index 1..2 100644 --- a/multi.ts @@ -101,8 +98,7 @@ index 0..6 +line three `; -// A trailing "\ No newline at end of file" marker on both sides must not -// perturb the RIGHT counter: anchors are still {1,2}. +// A trailing "\ No newline at end of file" marker must not perturb the RIGHT counter. const NO_NEWLINE_DIFF = `diff --git a/nonewline.ts b/nonewline.ts index 7..8 100644 --- a/nonewline.ts @@ -115,9 +111,7 @@ index 7..8 100644 \\ No newline at end of file `; -// git appends a literal TAB after a `+++` path that needs quoting (here, -// because it contains a space); the tab must be stripped so anchors key on -// "has space.ts", not "has space.ts\t". +// git appends a literal TAB after a quoted `+++` path; it must be stripped. const TAB_PATH_DIFF = `diff --git a/has space.ts b/has space.ts index 9..a 100644 --- a/has space.ts @@ -127,10 +121,7 @@ index 9..a 100644 +added line `; -// A pure rename (100% similarity) carries no `---`/`+++`/`@@` lines at all, -// followed by a normal file's diff — the parser must not leak state (e.g. a -// leftover `currentFile`) from the header-less rename section into the next -// file. +// A pure rename carries no `---`/`+++`/`@@` lines; state must not leak into the next file's diff. const RENAME_ONLY_THEN_NORMAL_DIFF = `diff --git a/old-name.ts b/new-name.ts similarity index 100% rename from old-name.ts @@ -144,10 +135,7 @@ index 1..2 100644 +added `; -// An added line whose literal content is "++ b/not-a-real-header.ts" appears -// in the diff, prefixed by the diff's own "+", as "+++ b/not-a-real-header.ts" -// — a `+++`-lookalike that must not hijack `currentFile` because it occurs -// inside a hunk, not between a `diff --git` boundary and the first `@@`. +// An added line whose content looks like a `+++` header must not hijack `currentFile`. const PLUS_LOOKALIKE_DIFF = `diff --git a/lookalike.ts b/lookalike.ts index 1..2 100644 --- a/lookalike.ts @@ -828,10 +816,6 @@ describe("buildReviewPayload", () => { }); test("truncates the very first payload's body when it already exceeds the cap with zero comments to fold", () => { - // Not anchorable (line 999 is outside the diff hunk), so this produces a - // body-only payload with no inline comments — the 422-retry fold path - // never runs, so only truncating `buildReviewPayload`'s own body catches - // an oversized initial POST. const finding = makeFinding({ file: "file.ts", line: 999, claim: "x".repeat(70_000) }); const review = makeMergedReview({ findings: [finding] }); const payload = buildReviewPayload(review, anchors, footer); @@ -1045,10 +1029,7 @@ describe("post flow via injected ReviewIo", () => { if (opts.failSupersede) { return Promise.reject(new Error("listReviews failed")); } - // Mirror real GitHub: a review posted earlier in the same run shows - // up in later listings as a marker-bearing bot review. The supersede - // pass must snapshot BEFORE posting or it would wrap the fresh - // review as "superseded" too. + // Mirrors real GitHub: a review posted earlier in the same run shows up in later listings. const alreadyPosted = postedReviews.map((payload, i) => ({ id: 900 + i, body: payload.body, @@ -1141,9 +1122,6 @@ describe("post flow via injected ReviewIo", () => { await postConsolidatedReview(io, 42, review, footer); - // With no prior AI review on the PR, nothing may be wrapped as superseded - // — especially not the review this run just posted (which the fake's - // listReviews, like real GitHub, includes in post-POST listings). expect(postedReviews).toHaveLength(1); expect(updatedReviews).toEqual([]); expect(updatedComments).toEqual([]); @@ -1230,8 +1208,6 @@ describe("post flow via injected ReviewIo", () => { }); test("posts a truncated body on the very first attempt for an oversized body-only review (no comments to fold)", async () => { - // Not anchorable, so there's no inline comment for GitHub to 422 on — the - // old behavior threw here instead of posting a truncated body. const finding = makeFinding({ file: "file.ts", line: 999, claim: "x".repeat(70_000) }); const review = makeMergedReview({ findings: [finding] }); const { io, postedReviews } = makeReviewIo({ diff: SINGLE_HUNK_DIFF }); diff --git a/.github/scripts/ai-review/post-review.ts b/.github/scripts/ai-review/post-review.ts index c56949d1d3..93a042ed44 100644 --- a/.github/scripts/ai-review/post-review.ts +++ b/.github/scripts/ai-review/post-review.ts @@ -1,56 +1,38 @@ /** - * AI review poster: validates the structured findings both model passes - * produce, and posts the ONE consolidated PR review the pipeline is allowed - * to post per run. + * AI review poster: validates the structured findings the two model passes + * produce, and posts the one consolidated PR review this pipeline posts per run. * - * Four subcommands, dispatched from `argv`: - * - `validate-findings <path>` — checks a Claude findings JSON file against - * the shape `.github/ai-review/findings.schema.json` describes. The - * `--json-schema` flag passed to `claude` is a hint to the model, not a - * runtime guarantee, so the CI step re-checks the extracted output here - * before it is trusted. - * - `validate-merged <path>` — same idea for the Codex-adjudicated merged - * review, against `.github/ai-review/merged-review.schema.json`. - * - `redact <path>` — reads a JSON file, deep-walks every string value - * through `redactSecrets`, and writes it back in place. Run on every - * model-output JSON file before it's uploaded as a (public-repo) - * artifact, so a prompt-injected `Read` of a secret-bearing path can't - * smuggle a credential out through the artifact even though the posted - * review is already scrubbed at render time. - * - `post` — snapshots the PR's prior AI reviews, posts the consolidated - * review, THEN best-effort supersedes the snapshotted ones (the - * marker/dedup guard in `resolve.ts` should normally prevent a second - * run, but `/ai-review` lets a maintainer force one). The snapshot must - * happen BEFORE the POST — the fresh review is itself a marker-bearing - * bot review, so a post-hoc listing would sweep it into its own - * supersede pass and every new review would collapse itself. Posting - * before superseding, and treating both the snapshot and the supersede - * as best-effort, means a cosmetic failure can never cost the real - * review. + * Subcommands, dispatched from `argv`: + * - `validate-findings <path>` / `validate-merged <path>` — check a model's + * findings/merged-review JSON against its schema under `.github/ai-review/`. + * The `--json-schema` flag passed to `claude` is only a hint to the model, + * so this re-validates its output before it's trusted. + * - `redact <path>` — scrubs secret-shaped strings from a JSON file in + * place; run on every model-output file before it's uploaded as a + * (public-repo) artifact. + * - `post` — builds and posts the consolidated review, then supersedes any + * prior AI reviews on the PR (see `listPriorRunsBestEffort` for why the + * snapshot must happen before the POST). * * `parseDiffAnchors`, `partitionFindings`, `renderReviewBody`, * `renderInlineComment`, `buildReviewPayload`, `foldInlineCommentsIntoBody`, * `supersededBody`, `isSuperseded`, `sanitizeFilePath`, and `redactSecrets` - * are pure and exported for tests. `postConsolidatedReview` is the I/O - * orchestration function for the `post` subcommand; it's exported so a test can drive it against - * an injected `ReviewIo` fake without the network, the same way - * `resolveDecision` is tested in `resolve.ts`. `main()` wires up the real - * GitHub I/O and argv dispatch. + * are pure and exported for tests. `postConsolidatedReview` drives the `post` + * subcommand against an injected `ReviewIo` fake in tests. * * Run in CI as: `bun .github/scripts/ai-review/post-review.ts <command>`. */ export const AI_REVIEW_MARKER = "<!-- supabase-ai-review -->"; const SUPERSEDED_SUMMARY = "Superseded by a newer AI review"; -/** Hidden marker `isSuperseded` looks for. Kept out of the human-readable - * `SUPERSEDED_SUMMARY` text and broken by `sanitizeModelText` so a model - * can't forge or evade a supersede by echoing the visible text into a - * `claim`/`summary` field. */ +/** Hidden marker `isSuperseded` checks for. Kept out of the human-readable + * `SUPERSEDED_SUMMARY` text, and broken by `sanitizeModelText`, so a model + * can't forge or evade a supersede by echoing it into a `claim`/`summary` field. */ const SUPERSEDED_MARKER = "<!-- supabase-ai-review:superseded -->"; const WORKFLOW_BOT_LOGIN = "github-actions[bot]"; const GITHUB_REVIEW_BODY_MAX = 65536; -// --- Shared types (mirror the two schema files by hand; keep in sync) --- +// Mirrors the two schema files under `.github/ai-review/`; keep them in sync by hand. export type Severity = "critical" | "major" | "minor" | "nit"; export type Verdict = "confirmed" | "refuted" | "uncertain"; @@ -107,12 +89,8 @@ export interface MergedReview { stats: MergedReviewStats; } -// --- Hand-rolled schema validators --- -// -// `.github/ai-review/findings.schema.json` and `merged-review.schema.json` -// are the model-facing contract (passed as `--json-schema`/`output-schema-file`); -// these validators are the runtime enforcement and must be kept in sync with -// them by hand whenever either shape changes. +// Runtime enforcement for the schemas in `.github/ai-review/`; keep these in +// sync by hand whenever either shape changes. function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -219,12 +197,11 @@ function expectCategory(value: unknown, path: string, context: string): string { return str; } -/** `file` is model-controlled and rendered inside `` `code` `` spans at - * several sites; a backtick, newline, other ASCII control char, or `<` in it - * could break out of the span (markdown/HTML injection, mention/#ref pings) - * or forge one of the hidden HTML-comment markers. Reject those at parse - * time as the primary defense; `sanitizeFilePath` neutralizes the same - * characters again at render time in case a caller ever skips validation. */ +/** `file` is model-controlled and rendered inside `` `code` `` spans; a + * backtick, newline, other ASCII control char, or `<` in it could break out + * of the span or forge a hidden marker, so reject those at parse time. + * `sanitizeFilePath` neutralizes the same characters again at render time in + * case a caller skips validation. */ // eslint-disable-next-line no-control-regex -- matching control characters is the point of this pattern const FILE_PATH_FORBIDDEN_PATTERN = /[`<\x00-\x1f\x7f]/; @@ -385,8 +362,6 @@ export function assertMergedReview(value: unknown): asserts value is MergedRevie parseMergedReview(value); } -// --- Diff anchoring --- - const DIFF_GIT_HEADER = /^diff --git /; const HUNK_HEADER = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/; const NEW_FILE_HEADER = /^\+\+\+ (?:b\/(.+)|\/dev\/null)$/; @@ -408,15 +383,13 @@ function stripTrailingTab(path: string): string { } /** - * Parses a unified diff into, for each file, the set of new-side (RIGHT) line - * numbers present in the diff — i.e. the lines a PR review comment can - * anchor to. Context and `+` lines advance the RIGHT counter and are - * anchorable; `-` lines don't exist on the new side and are skipped. + * Parses a unified diff into, for each file, the new-side (RIGHT) line + * numbers a PR review comment can anchor to. Context and `+` lines advance + * the RIGHT counter; `-` lines don't exist on the new side and are skipped. * - * Tracks whether we're inside a hunk so a `+++ ` file header is only ever - * recognized between a `diff --git` boundary and that file's first `@@` - * hunk — otherwise an added/context line whose literal content happens to - * start with `+++ ` (a `+++`-lookalike) could hijack `currentFile`. + * A `+++ ` file header is only recognized between a `diff --git` boundary and + * that file's first `@@` hunk, so an added/context line that happens to start + * with `+++ ` can't hijack `currentFile`. */ export function parseDiffAnchors(diff: string): Map<string, Set<number>> { const anchors = new Map<string, Set<number>>(); @@ -461,8 +434,6 @@ function isAnchorable(anchors: Map<string, Set<number>>, file: string, line: num return anchors.get(file)?.has(line) ?? false; } -// --- Findings partitioning and rendering --- - export interface PartitionedFindings { /** Confirmed/uncertain findings whose start line lands on a diff hunk; posted as inline comments. */ anchorable: MergedFinding[]; @@ -510,17 +481,14 @@ const HTML_COMMENT_OPENER_PATTERN = /<!--/g; const REDACTED_SECRET = "«redacted»"; -/** Credential shapes commonly seen in Anthropic/OpenAI API keys and GitHub - * personal-access/app/OAuth/Actions tokens. Not exhaustive — this is - * defense-in-depth alongside a dedicated, spend-capped, rotatable - * `ANTHROPIC_API_KEY` (see the README); the dedicated key is the real - * containment. */ +/** Credential shapes seen in Anthropic/OpenAI API keys and GitHub + * personal-access/app/OAuth/Actions tokens. Not exhaustive — defense-in-depth + * alongside a dedicated, spend-capped, rotatable `ANTHROPIC_API_KEY`. */ const SECRET_PATTERNS: readonly RegExp[] = [ /sk-ant-[A-Za-z0-9_-]{20,}/g, // OpenAI keys embed hyphenated prefixes (`sk-proj-…`, `sk-svcacct-…`, // `sk-admin-…`) as well as the legacy `sk-<40 alnum>` shape, so the class - // must allow `-`/`_` — otherwise the match stops at the first hyphen and a - // leaked project-scoped key reaches the posted review/artifact unredacted. + // must allow `-`/`_` or a leaked project-scoped key reaches the review unredacted. /sk-[A-Za-z0-9_-]{20,}/g, /ghp_[A-Za-z0-9]{36}/g, /github_pat_[A-Za-z0-9_]{22,}/g, @@ -530,21 +498,17 @@ const SECRET_PATTERNS: readonly RegExp[] = [ ]; /** - * Replaces common credential formats with a redaction marker. Pure; composed - * into `sanitizeModelText` below so every model-provided string rendered into - * the posted review is scrubbed, and applied again (via the `redact` - * subcommand) to the raw JSON artifacts before upload. Defense-in-depth - * against a prompt-injected model `Read`-ing a secret-bearing path (e.g. - * `/proc/self/environ`) and echoing the value back in a finding. + * Replaces common credential formats with a redaction marker. Composed into + * `sanitizeModelText` so every rendered model string is scrubbed, and applied + * again via the `redact` subcommand to raw JSON artifacts before upload — + * defense-in-depth against a prompt-injected model echoing a leaked secret + * back into a finding. */ export function redactSecrets(text: string): string { return SECRET_PATTERNS.reduce((acc, pattern) => acc.replace(pattern, REDACTED_SECRET), text); } -/** Deep-walks an arbitrary JSON value, redacting every string it contains. - * Exported for tests; the `redact` subcommand (see `runRedact` below) is the - * thin file-I/O wrapper around it that scrubs the raw JSON artifacts before - * they're uploaded. */ +/** Deep-walks an arbitrary JSON value, redacting every string it contains. */ export function redactSecretsDeep(value: unknown): unknown { if (typeof value === "string") { return redactSecrets(value); @@ -564,13 +528,10 @@ export function redactSecretsDeep(value: unknown): unknown { /** * Neutralizes a model-provided string before it's rendered into a - * `github-actions[bot]` review: redacts secret-shaped substrings first, breaks - * HTML comment openers (so injected diff content can't forge the hidden - * `AI_REVIEW_MARKER`/`SUPERSEDED_MARKER` comments), then breaks - * `@mention`/`#123` syntax with a zero-width HTML comment so GitHub never - * renders them as a live mention or issue reference. Pure; apply to every - * model-provided string (`summary`, `claim`, `evidence`, `suggested_fix`, - * `adjudication.reason`) at render time. + * `github-actions[bot]` review: redacts secrets, breaks HTML comment openers + * so injected diff content can't forge the hidden `AI_REVIEW_MARKER`/ + * `SUPERSEDED_MARKER` comments, then breaks `@mention`/`#123` syntax so + * GitHub never renders a live mention or issue reference. */ export function sanitizeModelText(text: string): string { return redactSecrets(text) @@ -579,12 +540,10 @@ export function sanitizeModelText(text: string): string { .replace(ISSUE_REF_PATTERN, "#<!---->"); } -/** Neutralizes the same characters `expectFile` rejects at parse time - * (backtick, `<`, ASCII control chars) inside a model-provided `file` path - * before it's rendered into a `` `code` `` span. Every finding reaching a - * render site will already have passed `expectFile`; this is defense-in-depth - * for any caller that renders a `MergedFinding` without going through - * `assertMergedReview` first. */ +/** Strips the same characters `expectFile` rejects at parse time (backtick, + * `<`, ASCII control chars) so a caller that renders a `MergedFinding` + * without going through `assertMergedReview` first still can't break a + * rendered `` `code` `` span. */ // eslint-disable-next-line no-control-regex -- matching control characters is the point of this pattern const FILE_PATH_UNSAFE_CHARS = /[`<\x00-\x1f\x7f]/g; @@ -629,9 +588,8 @@ export function renderInlineComment(finding: MergedFinding): string { export interface ReviewFooterInfo { trigger: Trigger; runUrl: string; - /** e.g. `` `claude-fable-5` + `gpt-5.6-sol` ``. Passed in from the workflow's - * `CLAUDE_MODEL`/`CODEX_MODEL` env vars instead of being hardcoded here, so - * the model names have one source of truth. */ + /** e.g. `` `claude-fable-5` + `gpt-5.6-sol` ``. Sourced from the workflow's + * model env vars so there's one source of truth for the model names. */ modelsFooter: string; } @@ -768,10 +726,8 @@ export function buildReviewPayload( const partitioned = partitionFindings(review.findings, anchors); const comments = partitioned.anchorable.map((finding) => buildInlineComment(finding, anchors)); const body = renderReviewBody(review, partitioned, footer); - // A body-only review (many non-anchorable findings, few or no inline - // comments) has no fold-retry path to truncate it on a 422 — truncate the - // very first payload too, so an oversized body posts truncated instead of - // throwing when GitHub rejects it for exceeding the review body cap. + // A body-only review has no 422 fold-retry to truncate it later, so + // truncate the first payload too rather than posting an oversized body. return { event: "COMMENT", body: truncateReviewBody(body, footer.runUrl), comments }; } @@ -791,10 +747,7 @@ export function foldInlineCommentsIntoBody(payload: ReviewPayload): ReviewPayloa } /** Truncates a review body to GitHub's 65536-char review body cap, appending - * an explicit truncation marker + the workflow run URL. Applied to both the - * very first payload (`buildReviewPayload`) and the folded 422-retry body - * (every inline comment stuffed into one body), a no-op when the body is - * already under the cap. */ + * a truncation marker with the workflow run URL. No-op under the cap. */ export function truncateReviewBody(body: string, runUrl: string): string { if (body.length <= GITHUB_REVIEW_BODY_MAX) { return body; @@ -822,8 +775,6 @@ export function supersededBody(oldBody: string): string { ].join("\n"); } -// --- Injected GitHub I/O --- - export interface MarkedEntry { id: number; body: string; @@ -863,12 +814,10 @@ function isSupersedableAiEntry(entry: MarkedEntry): boolean { ); } -/** Snapshots the prior AI reviews/comments to supersede. MUST run before the - * new review is posted: the fresh review is itself a marker-bearing bot - * review, so a post-hoc listing would sweep it into its own supersede pass - * and every new review would immediately collapse as "superseded". - * Best-effort — a listing failure degrades to an empty snapshot (prior runs - * stay unwrapped) rather than costing the real review. */ +/** Snapshots the prior AI reviews/comments to supersede. Must run before the + * new review is posted, or a post-hoc listing would sweep the fresh review + * into its own supersede pass. Best-effort — a listing failure degrades to + * an empty snapshot rather than costing the real review. */ async function listPriorRunsBestEffort(io: ReviewIo, prNumber: number): Promise<PriorRuns> { try { const [reviews, comments] = await Promise.all([ @@ -886,9 +835,8 @@ async function listPriorRunsBestEffort(io: ReviewIo, prNumber: number): Promise< } /** Wraps the snapshotted prior AI reviews/comments in a superseded `<details>` - * block. Best-effort: a cosmetic failure here (e.g. a transient 404 on a - * review that was deleted mid-run) must never fail the pipeline after the - * real review has already been posted. */ + * block. Best-effort: a cosmetic failure here must never fail the pipeline + * after the real review has already been posted. */ async function supersedePriorRunsBestEffort( io: ReviewIo, prNumber: number, @@ -945,8 +893,6 @@ export async function postConsolidatedReview( await supersedePriorRunsBestEffort(io, prNumber, prior); } -// --- Real GitHub I/O (only runs when executed directly) --- - function requireEnv(name: string): string { const value = process.env[name]; if (!value) { @@ -995,11 +941,7 @@ function isRecordEntry(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); } -/** - * The validated boundary between `Response.json()` (typed `Promise<unknown>` - * under `@tsconfig/bun`) and this file's typed shapes: `assert` narrows the - * parsed value to `T` before any caller reads a field off it. - */ +/** Narrows `Response.json()`'s `unknown` result to `T` via `assert` before any caller reads a field off it. */ async function githubJson<T>( response: Response, assert: (value: unknown) => asserts value is T, @@ -1132,9 +1074,7 @@ async function postReview( "application/vnd.github+json", [422], ); - // `githubFetch` only returns without throwing for a 2xx or the allowed - // 422; read the body for the 422 case too so a second failed retry can - // surface it instead of discarding it. + // Read the body for the 422 case too so a second failed retry can surface it. if (response.status === 422) { return { status: response.status, body: await response.text() }; } @@ -1172,9 +1112,6 @@ async function runPost(): Promise<void> { const trigger = parseTrigger(requireEnv("TRIGGER")); const runUrl = requireEnv("RUN_URL"); const mergedReviewPath = requireEnv("MERGED_REVIEW_PATH"); - // Sourced from the workflow's top-level `env:` block (the same values fed - // to the `claude`/`codex-action` invocations), not hardcoded here, so the - // model names have one source of truth. const claudeModel = requireEnv("CLAUDE_MODEL"); const codexModel = requireEnv("CODEX_MODEL"); @@ -1189,8 +1126,7 @@ async function runPost(): Promise<void> { console.log(`Posted AI review on PR #${prNumber} (${raw.findings.length} finding(s)).`); } -/** Reads a JSON file, redacts every string value in place through - * `redactSecretsDeep`, and writes it back — the `redact` subcommand's I/O. */ +/** Reads a JSON file, redacts every string value in place, and writes it back. */ async function runRedact(path: string): Promise<void> { const raw: unknown = JSON.parse(await Bun.file(path).text()); const redacted = redactSecretsDeep(raw); diff --git a/.github/scripts/ai-review/resolve.test.ts b/.github/scripts/ai-review/resolve.test.ts index 0beb52747d..07453c1c32 100644 --- a/.github/scripts/ai-review/resolve.test.ts +++ b/.github/scripts/ai-review/resolve.test.ts @@ -361,8 +361,6 @@ describe("resolveDecision: issue_comment authorization", () => { io, ); expect(result.shouldRun).toBe(true); - // The effective permission is always resolved (only the write-permission - // requirement short-circuits for OWNER), so the lookup still happens. expect(permissionLookups).toEqual(["maintainer"]); expect(reactions).toEqual([555]); }); diff --git a/.github/scripts/ai-review/resolve.ts b/.github/scripts/ai-review/resolve.ts index 400d0e3973..58df368df1 100644 --- a/.github/scripts/ai-review/resolve.ts +++ b/.github/scripts/ai-review/resolve.ts @@ -1,25 +1,19 @@ /** * AI review resolver: decides whether the one-shot AI review pipeline should - * run for a PR. + * run for a PR. The pipeline runs once per PR, so this is the only gate + * between a new commit landing and Claude/Codex burning API budget again. * - * The pipeline runs EXACTLY ONCE per PR, so this is the only gate standing - * between "new commit lands" and "Claude + Codex burn API budget again". Two - * triggers feed it: - * - manual (`workflow_dispatch` or an internal maintainer's `/ai-review` - * issue comment): a human explicitly asked for a review, so the - * marker/dedup guard and the draft/fork/bot skips are bypassed. - * - auto (`pull_request` `opened`/`ready_for_review`): only PRs whose - * author has repository write access get the automatic review. Skips - * drafts, bots, fork PRs, authors without write access (external - * contributors go through the manual maintainer path), and PRs that - * already carry a marker comment/review from a prior run. + * Two triggers: + * - manual (`workflow_dispatch` or a maintainer's `/ai-review` issue + * comment): bypasses the marker/dedup guard and the draft/fork/bot skips. + * - auto (`pull_request` `opened`/`ready_for_review`): only for PRs whose + * author has repository write access; skips drafts, bots, fork PRs, and + * PRs that already carry a marker comment/review from a prior run. * - * `resolveDecision` is the pure orchestration function (I/O injected, like - * `evaluateAllOpenPrs` in `contribution-gate.ts`) that a test can drive - * without the network; `main()` wires up the real GitHub I/O, writes the - * step outputs `should_run`, `pr_number`, `head_ref`, and `trigger` to - * `$GITHUB_OUTPUT`, and surfaces the skip reason (if any) in - * `$GITHUB_STEP_SUMMARY`. + * `resolveDecision` is the pure orchestration function (I/O injected) a test + * can drive without the network; `main()` wires up the real GitHub I/O, + * writes `should_run`/`pr_number`/`head_ref`/`trigger` to `$GITHUB_OUTPUT`, + * and surfaces the skip reason (if any) in `$GITHUB_STEP_SUMMARY`. * * Run in CI as: `bun .github/scripts/ai-review/resolve.ts`. */ @@ -29,14 +23,11 @@ import { appendFileSync } from "node:fs"; import { fetchAuthorPermission, WRITE_PERMISSIONS } from "../contribution-gate.ts"; import { AI_REVIEW_MARKER } from "./post-review.ts"; -// Re-export so existing consumers (tests, this file's own dedup check) can -// keep importing the marker from `resolve.ts`; `post-review.ts` — which owns -// posting — is the single source of truth for the literal. +// `post-review.ts` owns the literal; re-exported here for convenience. export { AI_REVIEW_MARKER }; /** Login every review/comment posted by this workflow carries. Duplicated - * (not imported) from `post-review.ts`'s `WORKFLOW_BOT_LOGIN`; keep the two - * literals in sync. */ + * from `post-review.ts`'s `WORKFLOW_BOT_LOGIN`; keep the two in sync. */ const WORKFLOW_BOT_LOGIN = "github-actions[bot]"; export type EventName = "workflow_dispatch" | "issue_comment" | "pull_request"; @@ -96,11 +87,9 @@ export interface ResolveResult { trigger: Trigger; } -/** No size gate: Claude and Codex review agentically — reading the diff and the - * changed files via their own tools over many turns, like the local CLI — so a - * PR that clears the draft/bot/fork/dedup checks is reviewed regardless of its - * size. Very large diffs are handled best-effort within the model's - * context/turn budget. */ +/** No size gate: Claude and Codex review agentically over many turns, so any + * PR that clears the draft/bot/fork/dedup checks is reviewed regardless of + * size, best-effort within the model's context/turn budget. */ function decideForPr(trigger: Trigger): ResolveResult { return { shouldRun: true, trigger }; } @@ -128,9 +117,8 @@ export async function resolveDecision(input: ResolveInput, io: ResolveIo): Promi throw new Error("issue_comment trigger requires comment details"); } - // Authoritative command match: the workflow's job `if:` only - // pre-filters on `startsWith('/ai-review')`, so `/ai-reviewers` or - // `/ai-review-please` would otherwise also reach here. + // The workflow's job `if:` only pre-filters on `startsWith('/ai-review')`, + // so `/ai-reviewers` or `/ai-review-please` would otherwise also reach here. const firstLine = comment.body.split("\n")[0]?.trim() ?? ""; if (firstLine !== "/ai-review") { return { @@ -140,13 +128,9 @@ export async function resolveDecision(input: ResolveInput, io: ResolveIo): Promi }; } - // Authoritative authorization: always resolve the commenter's - // effective repository permission and require write/admin. Only the - // repository OWNER may short-circuit that requirement — any other - // association (including MEMBER/COLLABORATOR, which merely mean "in - // the org"/"added as a collaborator", not necessarily push-capable) - // must pass the permission check. Mirrors `contribution-gate.ts`'s - // `WRITE_PERMISSIONS`. + // Only the repository OWNER short-circuits the permission check; any + // other association (MEMBER/COLLABORATOR only mean "in the org"/"added + // as a collaborator", not necessarily push-capable) must pass it. const permission = await io.fetchPermission(comment.authorLogin); const authorized = comment.authorAssociation === "OWNER" || @@ -175,8 +159,6 @@ export async function resolveDecision(input: ResolveInput, io: ResolveIo): Promi return decideForPr(trigger); } - // Auto trigger (`pull_request` events): internal PRs only, fires at most - // once per PR. if (pr.draft) { return { shouldRun: false, skipReason: "PR is a draft.", trigger }; } @@ -191,13 +173,10 @@ export async function resolveDecision(input: ResolveInput, io: ResolveIo): Promi }; } - // Authoritative auto-trigger authorization: only PRs authored by someone - // with effective repository write access are reviewed automatically. This - // is the actual author check, not defense-in-depth — a same-repo head - // branch only proves the branch exists in this repo, not that the AUTHOR - // pushed it (a PR can be opened from a branch someone else pushed). An - // unresolvable permission counts as unauthorized. Mirrors the manual - // path's gate above and `contribution-gate.ts`'s `WRITE_PERMISSIONS`. + // A same-repo head branch only proves the branch exists in this repo, not + // that the author pushed it, so this checks the author's own permission + // rather than trusting the branch's origin. An unresolvable permission + // counts as unauthorized. const authorPermission = await io.fetchPermission(pr.authorLogin); if (authorPermission === undefined || !WRITE_PERMISSIONS.has(authorPermission)) { return { @@ -214,9 +193,8 @@ export async function resolveDecision(input: ResolveInput, io: ResolveIo): Promi io.listReviews(pr.number), io.listIssueComments(pr.number), ]); - // Only a marker posted BY the workflow bot counts — otherwise anyone could - // paste the (invisible) marker into a comment to permanently suppress the - // auto review of their own PR. + // Only a marker posted by the workflow bot counts, or anyone could paste + // the invisible marker into a comment to suppress their own PR's review. const alreadyReviewed = [...reviews, ...comments].some( (entry) => entry.authorLogin === WORKFLOW_BOT_LOGIN && entry.body.includes(AI_REVIEW_MARKER), ); @@ -231,8 +209,6 @@ export async function resolveDecision(input: ResolveInput, io: ResolveIo): Promi return decideForPr(trigger); } -// --- GitHub I/O (only runs when executed directly) --- - function requireEnv(name: string): string { const value = process.env[name]; if (!value) { @@ -275,11 +251,7 @@ function isRecordEntry(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); } -/** - * The validated boundary between `Response.json()` (typed `Promise<unknown>` - * under `@tsconfig/bun`) and this file's typed shapes: `assert` narrows the - * parsed value to `T` before any caller reads a field off it. - */ +/** Narrows `Response.json()`'s `unknown` result to `T` via `assert` before any caller reads a field off it. */ async function githubJson<T>( response: Response, assert: (value: unknown) => asserts value is T, @@ -389,10 +361,8 @@ async function reactToComment(token: string, base: string, commentId: number): P }); } -/** Writes each `$GITHUB_OUTPUT` value using the heredoc/delimiter form (with - * a random delimiter per line) rather than `name=value`, defensively — none - * of today's values can contain a newline, but a future value shouldn't be - * able to inject extra output lines either. */ +/** Writes each `$GITHUB_OUTPUT` value with a random per-line delimiter rather + * than `name=value`, so a future multi-line value can't inject extra output lines. */ function writeOutputs(result: ResolveResult, prNumber: number): void { const outputFile = requireEnv("GITHUB_OUTPUT"); const entries: Record<string, string> = { @@ -410,9 +380,7 @@ function writeOutputs(result: ResolveResult, prNumber: number): void { appendFileSync(outputFile, `${lines.join("\n")}\n`); } -/** Surfaces the skip reason (if any) in the job's step summary — the only - * place it's actually read; it's not exposed as a job `outputs:` because - * nothing downstream consumes it there. */ +/** Surfaces the skip reason (if any) in the job's step summary. */ function writeStepSummary(result: ResolveResult): void { if (!result.skipReason) { return; diff --git a/.github/scripts/contribution-gate.test.ts b/.github/scripts/contribution-gate.test.ts index 7951de817a..6c8ed6a544 100644 --- a/.github/scripts/contribution-gate.test.ts +++ b/.github/scripts/contribution-gate.test.ts @@ -21,9 +21,6 @@ describe("isInternalAuthor", () => { ); test.each(["admin", "write"])("treats push-capable permission %s as internal", (permission) => { - // A private org member surfaces only as CONTRIBUTOR/NONE via - // author_association, but their effective repo permission gives them - // away as a maintainer. expect(isInternalAuthor("CONTRIBUTOR", permission)).toBe(true); expect(isInternalAuthor("NONE", permission)).toBe(true); }); @@ -130,8 +127,6 @@ describe("evaluateGate", () => { }); test("ignores an open, labeled issue from a different repository", () => { - // Cross-repo closing keyword (e.g. `Closes attacker/repo#1`): the issue is - // controlled by the contributor, so it must not satisfy the gate. const result = evaluateGate({ repository: REPO, isInternal: false, @@ -231,7 +226,6 @@ describe("evaluateAllOpenPrs", () => { expect(byNumber[6]?.pass).toBe(true); expect(byNumber[6]?.reason).toBe("internal"); expect(closed.find((c) => c.number === 1)?.message).toContain(GATE_LABEL); - // Bots and public members are settled without a permission lookup. expect(permissionLookups).not.toContain("maint"); expect(permissionLookups).not.toContain("dependabot"); }); @@ -275,9 +269,6 @@ describe("fetchAuthorPermission", () => { }); test("maps a 404 (non-collaborator fork author) to undefined", async () => { - // The endpoint 404s for users who are not collaborators — the common case - // for the external contributors the gate targets. It must map to external, - // not abort the run. stubFetch(404, { message: "Not Found" }); const permission = await fetchAuthorPermission("t", "supabase", "cli", "ext"); expect(permission).toBeUndefined(); diff --git a/.github/scripts/contribution-gate.ts b/.github/scripts/contribution-gate.ts index 061fc7107b..5d2666446d 100644 --- a/.github/scripts/contribution-gate.ts +++ b/.github/scripts/contribution-gate.ts @@ -1,13 +1,11 @@ /** - * Contribution gate: enforces the Supabase CLI contribution workflow across all - * OPEN pull requests opened by external contributors. + * Contribution gate: enforces the Supabase CLI contribution workflow across + * every open pull request opened by external contributors. * - * A PR passes only when it links to an OPEN GitHub issue that carries the - * `open-for-contribution` label. Maintainers (recognised by their effective - * repository permission, so private org members count too — not just the - * `author_association` GitHub exposes for public members) and bots are exempt - * (they work from Linear tickets or automation). PRs that do not follow the - * process are commented on and closed. + * A PR passes only when it links to an open GitHub issue that carries the + * `open-for-contribution` label. Maintainers (recognized by their effective + * repository permission, so private org members count too) and bots are + * exempt. PRs that don't follow the process are commented on and closed. * * Two modes, both driven from `main()`: * - single-PR (default): reacts to one PR on `pull_request_target` @@ -16,13 +14,10 @@ * open PR, for on-demand `workflow_dispatch` runs. Set `DRY_RUN=true` to * log decisions without commenting/closing. * - * In both modes the workflow checks out the base branch, so this only ever - * executes trusted repository code — it never runs a fork's code. + * The workflow checks out the base branch in both modes, so this only ever + * executes trusted repository code. * * Run in CI as: `bun .github/scripts/contribution-gate.ts`. - * The pure `evaluateGate` decision and the `evaluateAllOpenPrs` orchestrator - * (I/O injected) are unit-tested in `contribution-gate.test.ts`; `main()` wires - * up the real GitHub I/O. */ export const GATE_LABEL = "open-for-contribution"; @@ -30,25 +25,19 @@ export const GATE_LABEL = "open-for-contribution"; /** * Author associations treated as internal (exempt from the gate). * - * NOTE: `author_association` only reports `MEMBER` when a user's organization - * membership is *public*. A private org member (or a team member who keeps - * their membership private) is reported as `CONTRIBUTOR`/`NONE`, so this set - * alone is not enough to identify internal maintainers — see - * `isInternalAuthor`, which also consults the author's effective repository - * permission. + * `author_association` only reports `MEMBER` for a *public* org membership; a + * private member is reported as `CONTRIBUTOR`/`NONE`, so this set alone can't + * identify every internal maintainer — see `isInternalAuthor`. */ export const INTERNAL_ASSOCIATIONS = new Set(["OWNER", "MEMBER", "COLLABORATOR"]); /** - * Effective repository permissions that mark an author as internal. A user who - * can push to the repository (directly, or via a team/org grant that - * `author_association` does not surface) is a trusted maintainer, not an - * external contributor. The legacy REST `permission` field collapses the - * `maintain` role to `write`, so `admin`/`write` covers every push-capable - * role. + * Effective repository permissions that mark an author as internal: anyone + * who can push, directly or via a team/org grant `author_association` won't + * surface. The legacy REST `permission` field collapses `maintain` into + * `write`, so `admin`/`write` covers every push-capable role. * - * Exported for `resolve.ts`, which requires the same write-permission bar to - * authorize a `/ai-review` command. + * Exported for `resolve.ts`, which uses the same bar to authorize `/ai-review`. */ export const WRITE_PERMISSIONS = new Set(["admin", "write"]); @@ -147,9 +136,8 @@ export function evaluateGate(input: GateInput): GateResult { return { pass: true, reason: "internal" }; } - // Only issues in THIS repository count. A cross-repository closing keyword - // (e.g. `Closes attacker/repo#1`) links an issue the contributor controls, - // so it must never satisfy the gate. + // Only issues in this repository count; a cross-repository closing keyword + // (e.g. `Closes attacker/repo#1`) links an issue the contributor controls. const repo = input.repository.toLowerCase(); const repoIssues = input.linkedIssues.filter((issue) => issue.repository.toLowerCase() === repo); @@ -234,8 +222,6 @@ export async function evaluateAllOpenPrs(io: GateIo, repository: string): Promis return entries; } -// --- GitHub I/O (only runs when executed directly) --- - interface GraphQLIssueNode { number: number; state: "OPEN" | "CLOSED"; @@ -358,16 +344,11 @@ async function fetchOpenPullRequests( } /** - * Resolve an author's effective permission on the repository. Reflects access - * granted directly or through a team/org membership, so it recognises private - * org members that `author_association` reports only as `CONTRIBUTOR`. This - * endpoint needs just `Metadata: read` (covered by the workflow's - * `contents: read`). - * - * A 404 means the author is not a collaborator at all — the common case for - * external fork contributors, who are exactly who the gate targets — so it maps - * to `undefined` (external) rather than throwing. Other failures still throw so - * a transient API error aborts the run without wrongly closing PRs. + * Resolves an author's effective permission on the repository, recognizing + * private org members that `author_association` reports only as + * `CONTRIBUTOR`. A 404 means the author isn't a collaborator at all — the + * common case for external fork contributors — so it maps to `undefined` + * rather than throwing; other failures still throw. */ export async function fetchAuthorPermission( token: string, @@ -426,9 +407,8 @@ async function runSinglePr( const authorLogin = process.env.PR_AUTHOR_LOGIN ?? ""; const isBot = (process.env.PR_AUTHOR_TYPE ?? "User") === "Bot"; - // Resolve maintainer status from the effective repository permission unless a - // cheaper signal already settles it. `author_association` only exposes public - // org membership, so a private member must be confirmed via permission. + // `author_association` only exposes public org membership, so a private + // member must be confirmed via the effective repository permission. let permission: string | undefined; let isInternal = isBot || INTERNAL_ASSOCIATIONS.has(authorAssociation); if (!isInternal) { diff --git a/.github/scripts/sync-workload-catalog.test.ts b/.github/scripts/sync-workload-catalog.test.ts index 9f4f129795..4e362c4f35 100644 --- a/.github/scripts/sync-workload-catalog.test.ts +++ b/.github/scripts/sync-workload-catalog.test.ts @@ -148,7 +148,6 @@ describe("planCatalogUpdate", () => { expect(plan.target).toBe("default"); expect(plan.source).toContain(`"postgres",\n "17.6.1.169",`); expect(plan.source).toContain(`"ghcr.io/supabase/cli/postgres:17.6.1.169@${DIGEST_A}"`); - // The additional line must survive untouched. expect(plan.source).toContain(`"15.14.1.168":`); expect(plan.source).toContain(`sha256:${"f".repeat(64)}`); }); @@ -167,7 +166,6 @@ describe("planCatalogUpdate", () => { expect(plan.target).toBe("additional"); expect(plan.source).toContain(`"15.14.1.169":`); expect(plan.source).toContain(`"ghcr.io/supabase/cli/postgres:15.14.1.169@${DIGEST_B}"`); - // The 17.x default is the regression this guards: it must not move. expect(plan.source).toContain(`"postgres",\n "17.6.1.168",`); expect(plan.source).toContain(`sha256:${"9".repeat(64)}`); }); @@ -240,7 +238,6 @@ describe("planCatalogUpdate", () => { expect(plan.kind).toBe("updated"); if (plan.kind !== "updated") return; expect(plan.previousVersion).toBe("v16.2"); - // postgres keeps both of its own pins. expect(plan.source).toContain(`"17.6.1.168",`); expect(plan.source).toContain(`"15.14.1.168":`); }); @@ -276,8 +273,7 @@ describe("against the real catalog", () => { version: "99.99.99", digest: DIGEST_A, }); - // 99.x is a line no service carries, so postgres (multi-line) skips while - // every single-line service bumps. Either way it must be recognised. + // 99.x is a line no service carries: multi-line postgres skips, single-line services bump. expect( bumped.kind === "updated" || bumped.kind === "unmodelled-release-line", `${service} was not addressable in the real catalog`, diff --git a/apps/cli-e2e/src/server/pg-mock.ts b/apps/cli-e2e/src/server/pg-mock.ts index a5a734f28a..b419eed1b1 100644 --- a/apps/cli-e2e/src/server/pg-mock.ts +++ b/apps/cli-e2e/src/server/pg-mock.ts @@ -22,12 +22,8 @@ * - Terminate ('X') → closes connection gracefully */ -// --------------------------------------------------------------------------- -// Public types -// --------------------------------------------------------------------------- - export interface PgFixture { - /** Lowercase column names matching Go Result struct field names. */ + /** Lowercase column names, matching the field names the inspect commands map query results onto. */ columns: string[]; /** * Per-column Postgres type OIDs (text format is used throughout). @@ -60,10 +56,6 @@ export interface PgMockHandle { stop(): void; } -// --------------------------------------------------------------------------- -// Wire protocol helpers -// --------------------------------------------------------------------------- - const TEXT_OID = 25; const SSL_REQUEST_CODE = 80877103; const PROTOCOL_VERSION_3 = 196608; @@ -218,10 +210,6 @@ function buildCloseComplete(): Buffer { return msg(0x33, Buffer.alloc(0)); // '3' } -// --------------------------------------------------------------------------- -// Socket state -// --------------------------------------------------------------------------- - interface SocketData { buf: Buffer; phase: "startup" | "query"; @@ -229,10 +217,6 @@ interface SocketData { paramCount: number; } -// --------------------------------------------------------------------------- -// Response builders -// --------------------------------------------------------------------------- - function buildStartupResponse(): Buffer { return Buffer.concat([ buildAuthOk(), @@ -247,12 +231,10 @@ function buildStartupResponse(): Buffer { } /** - * Connection liveness probe (`SELECT 1`). The native TS driver eagerly forces its - * lazily-connected `pg` client by running `SELECT 1` over the simple-query - * protocol (Go's pgx connects eagerly at the protocol level and issues no such - * query). A real Postgres always answers `SELECT 1` regardless of any fixtures, so - * the mock must too — otherwise the empty-state "no fixture" guard rejects the - * probe and the native command fails to connect before doing any real work. + * Connection liveness probe (`SELECT 1`), run by the driver to force its lazy + * connection. A real Postgres always answers it regardless of fixtures, so the + * mock must too, or the empty-state "no fixture" guard would reject it before + * the command does any real work. */ function buildProbeResponse(): Buffer { return Buffer.concat([ @@ -342,10 +324,6 @@ function buildExecuteDataResponse(state: PgMockState): Buffer { return buildCommandComplete("SELECT 0"); } -// --------------------------------------------------------------------------- -// Message processing -// --------------------------------------------------------------------------- - function processMessages(socket: Bun.Socket<SocketData>, getState: () => PgMockState): void { while (true) { const { buf, phase } = socket.data; @@ -447,10 +425,6 @@ function processMessages(socket: Bun.Socket<SocketData>, getState: () => PgMockS } } -// --------------------------------------------------------------------------- -// Public factory -// --------------------------------------------------------------------------- - export function startPgMock(): PgMockHandle { let state: PgMockState = { type: "empty" }; diff --git a/apps/cli-e2e/src/server/placeholder.ts b/apps/cli-e2e/src/server/placeholder.ts index 37dd711e00..62c832c569 100644 --- a/apps/cli-e2e/src/server/placeholder.ts +++ b/apps/cli-e2e/src/server/placeholder.ts @@ -29,7 +29,7 @@ const LEGACY_KEY_PREFIX_PATTERN = /"prefix": "[A-Za-z0-9]{4,10}"/g; const INTEGER_ID_PATTERN = /"id": \d{4,}/g; // A fixed valid ISO 8601 timestamp used in place of real timestamps so that -// CLI code that calls time.Parse on response fields doesn't fail. +// date-parsing code on response fields doesn't fail. const FIXED_TIMESTAMP = "2000-01-01T00:00:00Z"; /** Replace dynamic values in a string with stable, unnumbered placeholders. @@ -61,15 +61,11 @@ export function applyPlaceholders(input: string): { output: string } { return { output }; } -// The project-ref placeholder (`__PROJECT_REF__`) is 15 characters, but the -// Management API schema constrains project refs to `^[a-z]{20}$` (minLength 20). -// The Go CLI doesn't validate response bodies, so it tolerates the short -// placeholder; the TS port decodes responses against the generated schema and -// rejects it (e.g. `link` calling `getProject`). When serving a recorded -// response we therefore substitute any field whose value is *exactly* the -// placeholder back to a valid 20-char ref. Substring occurrences such as -// `db.__PROJECT_REF__.supabase.red` are left untouched so tests that assert on -// the literal placeholder host keep matching. +// `__PROJECT_REF__` is 15 characters, but the Management API schema requires +// `^[a-z]{20}$`, and response decoding rejects a value that doesn't match. When +// serving a recorded response, substitute any field whose value is *exactly* +// the placeholder back to a valid 20-char ref; leave substring occurrences such +// as `db.__PROJECT_REF__.supabase.red` untouched so placeholder-host assertions still match. const PROJECT_REF_VALUE = /"__PROJECT_REF__"/g; const PLACEHOLDER_PROJECT_REF = "abcdefghijklmnopqrst"; diff --git a/apps/cli-e2e/src/server/replay-server.ts b/apps/cli-e2e/src/server/replay-server.ts index 7b0491e2bb..23b4a869db 100644 --- a/apps/cli-e2e/src/server/replay-server.ts +++ b/apps/cli-e2e/src/server/replay-server.ts @@ -171,11 +171,9 @@ export async function startReplayServer(options: ReplayServerOptions): Promise<R throw new Error("RECORD=true requires SUPABASE_STAGING_URL to be set"); } - // In record mode, wipe both fixture stores before serving any traffic. The - // recording session will repopulate only what the running tests exercise, so - // any orphan from a prior session (e.g. a scenario whose test became test.todo, - // or a recorded key the current run doesn't touch) is dropped. Replay mode is - // unaffected. + // In record mode, wipe both fixture stores before serving any traffic, so the + // recording session repopulates only what the running tests exercise and any + // orphan from a prior session is dropped. if (isRecord) { rmSync(join(options.fixturesDir, "recorded"), { recursive: true, force: true }); rmSync(join(options.fixturesDir, "scenarios"), { recursive: true, force: true }); @@ -288,8 +286,8 @@ export async function startReplayServer(options: ReplayServerOptions): Promise<R } // Replay mode: scenario takes priority for matching requests; out-of-band - // requests (e.g., post-command telemetry calls inserted by the Go CLI after - // every --project-ref command) fall through to the per-endpoint fixture store. + // requests (e.g. post-command telemetry calls) fall through to the + // per-endpoint fixture store. if (scenario.name !== null) { const expected = scenario.queue[scenario.index]; if ( @@ -382,9 +380,8 @@ async function proxyAndRecord( dockerProxySocketPath?: string, ): Promise<Response> { const isStoragePath = pathname.startsWith("/storage/v1/"); - // Docker versioned API paths start with /v1. (decimal) to distinguish from - // management API paths which start with /v1/ (slash). /_ping is the Docker - // health-check endpoint (no version prefix). + // Docker versioned API paths start with /v1. (decimal), not /v1/ (slash, the + // management API). /_ping is the Docker health-check endpoint (no version prefix). const isDockerPath = pathname.startsWith("/v1.") || pathname === "/_ping"; const FORWARD_HEADERS = new Set(["authorization", "content-type", "accept", "user-agent"]); @@ -575,8 +572,7 @@ function recordFixture(params: { request: FixtureRequest; response: FixtureResponse; }; - // Scenario interactions use unnumbered path placeholders so that comparison - // against incoming paths (normalized the same way) is always idempotent. + // Normalized so comparison against incoming paths (normalized the same way) is idempotent. normalized.request.path = normalizeUrlPath(params.pathname); const key = fixtureKey(params.method, params.pathname); @@ -594,9 +590,7 @@ function recordFixture(params: { mkdirSync(keyDir, { recursive: true }); const nextIndex = nextFixtureIndex(keyDir); - // Cap: the matcher's `index % entries.length` wrap means more than a few - // entries adds bytes without adding coverage. Stop persisting after the cap - // is reached; the proxied response is still returned to the caller. + // Beyond the cap, the proxied response is still returned but no longer persisted. if (nextIndex <= MAX_FIXTURE_ENTRIES) { const indexStr = nextIndex === 1 ? "default" : String(nextIndex); @@ -610,7 +604,6 @@ function recordFixture(params: { ); } - // If a scenario is active, also append this interaction to interactions.json. if (params.scenario.name !== null) { params.scenario.log.push({ request: normalized.request, response: normalized.response }); writeScenarioInteractions(params.fixturesDir, params.scenario.name, params.scenario.log); @@ -630,10 +623,9 @@ interface DockerProxyResult { bodyPromise: Promise<Buffer>; } -/** Idle timeout: abort if the upstream socket goes silent for this long. The - * previous hard timeout (60s wall-clock) killed legitimate slow operations - * like first-time image pulls. An idle timeout only kills truly stuck - * connections — anything still emitting progress events stays alive. */ +/** Idle timeout: abort if the upstream socket goes silent for this long. Only + * kills truly stuck connections — a slow but still-active operation (e.g. a + * first-time image pull) stays alive as long as it keeps emitting progress. */ const DOCKER_SOCKET_IDLE_TIMEOUT_MS = 60_000; async function proxyToDockerSocket( @@ -956,8 +948,8 @@ async function handleControl(req: Request, url: URL, ctx: ControlContext): Promi } if (req.method === "DELETE") { - // In record mode, always flush interactions.json (even when empty) so that - // tests which trigger a global error before any API call still get a scenario file. + // Flush interactions.json even when empty, so a test that hits a global + // error before any API call still gets a scenario file. if (ctx.isRecord && ctx.scenario.name !== null) { writeScenarioInteractions(ctx.fixturesDir, ctx.scenario.name, ctx.scenario.log); } diff --git a/apps/cli-e2e/src/server/request-matcher.ts b/apps/cli-e2e/src/server/request-matcher.ts index f8a12595b8..b77e02e0fd 100644 --- a/apps/cli-e2e/src/server/request-matcher.ts +++ b/apps/cli-e2e/src/server/request-matcher.ts @@ -84,12 +84,11 @@ export function resetCounters(counters: SequenceCounters): void { counters.clear(); } -/** Sort array bodies canonically so body comparison is order-insensitive for - * APIs where element ordering is not semantically meaningful (e.g. bulk-create - * secrets). Go maps iterate in non-deterministic order, so proxy commands that - * build request bodies from maps produce different orderings on Linux vs macOS. - * Nested object fields are still compared exactly — only the top-level array - * order is normalized. */ +/** Sorts array bodies canonically so comparison is order-insensitive where + * element order isn't semantically meaningful (e.g. bulk-create secrets) — a + * proxied command building a request body from a Go map can produce a + * different key order per platform. Nested object fields are still compared + * exactly; only the top-level array order is normalized. */ export function sortBody(body: unknown): unknown { if (Array.isArray(body)) { return [...body].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))); diff --git a/apps/cli-e2e/src/tests/auth.e2e.test.ts b/apps/cli-e2e/src/tests/auth.e2e.test.ts index a6b0dd25b8..d2ea7ae761 100644 --- a/apps/cli-e2e/src/tests/auth.e2e.test.ts +++ b/apps/cli-e2e/src/tests/auth.e2e.test.ts @@ -1,28 +1,8 @@ import { describe, it } from "vitest"; -// TODO: Implement auth e2e tests once the TS Legacy Port exposes a way to opt out of -// keychain access (e.g. a SUPABASE_NO_KEYRING env var respected by the Go -// binary). Without it, login/logout spawn macOS Security dialogs ("A keychain -// cannot be found to store 'test'") that block the subprocess indefinitely, -// causing every test to time out. -// -// Required before implementing: -// - The Go CLI must honour SUPABASE_NO_KEYRING (or equivalent) so that -// keyring operations fall back to the filesystem inside the isolated -// SUPABASE_HOME / workspace directory instead of touching the OS keychain. -// -// Planned test coverage (see CLI-1365 for full spec): -// -// login -// - testBehaviour: saves token and prints success with --token -// - testBehaviour: saves token with --token and custom --name -// - testBehaviour: includes debug output with --debug -// - testBehaviour: exits non-zero on invalid token format -// -// logout -// - testBehaviour: exits zero after login with --yes -// - testBehaviour: exits zero gracefully when not logged in with --yes -// (double-logout pattern to clear any stale keychain state between runs) +// TODO(CLI-1365): implement auth e2e tests once the CLI can opt out of keychain +// access (e.g. SUPABASE_NO_KEYRING) — without it, login/logout spawn a macOS +// keychain dialog that blocks the subprocess indefinitely. describe("auth", () => { it.todo("login and logout tests blocked on Go CLI keyring opt-out (see file comment)"); diff --git a/apps/cli-e2e/src/tests/config-push.e2e.test.ts b/apps/cli-e2e/src/tests/config-push.e2e.test.ts index c4d7abc20c..617c6158cc 100644 --- a/apps/cli-e2e/src/tests/config-push.e2e.test.ts +++ b/apps/cli-e2e/src/tests/config-push.e2e.test.ts @@ -5,9 +5,9 @@ import { PROJECT_REF } from "./env.ts"; import { testBehaviour } from "./test-context.ts"; /** - * Write a supabase/config.toml covering every section the Go updater touches. - * For each section, it'll create a small diff to the recorded test project. - * Without any diff, no PATCH/POST requests will be sent to the management API. + * Writes a supabase/config.toml covering every section `config push` + * reconciles, each with a small diff against the recorded test project — a + * section without a diff sends no PATCH/POST to the management API. */ function writeConfigToml(dir: string): void { mkdirSync(join(dir, "supabase"), { recursive: true }); @@ -55,10 +55,7 @@ enabled = true ); } -/** - * The CLI will prompt the user y/n for each section that has a diff. - * The test process runs with stdin closed, so run the commands with the `--yes` flag. - */ +// `--yes` skips the per-section y/n prompt, since the test process runs with stdin closed. describe("config push", () => { testBehaviour("reconciles every section against the remote", async ({ run, workspace }) => { writeConfigToml(workspace.path); diff --git a/apps/cli-e2e/src/tests/config-toml.e2e.test.ts b/apps/cli-e2e/src/tests/config-toml.e2e.test.ts index 7ce1786ae2..39cb7b0208 100644 --- a/apps/cli-e2e/src/tests/config-toml.e2e.test.ts +++ b/apps/cli-e2e/src/tests/config-toml.e2e.test.ts @@ -4,17 +4,11 @@ import { describe, expect } from "vitest"; import { PROJECT_REF } from "./env.ts"; import { testBehaviour } from "./test-context.ts"; -// CLI-1489: v2.99.0 introduced a TypeScript config loader in the Bun shell -// that strictly decoded supabase/config.toml through an Effect schema. Any -// non-string field written as env(VAR) — e.g. a port — was rejected before -// env-resolution could run, crashing the CLI at boot with -// CliConfigParseError. This test runs against the compiled CLI so the -// regression cannot return. +// Regression guard (CLI-1489): a non-string field written as `env(VAR)` (e.g. +// a port) must not crash config decoding before env resolution runs. // -// A 401 is injected so the test does not need a real API fixture: pre-fix the -// TS shells crashed before any API call, post-fix they reach the (faked) API -// and get the injected error. Either way we only assert that the CLI got -// past config decode. +// A 401 is injected so the test doesn't need a real API fixture; it only +// asserts that the CLI got past config decode. function writeConfigWithEnvPorts(dir: string): void { mkdirSync(join(dir, "supabase"), { recursive: true }); diff --git a/apps/cli-e2e/src/tests/database-core.e2e.test.ts b/apps/cli-e2e/src/tests/database-core.e2e.test.ts index dd07f0b650..0adcb9b5ad 100644 --- a/apps/cli-e2e/src/tests/database-core.e2e.test.ts +++ b/apps/cli-e2e/src/tests/database-core.e2e.test.ts @@ -3,19 +3,11 @@ import { join } from "node:path"; import { describe, expect } from "vitest"; import { testBehaviour } from "./test-context.ts"; -// --------------------------------------------------------------------------- -// Workspace helpers -// --------------------------------------------------------------------------- - -/** Write .supabase/.temp/project-ref and a stub pooler-url so --linked commands - * can pass ParseDatabaseConfig without a real postgres TCP connection. - * - * The Go CLI's PersistentPreRunE calls ParseDatabaseConfig which, for --linked, - * tries a TCP probe to db.{ref}.localhost:5432. Nothing listens there in the - * test harness. By writing a pooler-url file (which GetPoolerConfig reads), the - * CLI takes the pooler path instead. Combined with SUPABASE_DB_PASSWORD (set in - * harness.ts), ParseDatabaseConfig succeeds without any network call, so the - * command reaches its RunE and makes the Management API call under test. */ +/** Writes .supabase/.temp/project-ref and a stub pooler-url so --linked + * commands resolve a connection without a live postgres TCP probe (nothing + * listens on db.{ref}.localhost:5432 in the test harness); combined with + * SUPABASE_DB_PASSWORD (set in harness.ts), the command reaches its handler + * and makes the Management API call under test. */ function linkProject(dir: string, ref: string): void { const tempDir = join(dir, "supabase", ".temp"); mkdirSync(tempDir, { recursive: true }); @@ -36,10 +28,6 @@ function seedMigration(dir: string): void { writeFileSync(join(migrationsDir, "20240101000000_e2e_test.sql"), TEST_MIGRATION_SQL); } -// --------------------------------------------------------------------------- -// db advisors -// --------------------------------------------------------------------------- - describe("db advisors", () => { describe("db advisors:security", () => { testBehaviour("returns security advisors", async ({ run, projectRef, workspace }) => { @@ -139,10 +127,6 @@ describe("db advisors", () => { }); }); -// --------------------------------------------------------------------------- -// db query -// --------------------------------------------------------------------------- - describe("db query", () => { describe("db query:linked", () => { testBehaviour( @@ -193,10 +177,6 @@ describe("db query", () => { }); }); -// --------------------------------------------------------------------------- -// db push -// --------------------------------------------------------------------------- - describe("db push", () => { describe("db push:dry-run", () => { testBehaviour( @@ -232,10 +212,6 @@ describe("db push", () => { }); }); -// --------------------------------------------------------------------------- -// db pull -// --------------------------------------------------------------------------- - describe("db pull", () => { testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { const result = await run(["db", "pull", "--local"]); @@ -244,10 +220,6 @@ describe("db pull", () => { }); }); -// --------------------------------------------------------------------------- -// db lint -// --------------------------------------------------------------------------- - describe("db lint", () => { testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { const result = await run(["db", "lint", "--local"]); @@ -256,10 +228,6 @@ describe("db lint", () => { }); }); -// --------------------------------------------------------------------------- -// db dump -// --------------------------------------------------------------------------- - describe("db dump", () => { testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { const result = await run(["db", "dump", "--local"]); @@ -274,10 +242,6 @@ describe("db dump", () => { }); }); -// --------------------------------------------------------------------------- -// db reset -// --------------------------------------------------------------------------- - describe("db reset", () => { testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { const result = await run(["db", "reset", "--local"]); @@ -286,10 +250,6 @@ describe("db reset", () => { }); }); -// --------------------------------------------------------------------------- -// test new -// --------------------------------------------------------------------------- - describe("test new", () => { testBehaviour("creates a pgTAP test file", async ({ run, workspace }) => { const result = await run(["test", "new", "my_test"]); @@ -307,10 +267,6 @@ describe("test new", () => { }); }); -// --------------------------------------------------------------------------- -// test db -// --------------------------------------------------------------------------- - describe("test db", () => { testBehaviour("exits non-zero on connection refused with --local", async ({ run }) => { const result = await run(["test", "db", "--local"]); diff --git a/apps/cli-e2e/src/tests/env.ts b/apps/cli-e2e/src/tests/env.ts index 52fc3d405f..086843f3d8 100644 --- a/apps/cli-e2e/src/tests/env.ts +++ b/apps/cli-e2e/src/tests/env.ts @@ -9,10 +9,9 @@ const MODE: CliE2eMode = export const isRecording = MODE === "record"; -// The replay server + tests/setup.ts key recording off the RECORD env var -// directly. Keep RECORD in sync with MODE in BOTH directions so an explicit -// CLI_E2E_MODE wins over a stale RECORD env — e.g. CLI_E2E_MODE=replay must NOT -// record and wipe fixtures just because RECORD=true lingers in the shell. +// The replay server + tests/setup.ts key recording off RECORD directly, so keep +// it in sync with MODE in both directions: an explicit CLI_E2E_MODE=replay must +// not record and wipe fixtures just because RECORD=true lingers in the shell. if (isRecording) { process.env["RECORD"] = "true"; } else { diff --git a/apps/cli-e2e/src/tests/go-binary-surface.e2e.test.ts b/apps/cli-e2e/src/tests/go-binary-surface.e2e.test.ts index 14eadc3330..fc0b9acfe0 100644 --- a/apps/cli-e2e/src/tests/go-binary-surface.e2e.test.ts +++ b/apps/cli-e2e/src/tests/go-binary-surface.e2e.test.ts @@ -3,22 +3,18 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; -// CLI-1970 shrank the bundled `supabase-go` binary down to exactly the -// commands the TypeScript CLI's `GoProxy` can spawn — every other Go -// command was deleted outright. Every TS integration test stubs that -// subprocess boundary, so nothing in the normal test pyramid notices when a -// still-reachable Go command gets trimmed away by mistake: that regression -// was previously caught only by a human audit. +// The bundled `supabase-go` binary retains only the commands the TypeScript +// CLI's `GoProxy` can spawn; every other Go command was deleted. TS +// integration tests stub that subprocess boundary, so nothing else notices if +// a still-reachable Go command gets trimmed away by mistake. // -// This suite is the durable guard: it enumerates every argv shape the TS -// side can hand to `GoProxy` and asserts the built `supabase-go` -// binary still resolves it. If this fails after trimming the Go binary, -// either the TS spawn surface grew (add the new command to the retained +// This suite enumerates every argv shape the TS side can hand to `GoProxy` +// and asserts the built `supabase-go` binary still resolves it. If this +// fails, either the TS spawn surface grew (add the command to the retained // set in `apps/cli-go`) or the trim cut too deep (restore the command). // // `SUPABASE_GO_BINARY` is only set to a freshly built binary in CI (see -// `.github/workflows/test.yml`); locally this whole suite no-ops so a -// developer without a Go toolchain/build isn't forced to fail it. +// `.github/workflows/test.yml`); locally this whole suite no-ops. const GO_BINARY = process.env["SUPABASE_GO_BINARY"]; describe.skipIf(GO_BINARY === undefined)("go binary spawn surface (CLI-1970)", () => { @@ -30,20 +26,17 @@ describe.skipIf(GO_BINARY === undefined)("go binary spawn surface (CLI-1970)", ( beforeAll(() => { workspaceDir = mkdtempSync(join(tmpdir(), "cli-e2e-go-binary-surface-")); - // `cmd/root.go`'s `checkUpgrade` hits the real GitHub releases API on - // every invocation that returns a nil error (e.g. every `--help` call - // below), unless a `supabase/.temp/cli-latest` cache file already exists - // relative to the spawn's cwd and is less than 10h old (`shouldFetchRelease`). - // Pre-seeding it here keeps this whole suite hermetic instead of quietly - // depending on network access. + // The upgrade check hits the real GitHub releases API on every invocation + // that returns a nil error (e.g. every `--help` call below), unless a + // `supabase/.temp/cli-latest` cache file already exists and is less than + // 10h old. Pre-seeding it here keeps this suite hermetic. mkdirSync(join(workspaceDir, "supabase", ".temp"), { recursive: true }); writeFileSync(join(workspaceDir, "supabase", ".temp", "cli-latest"), "v0.0.0"); // A bogus `--profile` for the two Management-API-gated delegates (`gen - // keys`, `functions download --legacy-bundle`). The unique profile name - // guarantees an OS-keyring credential from a real `supabase login` can - // never match it, and the unreachable api_url means even a stray token - // match still fails at connect instead of reaching a real API. + // keys`, `functions download --legacy-bundle`): the unique profile name + // can never match a real stored credential, and the unreachable api_url + // means even a stray match still fails at connect. bogusProfilePath = join(workspaceDir, "profile.yaml"); writeFileSync( bogusProfilePath, @@ -107,27 +100,19 @@ describe.skipIf(GO_BINARY === undefined)("go binary spawn surface (CLI-1970)", ( const output = stdout + stderr; expect(exitCode).toBe(0); expect(output).not.toContain("unknown command"); - // A deleted NESTED subcommand (as opposed to a deleted top-level - // one) does not produce an "unknown command" error at all: cobra's - // default Args validator only rejects unmatched positional args on - // the root command, so `db diff --help` with `diff` deleted would - // otherwise silently fall through to printing `db`'s own help and - // exit 0 — verified empirically by deleting a command and watching - // this exact assertion catch it. Asserting the full resolved - // command path appears in the printed usage closes that gap: cobra - // renders `Usage:\n supabase <path> [flags]` using each ancestor's - // Name() (the Use string's first token), so this substring is only - // present when the whole path actually resolved. + // A deleted nested subcommand doesn't error "unknown command": cobra + // only rejects unmatched args on the root command, so `db diff --help` + // with `diff` deleted would fall through to `db`'s own help (exit 0). + // Asserting the full command path appears in the usage output closes + // that gap — cobra only prints it once the whole path has resolved. expect(output).toContain(`supabase ${path.join(" ")}`); }, 5_000); } }); describe("accepts the exact argv shape the TS proxy builds", () => { - // `db diff --use-pg-schema` (diff.handler.ts's `rebuildPgSchemaDelegateArgs`). - // `db diff` always provisions a Docker shadow database first, regardless - // of the differ engine, so the bogus DOCKER_HOST is what makes this fail - // fast rather than the bogus --db-url. + // `db diff` always provisions a Docker shadow database first, so the bogus + // DOCKER_HOST is what makes this fail fast, not the bogus --db-url. test("db diff --use-pg-schema", () => { const { exitCode, stderr } = runGo([ "db", @@ -142,10 +127,8 @@ describe.skipIf(GO_BINARY === undefined)("go binary spawn surface (CLI-1970)", ( expect(stderr).not.toMatch(/unknown flag|invalid argument/i); }, 5_000); - // `db remote changes` (changes.handler.ts), with the complete global-flag - // set root.ts can prepend (globalArgs) — the only invocation in this - // suite exercising all ten at once. Also provisions a Docker shadow first - // (same as `db diff`), so the bogus DOCKER_HOST is what trips this one. + // The only invocation in this suite exercising the full global-flag set at + // once. Also provisions a Docker shadow first, so DOCKER_HOST trips it. test("db remote changes (full global flag set)", () => { const { exitCode, stderr } = runGo([ "--output", @@ -176,10 +159,8 @@ describe.skipIf(GO_BINARY === undefined)("go binary spawn surface (CLI-1970)", ( expect(stderr).not.toMatch(/unknown flag|invalid argument/i); }, 5_000); - // `gen keys` (keys.handler.ts). Gated behind Go's Management-API login - // check before any network call, so an isolated SUPABASE_HOME (no stored - // credentials) plus the bogus --profile fails fast without ever reaching - // a real API — regardless of whatever is logged into on this machine. + // Gated behind a login check before any network call, so an isolated + // SUPABASE_HOME plus the bogus --profile fails fast without reaching a real API. test("gen keys", () => { const { exitCode, stderr } = runGo([ "gen", @@ -196,8 +177,7 @@ describe.skipIf(GO_BINARY === undefined)("go binary spawn surface (CLI-1970)", ( expect(stderr).not.toMatch(/unknown flag|invalid argument/i); }, 5_000); - // `functions download --legacy-bundle` (shared/functions/download.ts's - // `makeGoProxyLegacyBundleArgs`). Same login-gate fail-fast as `gen keys`. + // Same login-gate fail-fast as `gen keys`. test("functions download --legacy-bundle", () => { const { exitCode, stderr } = runGo([ "functions", @@ -215,14 +195,10 @@ describe.skipIf(GO_BINARY === undefined)("go binary spawn surface (CLI-1970)", ( }); describe("negative control: a deleted command still reports unknown", () => { - // `["db", "start"]` deliberately is NOT used here even though it's a - // deleted command: cobra's default Args validator only rejects unmatched - // positional args on the ROOT command. A nested group like `db` silently - // falls through to printing its own help (exit 0) for an unrecognized - // child instead of erroring — verified empirically against the built - // binary. Only a deleted TOP-LEVEL command reliably reproduces the - // "unknown command" failure this guard needs to detect drift in both - // directions. + // `["db", "start"]` isn't used here even though it's a deleted command: + // like the nested-subcommand case above, `db` would fall through to its + // own help (exit 0) instead of erroring. Only a deleted top-level command + // reliably reproduces "unknown command". for (const deletedCommand of ["inspect", "start"]) { test(`supabase-go ${deletedCommand} reports unknown command`, () => { const { exitCode, stderr } = runGo([deletedCommand]); diff --git a/apps/cli-e2e/src/tests/inspect.e2e.test.ts b/apps/cli-e2e/src/tests/inspect.e2e.test.ts index 7c59917e3e..9f5014dab1 100644 --- a/apps/cli-e2e/src/tests/inspect.e2e.test.ts +++ b/apps/cli-e2e/src/tests/inspect.e2e.test.ts @@ -3,10 +3,6 @@ import { join } from "node:path"; import { describe, expect } from "vitest"; import { testBehaviour } from "./test-context.ts"; -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - function setupInspectWorkspace(dir: string, pgPort: number): void { mkdirSync(join(dir, "supabase"), { recursive: true }); writeFileSync( @@ -24,10 +20,6 @@ async function setPgFixture(apiUrl: string, key: string): Promise<void> { if (!res.ok) throw new Error(`Failed to set PG fixture "${key}": ${await res.text()}`); } -// --------------------------------------------------------------------------- -// Subcommand table -// --------------------------------------------------------------------------- - const SUBCOMMANDS = [ { name: "db-stats", fixtureKey: "db-stats", assertValue: "42 MB" }, { name: "replication-slots", fixtureKey: "replication-slots", assertValue: "test-slot" }, @@ -44,10 +36,6 @@ const SUBCOMMANDS = [ { name: "traffic-profile", fixtureKey: "traffic-profile", assertValue: "sessions" }, ] as const; -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - describe("inspect:flags", () => { testBehaviour("rejects --db-url with --local", async ({ run, workspace, pgMockPort }) => { setupInspectWorkspace(workspace.path, pgMockPort); diff --git a/apps/cli-e2e/src/tests/migrations.e2e.test.ts b/apps/cli-e2e/src/tests/migrations.e2e.test.ts index c92dbe0058..3a0c349e42 100644 --- a/apps/cli-e2e/src/tests/migrations.e2e.test.ts +++ b/apps/cli-e2e/src/tests/migrations.e2e.test.ts @@ -18,8 +18,7 @@ describe("migrations", () => { testBehaviour("exits non-zero without name argument", async ({ run }) => { const result = await run(["migration", "new"]); expect(result.exitCode).not.toBe(0); - // CLI-1901: a missing positional argument's usage block now prints to - // stderr (never stdout) instead of being duplicated across both. + // A missing positional argument's usage block prints to stderr only. expect(result.stdout).toBe(""); expect(result.stderr).toContain("migration name"); }); @@ -59,10 +58,8 @@ describe("migrations", () => { testBehaviour("exits non-zero when --status flag is missing", async ({ run }) => { const result = await run(["migration", "repair", "--local", "20230101000000"]); expect(result.exitCode).not.toBe(0); - // CLI-1901: a missing required flag now drops the vendored library's - // duplicate usage dump entirely (Go's cobra suppresses usage for this - // case too), leaving only this repo's existing Go-parity error line, - // which spells the flag name without its `--` prefix. + // A missing required flag's error prints once, without a duplicate + // usage dump, and spells the flag name without its `--` prefix. expect(result.stderr).toContain('"status" not set'); }); diff --git a/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts b/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts index ec04a5dc76..d4818f851c 100644 --- a/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts +++ b/apps/cli-e2e/src/tests/project-lifecycle.e2e.test.ts @@ -61,10 +61,10 @@ describe("init", () => { }); describe("link", () => { - // Not using testBehaviour here because the testBehaviour `run` fixture always - // injects SUPABASE_PROJECT_ID, which the new Go CLI accepts as a substitute for - // --project-ref in non-TTY mode, bypassing the required-flag check. A raw test - // lets us omit projectId so the CLI correctly requires the --project-ref flag. + // Not using testBehaviour here because its `run` fixture always injects + // SUPABASE_PROJECT_ID, which the CLI accepts as a substitute for + // --project-ref in non-TTY mode. A raw test omits it so the CLI correctly + // requires the --project-ref flag. test("exits non-zero without --project-ref in non-TTY", async () => { const serverUrl = inject("replayServerUrl") as string; const dir = makeTempDir("cli-e2e-link-no-ref-"); @@ -80,8 +80,7 @@ describe("link", () => { }); // The testBehaviour run fixture always injects SUPABASE_PROJECT_ID, which the - // new Go CLI accepts in place of --project-ref, bypassing the required-flag - // check. Link therefore proceeds to the API and succeeds. + // CLI accepts in place of --project-ref, so link proceeds to the API and succeeds. testBehaviour("links when only SUPABASE_PROJECT_ID is set in non-TTY", async ({ run }) => { const result = await run(["link"]); expect(result.exitCode).toBe(0); @@ -120,9 +119,8 @@ describe("link", () => { expect(result.stderr).toContain("Internal Server Error"); }); - // link makes concurrent Management API calls after the initial project-status and - // api-keys calls. The concurrent service calls fail silently (non-fatal). Only - // the first two sequential calls need fixture entries. + // link's concurrent service calls (after project-status and api-keys) fail + // silently, so only the first two sequential calls need fixture entries. testBehaviour.skipIf(isRecording)( "links project successfully", async ({ run, projectRef, workspace }) => { @@ -151,9 +149,7 @@ describe("unlink", () => { expect(result.stderr).toContain("supabase link"); }); - // The success path (pre-populate project-ref → unlink succeeds) is omitted: the - // unlink handler deletes the database-password keyring entry on success. On - // Linux CI (no D-Bus session bus) the keyring call returns an unhandled error - // and the command exits 1. The not-linked error path above gives meaningful - // coverage; deeper success-path behaviour is covered by unlink.integration.test.ts. + // The success path is omitted: unlink deletes a keyring entry on success, and + // Linux CI (no D-Bus session bus) makes that call exit 1. See + // unlink.integration.test.ts for success-path coverage. }); diff --git a/apps/cli-e2e/src/tests/stack.e2e.test.ts b/apps/cli-e2e/src/tests/stack.e2e.test.ts index cd99b86827..8993de60eb 100644 --- a/apps/cli-e2e/src/tests/stack.e2e.test.ts +++ b/apps/cli-e2e/src/tests/stack.e2e.test.ts @@ -40,12 +40,7 @@ const testStack = testBehaviour.extend<StackFixtures>({ }, }); -// --------------------------------------------------------------------------- -// services -// --------------------------------------------------------------------------- -// `services` prints a baked-in Go-parity service matrix, so DOCKER_HOST is not -// needed. - +// `services` prints a static service matrix, so DOCKER_HOST is not needed. describe("services", () => { testBehaviour("lists known service images", async ({ run }) => { const result = await run(["services"]); @@ -57,14 +52,8 @@ describe("services", () => { }); }); -// --------------------------------------------------------------------------- -// status -// --------------------------------------------------------------------------- - -// CLI-2167: `status` resolves and prints the current linked -// project/branch on stdout, before any Docker/daemon work runs, in every -// output mode — an adjudicated, deliberate TS-only extension with no Go -// counterpart (Go's `status` never had a link-state concept). Go's stdout for +// `status` resolves and prints the current linked project/branch on stdout +// before any Docker/daemon work runs, in every output mode. describe("status", () => { testStack("exits 1 when stack is not running", async ({ workspace, stackRun }) => { setupStackWorkspace(workspace.path); @@ -74,10 +63,6 @@ describe("status", () => { }); }); -// --------------------------------------------------------------------------- -// stop -// --------------------------------------------------------------------------- - describe("stop", () => { testStack("succeeds when stack is not running", async ({ workspace, stackRun }) => { setupStackWorkspace(workspace.path); @@ -85,8 +70,7 @@ describe("stop", () => { expect(result.exitCode).toBe(0); }); - // cobra's MarkFlagsMutuallyExclusive validates this before the command runs — - // no Docker or API calls are made. + // Flag validation happens before the command runs, so no Docker or API calls are made. testStack( "exits 1 with mutual-exclusion error for --project-id and --all", async ({ workspace, stackRun }) => { @@ -98,10 +82,6 @@ describe("stop", () => { ); }); -// --------------------------------------------------------------------------- -// start -// --------------------------------------------------------------------------- - describe("start", () => { testStack( "exits 1 with Docker error when Docker is unavailable", @@ -115,21 +95,14 @@ describe("start", () => { }, ); - // start → status → status --override-name → stop lifecycle test. - // These must run in sequence in a single shared workspace so that status - // and stop see the stack that start brought up. - // TODO: record these in an environment where the full Supabase Docker stack starts - // cleanly through the TCP relay proxy (vector health check fails on this machine). + // Must run in sequence in a shared workspace so status/stop see the stack start brought up. + // TODO: record once the full stack starts cleanly through the relay proxy (vector health check fails here). test.todo("start → status → stop lifecycle"); test.todo("starts with --exclude studio and stops cleanly"); }); -// --------------------------------------------------------------------------- -// seed buckets -// --------------------------------------------------------------------------- // `seed buckets` makes storage HTTP calls (not Docker), so plain testBehaviour // with `run` is correct. - describe("seed buckets", () => { testBehaviour("creates buckets defined in config", async ({ workspace, run, apiUrl }) => { mkdirSync(join(workspace.path, "supabase"), { recursive: true }); diff --git a/apps/cli-e2e/src/tests/storage.e2e.test.ts b/apps/cli-e2e/src/tests/storage.e2e.test.ts index 257009d57b..b0975ba22d 100644 --- a/apps/cli-e2e/src/tests/storage.e2e.test.ts +++ b/apps/cli-e2e/src/tests/storage.e2e.test.ts @@ -39,10 +39,6 @@ async function clearOverrides(apiUrl: string): Promise<void> { await fetch(`${apiUrl}/_ctrl/overrides`, { method: "DELETE" }); } -// --------------------------------------------------------------------------- -// storage ls -// --------------------------------------------------------------------------- - describe("storage ls", () => { testBehaviour("lists objects in bucket", async ({ workspace, run, apiUrl }) => { setupStorageWorkspace(workspace.path, apiUrl); @@ -106,10 +102,6 @@ describe("storage ls", () => { }); }); -// --------------------------------------------------------------------------- -// storage cp -// --------------------------------------------------------------------------- - describe("storage cp", () => { testBehaviour("uploads local file to storage", async ({ workspace, run, apiUrl }) => { setupStorageWorkspace(workspace.path, apiUrl); @@ -254,10 +246,6 @@ describe("storage cp", () => { }); }); -// --------------------------------------------------------------------------- -// storage mv -// --------------------------------------------------------------------------- - describe("storage mv", () => { testBehaviour("moves file within bucket", async ({ workspace, run, apiUrl }) => { setupStorageWorkspace(workspace.path, apiUrl); @@ -344,10 +332,6 @@ describe("storage mv", () => { }); }); -// --------------------------------------------------------------------------- -// storage rm -// --------------------------------------------------------------------------- - describe("storage rm", () => { testBehaviour("removes a file from storage", async ({ workspace, run, apiUrl }) => { setupStorageWorkspace(workspace.path, apiUrl); diff --git a/apps/cli/scripts/backfill-release-notes.ts b/apps/cli/scripts/backfill-release-notes.ts index eff5b98139..eaa326a975 100644 --- a/apps/cli/scripts/backfill-release-notes.ts +++ b/apps/cli/scripts/backfill-release-notes.ts @@ -1,17 +1,15 @@ #!/usr/bin/env bun -// Re-derive a GitHub Release's changelog from its tag's commit using the -// *current* semantic-release config, regardless of what apps/cli/package.json +// Re-derives a GitHub Release's changelog from its tag's commit using the +// current semantic-release config, regardless of what apps/cli/package.json // looked like when the tag was cut. Used both as a local debugging tool and // as the engine behind .github/workflows/backfill-release-notes.yml. // -// Why every step matters: when backfilling an old tag, semantic-release -// trips on several things at once - it picks the wrong branch from CI env -// vars, can't read channel notes for historical tags, refuses to proceed if -// the local branch is "behind" the real remote, and uses whatever -// release.branches/plugins config existed at the tag's commit (which on -// this repo pre-dates the `channel: "beta"` fix from commit 2515885 and -// the release-notes-generator plugin from #5316). The script works around -// each of those in a temp clone so the original workspace stays clean. +// Backfilling an old tag trips semantic-release on several fronts at once: it +// picks the wrong branch from CI env vars, can't read channel notes for +// historical tags, refuses to proceed when the local branch is "behind" the +// real remote, and uses whatever release config existed at the tag's commit. +// The script works around each of those in a temp clone so the original +// workspace stays clean. // // Usage: // bun apps/cli/scripts/backfill-release-notes.ts --tag v2.99.0-beta.1 @@ -107,14 +105,11 @@ try { await $`git clone --quiet --no-local ${repoRoot} ${clone}`; // `git notes add` (used below to seed channel notes) requires a committer - // identity. CI runners don't ship one in ~/.gitconfig, so without this the - // seeding loop silently fails - semantic-release then can't see prior beta - // tags on the beta channel and computes the wrong next version. + // identity, which CI runners don't ship in ~/.gitconfig. await $`git -C ${clone} config --local user.email backfill-release-notes@supabase.local`; await $`git -C ${clone} config --local user.name backfill-release-notes`; - // Same reason - and `commit.gpgsign`/`tag.gpgsign` inherited from a user's - // global config would make `git notes add` fail in environments without a - // signing key. The temp clone never publishes anything, so disable signing. + // A signing key inherited from a user's global config would also make + // `git notes add` fail; the temp clone never publishes anything, so disable it. await $`git -C ${clone} config --local commit.gpgsign false`; await $`git -C ${clone} config --local tag.gpgsign false`; @@ -134,12 +129,10 @@ try { .quiet(); const sha = (await $`git -C ${clone} rev-list -n 1 ${tag}`.text()).trim(); - // Delete the target tag *and* any other local tags pointing at the same - // commit. When a stable and a beta share a commit (e.g. v2.100.0 and - // v2.100.0-beta.2 both at 9a22aff6), semantic-release picks the higher- - // semver one as lastRelease - which becomes HEAD itself, leaving 0 - // commits and "no release". Dropping the co-incident tags lets it fall - // back to the genuine prior release on the channel. + // Delete the target tag and any other local tag pointing at the same commit: + // when a stable and a beta share a commit, semantic-release picks the + // higher-semver one as lastRelease (which becomes HEAD itself, leaving "no + // release"). Dropping the co-incident tags falls back to the genuine prior release. const coincidentTagsOut = await $`git -C ${clone} tag --points-at ${sha}`.text(); const coincidentTags = coincidentTagsOut .split("\n") @@ -163,12 +156,11 @@ try { } } - // semantic-release's `git log --notes=refs/notes/semantic-release*` reader - // returns channels=[null] for any tag missing an annotation. With the - // current prerelease filter that drops the tag entirely, so the lastRelease - // walks past unannotated tags and ends up far enough back to drag - // unrelated commits into the changelog. Seed a channel note for every - // reachable tag that lacks one; convention is taken from the tag name. + // semantic-release's notes reader returns channels=[null] for any tag + // missing an annotation, and the prerelease filter drops those tags + // entirely — walking lastRelease back far enough to drag unrelated commits + // into the changelog. Seed a channel note for every reachable tag that + // lacks one, inferring the channel from the tag name. const mergedTagsOut = await $`git -C ${clone} tag --merged HEAD --sort=v:refname`.text(); const mergedTags = mergedTagsOut.split("\n").filter((t) => t && t !== tag); for (const prevTag of mergedTags) { @@ -185,36 +177,27 @@ try { await $`git -C ${clone} notes --ref semantic-release add -f -m ${payload} ${prevTag}^{commit}`.quiet(); } - // Apply the *current* release config to the historical checkout. Before - // commit 2515885 (May 11) the develop branch had no explicit `channel`, - // which silently broke prerelease tag matching; before #5316 the plugin - // chain didn't include release-notes-generator. Using the current config - // gives the right notes shape regardless of what shipped at the tag. + // Apply the current release config to the historical checkout, so the notes + // come out in the right shape regardless of what release config shipped at the tag. const clonePkgPath = path.join(clone, "apps/cli/package.json"); const clonePkg = JSON.parse(await readFile(clonePkgPath, "utf8")); clonePkg.release = rootPkg.release; await writeFile(clonePkgPath, `${JSON.stringify(clonePkg, null, 2)}\n`); await copyLocalReleasePlugins(clone); - // semantic-release runs `git ls-remote <repositoryUrl> <branch>` and - // silently exits with "behind remote" when the remote tip differs from - // HEAD - which it always does when backfilling an old tag. Use git's - // insteadOf to redirect the real GitHub URL to the local clone for the - // duration of this run; semantic-release still treats repositoryUrl as - // the GitHub URL so commit/PR links in the rendered notes are correct. + // semantic-release exits with "behind remote" whenever the remote tip + // differs from HEAD, which it always does when backfilling an old tag. Use + // git's insteadOf to redirect the GitHub URL to the local clone for this + // run, while semantic-release still renders commit/PR links against the real URL. await $`git -C ${clone} config --local url.file://${clone}.insteadOf ${repoUrl}`; console.error(`==> Re-staged on ${branch} @ ${sha} (without tag ${tag})`); console.error(`==> Running semantic-release --dry-run`); - // semantic-release uses env-ci to detect the current branch, which reads - // GITHUB_REF (and friends) from the GitHub Actions environment. `noCi: true` - // only bypasses the "not in CI" guard - it does not stop env-ci from - // resolving the branch from CI vars. When backfilling v2.100.1 from a - // workflow that ran on develop, env-ci returns "develop" even though the - // clone's HEAD points at main, and semantic-release then complains that - // local develop is behind remote. Strip the GitHub Actions detection vars - // so env-ci falls back to reading the branch from git HEAD in the clone. + // env-ci detects the branch from GITHUB_REF (and friends) even with `noCi: + // true`, which only bypasses the "not in CI" guard. That can name a branch + // other than the one the clone's HEAD actually points at, so strip the + // GitHub Actions detection vars and let env-ci read HEAD directly. const childEnv = { ...process.env }; for (const key of [ "GITHUB_ACTIONS", From 65857462f89fd67f909d05007fec477ca8d4b2d9 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:21:06 +0100 Subject: [PATCH 53/56] chore(repo): trim comments, shard 35 (1611 -> 843 comment lines) --- .../commands/start/start.integration.test.ts | 560 ++++++------------ .../start/start.lifecycle.e2e.test.ts | 54 +- apps/cli/src/commands/start/start.services.ts | 31 +- .../start/start.services.unit.test.ts | 19 +- ...art.string-slice-flags.integration.test.ts | 27 +- .../start/templates/custom_nginx.template.ts | 13 +- .../start/templates/kong-local-tls.ts | 14 +- .../src/commands/start/templates/kong.yml.ts | 9 +- .../commands/start/templates/pooler.exs.ts | 8 +- .../commands/start/templates/vector.yaml.ts | 8 +- .../cli/src/commands/status/status.command.ts | 29 +- .../status/status.command.unit.test.ts | 7 - .../cli/src/commands/status/status.handler.ts | 149 ++--- .../status/status.integration.test.ts | 240 ++------ ...tus.string-slice-flags.integration.test.ts | 22 +- apps/cli/src/commands/stop/stop.command.ts | 20 +- apps/cli/src/commands/stop/stop.e2e.test.ts | 43 +- apps/cli/src/commands/stop/stop.errors.ts | 12 +- apps/cli/src/commands/stop/stop.handler.ts | 138 ++--- .../commands/stop/stop.integration.test.ts | 172 ++---- .../storage/cp/cp.command.integration.test.ts | 54 +- .../cli/src/commands/storage/cp/cp.command.ts | 54 +- .../cli/src/commands/storage/cp/cp.handler.ts | 43 +- .../storage/cp/cp.integration.test.ts | 6 +- apps/cli/src/commands/storage/cp/cp.upload.ts | 29 +- .../cli/src/commands/storage/ls/ls.command.ts | 7 +- .../cli/src/commands/storage/ls/ls.handler.ts | 16 +- .../storage/ls/ls.integration.test.ts | 35 +- .../cli/src/commands/storage/mv/mv.command.ts | 7 +- .../cli/src/commands/storage/mv/mv.handler.ts | 14 +- 30 files changed, 536 insertions(+), 1304 deletions(-) diff --git a/apps/cli/src/commands/start/start.integration.test.ts b/apps/cli/src/commands/start/start.integration.test.ts index 68e2d60e6b..a1897cc1fd 100644 --- a/apps/cli/src/commands/start/start.integration.test.ts +++ b/apps/cli/src/commands/start/start.integration.test.ts @@ -42,17 +42,11 @@ import { start } from "./start.handler.ts"; import { KONG_LOCAL_TLS_CERT, KONG_LOCAL_TLS_KEY } from "./templates/kong-local-tls.ts"; /** - * Counts real invocations of `resolveLocalConfigValues` across this - * whole file — every test transparently delegates to the real - * implementation, so this is purely an observation point. It exists for the - * "resolved once, reused everywhere" regression test below (CLI-1323's - * status-print/bring-up JWT divergence): `start`'s success-path status print - * must reuse the SAME resolved `values` bring-up already used to build every - * container spec, not call this a second time — a second call re-signs - * `auth.signing_keys_path` JWTs with a different `exp` claim - * ({@link generateAsymmetricGoJwt}), producing a byte-different - * anon/service-role key than the one already baked into the running - * containers. + * Counts real invocations of `resolveLocalConfigValues` across this file (every test delegates + * to the real implementation). `start`'s success-path status print must reuse the same resolved + * `values` bring-up already used to build every container spec — calling it again re-signs + * `auth.signing_keys_path` JWTs with a different `exp`, producing a byte-different key than the + * one already baked into the running containers. */ const resolveLocalConfigValuesCalls = vi.hoisted(() => ({ count: 0 })); @@ -290,10 +284,7 @@ function freshVolumeRoute( base: (args: ReadonlyArray<string>) => RouteResult, ): (args: ReadonlyArray<string>) => RouteResult { return (args) => { - // `volumeExists` distinguishes a confirmed "not found" from any - // other inspect error — the stderr text is what makes this simulate a - // genuinely fresh/non-existent volume rather than an ambiguous inspect - // failure. + // This stderr text is what `volumeExists` treats as a confirmed missing volume. if (args[0] === "volume" && args[1] === "inspect") { return { exitCode: 1, stderr: [`Error: No such volume: ${args[2] ?? ""}`] }; } @@ -352,13 +343,10 @@ function mockStorageBucketHttpClient() { } /** - * A fake `DbSession` recording every `exec`/`query` call — the fresh-volume - * `SetupLocalDatabase`-equivalent path (`startSetupLocalDatabase`) needs an - * open session for PG<=14's schema SQL / `ApplyApiPrivileges`; PG15+ (this suite's - * default) never calls `exec`/`query` at all (its schema init is three one-shot - * `DockerRun` jobs instead — see `db-setup.ts`'s header), so this mostly just - * needs to exist and satisfy the type. Mirrors `db-setup.unit.test.ts`'s own - * `fakeSession()`. + * A fake `DbSession` recording every `exec`/`query` call — needed for PG<=14's schema SQL / + * `ApplyApiPrivileges` path; PG15+ (this suite's default) never calls `exec`/`query` at all (its + * schema init is three one-shot `DockerRun` jobs instead), so this mostly just needs to exist + * and satisfy the type. */ function fakeDbSession() { const calls: Array<{ kind: "exec" | "query"; sql: string }> = []; @@ -391,7 +379,7 @@ interface SetupOpts { readonly configContents?: string; /** Skip writing `config.toml` entirely — the test writes its own (e.g. a malformed file). */ readonly skipConfig?: boolean; - /** Every spawn attempt (docker AND its podman fallback) fails outright — neither runtime found. */ + /** Every spawn attempt (docker and its podman fallback) fails outright — neither runtime found. */ readonly failSpawn?: boolean; /** Defaults to `tempRoot.current` — override for `--workdir`-resolution failure tests. */ readonly workdir?: string; @@ -427,17 +415,13 @@ function setup(opts: SetupOpts = {}) { analytics.layer, child.layer, opts.httpClientLayer ?? alwaysReadyHttpClientLayer, - // Only ever exercised by a fresh-volume scenario (`volume inspect` exiting - // non-zero) — every other scenario's default "volume already exists" route - // never reaches `startSetupLocalDatabase`/`seedBucketsRun`, but - // both are still part of `start`'s aggregate Effect type, so every - // scenario needs these satisfied regardless of whether it exercises them. + // Only exercised by a fresh-volume scenario; every other scenario's default route never + // reaches `startSetupLocalDatabase`/`seedBucketsRun`, but both are part of `start`'s + // aggregate Effect type, so every scenario still needs this satisfied. Layer.succeed(DbConnection, { connect: () => Effect.succeed(dbSession.session) }), - // `Layer.mergeAll` never cross-wires sibling requirements (see - // `apps/cli/CLAUDE.md`'s "Layer.provide does not share to siblings" - // note) — `dockerRunLayer` needs `ChildProcessSpawner`/ - // `ProcessControl` provided to IT explicitly, not just present elsewhere - // in this same merge. + // `Layer.mergeAll` never cross-wires sibling requirements, so `dockerRunLayer` needs + // `ChildProcessSpawner`/`ProcessControl` provided to it explicitly, not just present + // elsewhere in this merge. dockerRunLayer.pipe(Layer.provide(child.layer), Layer.provide(mockProcessControl().layer)), mockProcessControl().layer, mockRuntimeInfo({ platform: "linux" }), @@ -457,16 +441,11 @@ function setup(opts: SetupOpts = {}) { } /** - * Maps each of the 13 valid `--exclude` keys (`start.exclude.ts`) to the - * container-name suffix(es) that key skips, for the parameterized exclusion - * matrix test below. `storage-api` is compound: excluding it also disables - * ImgProxy (`start.gates.ts`'s `imgproxy: storage && ...` dependency). - * `edge-runtime` maps to no suffix at all here — it DOES really start now - * (`startStackEdgeRuntimeContainer`, its own create/cp/start bring-up outside - * `createContainer`, which `createdContainerNames` excludes), so - * `--exclude edge-runtime` is exercised by its own - * dedicated scenarios below rather than through this `docker create`-based - * matrix. + * Maps each of the 13 valid `--exclude` keys to the container-name suffix(es) that key skips, + * for the parameterized exclusion matrix test below. `storage-api` is compound: excluding it + * also disables ImgProxy. `edge-runtime` maps to no suffix here — it still starts via its own + * create/cp/start bring-up outside `createContainer` (which `createdContainerNames` excludes), + * so it's covered by its own dedicated scenarios below instead of this `docker create`-based matrix. */ const CONTAINER_SUFFIX_BY_EXCLUDE_KEY: Readonly<Record<string, string>> = { gotrue: "auth", @@ -511,12 +490,8 @@ function missingSuffixesForExcludeKey(excludeKey: string): ReadonlyArray<string> return [CONTAINER_SUFFIX_BY_EXCLUDE_KEY[excludeKey]!]; } -// A known test vector: this ciphertext decrypts to "value" under the keypair -// below — same fixture used by `local-config-values.unit.test.ts`'s -// "encrypted auth secrets" suite. Hoisted to file scope so both the GoTrue-secret suite and the -// Edge-Runtime-secret suite below reuse the exact same known-good dotenvx ciphertext instead of -// each needing to produce their own (that requires the real ECIES encryption this fixture already -// captures). +// This ciphertext decrypts to "value" under the keypair below (same fixture as +// `local-config-values.unit.test.ts`'s "encrypted auth secrets" suite). const VAULT_PRIVATE_KEY = "7fd7210cef8f331ee8c55897996aaaafd853a2b20a4dc73d6d75759f65d2a7eb"; const VAULT_ENCRYPTED = "encrypted:BKiXH15AyRzeohGyUrmB6cGjSklCrrBjdesQlX1VcXo/Xp20Bi2gGZ3AlIqxPQDmjVAALnhZamKnuY73l8Dz1P+BYiZUgxTSLzdCvdYUyVbNekj2UudbdUizBViERtZkuQwZHIv/"; @@ -557,7 +532,6 @@ describe("start integration", () => { expect(out.stderrText).toContain("WARNING:"); expect(out.stderrText).toContain("db, postgres"); expect(out.stderrText).toContain("not valid to exclude"); - // An invalid --exclude value must never silently skip Postgres itself. expect(createdContainerNames(child.spawned).some((name) => name.includes("_db_"))).toBe( true, ); @@ -729,8 +703,6 @@ describe("start integration", () => { route: (args) => { if (args[0] === "container" && args[1] === "inspect") { inspectCalls += 1; - // Only the FIRST inspect (`AssertSupabaseDbIsRunning`) should ever fire — a second - // call here would mean the health re-check ran despite the flag. if (inspectCalls === 1) return { stdout: [HEALTHY_STATE] }; return { exitCode: 1, stderr: ["should not be called"] }; } @@ -764,13 +736,8 @@ describe("start integration", () => { it.live( "fails on a bucket's invalid file_size_limit even when already running, matching Go's Config.Load", () => { - // `checkDbToml` runs unconditionally at the very top of this handler, before - // `dbContainerId`/the already-running short-circuit are even computed — unlike the later - // wrapConfigOverride checks (e.g. storage.analytics.enabled), which sit AFTER the - // already-running early return and therefore never run in this branch. A malformed - // per-bucket file_size_limit must still fail here, before the already-running banner ever - // prints — config decrypting/decoding happens unconditionally regardless of - // whether the stack is already up. + // `checkDbToml` runs unconditionally before the already-running short-circuit; later + // checks (e.g. storage.analytics.enabled) sit after that early return and never run here. const { layer, child } = setup({ configContents: 'project_id = "demo"\n[storage.buckets.avatars]\nfile_size_limit = "bogus"\n', @@ -1234,8 +1201,6 @@ describe("start integration", () => { describe("config load / validation failures", () => { it.live("fails when --workdir/SUPABASE_WORKDIR points at a missing path", () => { - // The explicit workdir is `chdir`'d into before config load or any Docker call — a - // missing path must fail immediately, matching `status`/`stop`'s own equivalent test. const missingWorkdir = join(tempRoot.current, "does-not-exist"); const { layer, child } = setup({ workdir: missingWorkdir, skipConfig: true }); return Effect.gen(function* () { @@ -1330,12 +1295,9 @@ describe("start integration", () => { it.live( "rejects an out-of-root auth.email.template content_path before any Docker work, even with auth disabled", () => { - // `auth.enabled = false` skips `resolveLocalConfigValues`'s own - // `readAuthEmailTemplateContent` gate, but Kong mounts every configured template - // unconditionally (`buildKongEmailTemplateMounts`, regardless of `auth.enabled`) — the - // eager pre-Docker containment pass added to `start.handler.ts` (CLI-2339) is what closes - // that gap, resolving+checking every template/enabled-notification `content_path` before - // `create` is ever spawned. + // `auth.enabled = false` skips `readAuthEmailTemplateContent`'s own gate, but Kong + // mounts every configured template unconditionally — an eager pre-Docker containment + // pass in `start.handler.ts` checks every content_path before `create` is ever spawned. const { layer, child } = setup({ configContents: 'project_id = "demo"\n[auth]\nenabled = false\n[auth.email.template.invite]\ncontent_path = "/etc/hosts"\n', @@ -1346,9 +1308,8 @@ describe("start integration", () => { if (Exit.isFailure(exit)) { const serialized = JSON.stringify(exit.cause); expect(serialized).toContain("StartInvalidConfigError"); - // The thrown message echoes the DECLARED content_path value (quoted), not the - // fully-canonicalized target — see `resolveEmailTemplateContentPath`'s own doc - // comment for why (a deliberate recon-leak mitigation). + // The message echoes the declared content_path (quoted), not the canonicalized + // target — a recon-leak mitigation; see `resolveEmailTemplateContentPath`'s doc comment. expect(serialized).toContain( 'Invalid config for auth.email.template.invite.content_path: \\"/etc/hosts\\" resolves outside the project root', ); @@ -1361,12 +1322,10 @@ describe("start integration", () => { it.live( "fails on a missing (but in-root) auth.email.template content_path before any Docker work, even with auth disabled", () => { - // `auth.enabled = false` skips `local-config-values.ts`'s own gated - // `readAuthEmailTemplateContent` read entirely — this content_path resolves IN-ROOT - // (passes containment cleanly), so the only thing that can still catch a missing file - // here is the read-verification `resolveKongEmailTemplateMounts` added in `start. - // handler.ts` (CLI-2339's Kong-mount hardening pass). Without it, this would have - // reached `docker create` with a bind-mount source that doesn't exist on disk. + // `auth.enabled = false` skips the gated read entirely, and this content_path resolves + // in-root (passes containment), so only the read-verification Kong-mount check can still + // catch a missing file here — otherwise this would reach `docker create` with a + // bind-mount source that doesn't exist on disk. const { layer, workdir, child } = setup({ configContents: 'project_id = "demo"\n[auth]\nenabled = false\n[auth.email.template.invite]\ncontent_path = "./templates/missing.html"\n', @@ -1380,9 +1339,8 @@ describe("start integration", () => { expect(serialized).toContain( "Invalid config for auth.email.template.invite.content_path:", ); - // Distinguishes this from the containment-rejection test above: this content_path - // never escapes the project root at all, so a regression back to "no read- - // verification" would have this test's exit succeed instead of fail. + // This content_path never escapes the project root, so a regression back to "no + // read-verification" would make this succeed instead of fail. expect(serialized).not.toContain("resolves outside the project root"); } expect(existsSync(join(workdir, "templates", "missing.html"))).toBe(false); @@ -1486,19 +1444,11 @@ describe("start integration", () => { it.live( "reuses the bring-up-resolved local config values for the final status print instead of re-deriving them", () => { - // Regression test for the CLI-1323 status-print/bring-up divergence: - // `resolveLocalConfigValues` embeds a fresh `exp` claim - // (`Date.now()`-derived) into the anon/service-role key every time it - // asymmetrically signs them via `auth.signing_keys_path` - // (`generateAsymmetricGoJwt`, `go-jwt.ts:212`). A second, - // independent resolution for the final status print — rather than - // reusing the SAME `values` already used to build every container - // spec — would mint a byte-different key than the one baked into the - // already-running containers. Asserting a single resolution call is - // both the most direct way to pin this invariant and immune to the - // test itself racing real wall-clock time (unlike comparing two - // independently-generated JWTs, which could coincidentally still - // match if both calls land within the same clock second). + // `resolveLocalConfigValues` embeds a fresh `exp` claim into the anon/service-role key + // each time it signs them, so a second independent resolution would mint a + // byte-different key than the one baked into the running containers. Asserting a single + // resolution call pins this without racing wall-clock time, unlike comparing two + // independently-generated JWTs that could coincidentally match within the same second. const { layer, workdir } = setup({ format: "json", configContents: 'project_id = "demo"\n[auth]\nsigning_keys_path = "signing_keys.json"\n', @@ -1520,11 +1470,9 @@ describe("start integration", () => { it.live( "fails when a configured third-party auth issuer's JWKS endpoint is unreachable", () => { - // `resolveLocalJwks` (step 6, unconditional) fetches the third-party issuer's own - // JWKS document via a raw `fetch` — a real network boundary, not the `HttpClient` service - // this suite otherwise mocks, so it needs its own `globalThis.fetch` stub. A valid - // firebase config passes config load/validation cleanly (step 2 never performs this - // fetch), so this is the only way this specific failure surfaces. + // `resolveLocalJwks` fetches the JWKS document via a raw `fetch`, not the `HttpClient` + // service this suite otherwise mocks, so it needs its own `globalThis.fetch` stub — the + // only way this failure surfaces. const previousFetch = globalThis.fetch; globalThis.fetch = Object.assign(() => Promise.reject(new Error("ECONNREFUSED")), { preconnect: previousFetch.preconnect, @@ -1620,12 +1568,10 @@ describe("start integration", () => { it.live( "brings up the stack with every optional config.toml section populated (bigquery analytics, session pool mode, passkey/webauthn, external provider, SMTP, email templates)", () => { - // Exercises the config-document-shape branches `start.handler.ts` itself owns + // Exercises the config-document-shape branches `start.handler.ts` owns // (`resolveGotruePasskeyWebauthn`, `resolveGotrueExternalProviders`, - // `buildKongEmailTemplateMounts`, `values.analyticsBackend`) in one pass, none of - // which interact with each other. A malformed `db.health_timeout` is exercised - // separately below (it now hard-fails the whole command, so it can't - // be bundled into this "successful bring-up" scenario anymore). + // `buildKongEmailTemplateMounts`, `values.analyticsBackend`) in one pass; a malformed + // `db.health_timeout` hard-fails the whole command, so it's exercised separately below. const { layer, workdir, child } = setup({ configContents: `project_id = "demo" @@ -1698,10 +1644,9 @@ content_path = "./supabase/templates/custom_notice.html" ); it.live( - // The backoff policy for "0s" performs exactly one immediate health - // probe with no retries — this is NOT a 30s fallback. The mock's default route - // heals on the very first check either way, so this only proves "0s" is - // accepted and doesn't hang/fail, not the exact retry count. + // "0s" performs exactly one immediate health probe with no retries, not a 30s fallback. + // The mock heals on the first check either way, so this only proves "0s" doesn't hang, + // not the retry count. "accepts a zero db.health_timeout without hanging, and blanks webauthn fields on an empty [auth.webauthn] section", () => { const { layer } = setup({ @@ -1716,11 +1661,8 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails config loading on an unparseable db.health_timeout before any Docker work, matching Go's Config.Load", () => { - // db.health_timeout decodes in the same unconditional pass as every other - // duration field, before any Docker work — a malformed value fails before - // network/image/Postgres work, so rollback (only reached on a genuine run - // failure) never even runs. Resolved eagerly here, alongside the other - // config-override fields, for the same reason — no containers should ever be created. + // Decodes in the same unconditional pass as every other duration field, before any + // Docker work, so rollback never even runs. const { layer, child } = setup({ configContents: 'project_id = "demo"\n[db]\nhealth_timeout = "not-a-duration"\n', }); @@ -1740,10 +1682,6 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails on an invalid storage.file_size_limit even when storage is excluded, matching Go's Config.Load", () => { - // The size decoder rejects a malformed file_size_limit unconditionally at - // config load, regardless of --exclude — this proves the eager validation in - // start.handler.ts really is unconditional, not merely earlier-but-still-gated on - // Storage actually running. const { layer, child } = setup({ configContents: 'project_id = "demo"\n[storage]\nfile_size_limit = "not-a-size"\n', }); @@ -1763,9 +1701,6 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails on an invalid SUPABASE_STORAGE_S3_PROTOCOL_ENABLED even when storage is excluded, matching Go's Config.Load", () => { - // `storage.s3_protocol.enabled` is a plain bool decoded unconditionally at - // config load — same class of gap as storage.file_size_limit above, - // now fixed the same way (hoisted eager wrapConfigOverride in start.handler.ts). const previous = process.env["SUPABASE_STORAGE_S3_PROTOCOL_ENABLED"]; process.env["SUPABASE_STORAGE_S3_PROTOCOL_ENABLED"] = "not-a-bool"; const { layer, child } = setup(); @@ -1794,11 +1729,6 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails on an invalid SUPABASE_STORAGE_ANALYTICS_ENABLED even when storage is excluded, matching Go's Config.Load", () => { - // `storage.analytics.enabled` is the bool sibling of the - // max_namespaces/max_tables/max_catalogs uint fields below, decoded - // unconditionally at config load — same class of gap as - // storage.s3_protocol.enabled above, now fixed the same way (hoisted eager - // wrapConfigOverride in start.handler.ts). const previous = process.env["SUPABASE_STORAGE_ANALYTICS_ENABLED"]; process.env["SUPABASE_STORAGE_ANALYTICS_ENABLED"] = "not-a-bool"; const { layer, child } = setup(); @@ -1826,10 +1756,6 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails on an invalid SUPABASE_STORAGE_ANALYTICS_MAX_NAMESPACES even when storage is excluded, matching Go's Config.Load", () => { - // `storage.analytics.max_namespaces` is a plain uint decoded - // unconditionally at config load — same class of gap as - // storage.s3_protocol.enabled above, now fixed the same way - // (hoisted eager wrapConfigOverride in start.handler.ts). const previous = process.env["SUPABASE_STORAGE_ANALYTICS_MAX_NAMESPACES"]; process.env["SUPABASE_STORAGE_ANALYTICS_MAX_NAMESPACES"] = "not-a-uint"; const { layer, child } = setup(); @@ -1858,8 +1784,6 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails on an invalid SUPABASE_STORAGE_ANALYTICS_MAX_TABLES even when storage is excluded, matching Go's Config.Load", () => { - // Same gap as storage.analytics.max_namespaces above — `storage.analytics.max_tables` - // decodes in the same config-load pass. const previous = process.env["SUPABASE_STORAGE_ANALYTICS_MAX_TABLES"]; process.env["SUPABASE_STORAGE_ANALYTICS_MAX_TABLES"] = "not-a-uint"; const { layer, child } = setup(); @@ -1888,8 +1812,6 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails on an invalid SUPABASE_STORAGE_ANALYTICS_MAX_CATALOGS even when storage is excluded, matching Go's Config.Load", () => { - // Same gap as storage.analytics.max_namespaces above — `storage.analytics.max_catalogs` - // decodes in the same config-load pass. const previous = process.env["SUPABASE_STORAGE_ANALYTICS_MAX_CATALOGS"]; process.env["SUPABASE_STORAGE_ANALYTICS_MAX_CATALOGS"] = "not-a-uint"; const { layer, child } = setup(); @@ -1918,8 +1840,6 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails on an invalid SUPABASE_STORAGE_VECTOR_MAX_BUCKETS even when storage is excluded, matching Go's Config.Load", () => { - // `storage.vector.max_buckets` is a plain uint decoded in the same config-load pass as - // storage.analytics.* above, unconditionally. const previous = process.env["SUPABASE_STORAGE_VECTOR_MAX_BUCKETS"]; process.env["SUPABASE_STORAGE_VECTOR_MAX_BUCKETS"] = "not-a-uint"; const { layer, child } = setup(); @@ -1947,8 +1867,6 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails on an invalid SUPABASE_STORAGE_VECTOR_MAX_INDEXES even when storage is excluded, matching Go's Config.Load", () => { - // `storage.vector.max_indexes` is a plain uint decoded in the same config-load pass as - // storage.vector.max_buckets above, unconditionally. const previous = process.env["SUPABASE_STORAGE_VECTOR_MAX_INDEXES"]; process.env["SUPABASE_STORAGE_VECTOR_MAX_INDEXES"] = "not-a-uint"; const { layer, child } = setup(); @@ -1976,9 +1894,6 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails on an invalid auth.sms.max_frequency even when auth is disabled, matching Go's Config.Load", () => { - // Every GoTrue duration field decodes unconditionally, regardless of - // auth.enabled — proving the eager validation covers fields beyond auth.email.max_frequency - // and really is independent of whether GoTrue's own spec builder ever runs. const { layer, child } = setup({ configContents: 'project_id = "demo"\n[auth]\nenabled = false\n[auth.sms]\nmax_frequency = "not-a-duration"\n', @@ -1999,10 +1914,6 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails on an invalid SUPABASE_AUTH_RATE_LIMIT_ANONYMOUS_USERS even when auth is disabled, matching Go's Config.Load", () => { - // `auth.rate_limit.*` are plain uints decoded unconditionally at config - // load — `resolveGotrueRateLimit` only throws via an env var - // override (a bad TOML value is caught by @supabase/config's own schema first), so this - // models the override directly, same as the storage.s3_protocol.enabled test above. const previous = process.env["SUPABASE_AUTH_RATE_LIMIT_ANONYMOUS_USERS"]; process.env["SUPABASE_AUTH_RATE_LIMIT_ANONYMOUS_USERS"] = "not-a-uint"; const { layer, child } = setup({ @@ -2033,9 +1944,6 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails on an invalid SUPABASE_AUTH_WEB3_SOLANA_ENABLED even when auth is disabled, matching Go's Config.Load", () => { - // `auth.web3.*.enabled` are plain bools decoded unconditionally at - // config load — same override-only-throw reasoning as the rate_limit - // test above. const previous = process.env["SUPABASE_AUTH_WEB3_SOLANA_ENABLED"]; process.env["SUPABASE_AUTH_WEB3_SOLANA_ENABLED"] = "not-a-bool"; const { layer, child } = setup({ @@ -2065,9 +1973,6 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails on an invalid SUPABASE_AUTH_OAUTH_SERVER_ENABLED even when auth is disabled, matching Go's Config.Load", () => { - // `auth.oauth_server.enabled`/`allow_dynamic_registration` are plain bools decoded - // unconditionally at config load — same - // override-only-throw reasoning as the two tests above. const previous = process.env["SUPABASE_AUTH_OAUTH_SERVER_ENABLED"]; process.env["SUPABASE_AUTH_OAUTH_SERVER_ENABLED"] = "not-a-bool"; const { layer, child } = setup({ @@ -2097,9 +2002,6 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails on an invalid SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED even when auth is disabled, matching Go's Config.Load", () => { - // `auth.third_party.<provider>.enabled` are plain bools decoded unconditionally at - // config load, same override-only-throw reasoning as the - // web3/oauth_server tests above (review: PRRT_kwDOErm0O86WXFqj). const previous = process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"]; process.env["SUPABASE_AUTH_THIRD_PARTY_FIREBASE_ENABLED"] = "not-a-bool"; const { layer, child } = setup({ @@ -2130,12 +2032,9 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails on an invalid auth.passkey.enabled even when auth is disabled, matching Go's Config.Load", () => { - // `auth.passkey`/`auth.webauthn` have no `@supabase/config` schema at all — - // `auth.passkey.enabled` decodes unconditionally at config load via - // `resolveGotruePasskeyWebauthn`'s raw-document read, same override-only-throw - // reasoning as the web3/oauth_server tests above, except the malformed value lives directly in - // config.toml here since `@supabase/config` never sees (or rejects) this unmodeled field — - // there's no schema-level bool coercion to catch it first. + // `auth.passkey`/`auth.webauthn` have no `@supabase/config` schema, so the malformed + // value lives directly in config.toml here — there's no schema-level bool coercion to + // catch it first, unlike the modeled fields above. const { layer, child } = setup({ configContents: 'project_id = "demo"\n[auth]\nenabled = false\n[auth.passkey]\nenabled = "bad"\n', @@ -2207,11 +2106,6 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails on an invalid SUPABASE_EDGE_RUNTIME_POLICY even when edge-runtime is excluded, matching Go's Config.Load", () => { - // `edge_runtime.policy` is a strict enum in @supabase/config's schema, so a bad TOML - // value is already rejected at config load, before start.handler.ts runs — only the env - // var override path (a plain string, unchecked by the schema) can reach - // `envOverrideEdgeRuntimePolicy`'s own throw, same reasoning as the GoTrue - // override-only tests above. const previous = process.env["SUPABASE_EDGE_RUNTIME_POLICY"]; process.env["SUPABASE_EDGE_RUNTIME_POLICY"] = "not-a-policy"; const { layer, child } = setup(); @@ -2239,10 +2133,6 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails on an invalid SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT even when edge-runtime is excluded, matching Go's Config.Load", () => { - // `edge_runtime.inspector_port` is a plain number in @supabase/config's schema, so a bad - // TOML value is already rejected at config load — only the env var override path (a - // string parsed by `envOverridePort`) can throw here, same reasoning as the policy test - // above. const previous = process.env["SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT"]; process.env["SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT"] = "not-a-port"; const { layer, child } = setup(); @@ -2271,9 +2161,9 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "warns about a Windows npipe Docker daemon before starting Vector, in text mode, and excludes it from the health watch list", () => { - // `resolveDockerDaemonHost` checks `DOCKER_HOST` before ever shelling out to - // `docker context inspect`, so setting it directly is a reliable way to force the - // npipe branch without needing a real Windows Docker Desktop context. + // `resolveDockerDaemonHost` checks `DOCKER_HOST` before shelling out to `docker context + // inspect`, so setting it directly forces the npipe branch without a real Windows + // Docker Desktop context. const previousDockerHost = process.env["DOCKER_HOST"]; process.env["DOCKER_HOST"] = "npipe:////./pipe/docker_engine"; const { layer, out } = setup(); @@ -2319,9 +2209,8 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "skips storage and imgproxy together when storage.enabled = false, even with image_transformation on", () => { - // ImgProxy mounts Storage's own volumes (`start.gates.ts`'s `imgproxy: storage && ...` - // dependency) — disabling storage must take ImgProxy down with it, even though - // `storage.image_transformation.enabled` on its own would otherwise turn ImgProxy on. + // ImgProxy mounts Storage's own volumes, so disabling storage takes ImgProxy down with + // it even though `storage.image_transformation.enabled` alone would otherwise turn it on. const { layer, child } = setup({ configContents: 'project_id = "demo"\n[storage]\nenabled = false\n[storage.image_transformation]\nenabled = true\n', @@ -2339,10 +2228,8 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "ignores SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED when [storage.image_transformation] is absent from config.toml", () => { - // `storage.image_transformation` is a nil-unless-declared field — - // with no `[storage.image_transformation]` table, the env var is - // never even looked up, so ImgProxy must stay off even though - // storage itself is enabled. + // `storage.image_transformation` is a nil-unless-declared field: with no + // `[storage.image_transformation]` table, the env var is never looked up. const previous = process.env["SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED"]; process.env["SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED"] = "true"; const { layer, child } = setup(); @@ -2375,8 +2262,8 @@ content_path = "./supabase/templates/custom_notice.html" }); return Effect.gen(function* () { // `edge-runtime` excluded so its own HTTP readiness probe never reaches - // `unusedHttpClientLayer` — this scenario is only about Postgrest/Kong/ - // Realtime's `api.enabled` independence. + // `unusedHttpClientLayer` — this scenario is only about Postgrest/Kong/Realtime's + // `api.enabled` independence. yield* start(flags({ exclude: ["edge-runtime"] })); const createdNames = createdContainerNames(child.spawned); expect(createdNames.some((name) => name.includes("_kong_"))).toBe(true); @@ -2443,15 +2330,11 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "resolves an excluded service's migrate-job image through a project-dotenv-only registry override", () => { - // The auth/realtime/storage migrate jobs run regardless of `--exclude`, but - // `--exclude gotrue` removes the gotrue image from `imagePlan`, which used to - // make its migrate-job image fall back to `DockerRun`'s - // ambient-`process.env`-only registry resolver — invisible to a - // registry override that only exists in the project's own `.env` file. + // `--exclude gotrue` removes the gotrue image from `imagePlan`; its migrate-job image + // must still resolve a registry override that only exists in the project's own `.env` file. const workdir = tempRoot.current; const { layer, child } = setup({ route: freshVolumeRoute(defaultRoute()) }); - // `loadCliProjectEnvironment`'s `envPath` is `<workdir>/supabase/.env` (`findCliProjectPaths`), - // written after `setup()` so the `supabase/` dir (created by `writeConfig`) already exists. + // Written after `setup()` so the `supabase/` dir (created by `writeConfig`) already exists. writeFileSync( join(workdir, "supabase", ".env"), "SUPABASE_INTERNAL_IMAGE_REGISTRY=registry.example.com\n", @@ -2472,13 +2355,10 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "does not attempt to resolve an excluded service's migrate-job image on a non-fresh-volume restart", () => { - // The pre-pull step only ever touches non-excluded services, and the - // one-shot setup-job images are resolved lazily, only from inside - // `initSchema15` when it actually runs — a fresh volume AND - // PG15+. On an ordinary restart (this test's default, non-fresh-volume setup), - // `--exclude storage-api` must not even attempt to resolve Storage's image, or an - // unavailable/rate-limited Storage image would fail `start` even though nothing - // in this run needs it. + // Image resolution for the one-shot setup jobs only happens lazily inside + // `initSchema15` on a fresh volume; on an ordinary restart, `--exclude storage-api` + // must not attempt to resolve Storage's image at all, or an unavailable/rate-limited + // image would fail `start` even though nothing in this run needs it. const base = defaultRoute(); const route = (args: ReadonlyArray<string>): RouteResult => { const targetsStorageImage = @@ -2512,12 +2392,9 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails on an undecryptable [db.vault] secret even on a non-fresh volume, matching Go's Config.Load", () => { - // `checkDbToml`'s own internal call inside `startSetupLocalDatabase` only - // runs on a fresh volume — an undecryptable `[db.vault]` - // secret (a DB-specific field `@supabase/config`'s own schema never decrypts, only - // `checkDbToml`'s pipeline does) must still fail eagerly, before any Docker work, - // on an ordinary restart against an existing (non-fresh) volume: every `encrypted:` - // value decrypts unconditionally regardless of volume state. + // `checkDbToml`'s internal call inside `startSetupLocalDatabase` only runs on a fresh + // volume, but every `encrypted:` value decrypts unconditionally regardless of volume + // state, so an undecryptable `[db.vault]` secret must still fail eagerly here. const previous = process.env["DOTENV_PRIVATE_KEY"]; delete process.env["DOTENV_PRIVATE_KEY"]; const encrypted = @@ -2548,12 +2425,6 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails on a bucket's invalid file_size_limit even on a non-fresh volume, matching Go's Config.Load", () => { - // Same class of gap as the `[db.vault]` test above: the per-bucket `file_size_limit` - // (the same size decode hook as the storage-level default) was previously only - // parsed deep inside `seedBucketsRun`, reached only on a fresh volume with Storage - // actually seeding — a malformed value on an ordinary restart against an existing - // (non-fresh) volume went completely unvalidated. `checkDbToml` now catches it - // eagerly, before any Docker work, regardless of volume state. const { layer, child } = setup({ configContents: 'project_id = "demo"\n[storage.buckets.avatars]\nfile_size_limit = "bogus"\n', @@ -2646,10 +2517,8 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "seeds against the env-overridden SUPABASE_API_PORT, not config.toml's raw port", () => { - // seedBucketsRun previously reloaded config.toml independently instead of - // reusing start's own already env-overridden config, so a SUPABASE_API_PORT - // override that actually brought Kong up on a different port never reached - // the bucket-seeding gateway's base URL. + // `seedBucketsRun` must reuse `start`'s own already env-overridden config, so a + // `SUPABASE_API_PORT` override reaches the bucket-seeding gateway's base URL too. const previous = process.env["SUPABASE_API_PORT"]; process.env["SUPABASE_API_PORT"] = "65432"; const http = mockStorageBucketHttpClient(); @@ -2677,10 +2546,8 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "seeds against the env-overridden SUPABASE_API_EXTERNAL_URL, not config.toml's raw value", () => { - // `effectiveLocalStorageConfig` previously left `api.external_url` as the raw, - // un-overridden config value, so a `SUPABASE_API_EXTERNAL_URL` override that - // actually brought Kong/GoTrue up under a different external URL never reached - // the bucket-seeding gateway's base URL. + // `effectiveLocalStorageConfig` must use the overridden `api.external_url`, not the raw + // config value, so it reaches the bucket-seeding gateway's base URL too. const previous = process.env["SUPABASE_API_EXTERNAL_URL"]; process.env["SUPABASE_API_EXTERNAL_URL"] = "http://override.example.com:9999"; const http = mockStorageBucketHttpClient(); @@ -2780,7 +2647,7 @@ content_path = "./supabase/templates/custom_notice.html" "keeps the host-side staged env artifacts after a successful bring-up (no eager cleanup)", () => { // Staged under `<workdir>/supabase/.temp/start-secrets/<container>/` so a later - // `stop`/rollback can reclaim it; the bootstrap template is no longer part of it. + // `stop`/rollback can reclaim it. const { layer, child, workdir } = setup(); return Effect.gen(function* () { yield* start(flags()); @@ -2805,10 +2672,8 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "logs 'Skipped serving Function' for a disabled function via Studio's bind mounts, even with Edge Runtime excluded", () => { - // `resolveFunctionBindMounts` backs Studio's function bind mounts - // unconditionally of Edge Runtime being enabled (see - // `start.handler.ts`'s "studio" case doc comment) — the skip line still - // logs via that path alone here, since Edge Runtime itself never runs to log it too. + // `resolveFunctionBindMounts` backs Studio's function bind mounts regardless of Edge + // Runtime; see `start.handler.ts`'s "studio" case doc comment. const workdir = tempRoot.current; mkdirSync(join(workdir, "supabase", "functions", "foo"), { recursive: true }); writeFileSync(join(workdir, "supabase", "functions", "foo", "index.ts"), "export {};\n"); @@ -2832,14 +2697,9 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "does not pick up an unrelated ancestor project's functions for a config-less --workdir subdirectory", () => { - // `--workdir`/`SUPABASE_WORKDIR` pointing at a subdirectory with no - // `supabase/config.toml` of its own is a legitimate, reachable state - // (see `start.e2e.test.ts`'s "absent config" comment) — `start` still - // proceeds, resolving the main config with `search: false` - // (`local-project-context.ts`). `inferFunctionsManifest`'s own - // `search: false` here (CLI-1323 functions-manifest fix) must keep an - // UNRELATED ancestor project's `supabase/functions` from silently - // winning for this workdir, mirroring that same `search: false`. + // A subdirectory with no `supabase/config.toml` of its own is a legitimate state; + // `start` still proceeds with `search: false`. `inferFunctionsManifest` needs its own + // `search: false` too, so an unrelated ancestor project's `supabase/functions` never wins. const ancestorRoot = tempRoot.current; mkdirSync(join(ancestorRoot, "supabase", "functions", "foo"), { recursive: true }); writeFileSync( @@ -2879,10 +2739,8 @@ content_path = "./supabase/templates/custom_notice.html" const pullAttempts = new Map<string, number>(); const base = defaultRoute(); const route = (args: ReadonlyArray<string>): RouteResult => { - // Force every image through the pull path instead of the "already cached" shortcut — - // a confirmed "no such image" (not merely a non-zero exit) is what tells - // `hasLocalImage` this is a genuine cache miss rather than some other inspect - // failure, which now fails fast instead of falling through to a pull. + // A confirmed "no such image" (not merely a non-zero exit) is what tells + // `hasLocalImage` this is a genuine cache miss, forcing every image through the pull path. if (args[0] === "image" && args[1] === "inspect") { return { exitCode: 1, @@ -2921,9 +2779,8 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails with a pull error once all registry candidates are exhausted, without a rollback (nothing was created yet)", () => { - // Image pre-pull (`ensureImagesCached`) runs entirely before `bringUp` creates - // the network or any container, so a pull failure here has nothing to roll back — - // unlike a failure inside `bringUp` itself (see the "rollback" describe block below). + // Pre-pull runs entirely before `bringUp` creates anything, unlike a failure inside + // `bringUp` itself (see the "rollback" describe block below). const base = defaultRoute(); const route = (args: ReadonlyArray<string>): RouteResult => { if (args[0] === "image" && args[1] === "inspect") { @@ -2958,22 +2815,11 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "still fails when the daemon dies mid-pre-pull under --ignore-health-check — Go's exit-0 swallow is an unintended quirk this port deliberately does not reproduce (CLI-1987)", () => { - // Historically, matching any `errors.Join`-shaped error — which - // accidentally includes `ensureImagesCached`'s joined pull errors — meant - // `--ignore-health-check` swallowed a total pre-pull failure, printing - // "Started supabase local development setup." + the status table, and - // exiting 0 with no container running. Ruled an unintended quirk - // (CLI-1987): this port keeps the failure fatal regardless of the flag — - // no success banner, no status table on stdout, and no rollback (nothing - // was created yet). This scenario models the daemon-becoming-unreachable - // trigger: `hasLocalImage` (`docker-image-resolve.ts`) fails - // IMMEDIATELY on a daemon-unreachable `image inspect` stderr — no - // registry-candidate retries, no real 4s/8s backoff sleeps (review - // r3689619133) — while the flagless test above already pins the other - // trigger, pull-retry exhaustion. Both funnel into the same joined - // `ImagePrepullError` (`lib/image-prepull.ts`). See - // `isUnhealthyStartError`'s doc comment (`start.rollback.ts`) and - // `SIDE_EFFECTS.md`'s "Notes" before changing this behavior. + // Models the daemon-becoming-unreachable trigger: `hasLocalImage` fails immediately on + // a daemon-unreachable `image inspect` stderr, with no registry-candidate retries or + // real backoff sleeps — the flagless test above already covers the other trigger, + // pull-retry exhaustion. Both funnel into the same joined `ImagePrepullError`. See + // `isUnhealthyStartError`'s doc comment and `SIDE_EFFECTS.md`'s "Notes" before changing this. const base = defaultRoute(); const route = (args: ReadonlyArray<string>): RouteResult => { if (args[0] === "image" && args[1] === "inspect") { @@ -3021,15 +2867,10 @@ content_path = "./supabase/templates/custom_notice.html" // rollback being wired via `Effect.onError` (not `Effect.tapError`, which never sees a // pure interrupt's `Cause`). // - // Marking the `db` container never-healthy (mirroring the neighboring "post-bring-up - // bulk health-check" test below) keeps `bringUp`'s own Postgres health-check wait - // genuinely retrying on its real 1-second `Schedule.spaced` backoff, rather than relying - // on merely observing a `create` call: `mockStartContainerCliSpawner`'s synchronous, - // zero-delay mock lets a whole bring-up (10+ containers, no real waits of its own) run to - // full completion inside `Effect.forkChild({ startImmediately: true })`'s synchronous - // startup window, before this test's own polling loop ever gets scheduled — making - // `Fiber.interrupt` a no-op on an already-succeeded fiber and `rollbackWasAttempted` - // flakily `false`. + // Marking `db` never-healthy keeps `bringUp`'s Postgres health-check wait genuinely + // retrying on its real backoff, rather than the whole synchronous mock bring-up + // completing before this test's polling loop is even scheduled — which would make + // `Fiber.interrupt` a no-op on an already-succeeded fiber. const neverHealthy = new Set<string>(); const route = defaultRoute({ neverHealthy }); let dbContainerId: string | undefined; @@ -3050,9 +2891,8 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.forkChild({ startImmediately: true }), ); - // Wait until Postgres's own health check has actually probed the never-healthy `db` - // container at least once — proving the fiber is genuinely suspended inside - // `bringUp`'s health-check retry loop, not merely past the `create` call. + // Wait until the health check has actually probed the never-healthy `db` container, + // proving the fiber is suspended inside the retry loop, not merely past `create`. while ( dbContainerId === undefined || !child.spawned.some( @@ -3073,19 +2913,12 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "rolls back on a SIGINT-style interruption during the post-bring-up bulk health-check wait", () => { - // Regression test for the post-bring-up tail rollback fix: before it, the ONLY - // `Effect.onError` rollback wrapper covered `bringUp` itself (see the test above), - // which already resolves the instant every container has been created — a fiber - // interrupt landing anywhere in the tail that follows (the bulk health-check wait - // below, the `--ignore-health-check` storage-only recheck-and-seed, the success-path - // bucket seed, or the `cli_stack_started` capture) would slip past that earlier - // wrapper entirely and never call `rollbackStart`. Marking `auth` as - // never-healthy (mirroring the "non-Postgres service never becomes healthy" scenario - // below) keeps `waitForHealthyServices` genuinely retrying on its real 1-second - // `Schedule.spaced` backoff — not hung on `Effect.never` — so interrupting the fiber - // right after its first `container inspect` of that container lands the interrupt - // while still inside this exact step, not before "Waiting for health checks..." prints - // and not after the step has already failed/timed out on its own. + // The `Effect.onError` rollback wrapper around `bringUp` alone resolves the instant + // every container is created, so an interrupt landing anywhere in the tail that follows + // (health-check wait, storage recheck-and-seed, bucket seed, analytics capture) must + // still trigger `rollbackStart`. Marking `auth` never-healthy keeps + // `waitForHealthyServices` genuinely retrying so the interrupt lands inside this step, + // not before or after it. const neverHealthy = new Set<string>(); const route = defaultRoute({ neverHealthy }); let authContainerId: string | undefined; @@ -3100,9 +2933,8 @@ content_path = "./supabase/templates/custom_notice.html" } return route(args); }, - // Sidesteps the PostgREST/Edge Runtime HTTP-HEAD readiness probes entirely, so this - // scenario only exercises the Docker-inspect health path (mirrors the - // non-interrupt-based scenario below). + // Sidesteps the PostgREST/Edge Runtime HTTP-HEAD readiness probes, so this only + // exercises the Docker-inspect health path. httpClientLayer: unusedHttpClientLayer, }); return Effect.gen(function* () { @@ -3110,10 +2942,9 @@ content_path = "./supabase/templates/custom_notice.html" Effect.provide(layer), Effect.forkChild({ startImmediately: true }), ); - // Wait until the bulk health check has actually probed the never-healthy auth - // container at least once — proving the fiber is genuinely suspended inside - // `waitForHealthyServices`'s retry loop, not merely past the "Waiting for - // health checks..." message that precedes it. + // Wait until the bulk health check has probed the never-healthy `auth` container, + // proving the fiber is suspended inside the retry loop, not merely past the "Waiting + // for health checks..." message. while ( authContainerId === undefined || !child.spawned.some( @@ -3215,12 +3046,8 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails on a malformed auth.email.max_frequency before any Docker work, matching Go's Config.Load", () => { - // `auth.email.max_frequency` is a plain, unvalidated string in `@supabase/config`'s - // schema. It decodes in the same unconditional config-load pass as every - // other duration field, before any Docker work — this is now validated - // eagerly here too (see the `resolvedEmail` validation in start.handler.ts), so a - // malformed value fails before the network/Postgres/Kong/GoTrue sequence ever begins, - // not partway through it. + // Decodes in the same unconditional config-load pass as every other duration field, + // before any Docker work. const { layer, child } = setup({ configContents: 'project_id = "demo"\n[auth.email]\nmax_frequency = "not-a-duration"\n', }); @@ -3240,14 +3067,10 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails with a typed config error on a malformed auth.email override even when auth itself is disabled", () => { - // `resolveLocalConfigValues` only validates `auth.email.*` overrides inside its - // own `authEnabled` branch, so with auth disabled the unwrapped `resolveAuthEmail` - // call in `start.handler.ts` (used unconditionally for Kong's template mounts) becomes - // the FIRST place `SUPABASE_AUTH_EMAIL_OTP_LENGTH` gets parsed — a synchronous throw - // there would surface as an uncaught Effect defect instead. Unlike the max_frequency - // case above, this override is read before any network/container work starts, so - // there is nothing yet for rollback to prune — the point of this test is solely that - // the typed StartInvalidConfigError surfaces instead of a defect. + // With auth disabled, `resolveAuthEmail`'s unconditional call in `start.handler.ts` + // (for Kong's template mounts) becomes the first place this override is parsed — a + // synchronous throw there would otherwise surface as an uncaught defect instead of this + // typed error. Nothing has been created yet, so there's nothing for rollback to prune. const previous = process.env["SUPABASE_AUTH_EMAIL_OTP_LENGTH"]; process.env["SUPABASE_AUTH_EMAIL_OTP_LENGTH"] = "abc"; const { layer, child } = setup({ @@ -3276,15 +3099,9 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails with a typed config error on a malformed auth.sms override even when auth itself is disabled", () => { - // `resolveLocalConfigValues` only validates `auth.sms.*` overrides inside its - // own `authEnabled` branch, so with auth disabled the unwrapped `resolveAuthSms` - // call in `start.handler.ts` (used to detect the "no SMS provider enabled" warning) - // becomes the FIRST place `SUPABASE_AUTH_SMS_ENABLE_SIGNUP` gets parsed — a synchronous - // throw there would surface as an uncaught Effect defect instead. Same shape as the - // auth.email override regression test above, this override is read before any - // network/container work starts, so there is nothing yet for rollback to prune — the - // point of this test is solely that the typed StartInvalidConfigError surfaces - // instead of a defect. + // Same shape as the auth.email override test above: with auth disabled, + // `resolveAuthSms`'s unconditional call becomes the first place this override is + // parsed, so a synchronous throw must surface as this typed error, not an uncaught defect. const previous = process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"]; process.env["SUPABASE_AUTH_SMS_ENABLE_SIGNUP"] = "bad"; const { layer, child } = setup({ @@ -3313,9 +3130,8 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails and rolls back when Postgres itself never becomes healthy within its configured health_timeout", () => { - // `db.health_timeout` (unlike the generic 30s `serviceTimeout` every other service - // waits on) is a real config.toml-configurable seam — this keeps the scenario fast - // instead of waiting out a real default. + // `db.health_timeout` is config.toml-configurable, unlike the generic 30s + // `serviceTimeout` other services wait on, so this keeps the scenario fast. const neverHealthy = new Set<string>(); const base = defaultRoute({ neverHealthy }); const route = (args: ReadonlyArray<string>): RouteResult => { @@ -3346,11 +3162,9 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "exits 0 on --ignore-health-check when Postgres itself never becomes healthy, without rolling back and without starting any other service", () => { - // Mirrors the regression test above, but with `--ignore-health-check` set — the - // `ignoreHealthCheck && isUnhealthyStartError(err)` downgrade applies - // uniformly to whatever the run returns, including Postgres's own health-wait - // failure, which propagates immediately before any - // other service is even created. + // `ignoreHealthCheck && isUnhealthyStartError(err)` downgrades uniformly to whatever the + // run returns, including Postgres's own health-wait failure, which propagates before + // any other service is even created. const neverHealthy = new Set<string>(); const base = defaultRoute({ neverHealthy }); const route = (args: ReadonlyArray<string>): RouteResult => { @@ -3358,9 +3172,8 @@ content_path = "./supabase/templates/custom_notice.html" const name = containerNameFromCreateArgs(args); if (name.includes("_db_")) neverHealthy.add(name); } - // Postgres's own health wait builds its `images` map separately from the - // bulk one, so this scripts the marker here too rather than assuming the - // two call sites are wired the same way. + // Postgres's own health wait builds its `images` map separately from the bulk one, + // so this scripts the marker here too. if (args[0] === "logs" && (args[1] ?? "").includes("_db_")) { return { stdout: ["exec /usr/local/bin/docker-entrypoint.sh: exec format error\n"] }; } @@ -3375,34 +3188,26 @@ content_path = "./supabase/templates/custom_notice.html" expect(out.stderrText).toContain("is not ready"); expect(out.stderrText).toContain("Started"); expect(rollbackWasAttempted(child.spawned)).toBe(false); - // Reported by container name, with the recovery advice naming the image - // Postgres's own health wait resolved for it. expect(out.stderrText).toContain("supabase_db_demo container is not ready"); expect(out.stderrText).toContain("supabase_db_demo's image"); expect(out.stderrText).toContain("image rm -f public.ecr.aws/supabase/postgres:"); // `--ignore-health-check` leaves the stack up, so a bare restart would be a // no-op — the sequence must stop first. expect(out.stderrText).toContain("supabase stop"); - // No other service's container is ever created — the database bring-up - // returns before the "Starting containers..." message or any other - // service's bring-up even begins. + // The database bring-up returns before any other service's bring-up begins. expect(createdContainerNames(child.spawned)).toEqual([expect.stringContaining("_db_")]); - // `cli_stack_started` never fires on this fallthrough either — the - // capture sits after the entire bring-up + bulk health check, neither of - // which is reached once Postgres's own wait is downgraded to a warning. + // `cli_stack_started`'s capture sits after the entire bring-up + bulk health check, + // neither of which is reached once Postgres's wait is downgraded to a warning. expect(analytics.captured.some((c) => c.event === "cli_stack_started")).toBe(false); }).pipe(Effect.provide(layer)); }, 10_000, ); - // Real time, not `it.effect`/`TestClock`: same constraint as the `--ignore-health-check` - // scenario below — `start` performs genuine async I/O that never resolves under a - // virtualized clock. Unlike Postgres's own wait above, this second bulk health check has no - // config-configurable timeout seam in `start.handler.ts` (`waitForHealthyServices` - // is called with no `timeoutSeconds` override, so it falls back to the hardcoded 30s - // default) — there is no way to shorten this without editing production code, which is out - // of scope for this task, so this reuses the same generous real-time budget instead. + // Real time, not `it.effect`/`TestClock`: `start` performs genuine async I/O that never + // resolves under a virtualized clock. `waitForHealthyServices` has no config-configurable + // timeout seam here (it falls back to the hardcoded 30s default), hence the generous + // real-time budget. it.live( "fails and rolls back when a non-Postgres service never becomes healthy within the timeout (no --ignore-health-check)", () => { @@ -3420,9 +3225,8 @@ content_path = "./supabase/templates/custom_notice.html" }); return Effect.gen(function* () { - // `edge-runtime` also excluded so its own HTTP readiness probe never - // reaches `unusedHttpClientLayer` — this scenario is only about the - // Docker-inspect health path. + // `edge-runtime` also excluded so its own HTTP readiness probe never reaches + // `unusedHttpClientLayer` — this scenario is only about the Docker-inspect health path. const exit = yield* Effect.exit(start(flags({ exclude: ["postgrest", "edge-runtime"] }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -3436,20 +3240,16 @@ content_path = "./supabase/templates/custom_notice.html" ); }); - // Real time, not `it.effect`/`TestClock`: `start` performs genuine - // async I/O deep inside the forked effect (`resolveLocalJwks`'s - // `Effect.tryPromise`, `resolveDbImage`'s file read) that needs real - // Node event-loop turns to settle — under a virtualized `TestClock` those - // never resolve, so the forked fiber never even reaches the health-check - // phase. This exercises the real 30s `serviceTimeout` bulk health-check - // wait (`../../shared/db-bootstrap/health-check.ts`'s default), hence the generous timeout. + // Real time, not `it.effect`/`TestClock`: genuine async I/O deep inside the forked effect + // (`resolveLocalJwks`'s `Effect.tryPromise`, `resolveDbImage`'s file read) needs real Node + // event-loop turns to settle, so a virtualized clock would never let the fiber reach the + // health-check phase. Exercises the real 30s `serviceTimeout` bulk health-check wait, hence + // the generous timeout. it.live( "exits 0 on --ignore-health-check when a non-Postgres container never turns healthy, without rolling back", () => { - // GoTrue's own (post-create) container name is only known once `docker - // create` reports it — wrap `defaultRoute` to capture it into - // `neverHealthy` the moment it's created, so every later `container - // inspect` call on that same id reports "starting", never "healthy". + // The container name is only known once `docker create` reports it, so this wraps + // `defaultRoute` to capture it into `neverHealthy` the moment it's created. const neverHealthy = new Set<string>(); const route = defaultRoute({ neverHealthy }); const { layer, out, child, analytics } = setup({ @@ -3466,9 +3266,8 @@ content_path = "./supabase/templates/custom_notice.html" } return route(args); }, - // Sidesteps the PostgREST/Edge Runtime HTTP-HEAD readiness probes - // entirely, so this scenario only exercises the Docker-inspect health - // path. + // Sidesteps the PostgREST/Edge Runtime HTTP-HEAD readiness probes, so this only + // exercises the Docker-inspect health path. httpClientLayer: unusedHttpClientLayer, }); @@ -3477,12 +3276,10 @@ content_path = "./supabase/templates/custom_notice.html" expect(out.stderrText).toContain("is not ready"); expect(out.stderrText).toContain("Started"); expect(rollbackWasAttempted(child.spawned)).toBe(false); - // Reported by container name, not `docker create`'s opaque id, and the - // advice names the image actually resolved for that container. expect(out.stderrText).toContain("supabase_auth_demo container is not ready"); expect(out.stderrText).toContain("docker image rm -f public.ecr.aws/supabase/gotrue:"); - // `cli_stack_started` never fires on the ignored-unhealthy - // fallthrough — only a genuine bulk health-check SUCCESS reaches that capture. + // `cli_stack_started` never fires on the ignored-unhealthy fallthrough — only a genuine + // bulk health-check success reaches that capture. expect(analytics.captured.some((c) => c.event === "cli_stack_started")).toBe(false); }).pipe(Effect.provide(layer)); }, @@ -3522,11 +3319,9 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "a bucket-seed failure during the recheck becomes a hard failure with rollback, replacing the original health error", () => { - // A non-200 bucket-create response fails `seedBucketsRun` deep inside - // its Storage-gateway call (`StorageGatewayStatusError`) — unlike an - // invalid bucket NAME, which `resolveLocalConfigValues`'s own - // `validateResolvedConfig` call (step 2 of `start`, long before - // any container exists) would already reject before ever reaching Docker. + // A non-200 bucket-create response fails `seedBucketsRun` deep inside its + // Storage-gateway call — unlike an invalid bucket name, which config validation would + // already reject before reaching Docker. const failingBucketCreateHttpClientLayer = Layer.succeed( HttpClient.HttpClient, HttpClient.make((request) => { @@ -3575,7 +3370,7 @@ content_path = "./supabase/templates/custom_notice.html" if (Exit.isFailure(exit)) { const serialized = JSON.stringify(exit.cause); expect(serialized).toContain("StorageGatewayStatusError"); - // The seed error REPLACES the original health-check timeout entirely. + // The seed error replaces the original health-check timeout entirely. expect(serialized).not.toContain("HealthCheckTimeoutError"); } expect(rollbackWasAttempted(child.spawned)).toBe(true); @@ -3585,10 +3380,9 @@ content_path = "./supabase/templates/custom_notice.html" 45_000, ); - // Both the main bulk health check (auth) and this storage-only recheck run - // out their own full ~30s real-time retry budget in this scenario — hence - // the doubled timeout relative to every other real-time health-check test - // in this file. + // Both the main bulk health check (auth) and this storage-only recheck run out their own + // full ~30s real-time retry budget here, hence the doubled timeout relative to every other + // real-time health-check test in this file. it.live( "falls through to the original warning without attempting to seed when the storage recheck itself never turns healthy", () => { @@ -3662,9 +3456,7 @@ content_path = "./supabase/templates/custom_notice.html" }); it.live("falls back to SUPABASE_NETWORK_ID when the flag itself is omitted", () => { - // `--network-id` falls back to the `SUPABASE_NETWORK_ID` shell/project-dotenv env var - // ONLY when the flag was never passed (review: PRRT_kwDOErm0O86VlqIL) — see - // `start.handler.ts`'s own comment on this resolution for the full precedence. + // See `start.handler.ts`'s doc comment on this resolution for the full precedence. const previous = process.env["SUPABASE_NETWORK_ID"]; process.env["SUPABASE_NETWORK_ID"] = "env-net"; const { layer, child } = setup(); @@ -3795,9 +3587,7 @@ content_path = "./supabase/templates/custom_notice.html" "brings up the stack when the DB container's inspect reports 'No such object' instead of 'No such container'", () => { // Docker/Podman report a missing container as either "No such container" or "No such - // object" depending on daemon version/CLI path — `isContainerNotFoundMessage` - // must recognize both, or `start`'s "not running, bring up the stack" branch - // never fires and the inspect failure propagates instead. + // object" depending on daemon version — `isContainerNotFoundMessage` must recognize both. const created = new Set<string>(); const route = (args: ReadonlyArray<string>): RouteResult => { if (args[0] === "container" && args[1] === "inspect") { @@ -4557,8 +4347,8 @@ content_path = "./supabase/templates/custom_notice.html" }, ); - // An empty but present `cert_path`/`key_path` is treated the same as - // absent — it must NOT attempt a disk read. + // An empty but present `cert_path`/`key_path` is treated the same as absent — it must not + // attempt a disk read. it.live( "falls back to the embedded default cert/key when cert_path/key_path are present but empty", () => { @@ -4627,9 +4417,9 @@ content_path = "./supabase/templates/custom_notice.html" }); describe("SUPABASE_API_ENABLED override", () => { - // The entire TLS cert/key disk read is nested inside the API-enabled check — - // when API is disabled (however that happened), Kong - // keeps its embedded default cert/key regardless of `api.tls.enabled`/cert_path/key_path. + // The entire TLS cert/key disk read is nested inside the API-enabled check, so when API is + // disabled, Kong keeps its embedded default cert/key regardless of + // `api.tls.enabled`/cert_path/key_path. it.live( "skips the configured cert/key read for Kong when API is disabled only via env override", () => { @@ -4812,8 +4602,7 @@ content_path = "./supabase/templates/custom_notice.html" expect(envFilePath).toBeDefined(); const envFileContent = readFileSync(envFilePath ?? "", "utf-8"); expect(envFileContent).toContain("MY_SECRET=shh-do-not-tell"); - // Names reach the container UPPERCASED — every secret key is uppercased - // — and empty values are skipped, shared with + // Names reach the container uppercased, and empty values are skipped — shared with // `functions serve` via `toPlainEdgeRuntimeConfig`. expect(envFileContent).toContain("MY_LOWER_SECRET=keep-me"); expect(envFileContent).not.toContain("my_lower_secret="); @@ -4825,10 +4614,7 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "decrypts an encrypted [edge_runtime.secrets] entry into plaintext, not the raw ciphertext", () => { - // Mirrors "encrypted secrets reach GoTrue's container" above, but for - // `edge_runtime.secrets` — this field decrypts during config load too, so the real - // Edge Runtime container's env file must contain the decrypted "value", never the - // literal `encrypted:...` string. + // Mirrors "encrypted secrets reach GoTrue's container" above, for `edge_runtime.secrets`. const previous = process.env["DOTENV_PRIVATE_KEY"]; process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; const { layer, child } = setup({ @@ -4859,10 +4645,8 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails with a typed config error, before any container is created, on an undecryptable [edge_runtime.secrets] entry", () => { - // Caught eagerly by `checkDbToml`'s `assertDecryptableSecrets` pre-check - // (`edge_runtime.secrets.*` is one of `SECRET_PATHS`), well before the bring-up - // loop's own edge-runtime-specific decrypt — same shape as the sibling `[db.vault]` - // "even on a non-fresh volume" test above. + // Caught eagerly by `checkDbToml`'s `assertDecryptableSecrets` pre-check, before the + // bring-up loop's own edge-runtime-specific decrypt. const previous = process.env["DOTENV_PRIVATE_KEY"]; delete process.env["DOTENV_PRIVATE_KEY"]; const { layer, child } = setup({ @@ -4967,10 +4751,8 @@ content_path = "./supabase/templates/custom_notice.html" const poolerCreate = child.spawned.find( (s) => s.args[0] === "create" && containerNameFromCreateArgs(s.args).includes("_pooler_"), ); - // `db.pooler.port` defaults to 54329 (`packages/config/src/db.ts`) — - // the published `-p <hostPort>:<containerPort>` mapping is what - // changes with pool mode; the exposed-ports list always lists both - // 5432 and 6543 regardless, so assert on the specific mapping string. + // `db.pooler.port` defaults to 54329; the exposed-ports list always contains both 5432 + // and 6543, so assert on the specific `hostPort:containerPort` mapping instead. expect(poolerCreate?.args).toContain("54329:5432"); expect(poolerCreate?.args).not.toContain("54329:6543"); }).pipe( @@ -5090,11 +4872,9 @@ content_path = "./supabase/templates/custom_notice.html" it.live( "fails with a typed config error, before any container is created, on an invalid SUPABASE_ANALYTICS_VECTOR_PORT", () => { - // `analytics.vector_port` (Logflare's deprecated Vector port) is decoded in the same - // Config.Load pass as `analytics.port` above — nothing downstream in `start` reads the - // resolved value, but a malformed override must still fail eagerly, same reasoning as - // the SUPABASE_LOCAL_SMTP_SMTP_PORT/SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT tests elsewhere - // in this file. + // `analytics.vector_port` decodes in the same Config.Load pass as `analytics.port` + // above; nothing downstream reads the resolved value, but a malformed override must + // still fail eagerly. const previous = process.env["SUPABASE_ANALYTICS_VECTOR_PORT"]; process.env["SUPABASE_ANALYTICS_VECTOR_PORT"] = "not-a-port"; const { layer, child } = setup(); diff --git a/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts b/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts index 9d806de4fc..2347ee7a64 100644 --- a/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts +++ b/apps/cli/src/commands/start/start.lifecycle.e2e.test.ts @@ -24,23 +24,19 @@ const SHORT_E2E_TIMEOUT_MS = 30_000; const LIFECYCLE_OVERHEAD_MS = 90_000; /** - * `--exclude` values for the 3 heaviest/least-relevant services — same intent - * as the reduced-stack `start` calls in the sibling Docker e2e suites (Studio's - * Next.js build and the Logflare/Vector logging pipeline). The legacy service - * catalog uses `logflare` as the exclusion key for that logging service. + * `--exclude` values for the 3 heaviest, least-relevant services (same set the sibling Docker + * e2e suites use): Studio's Next.js build and the Logflare/Vector logging pipeline. The service + * catalog's exclusion key for the logging service is `logflare`. */ const EXCLUDED_SERVICE_KEYS: ReadonlySet<string> = new Set(["studio", "logflare", "vector"]); /** - * Services the running-container assertion below must NOT expect to be running, even though + * Services the running-container assertion below must not expect to be running, even though * they are neither in `EXCLUDED_SERVICE_KEYS` nor `--exclude`d on the `start` call itself: - * - `supavisor` — `db.pooler.enabled` defaults to `false` (`packages/config/src/db.ts`, - * `defaultPoolerEnabled`), and `runSupabase(["init"], ...)` above writes a config.toml - * with no override, so it's genuinely disabled on this test's stack, not merely unasserted. - * - `imgproxy` — gated on `storage.image_transformation.enabled` (`start.gates.ts:169`), - * which defaults to `false`/absent; `runSupabase(["init"], ...)` writes a config.toml - * with `[storage.image_transformation]` still commented out - * (`project-init.templates.ts:132-133`), so imgproxy is genuinely disabled on this test's stack. + * - `supavisor` — `db.pooler.enabled` defaults to `false`, and this test's `init`-written + * config.toml has no override, so it's genuinely disabled, not merely unasserted. + * - `imgproxy` — gated on `storage.image_transformation.enabled`, which the same config.toml + * leaves commented out (defaulting to disabled). */ const NEVER_RUNNING_SERVICE_KEYS: ReadonlySet<string> = new Set(["supavisor", "imgproxy"]); @@ -51,19 +47,15 @@ function splitNonEmptyLines(text: string): ReadonlyArray<string> { .filter((line) => line.length > 0); } -// `start` is the one local-dev-stack command whose correctness genuinely -// depends on a real Docker daemon — real label filtering and real container -// lifecycle, not just CLI exit codes. `describe` gates the -// "we're in a configured e2e runner" signal (see stop.e2e.test.ts's own -// comment for why this, not a Management-API gate, is correct here). See -// AGENTS.md's "e2e tests" section for the full convention. +// `start`'s correctness genuinely depends on a real Docker daemon — real label filtering and +// container lifecycle, not just CLI exit codes. See `stop.e2e.test.ts` and AGENTS.md's "e2e +// tests" section for the runner-gating convention. describe("supabase start (e2e)", () => { let projectDir: string | undefined; afterEach(async () => { if (projectDir === undefined) return; - // Best-effort cleanup even if an assertion above failed mid-lifecycle — a - // leaked local stack would otherwise pollute the CI runner for later jobs. + // Best-effort: a leaked local stack would otherwise pollute the CI runner for later jobs. await runSupabase(["stop", "--no-backup"], { cwd: projectDir, }).catch(() => undefined); @@ -76,10 +68,8 @@ describe("supabase start (e2e)", () => { { timeout: START_TIMEOUT_MS * 2 + LIFECYCLE_OVERHEAD_MS }, async () => { projectDir = await mkdtemp(path.join(tmpdir(), "sb-start-e2e-")); - // No `project_id` override, so the cli resolves it from the workdir - // basename (see docker-ids.ts). Sanitizing is a no-op for a - // `mkdtemp`-generated basename (already alphanumeric/`-`), but mirrors - // the port's actual resolution rather than assuming that stays true. + // No `project_id` override, so the CLI resolves it from the workdir basename. Sanitizing + // is currently a no-op for a `mkdtemp` basename, but mirrors the CLI's actual resolution. const projectId = sanitizeProjectId(path.basename(projectDir)); const projectFilter = `label=com.supabase.cli.project=${projectId}`; const dbContainerId = localDbContainerId(projectId); @@ -167,10 +157,6 @@ describe("supabase start (e2e)", () => { ]); expect(persistedData.trim()).toBe(persistedValue); - // The real Docker daemon must agree with the CLI's own exit code: every - // non-excluded service is actually running under this project's label, - // AND every excluded service is genuinely absent — not merely reported - // "stopped" by a status diff. const { stdout: psOutput } = await execFileAsync("docker", [ "ps", "--filter", @@ -262,10 +248,9 @@ describe("supabase start (e2e)", () => { }, ); - // The health watch inspects and dumps logs by container NAME against a real - // daemon, and derives recovery advice from a real container's real log bytes. - // Neither is observable through the in-process mocks, so this reproduces - // supabase/cli#5952 for real: a locally cached image that cannot be executed. + // The health watch inspects and dumps logs by container name against a real daemon and derives + // recovery advice from real log bytes — not observable through in-process mocks. Reproduces + // supabase/cli#5952: a locally cached image that cannot be executed. test( "names the container and its image when a cached image cannot be executed", { timeout: START_TIMEOUT_MS + LIFECYCLE_OVERHEAD_MS }, @@ -273,8 +258,8 @@ describe("supabase start (e2e)", () => { projectDir = await mkdtemp(path.join(tmpdir(), "sb-start-e2e-exec-")); const projectId = sanitizeProjectId(path.basename(projectDir)); const mailpitContainer = serviceContainerName("inbucket", projectId); - // The exact tag `start` resolves for Mailpit, so its already-cached check - // finds this deliberately broken build and never reaches a registry. + // The exact tag `start` resolves for Mailpit, so its already-cached check finds this + // broken build without reaching a registry. const mailpitImage = getRegistryImageUrl(dockerfileServiceImage("mailpit")); const init = await runSupabase(["init"], { @@ -312,7 +297,6 @@ describe("supabase start (e2e)", () => { // The container's established name is "inbucket", not the id `docker create` returns. expect(start.stderr).toContain(`${mailpitContainer} container logs:`); expect(start.stderr).toContain(`${mailpitContainer} container is not ready`); - // ...and the advice names that container's actual resolved image. expect(start.stderr).toContain(`${mailpitContainer}'s image ${mailpitImage}`); expect(start.stderr).toContain(`image rm -f ${mailpitImage}`); } finally { diff --git a/apps/cli/src/commands/start/start.services.ts b/apps/cli/src/commands/start/start.services.ts index 949ee61f36..cf27000ec0 100644 --- a/apps/cli/src/commands/start/start.services.ts +++ b/apps/cli/src/commands/start/start.services.ts @@ -4,31 +4,22 @@ import { } from "../../command-internal/service-catalog.ts"; /** - * Per-service orchestration metadata `start.handler.ts` (a later task) reads - * ON TOP OF `SERVICE_CATALOG`'s identity fields (`containerSuffix`, - * `excludeKey`, `startOrder`). Deliberately descriptive, not executable: the - * real config-boolean gating and image resolution live in the handler and - * each service's own module. + * Per-service orchestration metadata that `start.handler.ts` reads alongside `SERVICE_CATALOG`'s + * identity fields (`containerSuffix`, `excludeKey`, `startOrder`). Descriptive only — the actual + * config-boolean gating and image resolution live in the handler and each service's own module. */ export interface StartServiceMeta { - /** - * Which `config.toml`-resolved image field feeds this service's container. - * A string identifier only — actual resolution happens in the service's - * own module. - */ + /** Which `config.toml`-resolved image field feeds this service's container. */ readonly imageConfigField: string; /** - * The condition under which `start` brings up this service, expressed as a - * `config.toml` dotted-path boolean expression, or one of the sentinels - * `"always"` (Postgres, unconditional) / `"none"` (gated only by + * The condition under which `start` brings up this service: a `config.toml` dotted-path + * boolean expression, or a sentinel — `"always"` (Postgres) or `"none"` (gated only by * `!excluded`, e.g. Kong). */ readonly enabledGate: string; /** - * Other catalog `service` keys this service's startup additionally depends - * on (e.g. Vector waits on Logflare being healthy; Studio waits on - * pg-meta). Not a full dependency graph — just a note for the handler to - * sequence against. + * Other catalog `service` keys this service's startup depends on (e.g. Vector waits on + * Logflare; Studio waits on pg-meta). Not a full dependency graph, just a sequencing hint. */ readonly dependsOn?: ReadonlyArray<string>; } @@ -83,10 +74,8 @@ export function startServiceMeta(service: string): StartServiceMeta | undefined } /** - * `SERVICE_CATALOG`, augmented with this file's orchestration metadata. - * Preserves the catalog's `startOrder` ordering — `start.handler.ts` can - * iterate this array directly to bring services up in the real - * container-start sequence. + * `SERVICE_CATALOG` augmented with this file's orchestration metadata, preserving the catalog's + * `startOrder` so `start.handler.ts` can iterate it directly to bring services up in order. */ export const START_SERVICES: ReadonlyArray<StartServiceEntry> = SERVICE_CATALOG.map((entry) => { const meta = START_SERVICE_META_BY_SERVICE.get(entry.service); diff --git a/apps/cli/src/commands/start/start.services.unit.test.ts b/apps/cli/src/commands/start/start.services.unit.test.ts index d68e239f6b..73f6921233 100644 --- a/apps/cli/src/commands/start/start.services.unit.test.ts +++ b/apps/cli/src/commands/start/start.services.unit.test.ts @@ -116,15 +116,9 @@ describe("startServiceMeta", () => { }); /** - * Cross-check: `start.services.ts`'s `enabledGate` metadata (descriptive - * only, never read by runtime code — see that module's header) against - * `start.gates.ts`'s `resolveStartGates` (the REAL, executable gate). - * The two are hand-maintained separately and can silently drift (e.g. a gate - * condition changes in `start.gates.ts` without the matching `enabledGate` - * string being updated) — this mechanically evaluates every `enabledGate` - * boolean-string expression against a synthetic config and compares it - * against what `resolveStartGates` actually computes for the SAME - * config, so a future drift fails loudly here instead of silently. + * `start.services.ts`'s `enabledGate` strings and `start.gates.ts`'s `resolveStartGates` are + * hand-maintained separately and can drift silently; this evaluates every `enabledGate` + * expression against a synthetic config and compares it to what `resolveStartGates` computes. */ describe("START_SERVICES enabledGate cross-check against start.gates.ts", () => { const decodeConfig = Schema.decodeUnknownSync(CliConfigSchema); @@ -170,10 +164,9 @@ describe("START_SERVICES enabledGate cross-check against start.gates.ts", () => } /** - * Evaluates an `enabledGate` string ("x.enabled", "x.enabled && y.enabled", - * or the `"none"` sentinel) against a synthetic config. Deliberately - * ignores the `!excluded(...)` factor every real gate also ANDs in — the - * caller isolates that by resolving with `excludedKeys` empty. + * Evaluates an `enabledGate` string (`"x.enabled"`, `"x.enabled && y.enabled"`, or the + * `"none"` sentinel) against a synthetic config. Ignores the `!excluded(...)` factor real + * gates also apply — callers isolate that by resolving with `excludedKeys` empty. */ function evaluateEnabledGate(expr: string, config: CliConfig): boolean { if (expr === "none") return true; diff --git a/apps/cli/src/commands/start/start.string-slice-flags.integration.test.ts b/apps/cli/src/commands/start/start.string-slice-flags.integration.test.ts index 2392d94890..4fde3f0e64 100644 --- a/apps/cli/src/commands/start/start.string-slice-flags.integration.test.ts +++ b/apps/cli/src/commands/start/start.string-slice-flags.integration.test.ts @@ -16,24 +16,16 @@ import { } from "../../../tests/helpers/command-mocks.ts"; import { startCommand } from "./start.command.ts"; -// `--exclude`/`-x` is a string-slice flag (CLI-2005), so malformed CSV -// aborts flag parsing before the handler runs — before any Docker -// interaction — with the exact `invalid argument %q for %q flag: %v` line -// on stderr. Because the flag has a shorthand, the diagnostic frames BOTH -// spellings (`-x, --exclude`) regardless of which one the user typed. -// These scenarios run the whole command tree (`Command.runWith`), mirroring -// the network-bans/network-restrictions prior art from CLI-1983. +// Malformed CSV aborts flag parsing before the handler runs, with pflag's exact +// `invalid argument %q for %q flag: %v` line on stderr — a shorthand flag frames both spellings +// (`-x, --exclude`). These run the whole command tree (`Command.runWith`), not just the flag parser. const tempRoot = useTempWorkdir("supabase-start-string-slice-int-"); -// `withGlobalFlags` must come AFTER `withSubcommands`: it only excludes each -// global flag's context requirement from the R accumulated on the command -// SO FAR, and `withSubcommands` unions in every subcommand's own requirements -// (including `start`'s handler-chain reads of `DebugFlag`/ -// `NetworkIdFlag`/`DnsResolverFlag`/`ProfileFlag`/ -// `WorkdirFlag`/`YesFlag`). Reversing the order leaves those -// context tags in `Command.runWith`'s Environment type even though this -// parse-failure path never reaches the handler at runtime. +// `withGlobalFlags` must come after `withSubcommands`: it excludes each global flag's context +// requirement only from what's already accumulated, so subcommand requirements need to be unioned +// in first. Reversing the order would leave those context tags in `Command.runWith`'s Environment +// type, even though this parse-failure path never reaches the handler. const testRoot = Command.make("supabase").pipe( Command.withSubcommands([startCommand]), Command.withGlobalFlags(GLOBAL_FLAGS), @@ -75,9 +67,8 @@ function setup() { } describe("start --exclude flag (pflag CSV parity)", () => { - // Verified against pflag's actual output (CLI-2005): the rendered line is - // identical for both spellings — pflag always frames a shorthand flag as - // `-x, --exclude`. + // Both spellings render the identical diagnostic line: pflag always frames a shorthand + // flag as `-x, --exclude`. const spellings: ReadonlyArray<{ readonly name: string; readonly flag: string }> = [ { name: "--exclude", flag: "--exclude" }, { name: "-x", flag: "-x" }, diff --git a/apps/cli/src/commands/start/templates/custom_nginx.template.ts b/apps/cli/src/commands/start/templates/custom_nginx.template.ts index f0d781c551..1faddc2c7d 100644 --- a/apps/cli/src/commands/start/templates/custom_nginx.template.ts +++ b/apps/cli/src/commands/start/templates/custom_nginx.template.ts @@ -1,14 +1,7 @@ /** - * The canonical custom_nginx.template body — this is the sole source of - * truth; do not hand-edit it. Unlike `kong.yml`, this file is NOT parsed as - * a `text/template` — it's passed through unmodified into the Kong - * container, where Kong's own openresty templating substitutes the - * `${{VAR}}` placeholders (`LOG_LEVEL`, `NGINX_DAEMON`, - * `NGINX_WORKER_PROCESSES`) from its own container env at boot. - * - * The `\${{...}}` sequences below are literal Kong template syntax, not a JS - * template-literal interpolation — the backslash escapes are required so this - * TS template literal doesn't try to evaluate `{{VAR}}` as an object literal. + * Canonical custom_nginx.template body; do not hand-edit. Unlike `kong.yml`, this is not parsed + * as a Go template — Kong's own openresty templating substitutes `${{VAR}}` at container boot, so + * the backslash in `\${{...}}` here only escapes JS template-literal syntax. */ export const START_CUSTOM_NGINX_TEMPLATE = `pid pids/nginx.pid; # this setting is mandatory error_log logs/error.log \${{LOG_LEVEL}}; # can be set by kong.conf diff --git a/apps/cli/src/commands/start/templates/kong-local-tls.ts b/apps/cli/src/commands/start/templates/kong-local-tls.ts index f4da2f67a1..bf08f7431d 100644 --- a/apps/cli/src/commands/start/templates/kong-local-tls.ts +++ b/apps/cli/src/commands/start/templates/kong-local-tls.ts @@ -1,15 +1,7 @@ /** - * The default Kong TLS cert/key, seeded unconditionally onto - * `Api.Tls.{CertContent,KeyContent}` — config validation only overwrites them - * from disk when TLS is enabled AND both `api.tls.cert_path`/`key_path` are - * configured; otherwise this embedded pair survives untouched, and Kong's - * entrypoint always writes `CertContent`/`KeyContent` to - * `/home/kong/localhost.{crt,key}` regardless of `api.tls.enabled` — only the - * published host port (8000 vs 8443) depends on that flag. - * - * A real (if long-expired-eventually, valid until 2034-07-03) self-signed - * `CN=localhost` cert/key pair. This is the canonical source; do not - * reformat or re-wrap. + * Default Kong TLS cert/key pair, always seeded and only overwritten from disk when TLS is + * enabled and both cert/key paths are configured. A real, valid self-signed `CN=localhost` pair + * (expires 2034-07-03) — canonical source, do not reformat or re-wrap. */ export const KONG_LOCAL_TLS_CERT = diff --git a/apps/cli/src/commands/start/templates/kong.yml.ts b/apps/cli/src/commands/start/templates/kong.yml.ts index ede923e4ca..55c7a3957a 100644 --- a/apps/cli/src/commands/start/templates/kong.yml.ts +++ b/apps/cli/src/commands/start/templates/kong.yml.ts @@ -1,11 +1,10 @@ /** - * The canonical `kong.yml` template — this is the sole source of truth; do - * not hand-edit the YAML body. Also reused directly (not re-transcribed) by + * Canonical `kong.yml` template; do not hand-edit. Also reused directly (not re-transcribed) by * `shared/functions/serve-main-offline.e2e.test.ts`'s offline Kong fixture. * - * Placeholders (`{{ .Field }}`): GotrueId, RestId, RealtimeId, StorageId, - * StudioId, PgmetaId, EdgeRuntimeId, LogflareId, PoolerId, ApiHost, ApiPort, - * BearerToken, QueryToken. Rendered by `lib/template-render.ts`. + * Placeholders (`{{ .Field }}`): GotrueId, RestId, RealtimeId, StorageId, StudioId, PgmetaId, + * EdgeRuntimeId, LogflareId, PoolerId, ApiHost, ApiPort, BearerToken, QueryToken — rendered by + * `lib/template-render.ts`. */ export const START_KONG_YML_TEMPLATE = `_format_version: "1.1" services: diff --git a/apps/cli/src/commands/start/templates/pooler.exs.ts b/apps/cli/src/commands/start/templates/pooler.exs.ts index abc1e652fc..8d40cd5ee4 100644 --- a/apps/cli/src/commands/start/templates/pooler.exs.ts +++ b/apps/cli/src/commands/start/templates/pooler.exs.ts @@ -1,10 +1,8 @@ /** - * The canonical `pooler.exs` template — this is the sole source of truth; do - * not hand-edit the Elixir body. + * Canonical `pooler.exs` template; do not hand-edit the Elixir body. * - * Placeholders (`{{ .Field }}`): DbHost, DbPort, DbDatabase, DbPassword, - * ExternalId, ModeType, DefaultMaxClients, DefaultPoolSize. Rendered by - * `lib/template-render.ts`. + * Placeholders (`{{ .Field }}`): DbHost, DbPort, DbDatabase, DbPassword, ExternalId, ModeType, + * DefaultMaxClients, DefaultPoolSize — rendered by `lib/template-render.ts`. */ export const START_POOLER_EXS_TEMPLATE = `{:ok, _} = Application.ensure_all_started(:supavisor) diff --git a/apps/cli/src/commands/start/templates/vector.yaml.ts b/apps/cli/src/commands/start/templates/vector.yaml.ts index 3d591a3170..a3571f65bd 100644 --- a/apps/cli/src/commands/start/templates/vector.yaml.ts +++ b/apps/cli/src/commands/start/templates/vector.yaml.ts @@ -1,10 +1,8 @@ /** - * The canonical `vector.yaml` template — this is the sole source of truth; - * do not hand-edit the YAML body. + * Canonical `vector.yaml` template; do not hand-edit the YAML body. * - * Placeholders (`{{ .Field }}`): ApiKey, VectorId, LogflareId, KongId, - * GotrueId, RestId, RealtimeId, StorageId, EdgeRuntimeId, DbId. Rendered by - * `lib/template-render.ts`. + * Placeholders (`{{ .Field }}`): ApiKey, VectorId, LogflareId, KongId, GotrueId, RestId, + * RealtimeId, StorageId, EdgeRuntimeId, DbId — rendered by `lib/template-render.ts`. */ export const START_VECTOR_YAML_TEMPLATE = `api: enabled: true diff --git a/apps/cli/src/commands/status/status.command.ts b/apps/cli/src/commands/status/status.command.ts index 216ac11bbf..0652cc861d 100644 --- a/apps/cli/src/commands/status/status.command.ts +++ b/apps/cli/src/commands/status/status.command.ts @@ -16,11 +16,8 @@ import { withJsonErrorHandling } from "../../shared/output/json-error-handling.t import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { status } from "./status.handler.ts"; -// `--override-name` and `--exclude` are pflag-style string-slice flags, which -// CSV-split each occurrence and accumulate -// across repeats — `--override-name a=1,b=2` is two overrides, not one. -// Malformed CSV fails at parse time with pflag's exact diagnostic (CLI-2005, -// see `stringSliceFlag`). +// pflag-style string-slice flags: each occurrence is CSV-split and accumulated across repeats, +// so `--override-name a=1,b=2` is two overrides, not one. Malformed CSV fails at parse time. export const statusOverrideNameFlag = stringSliceFlag( "override-name", "Override specific variable names.", @@ -43,24 +40,14 @@ const config = { export type StatusFlags = CliCommand.Command.Config.Infer<typeof config>; -// `status` makes no Management API calls (it needs no access token), so it -// deliberately avoids `managementApiRuntimeLayer` (the EAGER -// `CommandPlatformApi` stack, which resolves a token at layer BUILD time and -// fails outright with none) — mirrors `unlink`'s runtime shape. -// `commandSettingsLayer` is exposed at the top level directly (nothing else in -// this runtime needs to consume it internally). +// `status` makes no Management API calls, so it avoids `managementApiRuntimeLayer` — the eager +// `CommandPlatformApi` stack that resolves a token at layer build time and fails outright +// without one. const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); -// TS-only QoL (CLI-2167 follow-up, no Go counterpart): a LAZY Management API -// handle for `resolveLinkedState`'s best-effort branch-name lookup -// (`linked-state.ts`). `commandPlatformApiFactoryLayer` — not the eager -// `commandPlatformApiLayer` — defers all token resolution and client -// construction to the first `factory.make` call (memoised via -// `Effect.cached`), i.e. only when a branch lookup actually fires, and its -// own layer build never fails without a token/network. `makeCommandPlatformApi` -// (which the factory wraps) also needs `IdentityStitch` for response -// stitching, hence the extra provide + top-level merge below (mirrors -// `db pull`'s `linkedDbResolverRuntimeLayer` composition). +// Lazy Management API handle for `resolveLinkedState`'s best-effort branch-name lookup: +// `commandPlatformApiFactoryLayer` defers token resolution to the first `factory.make` call, so +// its layer build never fails without a token, and provides `IdentityStitch` for response stitching. const credentials = commandCredentialsLayer.pipe( Layer.provide(cliSettings), Layer.provide(debugLoggerLayer), diff --git a/apps/cli/src/commands/status/status.command.unit.test.ts b/apps/cli/src/commands/status/status.command.unit.test.ts index 4ecfabbac7..d1d56fab21 100644 --- a/apps/cli/src/commands/status/status.command.unit.test.ts +++ b/apps/cli/src/commands/status/status.command.unit.test.ts @@ -42,9 +42,6 @@ describe("status --override-name flag (pflag StringSlice parity)", () => { }); test("keeps only the first CSV record of a multiline value (pflag reads ONE record)", async () => { - // Verified against pflag's actual CSV behavior (CLI-2005): `status --override-name $'a=1\nb"2'` - // raises no parse error — pflag calls `csv.Reader.Read()` once, so the malformed - // second line is silently dropped. const [, overrideName] = await Effect.runPromise( statusOverrideNameFlag .parse({ flags: { "override-name": ['a=1\nb"2'] }, arguments: [] }) @@ -64,7 +61,6 @@ describe("status --override-name flag (pflag StringSlice parity)", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - // Matches the established pflag CSV error text (`"api.url=FOO` is 12 bytes → EOF at column 13). expect(normalizeCause(exit.cause).message).toBe( 'invalid argument "\\"api.url=FOO" for "--override-name" flag: parse error on line 1, column 13: extraneous or missing " in quoted-field', ); @@ -72,8 +68,6 @@ describe("status --override-name flag (pflag StringSlice parity)", () => { }); test("rejects a blank-only value with pflag's EOF diagnostic", async () => { - // Verified against pflag's actual output (CLI-2005): `status --override-name $'\n'` → - // `invalid argument "\n" for "--override-name" flag: EOF`. const exit = await Effect.runPromise( statusOverrideNameFlag .parse({ flags: { "override-name": ["\n"] }, arguments: [] }) @@ -119,7 +113,6 @@ describe("status --exclude flag (pflag StringSlice parity)", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - // Verified against pflag's actual output (CLI-2005): `status --exclude 'a"b'` — bare quote at byte 2. expect(normalizeCause(exit.cause).message).toBe( 'invalid argument "a\\"b" for "--exclude" flag: parse error on line 1, column 2: bare " in non-quoted-field', ); diff --git a/apps/cli/src/commands/status/status.handler.ts b/apps/cli/src/commands/status/status.handler.ts index 7bf8a40b10..42f04194f1 100644 --- a/apps/cli/src/commands/status/status.handler.ts +++ b/apps/cli/src/commands/status/status.handler.ts @@ -51,12 +51,8 @@ import { validateWorkdirIsDirectory } from "../../command-internal/workdir-valid import type { StatusFlags } from "./status.command.ts"; /** - * Parses `--override-name api.url=NEXT_PUBLIC_SUPABASE_URL` entries into a - * `fieldKey -> outputName` map: each entry must be a `KEY=VALUE` - * pair, validated only for that shape. Unmatched/unknown keys are walked - * against the known field keys and looked up — an - * entry whose `KEY` isn't one of the 18 known field keys is - * silently ignored, not an error. + * Parses `--override-name api.url=NEXT_PUBLIC_SUPABASE_URL` entries into a `fieldKey -> outputName` + * map. Each entry must be `KEY=VALUE`; an unknown `KEY` is silently ignored, not an error. */ function parseOverrides( entries: ReadonlyArray<string>, @@ -95,36 +91,26 @@ export const status = Effect.fn("status")(function* (flags: StatusFlags) { const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fs = yield* FileSystem.FileSystem; - // Go's `-o` (env|json|toml|yaml|pretty) is a complete format choice and - // takes priority over `--output-format` — hoisted so both the linked-state - // print gate below and the render branching at the end of this handler - // share one computation. + // `-o` (env|json|toml|yaml|pretty) is a complete format choice and takes priority over + // `--output-format` — hoisted so the linked-state print gate below and the final render + // branching share one computation. const goFmt = Option.getOrUndefined(goOutputFlag); yield* Effect.gen(function* () { - // TS-only QoL (CLI-2167 follow-up, no Go counterpart): resolve the current - // linked project/branch in EVERY output mode — never fails, and machine - // formats fold the result into their own payload below — so agents driving - // `status` machine-readable output can discover which project/branch - // they're on without a separate `link`/`branches` call. Resolved before any - // daemon/stack work begins so, in human text mode, the printed line below - // is visible even when status subsequently fails to connect to Docker. - // Lives INSIDE the telemetry-ensured scope: an interruption during the - // (bounded) lookup must still flush telemetry state (PR #6168 review). + // Resolves the current linked project/branch in every output mode (never fails) so agents + // driving machine-readable output can discover it without a separate `link`/`branches` call. + // Runs before any daemon/stack work so the printed line stays visible even if status later + // fails to reach Docker, and inside the telemetry-ensured scope so an interrupted lookup + // still flushes telemetry state. const linkedState = yield* resolveLinkedState(); if (output.format === "text" && (goFmt === undefined || goFmt === "pretty")) { yield* output.raw(formatLinkedStateBlock(linkedState)); } if (output.format === "json" || output.format === "stream-json") { - // TS-only QoL (CLI-2167 follow-up): the agent-discovery use case for this - // field matters MOST when status fails to reach the daemon/stack — a - // stopped stack is the common state an agent probes in — so mirror it - // onto the shared machine error envelope too (`MachineErrorContext`, - // read by `output.fail`), not just the success payload below. Harmless - // on the success path, since `output.success` never reads this cell. - // Read optionally (adds no R requirement) so this command's runtime - // choosing not to provide the cell just skips the mirroring, matching - // `output.fail`'s own optional read on the other end. + // Mirrors the linked-project field onto the shared machine error envelope too + // (`MachineErrorContext`, read by `output.fail`), not just the success payload below — + // an agent probing a stopped stack needs this on the failure path too. Read optionally so a + // runtime that doesn't provide the cell just skips the mirroring. const machineErrorContext = yield* Effect.serviceOption(MachineErrorContext); if (Option.isSome(machineErrorContext)) { yield* machineErrorContext.value.set({ @@ -133,46 +119,27 @@ export const status = Effect.fn("status")(function* (flags: StatusFlags) { } } - // 0. The resolved `--workdir`/`SUPABASE_WORKDIR` is `chdir`'d into - // unconditionally — before `status`'s own - // override-name parsing or handler body. A missing or non-directory - // path fails immediately, so this must win over every later error. + // 0. A missing/non-directory `--workdir` must fail before any other validation or Docker work. yield* validateWorkdirIsDirectory(cliSettings.workdir, fs).pipe( Effect.mapError((error) => new StatusWorkdirError({ message: error.message })), ); - // 1. `--override-name KEY=VALUE` parsing runs before config load or any - // Docker work. So a malformed - // `--override-name` entry fails before the handler ever loads config or - // touches Docker — it must win over - // a config-load error or a Docker/DB health-check error, not be masked by - // either. `overrides` itself is only consumed much later, by - // `statusValuesFromState` below. + // 1. A malformed `--override-name` entry must fail before config load or any Docker work, + // not be masked by a later config or health-check error. `overrides` is only consumed much + // later, by `statusValuesFromState`. const overrides = yield* parseOverrides(flags.overrideName); - // 2. `status` always needs config, unlike `stop`. An - // ABSENT config.toml is not a hard failure: config loading treats a missing - // file as a no-op and proceeds with template defaults. Only a MALFORMED - // file is a hard error. - // `loadLocalProjectContext` mirrors that (decoding an empty document - // through the schema for its defaults) and also resolves the sanitized, - // config/env-derived project id used below — see its own doc comment for - // the full rationale (including why workdir validation stays out - // of it and is instead handled by step 0 above). + // 2. An absent config.toml is not a hard failure — only a malformed one is; a missing file + // proceeds with template defaults. `loadLocalProjectContext` also resolves the sanitized + // project id used below; see its own doc comment for the full rationale. const context = yield* loadLocalProjectContext( cliSettings.workdir, (message) => new StatusConfigLoadError({ message }), ); - // 3. Resolve + VALIDATE config-derived state before any Docker call — - // config load + validation run entirely before the health check/container - // listing below. `resolveStatusLocalState` - // can throw `InvalidJwtSecretError` (a short `auth.jwt_secret`), - // `InvalidPortEnvOverrideError`/`InvalidBoolEnvOverrideError` - // (a malformed `SUPABASE_*_PORT`/`SUPABASE_*_ENABLED` override), or a - // signing-keys-file read/parse error — all of these must fail here, not - // be masked by a Docker/DB error when the local stack happens to be - // unavailable. + // 3. Config validation runs entirely before the health check/container listing below, so a + // config error (`InvalidJwtSecretError`, a malformed `SUPABASE_*_PORT`/`_ENABLED` override, a + // signing-keys-file error) fails here rather than being masked by a Docker/DB error. const localState = yield* Effect.try({ try: () => resolveStatusLocalState( @@ -188,22 +155,15 @@ export const status = Effect.fn("status")(function* (flags: StatusFlags) { }), }); - // 4. status has no --project-id flag; resolution is always env → toml → - // workdir basename, then sanitized to match the singleton config - // validation produces once at config-load time — every reader, including - // the Docker LABEL `start` writes, sees that same - // sanitized string, so `status` must filter on it too (see + // 4. `status` has no --project-id flag: resolution is env → toml → workdir basename, then + // sanitized to match the same string the Docker label `start` writes (see // `cliProjectFilterValue`'s doc comment). const projectId = context.projectId; const dbContainerId = localDbContainerId(projectId); - // 5. Health check, skipped entirely with --ignore-health-check. - // The health check never special-cases "not found" — an absent - // container fails the inspect call itself, which surfaces as the generic - // inspect error, not the "not running" branch (which - // only applies to a present-but-stopped container). - // `inspectContainerState` mirrors that: a missing container is just - // another non-zero exit, mapped below with the real Docker stderr text. + // 5. Skipped entirely with --ignore-health-check. An absent container fails the inspect call + // itself (a generic inspect error), not the "not running" branch, which only applies to a + // present-but-stopped container. if (!flags.ignoreHealthCheck) { const state = yield* inspectContainerState(spawner, dbContainerId).pipe( Effect.mapError((cause) => new StatusDbInspectError({ message: cause.message })), @@ -242,45 +202,27 @@ export const status = Effect.fn("status")(function* (flags: StatusFlags) { // 7. Merge health-derived exclusions with the user's --exclude flag. const excluded = [...stopped, ...flags.exclude]; - // 8. Apply the exclude-based gating on top of the already-validated - // `localState`. - // Pure/non-throwing — see `gateStatusState`'s doc comment. Reused - // for both the real and pretty-mode (empty-override) value maps below, - // matching this handler's pre-split behavior. + // 8. Applies exclude-based gating on top of the already-validated `localState`; pure and + // non-throwing (see `gateStatusState`'s doc comment). Reused for both the real and + // pretty-mode (empty-override) value maps below. const containerIds = statusContainerIds(projectId); const state = gateStatusState(localState, containerIds, excluded); const { values } = statusValuesFromState(state, overrides); - // The pretty renderer always uses a FRESH, empty override map - // rather than reusing the CLI-supplied, override-populated `names` — - // `--override-name` only ever affects the env/json/toml/yaml path, never - // the pretty table. - // Remap names from the already-resolved `state` (empty override map) so the - // rendered table stays consistent without leaking `--override-name` into - // pretty-mode output, and without a second (throwing) state resolution. + // The pretty renderer always uses a fresh, empty override map — `--override-name` only + // affects the env/json/toml/yaml path, never the pretty table — remapped from the + // already-resolved `state` so it stays consistent without a second (throwing) resolution. const renderPretty = Effect.fnUntraced(function* () { yield* output.raw(`${aqua("supabase")} local development setup is running.\n\n`, "stderr"); const pretty = statusValuesFromState(state, new Map()); yield* output.raw(renderStatusPretty(pretty.values, pretty.names)); }); - // 9. Output branching (goFmt hoisted above): Go's -o (env|json|toml|yaml|pretty) - // is a complete format choice and takes priority over --output-format - // (root.ts:119-121, matching functions/list's list.handler.ts:115-118) — - // only an ABSENT -o defers to --output-format for json/stream-json. - // - // Every non-pretty branch below folds in the linked-state fields resolved - // above (TS-only QoL, CLI-2167 follow-up) — absent entirely when not linked - // (no `linked: false` noise in these machine formats). The pretty table - // never sees them; it reads its own separately-resolved `pretty.values` by - // known name only. - // - // `values` spreads LAST so its own keys always win a collision (PR #6168 - // review): `--override-name` lets a user rename any of the 18 known - // fields to an arbitrary output key — `--override-name api.url=linked_project_ref` - // would otherwise silently overwrite our additive field with the API URL, - // or vice versa depending on spread order. The existing/overridden payload - // always takes priority over this extension, never the other way round. + // 9. `-o` takes priority over `--output-format`; only an absent `-o` defers to + // `--output-format` for json/stream-json. Every non-pretty branch folds in the linked-state + // fields resolved above (absent entirely when not linked); the pretty table never sees them. + // `values` spreads last so an `--override-name`-renamed field always wins a key collision + // with the linked-state extension, never the other way round. const valuesWithLinkedState = { ...linkedStateGoFields(linkedState), ...values }; if (goFmt === "env") { @@ -304,13 +246,12 @@ export const status = Effect.fn("status")(function* (flags: StatusFlags) { return; } - // goFmt is undefined — defer to TS --output-format for json/stream-json, - // otherwise render the grouped rounded-table (the `-o pretty` default). + // goFmt is undefined: defer to --output-format for json/stream-json, otherwise render the + // grouped rounded-table (the `-o pretty` default). if (output.format === "json" || output.format === "stream-json") { - // `null` when not linked (TS-only QoL, CLI-2167 follow-up). `values` - // spreads LAST so an `--override-name`-renamed field named literally - // `linked_project` always wins over this extension (PR #6168 review) — - // same existing-payload-always-wins rule as `valuesWithLinkedState` above. + // `null` when not linked. `values` spreads last so an `--override-name`-renamed field + // literally named `linked_project` still wins over this extension — same rule as + // `valuesWithLinkedState` above. yield* output.success("", { linked_project: linkedStateJsonField(linkedState), ...values, diff --git a/apps/cli/src/commands/status/status.integration.test.ts b/apps/cli/src/commands/status/status.integration.test.ts index edc6f26e79..16a99bed20 100644 --- a/apps/cli/src/commands/status/status.integration.test.ts +++ b/apps/cli/src/commands/status/status.integration.test.ts @@ -54,12 +54,8 @@ function writeConfig(workdir: string, contents = 'project_id = "demo"\n') { writeFileSync(join(supabaseDir, "config.toml"), contents); } -// --------------------------------------------------------------------------- -// Linked-state fixtures (CLI-2167 follow-up: `status` shows the linked -// project/branch in every output mode). Distinct 20-lowercase-letter refs so -// it's unambiguous which candidate (branch vs. parent) a given assertion -// targets. -// --------------------------------------------------------------------------- +// Linked-state fixtures: distinct 20-lowercase-letter refs so it's unambiguous which candidate +// (branch vs. parent) a given assertion targets. const LINKED_PARENT_REF = "parentprojectrefxxxx"; const LINKED_BRANCH_REF = "branchprojectrefyyyy"; @@ -124,13 +120,10 @@ function transportFailureForMock() { } /** - * Wires `CommandPlatformApiFactory` directly (`make` resolves immediately to a - * stubbed client) — NOT `CommandPlatformApi`. Pins the actual production - * acquisition path `acquireBranchLookupApi` falls back to when only the - * lazy factory is in scope (`status`'s real runtime shape, via - * `commandPlatformApiFactoryLayer` in `status.command.ts`), as opposed to the - * direct-service `branches` mock above, which existing tests provide and which - * a real `status` invocation never has. + * Wires `CommandPlatformApiFactory` directly (`make` resolves immediately to a stubbed client), + * not `CommandPlatformApi`. Matches the runtime shape `status` actually provides + * (`commandPlatformApiFactoryLayer` in `status.command.ts`), as opposed to a direct-service + * `branches` mock, which a real `status` invocation never has. */ function mockCommandPlatformApiFactoryDirect(opts: { readonly ok?: LinkedStateBranches; @@ -305,17 +298,13 @@ interface SetupOpts { readonly workdir?: string; /** * When set, wires a `CommandPlatformApi` layer stubbing `listAllBranches` for - * `resolveLinkedState`'s branch lookup (CLI-2167 follow-up). Omitted - * entirely by default — matching `status`'s real runtime, which never wires - * a Management API layer at all — so `Effect.serviceOption(CommandPlatformApi)` - * resolves to `None` unless a test opts in here. + * `resolveLinkedState`'s branch lookup. Omitted by default, matching `status`'s real runtime + * (no Management API layer), so `Effect.serviceOption(CommandPlatformApi)` resolves to `None`. */ readonly branches?: { readonly ok?: LinkedStateBranches; readonly fail?: unknown }; /** - * When set INSTEAD of `branches`, wires only `CommandPlatformApiFactory` - * (never `CommandPlatformApi` directly) — the shape `status`'s real runtime - * actually provides. Pins `acquireBranchLookupApi`'s factory-fallback - * path (CLI-2167 follow-up bug fix). + * When set instead of `branches`, wires only `CommandPlatformApiFactory` — the shape + * `status`'s real runtime actually provides. Pins `acquireBranchLookupApi`'s factory-fallback path. */ readonly apiFactory?: { readonly ok?: LinkedStateBranches; @@ -375,11 +364,10 @@ function setup(opts: SetupOpts = {}) { } /** - * A REAL captured `Stdio` layer (mirrors `output.layer.unit.test.ts`'s local - * `mockStdio()`) — needed only by the failure-envelope tests below, since the - * `MachineErrorContext` merge they pin lives inside the real - * `jsonOutputLayer`/`streamJsonOutputLayer` `fail` implementations, which - * `setup()`'s `mockOutput()` fake never replicates. + * A real captured `Stdio` layer (mirrors `output.layer.unit.test.ts`'s local `mockStdio()`) — + * needed only by the failure-envelope tests below, since the `MachineErrorContext` merge they + * pin lives inside the real `jsonOutputLayer`/`streamJsonOutputLayer` `fail` implementations, + * which `setup()`'s `mockOutput()` fake never replicates. */ function mockCapturingStdio() { const stdout: string[] = []; @@ -415,19 +403,13 @@ interface FailureEnvelopeOpts { } /** - * Dedicated setup for the json/stream-json FAILURE-envelope tests (CLI-2167 - * follow-up): wires the REAL `jsonOutputLayer`/`streamJsonOutputLayer` over a - * captured `Stdio`, a real `mockProcessControl()` (failure in these formats - * signals via exit code, not `Effect.fail` — see `withJsonErrorHandling`), - * and — merged ALONGSIDE the output layer, not nested inside its own - * `Layer.provide`, matching how `status.command.ts` composes - * `statusRuntimeLayer` — `machineErrorContextLayer`, so the SAME live - * cell both the handler (`status`) and the output layer's `fail` see is - * one instance. Every scenario forces a daemon-connection failure - * (`failSpawnFor: () => true`, the same "docker and podman both missing" - * mechanism as the existing text-mode daemon-failure test) so the command - * fails with `StatusDbInspectError` after the linked-state block has - * already resolved. + * Dedicated setup for the json/stream-json failure-envelope tests: wires the real + * `jsonOutputLayer`/`streamJsonOutputLayer` over a captured `Stdio`, a real + * `mockProcessControl()` (failure in these formats signals via exit code, not `Effect.fail`), + * and `machineErrorContextLayer` merged alongside the output layer (matching + * `status.command.ts`'s composition) so the handler and the output layer's `fail` share the same + * live cell. Every scenario forces a daemon-connection failure (`failSpawnFor: () => true`) so + * the command fails with `StatusDbInspectError` after the linked-state block has already resolved. */ function setupFailureEnvelope(opts: FailureEnvelopeOpts) { const workdir = tempRoot.current; @@ -484,11 +466,6 @@ describe("status integration", () => { it.live( "sanitizes a dirty config.toml project_id before filtering, matching start's label", () => { - // Config validation rewrites the resolved project id to its sanitized form once - // at config-load time; every later reader — - // including the Docker label `start` writes — sees that same sanitized - // string. Filtering/inspecting with the raw value here would target - // containers `start` never created. const { layer, child } = setup({ configContents: 'project_id = "My App!!"\n' }); return Effect.gen(function* () { yield* status(flags()); @@ -522,8 +499,8 @@ describe("status integration", () => { it.live( "succeeds against an unhealthy db when --ignore-health-check is set (status.go:104-108)", () => { - // Pairs with "fails when the db container is unhealthy" below (ignoreHealthCheck: false, - // the default) to cover both sides of the `if !ignoreHealthCheck { assertContainerHealthy }` gate. + // Pairs with "fails when the db container is unhealthy" below to cover both sides of + // the ignore-health-check gate. const { layer, child } = setup({ route: defaultRoute({ dbInspectStdout: JSON.stringify({ @@ -569,11 +546,6 @@ describe("status integration", () => { }); it.live("fails when [remotes.*] has a duplicate project_id, even with no projectRef", () => { - // The duplicate-project_id check runs unconditionally - // on every config load, inside the same loop that resolves the [remotes.*] - // override — it is not gated on a caller actually selecting a remote. - // `status` never binds a --project-ref flag, so it must still fail on a - // config-wide duplicate, before ever reaching Docker. const workdir = tempRoot.current; mkdirSync(join(workdir, "supabase"), { recursive: true }); writeFileSync( @@ -627,11 +599,6 @@ project_id = "short" it.live( "decodes a comma-separated string into an array field ([]string) for status to proceed", () => { - // The decode hook splits a plain string literal - // into a []string for a []string field like additional_redirect_urls — - // this only runs when goViperCompat is on. Pin that status still proceeds - // past config load (and on to a successful Docker inspect/list) rather - // than treating the string literal as a decode error. const { layer } = setup({ configContents: 'project_id = "demo"\n[auth]\nadditional_redirect_urls = "http://a,http://b"\n', @@ -643,13 +610,8 @@ project_id = "short" ); it.live("warns on stderr for a deprecated auth.external provider", () => { - // External-provider validation disables a bare - // [auth.external.slack] block and warns — mirrored by - // `normalizeDeprecatedExternalProviders` in packages/config's io.ts, gated - // on `goViperCompat` (confirmed already wired in status.handler.ts). The - // WARN goes out via Effect's `Console.error`, not this file's `Output` - // service, so it must be observed with a raw console.error spy — same - // idiom as packages/config/src/io.unit.test.ts's deprecated-provider tests. + // `normalizeDeprecatedExternalProviders` (packages/config/src/io.ts) emits this warning via + // `Console.error` only when `goViperCompat` is set. const { layer } = setup({ configContents: 'project_id = "demo"\n[auth.external.slack]\nenabled = true\n', }); @@ -663,9 +625,7 @@ project_id = "short" }); it.live("fails when --workdir/SUPABASE_WORKDIR points at a missing path", () => { - // The explicit workdir is `chdir`'d into before config - // load or any Docker call — a missing path must fail immediately, not - // fall through to the workdir-basename default and inspect Docker. + // Must fail before falling through to the workdir-basename default. const missingWorkdir = join(tempRoot.current, "does-not-exist"); const { layer, child } = setup({ workdir: missingWorkdir, skipConfig: true }); return Effect.gen(function* () { @@ -699,10 +659,6 @@ project_id = "short" }); it.live("fails when auth.jwt_secret is configured but shorter than 16 characters", () => { - // Config validation rejects this at config-load time, entirely before the - // health check/ - // container listing — so no Docker - // call happens, same as the malformed config.toml case above. const { layer, child } = setup({ configContents: 'project_id = "demo"\n[auth]\njwt_secret = "too-short"\n', }); @@ -744,9 +700,6 @@ content_path = "./supabase/templates/password_changed_notification.html" }); it.live("honors SUPABASE_AUTH_JWT_SECRET over a config.toml value with -o env", () => { - // Env vars resolve with higher precedence than config.toml — - // a stack started with this env var set - // must report the env-derived secret, not the one in config.toml. const { layer, out } = setup({ goOutput: Option.some("env"), configContents: `project_id = "demo"\n[auth]\njwt_secret = "${"a".repeat(32)}"\n`, @@ -760,9 +713,7 @@ content_path = "./supabase/templates/password_changed_notification.html" }); it.live("signs anon/service_role keys asymmetrically when signing_keys_path is set", () => { - // JWT generation signs with the first key in auth.signing_keys_path - // (RS256/ES256) instead of HMAC when that file resolves to a non-empty JWK - // array. + // Uses the first key in `auth.signing_keys_path` (RS256/ES256) instead of HMAC. const { layer, out, workdir } = setup({ goOutput: Option.some("json"), configContents: 'project_id = "demo"\n[auth]\nsigning_keys_path = "signing_keys.json"\n', @@ -780,15 +731,8 @@ content_path = "./supabase/templates/password_changed_notification.html" }); it.live("reports status using schema defaults when config.toml is missing entirely", () => { - // Config loading treats a missing file as a no-op, not an error — - // `status` proceeds - // using template defaults. Only a malformed file is a hard failure (see - // the sibling "malformed" test above). - // - // Without config.toml, the resolved project id falls back to the workdir - // basename (not the module-level `ALL_RUNNING_NAMES`, which is fixed to - // "demo") — route `ps` off that so the expected services actually show as - // running rather than all appearing "stopped" and excluded. + // Without config.toml, the resolved project id falls back to the workdir basename, not the + // module-level `ALL_RUNNING_NAMES` (fixed to "demo") — route `ps` off that basename instead. const projectId = basename(tempRoot.current); const { layer, out } = setup({ skipConfig: true, @@ -803,10 +747,6 @@ content_path = "./supabase/templates/password_changed_notification.html" }); it.live("resolves SUPABASE_PROJECT_ID from supabase/.env over config.toml", () => { - // Config loading loads the nested env (supabase/.env(.local)) - // before reading SUPABASE_PROJECT_ID from the resolved environment — - // an env-file-only value overrides - // config.toml's project_id too, not just an ambient shell export. const supabaseDir = join(tempRoot.current, "supabase"); mkdirSync(supabaseDir, { recursive: true }); writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=env-file-project\n"); @@ -845,9 +785,6 @@ content_path = "./supabase/templates/password_changed_notification.html" }); it.live("resolves SUPABASE_PROJECT_ID from a project-root .env file", () => { - // The nested env load walks past supabase/ one more level, to the project - // root/workdir — a project-root-only - // dotenv value must override config.toml too, not just supabase/.env. writeFileSync(join(tempRoot.current, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); const { layer, child } = setup({ configContents: 'project_id = "toml-project"\n', @@ -865,12 +802,6 @@ content_path = "./supabase/templates/password_changed_notification.html" it.live( "does not climb to an ancestor project's config.toml when workdir has none of its own", () => { - // The resolved workdir is used exactly, with no - // ancestor search — mirrored - // here by `search: false`. A workdir with no supabase/config.toml of its - // own must fall back to defaults (workdir-basename project id), not an - // ancestor project's config.toml, even though `cliSettings.workdir` sits - // right inside one. const nestedWorkdir = join(tempRoot.current, "nested"); mkdirSync(nestedWorkdir, { recursive: true }); writeConfig(tempRoot.current, 'project_id = "ancestor-project"\n'); @@ -892,10 +823,6 @@ content_path = "./supabase/templates/password_changed_notification.html" ); it.live("resolves SUPABASE_PROJECT_ID from supabase/.env even when config.toml is absent", () => { - // The nested env load runs unconditionally, before config.toml is ever - // opened — a supabase/.env-only project id - // must still be honored even when there's no config.toml to fall back to - // template defaults from. const supabaseDir = join(tempRoot.current, "supabase"); mkdirSync(supabaseDir, { recursive: true }); writeFileSync(join(supabaseDir, ".env"), "SUPABASE_PROJECT_ID=no-config-project\n"); @@ -913,10 +840,6 @@ content_path = "./supabase/templates/password_changed_notification.html" }); it.live("honors SUPABASE_AUTH_JWT_SECRET from supabase/.env, not just the ambient shell", () => { - // Config loading loads the nested env (supabase/.env(.local)) - // before reading SUPABASE_AUTH_JWT_SECRET from the resolved environment — - // a dotenv-file-only value must be visible here too, not just an ambient - // shell export (see the sibling "-o env" ambient test above). const supabaseDir = join(tempRoot.current, "supabase"); mkdirSync(supabaseDir, { recursive: true }); writeFileSync(join(supabaseDir, ".env"), `SUPABASE_AUTH_JWT_SECRET=${"c".repeat(32)}\n`); @@ -932,9 +855,6 @@ content_path = "./supabase/templates/password_changed_notification.html" }); it.live("fails when both docker and podman are missing", () => { - // Neither container runtime can be spawned at all — distinct from a spawned - // process exiting non-zero (covered by the malformed/unhealthy scenarios - // above). const { layer } = setup({ failSpawnFor: () => true }); return Effect.gen(function* () { const exit = yield* Effect.exit(status(flags())); @@ -949,8 +869,7 @@ content_path = "./supabase/templates/password_changed_notification.html" const { layer, child } = setup({ dockerMissing: true }); return Effect.gen(function* () { yield* status(flags()); - // The failed `docker` attempt is recorded before the `podman` fallback fires - // (`spawnContainerCli`'s `Effect.catch` retries the same argv), so the last + // The failed `docker` attempt is recorded before the `podman` fallback fires, so the last // matching record for a given argv is the successful one. const psCalls = child.spawned.filter((s) => s.args[0] === "ps"); expect(psCalls.at(-1)?.command).toBe("podman"); @@ -997,10 +916,8 @@ content_path = "./supabase/templates/password_changed_notification.html" it.live( "succeeds against a paused-but-healthy db, matching Go's boolean-based running gate", () => { - // The health check gates on the boolean - // `resp.State.Running`, not the status string — a paused container can - // report `Running: true` alongside `Status: "paused"`, and the handler continues - // past the not-running branch to the health check in that case. + // Gates on the boolean `Running`, not the status string — `Running: true` can coexist + // with `Status: "paused"`, and the handler continues past the not-running branch. const { layer } = setup({ route: defaultRoute({ dbInspectStdout: JSON.stringify({ @@ -1017,9 +934,6 @@ content_path = "./supabase/templates/password_changed_notification.html" ); it.live("fails when the db container is absent, preserving the real Docker stderr text", () => { - // The health check never special-cases "not found" — it wraps - // whatever the inspect call returns, so the real - // Docker stderr must flow through rather than a hardcoded TS string. const { layer } = setup({ route: defaultRoute({ dbInspectExitCode: 1, @@ -1115,9 +1029,6 @@ content_path = "./supabase/templates/password_changed_notification.html" }); it.live("merges an auto-detected stopped service with a --exclude entry (status.go:116)", () => { - // The exclusion list merges the health-derived - // stopped list with the user-supplied --exclude list — both must take effect - // together, not just whichever one the command would have applied alone. const { layer, out } = setup({ goOutput: Option.some("json"), // kong (index 0) is absent from the running set, so it's auto-detected as stopped. @@ -1171,9 +1082,6 @@ content_path = "./supabase/templates/password_changed_notification.html" }); it.live("silently ignores an --override-name entry with an unknown field key", () => { - // The override-name resolver walks the known field keys - // and looks up each one in the override map — it never checks - // for leftover/unmatched keys, so an unrecognized key is a no-op, not an error. const { layer, out } = setup({ goOutput: Option.some("json") }); return Effect.gen(function* () { yield* status(flags({ overrideName: ["not.a.real.field=NAME"] })); @@ -1216,9 +1124,8 @@ content_path = "./supabase/templates/password_changed_notification.html" }); it.live("lets --output pretty win over --output-format json", () => { - // Explicit `-o pretty` is a complete format choice (matching functions/list) - // and must render the table, not defer to the - // TS-only --output-format json/stream-json branch. + // `-o pretty` is a complete format choice and must render the table, not defer to + // --output-format. const { layer, out } = setup({ format: "json", goOutput: Option.some("pretty") }); return Effect.gen(function* () { yield* status(flags()); @@ -1255,9 +1162,8 @@ content_path = "./supabase/templates/password_changed_notification.html" it.live( "linked to a real project: prints the Linked Project block (Org + Project) with zero Management API calls", () => { - // An api mock IS wired here (unlike the "no layer at all" test below) so - // the assertion is a genuine runtime count, not just "we didn't ask for - // one" — the plain-project match must return before ever touching it. + // A mock is wired here (unlike the "no layer at all" test below), so the empty + // `requests` count is a genuine runtime assertion, not just "no layer" avoidance. const { layer, out, workdir, apiMock } = setup({ branches: { ok: [] } }); writeProjectRefFile(workdir, LINKED_PLAIN_REF); writeLinkedProjectCacheFile(workdir, LINKED_PLAIN_REF, { name: "My Project" }); @@ -1300,12 +1206,6 @@ content_path = "./supabase/templates/password_changed_notification.html" it.live( "branch-linked with the production factory-fallback path (CommandPlatformApiFactory, not CommandPlatformApi directly): resolves the branch", () => { - // Pins the actual bug this feature shipped to fix: `status`'s real - // runtime never wires `CommandPlatformApi` directly (only the lazy - // `CommandPlatformApiFactory`, via `commandPlatformApiFactoryLayer` in - // `status.command.ts`) — a test using the `branches` mock above - // would pass even if `acquireBranchLookupApi`'s factory fallback - // were broken or missing entirely. const { layer, out, workdir, apiFactoryMock } = setup({ apiFactory: { ok: [LINKED_BRANCH] }, }); @@ -1348,7 +1248,6 @@ content_path = "./supabase/templates/password_changed_notification.html" ` Branch: ${LINKED_BRANCH_REF}\n`, ), ).toBe(true); - // `make` failed before any `v1` call was attempted. expect(apiFactoryMock?.requests).toEqual([]); }).pipe(Effect.provide(layer)); }, @@ -1362,10 +1261,9 @@ content_path = "./supabase/templates/password_changed_notification.html" writeLinkedProjectCacheFile(workdir, LINKED_PARENT_REF, { name: "Parent Project" }); return Effect.gen(function* () { yield* status(flags()); - // The cache CONFIRMS a distinct parent, so the degraded state still - // carries it (and the org/name) — only the branch's own name is - // missing, rendered as a bare ref so the user still sees they're on - // a branch (never silently collapsing to a plain-project line). + // The cache confirms a distinct parent, so org/name still show; only the branch's + // own name is missing, rendered as a bare ref rather than silently collapsing to a + // plain-project line. expect( out.stdoutText.startsWith( `Linked Project:\n` + @@ -1399,17 +1297,13 @@ content_path = "./supabase/templates/password_changed_notification.html" () => { const { layer, out, workdir } = setup(); writeProjectRefFile(workdir, LINKED_BRANCH_REF); - // No linked-project.json and no API at all — the parent-chain's only - // candidate (the project-ref file itself) is never trustworthy enough - // on its own to claim a branch link; there must be no Org/Branch line. return Effect.gen(function* () { yield* status(flags()); expect( out.stdoutText.startsWith(`Linked Project:\n Project: ${LINKED_BRANCH_REF}\n`), ).toBe(true); - // `startsWith` alone would still pass if a Branch:/Org: line were - // wrongly appended right after (a real regression this rule guards - // against) — assert their absence explicitly, everywhere. + // `startsWith` alone would still pass a wrongly-appended Branch:/Org: line — + // assert their absence explicitly. expect(out.stdoutText).not.toContain("\n Branch:"); expect(out.stdoutText).not.toContain("\n Org:"); }).pipe(Effect.provide(layer)); @@ -1419,10 +1313,8 @@ content_path = "./supabase/templates/password_changed_notification.html" it.live( "no-false-claim rule: no cache, an API IS available but finds no matching branch — still the bare project line, no Branch/parent claim", () => { - // Distinct from the "no API at all" test above: here a lookup DOES run - // (against the project-ref file's own value as the query target) and - // comes back with branches, none of which match — a real, connected - // API that simply found nothing is not "positive confirmation" either. + // Distinct from the "no API at all" test above: here a lookup runs and returns + // branches, none matching — finding nothing is not positive confirmation either. const { layer, out, workdir } = setup({ branches: { ok: [{ ...LINKED_BRANCH, project_ref: "unrelatedbranchrefaaaa" }] }, }); @@ -1474,9 +1366,9 @@ content_path = "./supabase/templates/password_changed_notification.html" it.live( "-o json: a symlink/garbage project-ref file's content never reaches machine output (PR #6168 review, token-exfiltration vector)", () => { - // A malicious worktree can symlink supabase/.temp/project-ref at - // ~/.supabase/access-token; the pattern gate must keep any - // non-ref-shaped content (e.g. a token) out of every output channel. + // A malicious worktree can symlink `supabase/.temp/project-ref` at a real token file; + // the pattern gate must keep non-ref-shaped content (e.g. a token) out of every + // output channel. const { layer, out, workdir } = setup({ goOutput: Option.some("json") }); writeProjectRefFile(workdir, "sbp_0102030405060708090a0b0c0d0e0f10111213"); return Effect.gen(function* () { @@ -1529,13 +1421,9 @@ content_path = "./supabase/templates/password_changed_notification.html" it.live( "branch-linked with no cache at all: plain project block, ZERO API calls (cache alone is never proof of a link, PR #6168 review)", () => { - // An api mock IS wired (matching LINKED_BRANCH_REF as a self-referential - // branch) so the zero-requests assertion below is a genuine runtime - // count, not just "we didn't need one" — with no `linked-project.json`, - // `resolveLinkedParentRef`'s cache candidate never participates - // either (its own fix), so there is no parent to resolve at all, and - // `resolveLinkedState` never attempts the old self-referential - // branch lookup. + // A mock is wired here (self-referential branch) so the zero-requests assertion is a + // genuine runtime count: with no `linked-project.json`, there is no parent to resolve, + // so `resolveLinkedState` never attempts a lookup at all. const { layer, out, workdir, apiMock } = setup({ branches: { ok: [{ ...LINKED_BRANCH, parent_project_ref: LINKED_BRANCH_REF }], @@ -1669,10 +1557,6 @@ content_path = "./supabase/templates/password_changed_notification.html" it.live( "failure preservation: a daemon connection failure still fails with its existing error, with the linked block already on stdout", () => { - // Extends the existing "fails when both docker and podman are missing" - // scenario: linking a real project must not change the failure, and the - // linked-state block — resolved and printed BEFORE any daemon work — - // must already be present on stdout when the command fails. const { layer, out, workdir } = setup({ failSpawnFor: () => true, }); @@ -1692,10 +1576,9 @@ content_path = "./supabase/templates/password_changed_notification.html" ); describe("env-override guard (PR #6168 review)", () => { - // SUPABASE_PROJECT_ID (env) always wins before the project-ref FILE is - // ever read, so none of these write a project-ref file — the cache - // below belongs to the WORKDIR (an unrelated project A), not - // necessarily to whatever the env override happens to point at (B). + // SUPABASE_PROJECT_ID (env) always wins before the project-ref file is ever read, so none + // of these write a project-ref file — the cache below belongs to the workdir (project A), + // not necessarily to whatever the env override points at (B). it.live( "SUPABASE_PROJECT_ID overriding an unrelated workdir's cache: no positive lookup match degrades to the plain project line, no parent claim on cache presence alone", () => { @@ -1784,10 +1667,8 @@ content_path = "./supabase/templates/password_changed_notification.html" it.live( "a control-char/ANSI-laden project-ref file is treated as not linked; nothing of it reaches stdout (PR #6168 review)", () => { - // Superseded behavior: this used to render a sanitized block. The - // pattern gate in resolveSoftLinkedRef now rejects any - // non-ref-shaped content outright (stronger: also closes the - // symlink-to-secret exfiltration vector), so sanitization is + // The pattern gate in `resolveSoftLinkedRef` rejects any non-ref-shaped content + // outright, closing the symlink-to-secret exfiltration vector; sanitization is // defense-in-depth behind it. const DIRTY_REF = "\x1b[31mmalicious\x1b[0m"; const { layer, out, workdir } = setup(); @@ -1813,9 +1694,8 @@ content_path = "./supabase/templates/password_changed_notification.html" return Effect.gen(function* () { yield* status(flags({ overrideName: ["api.url=linked_project_ref"] })); const parsed = JSON.parse(out.stdoutText) as Record<string, string>; - // `values` (the override-renamed field) spreads LAST over - // `linkedStateGoFields`, so the API URL — not the branch ref — - // is what ends up under this key. + // `values` spreads last over `linkedStateGoFields`, so the API URL, not the branch + // ref, ends up under this key. expect(parsed.linked_project_ref).toBe("http://127.0.0.1:54321"); expect(parsed.API_URL).toBeUndefined(); }).pipe(Effect.provide(layer)); @@ -2214,12 +2094,8 @@ content_path = "./supabase/templates/password_changed_notification.html" it.live( "inertness guard: without machineErrorContextLayer in scope, the envelope has no linked_project key at all, and the command still fails identically", () => { - // Same branch-linked setup as the enriched test above — the point is - // that resolving linked state successfully is not enough on its own; - // omitting the cell from the layer graph must silently skip both the - // handler's `Effect.serviceOption(MachineErrorContext)` write AND the - // output layer's read, leaving the envelope byte-identical to the - // pre-feature shape. + // Omitting the cell from the layer graph must silently skip both the handler's + // `Effect.serviceOption(MachineErrorContext)` write and the output layer's read. const { layer, workdir, stdio, processControl } = setupFailureEnvelope({ format: "json", branches: { ok: [LINKED_BRANCH] }, diff --git a/apps/cli/src/commands/status/status.string-slice-flags.integration.test.ts b/apps/cli/src/commands/status/status.string-slice-flags.integration.test.ts index 9ca6140262..d6dc6811b8 100644 --- a/apps/cli/src/commands/status/status.string-slice-flags.integration.test.ts +++ b/apps/cli/src/commands/status/status.string-slice-flags.integration.test.ts @@ -16,22 +16,16 @@ import { } from "../../../tests/helpers/command-mocks.ts"; import { statusCommand } from "./status.command.ts"; -// `--override-name` and `--exclude` are string-slice flags (CLI-2005), so -// malformed CSV aborts flag parsing before the handler runs — before any Docker -// interaction — with the exact `invalid argument %q for %q flag: %v` line on -// stderr. These scenarios -// run the whole command tree (`Command.runWith`), mirroring the network-bans/ -// network-restrictions prior art from CLI-1983. +// `--override-name` and `--exclude` are string-slice flags, so malformed CSV aborts flag parsing +// before the handler runs, with the exact `invalid argument %q for %q flag: %v` line on stderr. +// These run the whole command tree (`Command.runWith`), not just the flag parser. const tempRoot = useTempWorkdir("supabase-status-string-slice-int-"); -// `withGlobalFlags` must come AFTER `withSubcommands`: it only excludes each -// global flag's context requirement from the R accumulated on the command -// SO FAR, and `withSubcommands` unions in every subcommand's own requirements -// (including `status`'s handler-chain reads of `DebugFlag`/ -// `ProfileFlag`/`WorkdirFlag`). Reversing the order leaves those -// context tags in `Command.runWith`'s Environment type even though this -// parse-failure path never reaches the handler at runtime. +// `withGlobalFlags` must come after `withSubcommands`: it excludes each global flag's context +// requirement only from what's already accumulated, so subcommand requirements need to be unioned +// in first. Reversing the order would leave those context tags in `Command.runWith`'s Environment +// type, even though this parse-failure path never reaches the handler. const testRoot = Command.make("supabase").pipe( Command.withSubcommands([statusCommand]), Command.withGlobalFlags(GLOBAL_FLAGS), @@ -73,8 +67,6 @@ function setup() { } describe("status StringSlice flags (pflag CSV parity)", () => { - // Every rendered line below was verified against pflag's actual output - // (pflag v1.0.10 → encoding/csv). const cases: ReadonlyArray<{ readonly name: string; readonly args: ReadonlyArray<string>; diff --git a/apps/cli/src/commands/stop/stop.command.ts b/apps/cli/src/commands/stop/stop.command.ts index 599eaaa93b..311062eeb3 100644 --- a/apps/cli/src/commands/stop/stop.command.ts +++ b/apps/cli/src/commands/stop/stop.command.ts @@ -15,8 +15,7 @@ const config = { Flag.withDescription("Local project ID to stop."), Flag.optional, ), - // Hidden boolean kept for backward compatibility: `--backup=false` is the historical - // way to skip the backup and is functionally identical to `--no-backup`. + // Hidden for backward compatibility: `--backup=false` is equivalent to `--no-backup`. backup: Flag.boolean("backup").pipe( Flag.withDescription("Backs up the current database before stopping."), Flag.withDefault(true), @@ -26,12 +25,9 @@ const config = { Flag.withDescription("Deletes all data volumes after stopping."), Flag.withDefault(false), ), - // Modelled as `Option<boolean>` (presence = "explicitly set"), not a plain - // boolean: `--project-id`/`--all` are mutually exclusive whenever BOTH flags - // were explicitly set, regardless of the value `--all` was set to. - // A plain `Flag.boolean` here would make `--project-id x --all=false` - // indistinguishable from `--project-id x` (no `--all` at all), silently - // accepting a combination that must be rejected. + // `Option<boolean>` so presence means "explicitly set": `--project-id`/`--all` are mutually + // exclusive whenever both were explicitly set, regardless of `--all`'s value. A plain + // `Flag.boolean` couldn't distinguish `--project-id x --all=false` from `--project-id x` alone. all: Flag.boolean("all").pipe( Flag.withDescription("Stop all local Supabase instances from all projects across the machine."), Flag.optional, @@ -40,11 +36,9 @@ const config = { export type StopFlags = CliCommand.Command.Config.Infer<typeof config>; -// `stop` makes no Management API calls (it needs no access token) and talks -// directly to Docker, so it deliberately avoids `managementApiRuntimeLayer` — -// it provides only the services the handler + instrumentation consume. -// `ChildProcessSpawner` is not listed here: it comes from `BunServices` in the root -// runtime (`shared/cli/run.ts`), the same way `gen types`/`unlink` rely on it. +// `stop` talks directly to Docker and needs no Management API access, so it provides only +// the services the handler and instrumentation consume, not `managementApiRuntimeLayer`. +// `ChildProcessSpawner` comes from `BunServices` in the root runtime instead. const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); const stopRuntimeLayer = Layer.mergeAll( diff --git a/apps/cli/src/commands/stop/stop.e2e.test.ts b/apps/cli/src/commands/stop/stop.e2e.test.ts index adf7792baf..d232a2a42f 100644 --- a/apps/cli/src/commands/stop/stop.e2e.test.ts +++ b/apps/cli/src/commands/stop/stop.e2e.test.ts @@ -28,12 +28,9 @@ const STOP_TEST_TIMEOUT_MS = DOCKER_INSPECT_TIMEOUT_MS + LIFECYCLE_MARGIN_MS; -// `stop` never calls the Management API — it talks directly to the real local -// Docker stack `start` creates. `describe` gates -// purely as the "we're in the full cli-e2e-ci runner" signal (it also has a -// real Docker daemon, since that's how supabox itself runs); the -// SUPABASE_ACCESS_TOKEN it gates on is otherwise irrelevant here. See -// AGENTS.md's "e2e tests" section for the full convention. +// `stop` never calls the Management API — it talks directly to the real local Docker stack +// `start` creates. The suite gates on `SUPABASE_ACCESS_TOKEN` purely as a "real e2e runner" +// signal, which also guarantees a Docker daemon; see AGENTS.md's "e2e tests" section. describe("supabase stop (e2e)", () => { let project: Awaited<ReturnType<typeof makeTempCliStackProject>> | undefined; let projectId: string | undefined; @@ -61,17 +58,15 @@ describe("supabase stop (e2e)", () => { requireCliSuccess(init, "init setup"); await overrideStackPorts(projectDir); - // Exclude the heaviest, least relevant services (Next.js Studio build, the - // logging pipeline) — `stop`'s Docker label-filtering logic doesn't care - // which services are running, only that at least one real container - // exists to stop. + // Exclude heavy, irrelevant services (Studio's Next.js build, the logging pipeline); + // `stop`'s label-filtering only needs at least one real container to exist. const start = await runSupabase( ["start", "--exclude", "studio", "--exclude", "logflare", "--exclude", "vector"], { cwd: projectDir, exitTimeoutMs: STACK_START_TIMEOUT_MS }, ); requireCliSuccess(start, "start setup"); - // Sanity: confirm the stack is actually up before testing `stop` against it. + // Confirm the stack is actually up before testing `stop` against it. const before = await runSupabase(["status"], { cwd: projectDir, exitTimeoutMs: CLI_COMMAND_TIMEOUT_MS, @@ -85,9 +80,6 @@ describe("supabase stop (e2e)", () => { expect(stop.exitCode, `stdout:\n${stop.stdout}\nstderr:\n${stop.stderr}`).toBe(0); expect(stop.stdout).toContain("Stopped"); - // The real Docker daemon must agree: no container carrying this project's - // label survives `stop` — the actual behavior under test, not just the - // cli's own exit code. const { stdout: remaining } = await execFileAsync( "docker", [ @@ -110,9 +102,8 @@ describe("supabase stop (e2e)", () => { async () => { project = await makeTempCliStackProject("sb-stop-e2e-"); const projectDir = project.dir; - // Sanitizing is a no-op for a `mkdtemp`-generated basename (already - // alphanumeric/`-`), but mirrors the port's actual resolution rather - // than assuming that stays true (same note as `start.e2e.test.ts`). + // Sanitizing is currently a no-op for a `mkdtemp` basename (already alphanumeric/`-`), but + // this mirrors the CLI's actual project-id resolution instead of assuming that stays true. projectId = sanitizeProjectId(path.basename(projectDir)); const init = await runSupabase(["init"], { @@ -128,11 +119,9 @@ describe("supabase stop (e2e)", () => { ); requireCliSuccess(start, "start setup"); - // `--no-backup` exercises the volume-prune branch; `--debug` turns on - // the `Pruned …:` stderr reports, which are - // backed by parsing REAL `docker`/`podman` prune stdout — the format - // assumption (`Deleted …:` headers, `Total reclaimed space:` trailer) - // that mocked integration fixtures cannot validate by construction. + // `--no-backup` exercises the volume-prune branch; `--debug` turns on the `Pruned …:` + // stderr reports, which parse real `docker`/`podman` prune stdout — the format assumption + // (`Deleted …:` headers, `Total reclaimed space:` trailer) that mocked fixtures can't validate. const stop = await runSupabase(["stop", "--no-backup", "--debug"], { cwd: projectDir, exitTimeoutMs: STOP_COMMAND_TIMEOUT_MS, @@ -140,21 +129,15 @@ describe("supabase stop (e2e)", () => { expect(stop.exitCode, `stdout:\n${stop.stdout}\nstderr:\n${stop.stderr}`).toBe(0); expect(stop.stdout).toContain("Stopped"); - // Containers: real Docker reports full hex IDs — the list must be - // non-empty, since the started stack's containers were just removed. expect(stop.stderr).toMatch(/^Pruned containers: \[[0-9a-f][^\]]*\]$/mu); - // Volumes: the db volume always exists (db is never excluded), so the - // report must name it. Other project volumes may also appear. + // The db volume always exists (never excluded), so the report must name it. const volumesLine = stop.stderr .split("\n") .find((line) => line.startsWith("Pruned volumes: [")); expect(volumesLine, `stderr:\n${stop.stderr}`).toContain(`supabase_db_${projectId}`); - // Network: exactly the project network; the established label is singular - // "network", unlike the other two reports. + // The prune report's network label is singular ("network"), unlike containers/volumes. expect(stop.stderr).toContain(`Pruned network: [supabase_network_${projectId}]`); - // The real Docker daemon must agree with the report: nothing carrying - // this project's label survives. const { stdout: remaining } = await execFileAsync( "docker", [ diff --git a/apps/cli/src/commands/stop/stop.errors.ts b/apps/cli/src/commands/stop/stop.errors.ts index 72f0ce4102..65f5e6f1b9 100644 --- a/apps/cli/src/commands/stop/stop.errors.ts +++ b/apps/cli/src/commands/stop/stop.errors.ts @@ -19,11 +19,7 @@ export class StopWorkdirError extends Data.TaggedError("StopWorkdirError")<{ } } -/** - * `--project-id` and `--all` were both set. Matches the established - * mutually-exclusive-flags message shape already - * used for `gen types`'s mutually-exclusive flag groups (`types.handler.ts`). - */ +/** `--project-id` and `--all` were both set. */ export class StopMutuallyExclusiveError extends Data.TaggedError("StopMutuallyExclusiveError")<{ readonly message: string; }> { @@ -41,11 +37,7 @@ export class StopConfigLoadError extends Data.TaggedError("StopConfigLoadError") } } -/** - * Listing containers to stop failed. `stop`-specific wrapper over - * `DockerLifecycleListError` (see `docker-lifecycle.ts`) so this command's - * errors are all in one file with a `Stop*` tag, matching the plan's error list. - */ +/** Listing containers to stop failed; wraps `DockerLifecycleListError` (see `docker-lifecycle.ts`). */ export class StopListError extends Data.TaggedError("StopListError")<{ readonly message: string; }> { diff --git a/apps/cli/src/commands/stop/stop.handler.ts b/apps/cli/src/commands/stop/stop.handler.ts index f08443301c..28d58fc5cf 100644 --- a/apps/cli/src/commands/stop/stop.handler.ts +++ b/apps/cli/src/commands/stop/stop.handler.ts @@ -30,72 +30,34 @@ import { } from "./stop.errors.ts"; /** - * Resolve the Docker label filter `stop` searches on. Flag precedence: - * `--all` bypasses config entirely with an empty filter; - * `--project-id` overrides the resolved project id directly, also bypassing - * config.toml; otherwise config loads and the resolved project id - * (env → toml → workdir basename) is used. - * - * "env" is the post-nested-env-load value, not just the ambient shell - * environment: config loading loads `supabase/.env`/`.env.local` *and* - * project-root/`SUPABASE_ENV`-selected dotenv files into the process env - * (never overriding an already-set var) *before* reading - * `SUPABASE_PROJECT_ID` — so an env-file-only value - * overrides config.toml too, not only an ambient shell export. - * `resolveProjectEnvironmentValues` implements that full precedence - * chain (see its doc comment) on top of `loadCliProjectEnvironment`'s - * `supabase/`-dir-only result, so it's used here instead of reading - * `process.env` directly. It still returns a usable map (falling back to - * `<workdir>/supabase`/`workdir` and `process.env` itself) even when no - * `supabase/` config file exists at `workdir`, matching the nested-env-load - * running unconditionally before `config.toml` is ever opened — - * the `?? process.env[...]` fallback below - * only still matters for keys neither source produced. - * - * The config/env-derived (default) branch is sanitized with - * `sanitizeProjectId` before it's used as a filter value, - * matching how config validation sanitizes the resolved project id - * singleton once at config-load time — every - * later reader, including the Docker LABEL `start` writes, sees that same - * sanitized string. The - * explicit `--project-id` bypass stays RAW to match: the flag - * value assigns straight to the resolved project id without going through - * validation. - * - * The check is `len(projectId) > 0`, not merely - * "was the flag set" — an explicit but empty `--project-id ""` falls through - * to the config.toml branch exactly like an absent flag, so that's mirrored - * here with a non-empty check rather than `Option.isSome` alone. + * Resolves the Docker label filter `stop` searches on: `--all` bypasses config with an empty + * filter; a non-empty `--project-id` (an empty string falls through like an absent flag) + * overrides the resolved id directly, unsanitized; otherwise it resolves via config (env → toml + * → workdir basename, see `resolveProjectEnvironmentValues`), sanitized with `sanitizeProjectId` + * to match the string the Docker label `start` writes. */ const resolveSearchProjectIdFilter = Effect.fn("stop.resolveSearchProjectIdFilter")(function* ( flags: StopFlags, cliSettings: CommandSettings["Service"], ) { - // The `!all` check reads the resolved value (not - // presence), so this branch stays value-based — `Option.getOrElse` mirrors - // the boolean flag's default of `false` when `--all` was never passed. + // Reads `--all`'s resolved value, not its presence; `Option.getOrElse` defaults to `false` + // when it was never passed. if (Option.getOrElse(flags.all, () => false)) return ""; if (Option.isSome(flags.projectId) && flags.projectId.value.length > 0) { return flags.projectId.value; } - // `loadLocalProjectContext` covers the config-load/env/project-id - // resolution sequence — see its own doc comment for the full - // rationale (including why workdir validation stays out of it - // and is instead handled by `stop`'s own unconditional call above). + // `loadLocalProjectContext` covers the config-load/env/project-id resolution sequence; see its + // own doc comment (workdir validation is handled separately, by `stop`'s own call above). const context = yield* loadLocalProjectContext( cliSettings.workdir, (message) => new StopConfigLoadError({ message }), ); - // VALIDATE config before any Docker call — the default `stop` path runs - // full config validation before ever touching Docker — unlike the - // `--all`/`--project-id` branches above, which bypass config loading - // entirely and so must NOT run this. `resolveLocalConfigValues` is - // reused purely for its throwing side effects (its resolved URLs/keys are - // discarded); it gives `stop` the same partial-but-growing config validation - // coverage `status` already has (`status.handler.ts`), rather than a one-off - // re-implementation. + // Runs full config validation before touching Docker, unlike the `--all`/`--project-id` + // branches above which bypass config loading. `resolveLocalConfigValues` is reused purely for + // its throwing side effects — its resolved URLs/keys are discarded — giving `stop` the same + // config-validation coverage `status` already has. yield* Effect.try({ try: () => resolveLocalConfigValues( @@ -125,18 +87,14 @@ export const stop = Effect.fn("stop")(function* (flags: StopFlags) { const debug = yield* DebugFlag; yield* Effect.gen(function* () { - // The resolved `--workdir`/`SUPABASE_WORKDIR` is `chdir`'d into - // unconditionally before any of `stop`'s - // own flag validation or handler body. A missing or non-directory path fails - // immediately, so this must win over every later error, including the + // A missing/non-directory `--workdir` must fail before any other validation, including the // `--project-id`/`--all` mutual-exclusivity check below. yield* validateWorkdirIsDirectory(cliSettings.workdir, fs).pipe( Effect.mapError((error) => new StopWorkdirError({ message: error.message })), ); - // Presence-based, matching the "explicitly set" check (see the doc comment on - // `all`'s flag definition in `stop.command.ts`) — `--project-id x --all=false` - // must reject too, not just `--all`/`--all=true`. + // Presence-based (see `all`'s flag doc comment in stop.command.ts): `--project-id x + // --all=false` must reject too, not just `--all=true`. if (Option.isSome(flags.projectId) && Option.isSome(flags.all)) { return yield* Effect.fail( new StopMutuallyExclusiveError({ @@ -149,55 +107,32 @@ export const stop = Effect.fn("stop")(function* (flags: StopFlags) { } const searchProjectIdFilter = yield* resolveSearchProjectIdFilter(flags, cliSettings); - // The hidden `--backup` flag's return value is discarded — never bound to a - // variable, so the handler always uses `!noBackup` regardless of - // `--backup`'s value. `--backup=false` is a no-op; - // only `--no-backup` deletes volumes. Matching that exactly (not the - // seemingly-intended-but-dead semantics of the flag's own description). + // The hidden `--backup` flag's value is never read; only `--no-backup` deletes volumes, so + // `--backup=false` alone is a no-op. const deleteVolumes = flags.noBackup; const filterValue = cliProjectFilterValue(searchProjectIdFilter); - // This line prints unconditionally and immediately, straight to stdout, - // before any Docker call runs. The debounced - // `output.task` spinner used elsewhere in this codebase gates its message - // behind a delay, which drops this line whenever the underlying calls - // resolve faster than that threshold — exactly what happens against the - // mocked/replayed Docker CLI. Print it directly so it always appears. + // Printed directly (not via `output.task`'s debounced spinner) so it always appears — the + // spinner's delay drops the message when Docker calls resolve faster than the threshold, as + // they do against mocked/replayed Docker. if (output.format === "text") { yield* output.raw("Stopping containers...\n"); } - // `dockerRemoveAll`: list -> stop - // concurrently -> container prune -> conditional volume prune -> network prune. See - // `docker-remove-all.ts` for the full rationale. Its 5 neutral, stage-tagged - // failure variants are remapped here into `stop`'s own tagged errors. + // `dockerRemoveAll` runs list -> stop -> container prune -> conditional volume prune -> + // network prune (see `docker-remove-all.ts`); its 5 stage-tagged failures are remapped into + // `stop`'s own tagged errors below. // - // The containers its listing step finds are captured via `onContainersRemoved` — fired only - // once `container prune` has CONFIRMED they're actually gone (not merely listed), so - // `cleanupStartSecrets` reclaims exactly the staged-secret directories - // (`<workdir>/supabase/.temp/start-secrets/<name>`) belonging to containers this run actually - // tore down, never a guess, never a container the stop stage itself failed on (so `container - // prune` never ran and nothing was actually removed), and never a blanket delete of the whole - // parent directory (see that function's doc comment for why that'd be unsafe) — and without a - // second, separately `docker ps`'d listing call, which would cost an extra real Docker Engine - // API request (see `dockerRemoveAll`'s doc comment). Each container's own - // `CLI_WORKDIR_LABEL` value is used to locate its directory — NOT `cliSettings.workdir` - // unconditionally — since `stop --all`/`stop --project-id <other>` may be tearing down a - // DIFFERENT project's containers than the one this invocation's own cwd/`--workdir` points at; - // `cliSettings.workdir` is passed through only as the fallback for a container with no such label - // (created before this label existed). + // `onContainersRemoved` fires only once container prune confirms removal, so + // `cleanupStartSecrets` reclaims exactly the directories for containers this run tore down. + // Each container's own `CLI_WORKDIR_LABEL` locates its directory, since `stop --all`/ + // `--project-id <other>` may be tearing down a different project's containers than this + // invocation's own `--workdir`. // - // Run via `Effect.ensuring` rather than a plain statement after this pipe: `dockerRemoveAll`'s - // LATER stages (volume prune, network prune) can still independently fail AFTER `container - // prune` has already confirmed removal, and a plain `yield*` below would never run once that - // later failure propagates — leaking staged secret directories for containers a later `stop` - // can no longer rediscover (they're already gone). `rollbackStart` (`command-internal/db-bootstrap/rollback.ts`) - // already runs this same cleanup unconditionally after its own `dockerRemoveAll` call for - // the identical reason; this makes `stop` consistent with that sibling caller, just without - // swallowing the teardown error itself. The finalizer is wrapped in `Effect.suspend` so - // `removedContainers` is read at FINALIZER-RUN time, not at pipe-construction time (before - // `onContainersRemoved` has fired) — same pattern as - // `storage/ls/ls.handler.ts`/`storage/rm/rm.handler.ts`. + // Runs via `Effect.ensuring` (in `Effect.suspend`, so `removedContainers` is read at + // finalizer-run time) because a later prune stage can still fail after containers are + // already confirmed removed — a plain statement here would skip cleanup and leak secret + // directories. let removedContainers: ReadonlyArray<ContainerIdName> = []; yield* dockerRemoveAll( spawner, @@ -226,8 +161,8 @@ export const stop = Effect.fn("stop")(function* (flags: StopFlags) { ); if (output.format === "text") { - // Written to stdout (no stream arg): `aqua` must target stdout's own - // TTY status, not stderr's — see `colors.ts`'s doc comment. + // Written to stdout: `aqua` must target stdout's own TTY status, not stderr's; see + // `colors.ts`'s doc comment. yield* output.raw(`Stopped ${aqua("supabase", process.stdout)} local development setup.\n`); } else { yield* output.success("Stopped supabase local development setup.", { @@ -236,8 +171,7 @@ export const stop = Effect.fn("stop")(function* (flags: StopFlags) { }); } - // Post-run suggestion: only meaningful in text mode — json/ - // stream-json payloads have no equivalent field to carry this hint. + // Only meaningful in text mode; json/stream-json payloads have no equivalent field for this hint. if (output.format === "text") { const remainingVolumes = yield* listVolumesByLabel(spawner, filterValue).pipe( Effect.orElseSucceed(() => []), diff --git a/apps/cli/src/commands/stop/stop.integration.test.ts b/apps/cli/src/commands/stop/stop.integration.test.ts index 17b718d1af..9ee68001de 100644 --- a/apps/cli/src/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/stop/stop.integration.test.ts @@ -53,31 +53,23 @@ type RouteResult = { }; /** - * Routes each spawned invocation to a caller-supplied result by matching argv - * (rather than a fixed call sequence): `stop` issues five distinct docker - * subcommands (`ps`, `stop`, `container prune`, `volume prune`, `network prune`, - * `volume ls`) whose relative order/count varies per scenario (N `stop` calls for - * N listed containers), so a routing table is a better fit than the sequential - * step-array mock `gen types` uses for its single linear pipeline. + * Routes each spawned invocation to a caller-supplied result by matching argv (rather than a + * fixed call sequence): `stop` issues five distinct docker subcommands whose relative + * order/count varies per scenario (N `stop` calls for N listed containers), so a routing table + * fits better than a sequential step array. * - * `stop`'s single `ps` listing uses the combined `--format "{{.ID}}\t{{.Names}}\t{{.Label - * \"com.supabase.cli.workdir\"}}"` (via `dockerRemoveAll`'s `onContainersRemoved` - * hook, see that function's doc comment) so `cleanupStartSecrets` gets container - * names/workdirs from the same request that lists ids to stop, rather than a second, - * separately-formatted `docker ps` call — which would cost an extra real Docker Engine - * API request. `stdout` for a `ps` route response is one `<id>\t<name>` - * line per container (no third, workdir column — every test here exercises the - * `cliSettings.workdir` fallback path); `defaultRoute` below tab-joins each configured id - * with itself. + * `stop`'s `ps` listing uses the combined `--format "{{.ID}}\t{{.Names}}\t{{.Label + * \"com.supabase.cli.workdir\"}}"` so `cleanupStartSecrets` gets container names/workdirs from + * the same request that lists ids to stop, without a second `docker ps` call. A `ps` route's + * `stdout` is one `<id>\t<name>` line per container (no workdir column — every test here + * exercises the `cliSettings.workdir` fallback); `defaultRoute` below tab-joins each id with itself. */ function mockRoutedContainerCliSpawner( route: (args: ReadonlyArray<string>) => RouteResult, opts: { readonly dockerMissing?: boolean; - // Fails BOTH docker and podman spawn attempts for argv matching this predicate, - // simulating a runtime that cannot be spawned at all (as opposed to a spawned - // process exiting non-zero) — exercises the `Effect.mapError`/`orElseSucceed` - // spawn-failure branches distinct from the exit-code-checking branches. + // Fails both docker and podman spawn attempts matching this predicate, simulating a + // runtime that cannot be spawned at all (vs. a spawned process exiting non-zero). readonly failSpawnFor?: (args: ReadonlyArray<string>) => boolean; } = {}, ) { @@ -154,9 +146,9 @@ function mockRoutedContainerCliSpawner( } /** - * Default happy-path router: `ps` lists one container, `docker version` reports - * an API version comfortably at/above the `volume prune --all` gate (1.42, see - * `dockerSupportsVolumePruneAllFlag`), everything else succeeds empty. + * Default happy-path router: `ps` lists one container, `docker version` reports an API version + * above the `volume prune --all` gate (see `dockerSupportsVolumePruneAllFlag`), everything else + * succeeds empty. */ function defaultRoute( opts: { @@ -257,16 +249,8 @@ describe("stop integration", () => { it.live( "reclaims staged-secret directories for containers it tears down, leaving unrelated ones alone", () => { - // cleanupStartSecrets (command-internal/start-secrets-cleanup.ts) - // reclaims start's staged plaintext-secret directories - // (<workdir>/supabase/.temp/start-secrets/<container-name>) for exactly - // the containers this stop run tore down — captured via - // `dockerRemoveAll`'s `onContainersRemoved` hook, never a blanket - // delete of the whole start-secrets/ parent (see that function's doc - // comment for why). `defaultRoute`'s `ps` stdout carries no third - // (workdir-label) column, so this also exercises the backward-compatible - // fallback to `cliSettings.workdir` for a container with no - // `com.supabase.cli.workdir` label (created before that label existed). + // `defaultRoute`'s `ps` stdout has no workdir-label column, so this also exercises the + // fallback to `cliSettings.workdir` for containers with no `com.supabase.cli.workdir` label. const { layer, workdir } = setup({ configuredProjectId: "demo", route: defaultRoute({ containerIds: ["supabase_kong_demo"] }), @@ -289,12 +273,8 @@ describe("stop integration", () => { it.live( "reclaims a container's staged-secret directory at its OWN labeled workdir, not this invocation's cwd", () => { - // `stop --all`/`stop --project-id <other>` can tear down a DIFFERENT project's containers - // than the one this invocation's own cwd/`--workdir` points at. Each container's own - // `com.supabase.cli.workdir` label (read back via the widened `docker ps --format`, see - // `listContainerIdsAndNames`) must be used to locate its staged-secret directory — - // never this invocation's `cliSettings.workdir` unconditionally, which would look in the - // wrong place and silently orphan the other project's secrets forever. + // Simulates `stop --all` tearing down a container from a different project than this + // invocation's own cwd/`--workdir`. const workdir = tempRoot.current; const otherProjectWorkdir = join(workdir, "other-project-root"); const { layer } = setup({ @@ -314,7 +294,7 @@ describe("stop integration", () => { "start-secrets", "supabase_kong_other", ); - // Same container name, but rooted at THIS invocation's own workdir — must survive, proving + // Same container name, rooted at this invocation's own workdir — must survive, proving // cleanup never falls back to `cliSettings.workdir` while a real label is present. const wrongDir = join(workdir, "supabase", ".temp", "start-secrets", "supabase_kong_other"); mkdirSync(correctDir, { recursive: true }); @@ -332,11 +312,6 @@ describe("stop integration", () => { it.live( "sanitizes a dirty config.toml project_id before filtering, matching start's label", () => { - // Config validation rewrites the resolved project id to its sanitized form once - // at config-load time; every later reader — - // including the Docker label `start` writes — sees that same sanitized - // string. Filtering on the raw value here would match nothing `start` - // ever labeled. const { layer, child } = setup({ configuredProjectId: "My App!!", route: defaultRoute(), @@ -357,9 +332,6 @@ describe("stop integration", () => { ); it.live("keeps an explicit --project-id raw, unsanitized (Go's bypass)", () => { - // The --project-id flag value assigns straight to the resolved project id - // without going through validation, so this - // path must NOT sanitize even though the default (config-derived) path does. const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); return Effect.gen(function* () { yield* stop(flags({ projectId: Option.some("Raw Value!!") })); @@ -433,9 +405,6 @@ describe("stop integration", () => { }); it.live("falls back to config.toml when --project-id is an empty string", () => { - // The check is `len(projectId) > 0`, not just - // "was --project-id set" — an empty value must fall through to config.toml - // exactly like an absent flag, not resolve to the bare/all-projects filter. const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute() }); return Effect.gen(function* () { yield* stop(flags({ projectId: Option.some("") })); @@ -452,10 +421,6 @@ describe("stop integration", () => { }); it.live("resolves SUPABASE_PROJECT_ID from supabase/.env over config.toml", () => { - // Config loading loads the nested env (supabase/.env(.local)) - // before reading SUPABASE_PROJECT_ID from the resolved environment — - // an env-file-only value overrides - // config.toml's project_id too, not just an ambient shell export. const { layer, child } = setup({ configuredProjectId: "toml-project", route: defaultRoute() }); writeEnvFile(tempRoot.current, ".env", "SUPABASE_PROJECT_ID=env-file-project\n"); return Effect.gen(function* () { @@ -496,12 +461,6 @@ describe("stop integration", () => { it.live( "does not climb to an ancestor project's config.toml when workdir has none of its own", () => { - // The resolved workdir is used exactly, with no - // ancestor search — mirrored - // here by `search: false`. A workdir with no supabase/config.toml of its - // own must fall back to defaults (workdir-basename project id), not an - // ancestor project's config.toml, even though `cliSettings.workdir` sits - // right inside one. const nestedWorkdir = join(tempRoot.current, "nested"); mkdirSync(nestedWorkdir, { recursive: true }); writeConfig(tempRoot.current, "ancestor-project"); @@ -527,10 +486,6 @@ describe("stop integration", () => { ); it.live("resolves SUPABASE_PROJECT_ID from supabase/.env even when config.toml is absent", () => { - // The nested env load runs unconditionally, before config.toml is ever - // opened — a supabase/.env-only project id - // must still be honored even when there's no config.toml to fall back to - // template defaults from. const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); writeEnvFile(tempRoot.current, ".env", "SUPABASE_PROJECT_ID=no-config-project\n"); return Effect.gen(function* () { @@ -548,9 +503,6 @@ describe("stop integration", () => { }); it.live("resolves SUPABASE_PROJECT_ID from a project-root .env file", () => { - // The nested env load walks past supabase/ one more level, to the project - // root/workdir — a project-root-only - // dotenv value must override config.toml too, not just supabase/.env. const { layer, child } = setup({ configuredProjectId: "toml-project", route: defaultRoute() }); writeFileSync(join(tempRoot.current, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); return Effect.gen(function* () { @@ -568,10 +520,7 @@ describe("stop integration", () => { }); it.live("fails when --workdir/SUPABASE_WORKDIR points at a missing path", () => { - // The explicit workdir is `chdir`'d into before any of - // `stop`'s own flag validation, config load, or Docker access — a missing - // path must fail immediately, not fall through to the workdir-basename - // default and prune under that name. + // Must fail before falling through to the workdir-basename default. const missingWorkdir = join(tempRoot.current, "does-not-exist"); const { layer, child } = setup({ workdir: missingWorkdir, skipConfig: true }); return Effect.gen(function* () { @@ -618,9 +567,8 @@ describe("stop integration", () => { }).pipe(Effect.provide(layer)); }); - // Cobra's `MarkFlagsMutuallyExclusive` mutex is presence-based (`Changed`), - // not value-based — `--all=false` still counts as "set" alongside - // `--project-id`, so this must reject too, not just `--all`/`--all=true`. + // Presence-based, not value-based: `--all=false` still counts as "set" alongside + // `--project-id`, so this must reject too, not just `--all=true`. it.live("rejects --project-id together with an explicit --all=false", () => { const { layer, child } = setup({ skipConfig: true, route: defaultRoute() }); return Effect.gen(function* () { @@ -656,9 +604,8 @@ describe("stop integration", () => { it.live( "omits --all from docker's volume prune on a pre-1.42 API host, matching Go's gate", () => { - // Docker CLI's own `volume prune --all` flag requires API >= 1.42 and - // hard-fails (pruning nothing) on an older daemon — this gate avoids ever - // sending it by checking the client version via `docker version`. + // Docker's own `volume prune --all` requires API >= 1.42 and hard-fails (pruning + // nothing) on an older daemon. const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute({ dockerApiVersion: "1.41" }), @@ -701,9 +648,6 @@ describe("stop integration", () => { }); it.live("--backup=false alone does not delete data volumes, matching Go's dead flag", () => { - // `--backup` is declared but never bound to a variable — - // the handler always uses `!noBackup`, so `--backup=false` has zero effect. - // Only `--no-backup` deletes volumes. const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute() }); return Effect.gen(function* () { yield* stop(flags({ backup: false })); @@ -759,10 +703,6 @@ describe("stop integration", () => { }); it.live("fails when [remotes.*] has a duplicate project_id, even with no projectRef", () => { - // Config validation builds the duplicate map across all [remotes.*] - // blocks unconditionally, so this must fail before - // stop ever selects a remote or touches Docker — not just when a - // matching --project-ref is requested. const workdir = tempRoot.current; mkdirSync(join(workdir, "supabase"), { recursive: true }); writeFileSync( @@ -788,9 +728,6 @@ project_id = "aaaaaaaaaaaaaaaaaaaa" }); it.live("fails when a [remotes.*] project_id is not a valid 20-letter ref", () => { - // Config validation checks every [remotes.*].project_id - // against the ref pattern unconditionally on every config load, so an invalid - // format must fail closed before stop reaches Docker. const workdir = tempRoot.current; mkdirSync(join(workdir, "supabase"), { recursive: true }); writeFileSync( @@ -815,10 +752,6 @@ project_id = "short" it.live( "decodes a comma-separated string into an array field ([]string) for stop to proceed", () => { - // The decode hook wires a comma-split decoder - // unconditionally, so a plain string value for a `[]string` field like - // `additional_redirect_urls` decodes fine and must not block stop from - // reaching Docker. const workdir = tempRoot.current; mkdirSync(join(workdir, "supabase"), { recursive: true }); writeFileSync( @@ -846,9 +779,8 @@ additional_redirect_urls = "http://a,http://b" ); it.live("warns on stderr for a deprecated auth.external provider", () => { - // `normalizeDeprecatedExternalProviders` (packages/config/src/io.ts) emits - // this WARN via `Console.error` only when `goViperCompat` is set — verify - // legacy stop keeps that behavior wired on. + // `normalizeDeprecatedExternalProviders` (packages/config/src/io.ts) emits this warning via + // `Console.error` only when `goViperCompat` is set. const workdir = tempRoot.current; mkdirSync(join(workdir, "supabase"), { recursive: true }); writeFileSync( @@ -875,10 +807,6 @@ enabled = true it.live( "fails and never spawns docker when config.toml has an unsupported db.major_version", () => { - // The default `stop` path runs config load + validation entirely before - // any Docker call — a config - // that fails validation must fail `stop` before it touches containers, not just when - // reading `project_id`. const workdir = tempRoot.current; mkdirSync(join(workdir, "supabase"), { recursive: true }); writeFileSync( @@ -899,7 +827,6 @@ enabled = true ); it.live("does not run config Validate for --all (bypasses config entirely)", () => { - // The `--all` branch never loads config, so an otherwise-invalid config.toml must not block it. const workdir = tempRoot.current; mkdirSync(join(workdir, "supabase"), { recursive: true }); writeFileSync( @@ -916,8 +843,6 @@ enabled = true }); it.live("does not run config Validate for --project-id (bypasses config entirely)", () => { - // An explicit `--project-id` sets - // the resolved project id directly and never loads config. const workdir = tempRoot.current; mkdirSync(join(workdir, "supabase"), { recursive: true }); writeFileSync( @@ -954,10 +879,8 @@ enabled = true it.live( "preserves a container's staged-secret directory when the stop stage itself fails", () => { - // The stop stage failing means `container prune` never even runs, so none of the listed - // containers were actually removed — some may still be live. `onContainersRemoved` only - // fires once `container prune` confirms removal, so it never fires here, and - // `cleanupStartSecrets` must not delete anything. + // The stop stage failing means container prune never runs, so `onContainersRemoved` never + // fires and `cleanupStartSecrets` must not delete anything. const { layer, workdir } = setup({ configuredProjectId: "demo", route: (args) => { @@ -981,9 +904,8 @@ enabled = true ); it.live("fails when a container cannot be spawned to stop it at all", () => { - // Distinct from a spawned `docker stop` exiting non-zero (covered above) — - // this exercises the branch where docker AND podman both fail to spawn for - // the `stop <id>` argv specifically. + // Distinct from a spawned `docker stop` exiting non-zero: here docker and podman both + // fail to spawn. const { layer } = setup({ configuredProjectId: "demo", route: (args) => (args[0] === "ps" ? { stdout: ["c1"] } : { exitCode: 0 }), @@ -1001,9 +923,6 @@ enabled = true it.live( "fails the same way in json mode, where 'Stopping containers...' is never printed", () => { - // The `output.format === "text"` gate around the "Stopping containers..." - // line means json mode skips it entirely; this exercises that the - // list/stop/prune failure path is unaffected by that gate. const { layer } = setup({ format: "json", configuredProjectId: "demo", @@ -1074,10 +993,9 @@ enabled = true }).pipe(Effect.provide(layer)); }); - // By the time volume/network prune fails, the container-prune stage before it has already - // `docker rm`'d the matching containers — a subsequent `stop` can no longer rediscover their - // names via `docker ps` to reclaim their staged-secret directories, so this cleanup must run - // even though the command itself still fails (see the `Effect.ensuring` finalizer above). + // By the time a later prune stage fails, container-prune has already removed the containers; + // a subsequent `stop` could no longer rediscover them via `docker ps`, so this cleanup must + // still run (see the `Effect.ensuring` finalizer above). it.live("still reclaims staged-secret directories when a later prune stage fails", () => { const { layer, workdir } = setup({ configuredProjectId: "demo", @@ -1121,9 +1039,8 @@ enabled = true }); return Effect.gen(function* () { yield* stop(flags()); - // The failed `docker` attempt is recorded before the `podman` fallback fires - // (`spawnContainerCli`'s `Effect.catch` retries the same argv), so the - // successful call is the LAST matching record, not the first. + // The failed `docker` attempt is recorded before the `podman` fallback fires, so the + // successful call is the last matching record, not the first. const psCalls = child.spawned.filter((s) => s.args[0] === "ps"); expect(psCalls.at(-1)?.command).toBe("podman"); expect(psCalls.some((s) => s.command === "docker")).toBe(true); @@ -1131,10 +1048,9 @@ enabled = true }); it.live("omits --all from podman's volume prune (not a real Podman flag)", () => { - // No released Podman `volume prune` accepts `--all` (only `--filter`/`--force`/ - // `--help`), so passing Docker's `--all` argv straight through to the Podman - // fallback would hard-fail after containers are already stopped. Podman prunes - // every unused volume by default, so dropping `--all` there is lossless. + // Podman's `volume prune` has no `--all` flag; passing Docker's argv through would + // hard-fail after containers are already stopped. Podman prunes every unused volume by + // default, so dropping `--all` is lossless. const { layer, child } = setup({ configuredProjectId: "demo", route: defaultRoute(), @@ -1240,9 +1156,8 @@ enabled = true }); it.live("still reports success when the post-run volume listing fails", () => { - // The volume-suggestion check is best-effort (`Effect.orElseSucceed`): a - // failure listing volumes after a successful stop must not fail the command — - // a listing error there is silently ignored, not surfaced. + // Best-effort (`Effect.orElseSucceed`): a listing error here is silently ignored, never + // surfaced. const { layer, out } = setup({ configuredProjectId: "demo", route: defaultRoute(), @@ -1255,10 +1170,9 @@ enabled = true }).pipe(Effect.provide(layer)); }); - // `dockerRemoveAll`'s `--debug` prune reports (`docker-remove-all.ts`'s - // `reportPruned`) write straight to `process.stderr`, bypassing the mocked `Output` - // service entirely — a raw `vi.spyOn` on `process.stderr.write` is the only way to - // observe them, same boundary the file already spies at for `console.error` above. + // `dockerRemoveAll`'s `--debug` prune reports write straight to `process.stderr`, bypassing + // the mocked `Output` service — a raw `vi.spyOn` on `process.stderr.write` is the only way + // to observe them. const pruneReportRoutes = (args: ReadonlyArray<string>): RouteResult => { if (args[0] === "container" && args[1] === "prune") { return { stdout: ["Deleted Containers:", "abc123", "", "Total reclaimed space: 42B"] }; diff --git a/apps/cli/src/commands/storage/cp/cp.command.integration.test.ts b/apps/cli/src/commands/storage/cp/cp.command.integration.test.ts index cfef5640f6..f5bfa12138 100644 --- a/apps/cli/src/commands/storage/cp/cp.command.integration.test.ts +++ b/apps/cli/src/commands/storage/cp/cp.command.integration.test.ts @@ -19,17 +19,10 @@ import { makeTelemetryIdentity } from "../../../shared/telemetry/identity.ts"; import { TelemetryRuntime } from "../../../shared/telemetry/runtime.service.ts"; import { storageCommand } from "../storage.command.ts"; -// `--jobs` is a pflag-style uint: a negative value fails -// `strconv.ParseUint` at flag-parse time — before the `--experimental` gate, -// the mutual-exclusivity check, and the handler body. `cp.command.ts` -// establishes that ordering by rejecting inside the flag's own -// `Flag.mapTryCatch`, which Effect CLI runs while parsing the command tree — -// strictly ahead of the handler (where the experimental gate and the -// `--linked`/`--local` mutex check live). This suite proves the rejection is -// wired into the real command tree — not just reachable by calling -// `storageCp` directly with a handcrafted `Option.some(-1)` flags -// object, which `cp.integration.test.ts` cannot exercise since it calls the -// handler directly. +// Proves `--jobs`'s negative-value rejection is wired into the real command tree, ahead of the +// `--experimental` gate and the `--linked`/`--local` mutex check in the handler — not just +// reachable by calling `storageCp` directly with a handcrafted flags object, which +// `cp.integration.test.ts` can't exercise since it calls the handler directly. const testRoot = Command.make("supabase").pipe( Command.withSubcommands([storageCommand]), Command.withGlobalFlags(GLOBAL_FLAGS), @@ -42,9 +35,8 @@ function setup(args: ReadonlyArray<string>) { CliOutput.layer(textCliOutputFormatter()), out.layer, Layer.succeed(CliArgs, { args }), - // `storageGatewayRuntimeLayer`'s cliSettings/credentials layers read - // real env/files when built. The jobs check under test never reaches that - // lazy factory, but isolate ambient env defensively anyway. + // The jobs check never reaches `storageGatewayRuntimeLayer`'s lazy cliSettings/credentials + // factory, but isolate ambient env defensively anyway. processEnvLayer({ SUPABASE_NO_KEYRING: "1" }), mockRuntimeInfo(), mockProcessControl().layer, @@ -76,12 +68,6 @@ describe("storage cp --jobs negative rejection (command-tree wiring)", () => { it.live( "rejects --jobs=-1 with pflag's exact ParseUint message, ahead of the experimental gate and the --linked/--local mutex conflict", () => { - // `--experimental` is deliberately ABSENT and `--linked`/`--local` are - // BOTH set: in Go, pflag's ParseUint failure preempts the experimental - // gate (`PersistentPreRunE`) and the mutex validation, so this must - // fail with the flag-parse error — not - // `ExperimentalRequiredError`, and not - // `StorageMutuallyExclusiveFlagsError`. const args = [ "storage", "cp", @@ -98,15 +84,11 @@ describe("storage cp --jobs negative rejection (command-tree wiring)", () => { if (Exit.isFailure(exit)) { const failure = Cause.findErrorOption(exit.cause); expect(Option.isSome(failure)).toBe(true); - // The parse failure must never reach the handler: neither the - // experimental gate nor the mutex check may fire. expect(JSON.stringify(exit.cause)).not.toContain( "must set the --experimental flag to run this command", ); expect(JSON.stringify(exit.cause)).not.toContain("StorageMutuallyExclusiveFlags"); - // `normalizeCause` is the exact rendering path `runCli` uses for - // parse failures — the user-visible line must be pflag's message, - // byte-identical, with no `Invalid value for flag --jobs:` wrapper. + // `normalizeCause` is the same rendering path `runCli` uses for parse failures. expect(normalizeCause(exit.cause).message).toBe( 'invalid argument "-1" for "-j, --jobs" flag: strconv.ParseUint: parsing "-1": invalid syntax', ); @@ -115,11 +97,8 @@ describe("storage cp --jobs negative rejection (command-tree wiring)", () => { }, ); - // Go validates the RAW token as unsigned (`strconv.ParseUint(s, 0, 64)`), so - // `-0` — which numeric normalization turns into negative zero, passing a - // `value < 0` check — is rejected, and the message keeps the original - // spelling (`-01`, not a normalized `-1`). Non-numeric tokens get the same - // byte-exact pflag message. All expected strings are go1.26 ground truth. + // `-0` normalizes to negative zero in a numeric check but must still be rejected with its + // original spelling (`-01`, not `-1`); non-numeric tokens get the same exact pflag message. it.live.each([ { token: "-0", @@ -146,9 +125,8 @@ describe("storage cp --jobs negative rejection (command-tree wiring)", () => { message: 'invalid argument "18446744073709551616" for "-j, --jobs" flag: strconv.ParseUint: parsing "18446744073709551616": value out of range', }, - // pflag `%q`s the value and strconv's NumError `strconv.Quote`s `e.Num` - // (`strconv/number.go:258-260`), so escapable tokens stay one escaped - // line — never a raw quote/backslash/newline in stderr. + // The value and error both get shell-escaped, so quotes/backslashes/newlines stay one + // escaped line, never raw, in stderr. { token: 'a"b', message: @@ -182,9 +160,7 @@ describe("storage cp --jobs negative rejection (command-tree wiring)", () => { }, ); - // base-0 ParseUint ACCEPTS prefix/underscore forms (`0x10` → 16, - // `010` → octal 8, `1_0` → 10), so these must clear flag parsing and fail - // later at the experimental gate — proving the token was not rejected. + // `0x10`→16, `010`→octal 8, `1_0`→10: valid base-0 forms that must clear parsing. it.live.each([{ token: "0x10" }, { token: "010" }, { token: "1_0" }])( "accepts --jobs=$token (Go base-0 form) through flag parsing, reaching the experimental gate", ({ token }) => { @@ -202,10 +178,8 @@ describe("storage cp --jobs negative rejection (command-tree wiring)", () => { }, ); - // Go parses flags before validating `ExactArgs(2)` (`cmd/storage.go:63,107`), - // so a malformed `--jobs` wins even when `src`/`dst` are missing. The config - // record declares flags ahead of the positionals to reproduce that order — - // Effect CLI parses params in config-declaration order. + // Flags are declared ahead of the positionals in cp.command.ts's config record, so a + // malformed `--jobs` wins even when `src`/`dst` are missing. it.live.each([ { label: "zero positionals", args: ["storage", "cp", "--jobs=-1"] }, { label: "one positional", args: ["storage", "cp", "onearg", "--jobs=-1"] }, diff --git a/apps/cli/src/commands/storage/cp/cp.command.ts b/apps/cli/src/commands/storage/cp/cp.command.ts index 03d7efae87..80299cb1f0 100644 --- a/apps/cli/src/commands/storage/cp/cp.command.ts +++ b/apps/cli/src/commands/storage/cp/cp.command.ts @@ -17,20 +17,10 @@ import { } from "../storage.flags.ts"; import { storageCp } from "./cp.handler.ts"; -// `--linked`/`--local` are scoped globals on the `storage` group. Effect CLI -// renders no defaults at all, so the established `(default …)` tokens are -// reproduced inline in the descriptions below to keep `storage cp --help` -// stable. `--content-type` keeps its empty runtime default (`""` ⇒ -// auto-detect via sniffing), but only the *displayed* default is -// `auto-detect`, so the help text — not the resolved value — reads -// `auto-detect`. -// Flags are declared BEFORE the `src`/`dst` positionals on purpose: Effect CLI -// parses params in config-declaration order (`parseParams` walks -// `orderedParams`, built from record-key order), so a malformed `--jobs` -// must win over a missing operand — flags are validated before args. -// Positional mapping is unaffected (only the relative order of Arguments -// matters), and `--help` renders flags in relative declaration order, so the -// help output is unchanged. +// Effect CLI renders no flag defaults, so the established `(default …)` tokens are written into +// the descriptions below (`--content-type`'s actual runtime default is `""` for auto-detect). +// Flags are declared before `src`/`dst` on purpose: Effect CLI validates flags before positional +// args in declaration order, so a malformed `--jobs` must fail before a missing-operand error. const config = { recursive: Flag.boolean("recursive").pipe( Flag.withAlias("r"), @@ -47,26 +37,16 @@ const config = { ), jobs: Flag.string("jobs").pipe( Flag.withAlias("j"), - // Keep the help token `--jobs, -j integer` that `Flag.integer` rendered — - // the flag is a string only so the RAW token reaches the parser below. + // Declared as a string, not `Flag.integer`, so the raw token reaches the parser below; + // `withMetavar` keeps the `--jobs, -j integer` help token. Flag.withMetavar("integer"), Flag.withDescription("Maximum number of parallel jobs. (default 1)"), - // `--jobs` is a pflag-style uint, so a non-uint token fails - // `strconv.ParseUint(s, 0, 64)` at flag-parse time — before group - // validation, the experimental gate, the handler body, and without - // emitting telemetry. The raw token is parsed with `parseUintBase0` - // (an exact ParseUint port) rather than `Flag.integer`, because numeric - // normalization loses fidelity: `-0` normalizes to negative zero (which a - // `value < 0` check accepts, where the established behavior rejects every - // sign prefix), error messages must carry the original spelling (`-01`, - // not `-1`), and base-0 forms (`0x10` → 16, `010` → 8, `1_0` → 10) must - // keep parsing. - // The resulting `CliError.InvalidValue` carries pflag's complete message, - // which `formatInvalidValueMessage` surfaces verbatim. Must sit before - // `Flag.optional`, which passes `InvalidValue` failures through - // untouched. Because flags are declared ahead of the positionals (see the - // config comment above), this error also wins when `src`/`dst` are - // missing — flags are validated before args. + // `--jobs` is a pflag-style uint: a non-uint token must fail at parse time, before the + // experimental gate, the handler, or any telemetry, with pflag's exact message and original + // spelling preserved. `Flag.integer` loses that fidelity (`-0` normalizes to negative zero, + // which a `value < 0` check would wrongly accept), so this parses the raw token with + // `parseUintBase0` instead; it must sit before `Flag.optional`, which passes `InvalidValue` + // through untouched. Flag.mapTryCatch( (token) => { const parsed = parseUintBase0(token); @@ -107,10 +87,8 @@ export const storageCpCommand = Command.make("cp", config).pipe( ]), Command.withHandler((flags) => Effect.gen(function* () { - // Gate before the mutex check below — order matters; see - // requireExperimental's doc comment for why. (A non-uint `--jobs` - // never reaches this handler: the flag's own `Flag.mapTryCatch` rejects - // it at parse time, before the experimental gate.) + // Gate before the mutex check below; see requireExperimental's doc comment for why. A + // non-uint `--jobs` never reaches here — `Flag.mapTryCatch` rejects it at parse time. yield* requireExperimental; const cliArgs = yield* CliArgs; yield* assertStorageTargetsExclusive(cliArgs.args); @@ -124,9 +102,7 @@ export const storageCpCommand = Command.make("cp", config).pipe( "project-ref": flags.projectRef, }; return yield* storageCp(flags).pipe( - // TS-only flag with no Go telemetry-safety baseline; Go's nearest - // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most - // others) are unmarked, so it stays redacted. + // Not in the safe-flags allowlist, so `--project-ref` stays redacted. withCommandTelemetry({ flags: telemetryFlags }), ); }).pipe(withJsonErrorHandling), diff --git a/apps/cli/src/commands/storage/cp/cp.handler.ts b/apps/cli/src/commands/storage/cp/cp.handler.ts index f3adde7319..379a25effe 100644 --- a/apps/cli/src/commands/storage/cp/cp.handler.ts +++ b/apps/cli/src/commands/storage/cp/cp.handler.ts @@ -70,11 +70,8 @@ export const storageCp = Effect.fn("storage.cp")(function* (flags: StorageCpFlag const runtimeInfo = yield* RuntimeInfo; const jobsFlag = Option.getOrElse(flags.jobs, () => 1); - // A non-uint `--jobs` is already rejected in `cp.command.ts` with pflag's - // uint parse error. The remaining clamp handles `--jobs 0` specifically: a - // zero-sized job queue with an unbuffered channel and a zero-run priming - // loop deadlocks the first Put. We clamp `0 → 1` to avoid that hang — do - // not remove it. + // `--jobs 0` clamps to 1 to avoid a job-queue deadlock on the first item; a non-uint `--jobs` + // is already rejected in `cp.command.ts`. const jobs = jobsFlag < 1 ? 1 : jobsFlag; const contentTypeFlag = Option.getOrElse(flags.contentType, () => ""); const cacheControlRaw = Option.getOrElse(flags.cacheControl, () => "max-age=3600"); @@ -86,9 +83,7 @@ export const storageCp = Effect.fn("storage.cp")(function* (flags: StorageCpFlag yield* Effect.gen(function* () { yield* assertStorageWorkdir(cliSettings.workdir); - // `--project-ref` never implies `--linked` and must not be silently - // discarded on the local target — see push.handler.ts's identical guard - // (db push) for the full TS-only rationale. + // `--project-ref` only applies to the linked project; it never implies `--linked`. if (Option.isSome(flags.projectRef) && flags.local) { return yield* Effect.fail( new StorageMutuallyExclusiveFlagsError({ @@ -105,8 +100,8 @@ export const storageCp = Effect.fn("storage.cp")(function* (flags: StorageCpFlag yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); } - // Parse both URLs leniently (NOT the strict storage-URL parser), BEFORE - // building the client — an invalid url fails without an api-keys lookup. + // Parses both URLs leniently (not the strict storage-URL parser) before building the + // client, so an invalid URL fails without an api-keys lookup. const srcUrl = yield* parseCpUrl(flags.src, "src"); const dstUrl = yield* parseCpUrl(flags.dst, "dst"); const srcIsStorage = srcUrl.scheme === STORAGE_SCHEME; @@ -186,7 +181,7 @@ const writeChunk = (handle: FileSystem.File, chunk: Uint8Array) => handle.writeA // Download (remote → local) -/** Go `api.DownloadObject` (`objects.go:135-142`): O_EXCL create, then stream. */ +/** Downloads a single object: creates the file exclusively (fails if it exists), then streams into it. */ const downloadSingle = ( gateway: StorageGateway, fs: FileSystem.FileSystem, @@ -211,7 +206,7 @@ const downloadSingle = ( }), ); -/** Go `DownloadStorageObjectAll` (`cp.go:63-97`): BFS, O_TRUNC, mkdir parents. */ +/** Recursively downloads a remote prefix: BFS walk, truncating existing files, creating parent dirs. */ const downloadAll = ( gateway: StorageGateway, output: typeof Output.Service, @@ -233,13 +228,9 @@ const downloadAll = ( : localPath0; const tasks: Array<{ objectPath: string; dstPath: string; isDir: boolean }> = []; - // Capture the walk error as a value rather than failing on it immediately: - // Go returns `errors.Join(walkErr, jq.Collect())` (`cp.go:96`), so two - // ordering rules hold. (1) The `count == 0 → "Object not found"` check - // precedes the join (`cp.go:93-95`), masking a walk error when nothing was - // visited. (2) A walk that errors partway still runs the already-queued - // downloads before the walk error surfaces — so the check is sequenced after - // the download pass below, not before it. + // Captured as a value, not failed immediately: an "Object not found" (nothing visited) must + // mask a walk error, and a walk that errors partway must still run the already-queued + // downloads before the walk error surfaces — so this is checked after the download pass below. const iterError = yield* iterateStoragePathsAll(gateway, output, remotePath, (objectPath) => Effect.gen(function* () { const relPath = objectPath.startsWith(remotePath) @@ -285,10 +276,8 @@ const downloadAll = ( { concurrency: jobs }, ); - // Surface the walk error only after the queued downloads have run, matching - // `errors.Join(walkErr, jq.Collect())`. A download failure propagates from - // the pass above (the job queue's first error); the rare walk-error + - // download-error pair is collapsed to whichever fails first. + // Surfaced only after the queued downloads have run; a download failure propagates from the + // pass above first, so a rare walk-error + download-error pair collapses to whichever fails first. if (iterError !== undefined) { return yield* Effect.fail(iterError); } @@ -327,7 +316,7 @@ const resolveContentType = (ctx: UploadCtx, filePath: string) => return contentTypeForUpload(sniff, filePath); }); -/** Go `api.UploadObject` single (`cp.go:55`): no x-upsert, no "Uploading:" line. */ +/** Uploads a single file without overwrite and without an "Uploading:" progress line. */ const uploadSingle = (ctx: UploadCtx, remoteDstPath: string, localPath: string) => Effect.gen(function* () { const contentType = yield* resolveContentType(ctx, localPath); @@ -339,7 +328,7 @@ const uploadSingle = (ctx: UploadCtx, remoteDstPath: string, localPath: string) ctx.summary.uploaded.push({ from: localPath, to: remoteDstPath }); }); -/** Go `UploadStorageObjectAll` (`cp.go:99-172`): walk + dst-key + auto-create. */ +/** Recursively uploads a local directory: walks files, resolves each destination key, auto-creating the bucket if needed. */ const uploadAll = (ctx: UploadCtx, remotePath: string, localPath: string, jobs: number) => Effect.gen(function* () { const noSlash = remotePath.endsWith("/") ? remotePath.slice(0, -1) : remotePath; @@ -412,7 +401,7 @@ const autoCreateAndRetry = ( ) => Effect.gen(function* () { const [bucket, prefix] = splitBucketPrefix(dstPath); - // Go only auto-creates when a prefix follows the bucket (`cp.go:154`). + // Only auto-creates the bucket when a prefix follows it; a bare bucket destination fails instead. if (prefix.length === 0) { return yield* Effect.fail(original); } @@ -482,7 +471,7 @@ const walkUploadDir = ( const names = [...(yield* fs.readDirectory(dir))].sort(); for (const name of names) { const abs = path.join(dir, name); - // afero.Walk uses Lstat (no-follow); a symlink is not regular → skipped. + // Symlinks are detected via `readLink` and skipped without following them. const isSymlink = yield* fs.readLink(abs).pipe( Effect.as(true), Effect.catch(() => Effect.succeed(false)), diff --git a/apps/cli/src/commands/storage/cp/cp.integration.test.ts b/apps/cli/src/commands/storage/cp/cp.integration.test.ts index c333e6855d..3f5cce6328 100644 --- a/apps/cli/src/commands/storage/cp/cp.integration.test.ts +++ b/apps/cli/src/commands/storage/cp/cp.integration.test.ts @@ -60,7 +60,7 @@ describe("storage cp", () => { expect(Exit.isSuccess(exit)).toBe(true); const upload = requests.find((r) => r.url.includes(OBJECT("private/readme.md"))); expect(upload?.method).toBe("POST"); - // Single upload does NOT set x-upsert (Overwrite stays false). + // Single upload doesn't set x-upsert (overwrite stays false). expect(upload?.headers["x-upsert"]).toBeUndefined(); expect(upload?.headers["cache-control"]).toBe("max-age=3600"); expect(upload?.headers["content-type"]).toContain("text/plain"); @@ -456,8 +456,8 @@ describe("storage cp", () => { }); it.live("uploads to the project given via --project-ref, overriding VALID_REF", () => { - // `opts.projectRef` (the fake's own fallback) is left at its default - // (VALID_REF) — the flag must win over it and drive the gateway host. + // The fake's own fallback stays at its default (VALID_REF); the flag must win and drive + // the gateway host. const FLAG_REF = "flagflagflagflagflag"; writeFileSync(join(tmp.current, "readme.md"), "hello world"); const { layer, requests, linkedCache } = setupStorage(tmp.current, { diff --git a/apps/cli/src/commands/storage/cp/cp.upload.ts b/apps/cli/src/commands/storage/cp/cp.upload.ts index 41a7b450c9..232f07b434 100644 --- a/apps/cli/src/commands/storage/cp/cp.upload.ts +++ b/apps/cli/src/commands/storage/cp/cp.upload.ts @@ -2,21 +2,18 @@ import * as nodePath from "node:path"; import { splitBucketPrefix, storageIsDir } from "../../../command-internal/storage-url.ts"; -/** - * Pure destination-key resolution for `storage cp` recursive uploads. Kept - * free of Effect/services so the branch matrix stays unit-testable. - */ +/** Pure destination-key resolution for `storage cp` recursive uploads. */ export interface UploadDstPathInput { - /** The destination object path (`dstParsed.Path`, e.g. `/private/dir/`). */ + /** The destination object path (e.g. `/private/dir/`). */ readonly remotePath: string; - /** `filepath.Rel(localPath, filePath)` — `"."` when `localPath` is the file itself. */ + /** Path of the current file relative to `localPath`; `"."` when `localPath` is the file itself. */ readonly relPath: string; - /** Base name of the current file (`info.Name()`), used in the single-file branch. */ + /** Base name of the current file, used in the single-file branch. */ readonly fileName: string; - /** Base name of the walk root (`filepath.Base(localPath)`). */ + /** Base name of the walk root. */ readonly baseName: string; - /** `strings.TrimSuffix(remotePath, "/")`. */ + /** `remotePath` with any trailing slash removed. */ readonly noSlash: string; /** Whether `base(noSlash)` exists as a directory at the destination. */ readonly dirExists: boolean; @@ -25,15 +22,13 @@ export interface UploadDstPathInput { } /** - * Resolve the remote destination key for one walked file (`cp.go:135-148`): - * - single file (`relPath === "."`): append the file name only when the - * destination prefix is itself a directory, or the destination dir exists and - * no same-named file does; - * - otherwise: nest under `baseName` when the destination dir exists (or the - * destination is a bare bucket), then append the relative path. + * Resolves the remote destination key for one walked file: + * - single file (`relPath === "."`): append the file name only when the destination prefix is + * itself a directory, or the destination dir exists and no same-named file does; + * - otherwise: nest under `baseName` when the destination dir exists (or the destination is a + * bare bucket), then append the relative path. * - * Remote keys are joined with POSIX semantics (Go uses `path.Join`); the relative - * segment's OS separators are normalised to `/`. + * Remote keys use POSIX join semantics; the relative segment's OS separators are normalized to `/`. */ export function resolveUploadDstPath(input: UploadDstPathInput): string { let dstPath = input.remotePath; diff --git a/apps/cli/src/commands/storage/ls/ls.command.ts b/apps/cli/src/commands/storage/ls/ls.command.ts index 4a864abb11..7ee6306f50 100644 --- a/apps/cli/src/commands/storage/ls/ls.command.ts +++ b/apps/cli/src/commands/storage/ls/ls.command.ts @@ -43,8 +43,7 @@ export const storageLsCommand = Command.make("ls", config).pipe( ]), Command.withHandler((flags) => Effect.gen(function* () { - // Gate before the mutex check below — order matters; see - // requireExperimental's doc comment for why. + // Gate before the mutex check below; see requireExperimental's doc comment for why. yield* requireExperimental; const cliArgs = yield* CliArgs; yield* assertStorageTargetsExclusive(cliArgs.args); @@ -55,9 +54,7 @@ export const storageLsCommand = Command.make("ls", config).pipe( "project-ref": flags.projectRef, }; return yield* storageLs(flags).pipe( - // TS-only flag with no Go telemetry-safety baseline; Go's nearest - // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most - // others) are unmarked, so it stays redacted. + // Not in the safe-flags allowlist, so `--project-ref` stays redacted. withCommandTelemetry({ flags: telemetryFlags }), ); }).pipe(withJsonErrorHandling), diff --git a/apps/cli/src/commands/storage/ls/ls.handler.ts b/apps/cli/src/commands/storage/ls/ls.handler.ts index 982ca49e59..6f520da3ac 100644 --- a/apps/cli/src/commands/storage/ls/ls.handler.ts +++ b/apps/cli/src/commands/storage/ls/ls.handler.ts @@ -34,9 +34,7 @@ export const storageLs = Effect.fn("storage.ls")(function* (flags: StorageLsFlag yield* Effect.gen(function* () { yield* assertStorageWorkdir(cliSettings.workdir); - // `--project-ref` never implies `--linked` and must not be silently - // discarded on the local target — see push.handler.ts's identical guard - // (db push) for the full TS-only rationale. + // `--project-ref` only applies to the linked project; it never implies `--linked`. if (Option.isSome(flags.projectRef) && flags.local) { return yield* Effect.fail( new StorageMutuallyExclusiveFlagsError({ @@ -46,21 +44,19 @@ export const storageLs = Effect.fn("storage.ls")(function* (flags: StorageLsFlag ); } - // Routing reads the `--local` value (Go `storage.go:21-32`): local clears the - // ref, otherwise the linked path resolves it. No network — safe before the - // url parse below. + // `--local` clears the ref; otherwise the linked path resolves it. No network access yet, + // safe before the URL parse below. const projectRef = flags.local ? "" : yield* resolver.loadProjectRef(flags.projectRef); linkedRef = projectRef; - // Config is always loaded; a `[remotes.*]` match prints the override - // line. + // Config is always loaded; a `[remotes.*]` match prints the override line. const loaded = yield* loadStorageConfig(cliSettings, projectRef); if (loaded.appliedRemote !== undefined) { yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); } - // Parse the URL BEFORE building the client (Go `ls.go:17`), so an invalid URL - // fails without an api-keys lookup or any Storage call. + // Parse the URL before building the client, so an invalid URL fails before any + // api-keys lookup or Storage call. const remotePath = yield* parseStorageUrlEffect(Option.getOrElse(flags.path, () => "ss:///")); const paths: Array<string> = []; diff --git a/apps/cli/src/commands/storage/ls/ls.integration.test.ts b/apps/cli/src/commands/storage/ls/ls.integration.test.ts index 2a54ceebd7..400b68e559 100644 --- a/apps/cli/src/commands/storage/ls/ls.integration.test.ts +++ b/apps/cli/src/commands/storage/ls/ls.integration.test.ts @@ -54,7 +54,6 @@ describe("storage ls", () => { Effect.exit, ); expect(Exit.isSuccess(exit)).toBe(true); - // Only the prefix-matching bucket is printed, with a trailing slash. expect(out.stdoutText).toBe("test/\n"); }); }); @@ -149,8 +148,8 @@ describe("storage ls", () => { ); expect(Exit.isSuccess(exit)).toBe(true); const lines = out.stdoutText.split("\n").filter(Boolean); - // Default path is `ss:///` → remotePath `/`, so basePath is `/` and file - // paths get a leading slash; an empty bucket is reported bare as `<bucket>/`. + // Default path `ss:///` → remotePath `/`, so paths get a leading slash and an empty + // bucket is reported bare as `<bucket>/`. expect(lines).toContain("test/"); expect(lines).toContain("/private/folder/abstract.pdf"); }); @@ -227,8 +226,8 @@ describe("storage ls", () => { }); it.live("lists the project given via --project-ref, overriding VALID_REF", () => { - // `opts.projectRef` (the fake's own fallback) is left at its default - // (VALID_REF) — the flag must win over it and drive the gateway host. + // The fake's own fallback stays at its default (VALID_REF); the flag must win and drive + // the gateway host. const FLAG_REF = "flagflagflagflagflag"; const { layer, requests, linkedCache } = setupStorage(tmp.current, { routes: [{ method: "GET", match: BUCKET, body: [{ name: "remote", id: "remote" }] }], @@ -267,9 +266,8 @@ describe("storage ls", () => { }); it.live("signs --local requests with a SUPABASE_AUTH_SERVICE_ROLE_KEY from supabase/.env", () => { - // The storage frame loads the project dotenv itself (no db reset / seed - // caller hands one in), so the auth override must reach the resolver from - // that walk too. Pin the ambient var away so only the dotenv value counts. + // The storage frame loads the project dotenv itself, so the auth override must reach the + // resolver from that walk. Pin the ambient var away so only the dotenv value counts. const { layer, requests } = setupStorage(tmp.current, { toml: 'project_id = "test"\n', local: true, @@ -300,7 +298,6 @@ describe("storage ls", () => { return Effect.gen(function* () { const exit = yield* storageLs(lsFlags()).pipe(Effect.provide(layer), Effect.exit); expect(Exit.isSuccess(exit)).toBe(true); - // No streamed stdout lines in json mode; a single result carries the paths. expect(out.stdoutText).toBe(""); const success = out.messages.find((m) => m.type === "success"); expect(success?.data?.["paths"]).toEqual(["test/"]); @@ -324,7 +321,6 @@ describe("storage ls", () => { Effect.exit, ); expect(Exit.isSuccess(exit)).toBe(true); - // json/stream-json suppress the pagination notice. expect(out.stderrText).not.toContain("Loading page"); }); }); @@ -332,10 +328,6 @@ describe("storage ls", () => { it.live( "fails with a missing-project error when --workdir names a config-less subdirectory of a real ancestor project", () => { - // CLI-2285 regression, `loadStorageConfig`'s shared path: the - // ancestor project genuinely has a valid config.toml, and the - // subdirectory genuinely has none of its own — an EXPLICIT --workdir - // must never silently climb to the ancestor's config. writeAncestorConfig(tmp.current, 'project_id = "test"\n[api]\nport = 65432\n'); const sub = join(tmp.current, "nested", "dir"); mkdirSync(sub, { recursive: true }); @@ -356,10 +348,9 @@ describe("storage ls", () => { it.live( "a remote (--linked) target with the same config-less explicit workdir still succeeds", () => { - // The missing-project hard-fail is LOCAL-only: `resolveStorageCredentials` - // never reads local config on the remote path (Management API credentials - // only), so a config-less explicit workdir poses none of the "retargets a - // different local stack" risk the local-target hard-fail guards against. + // The missing-project hard-fail is local-only: `resolveStorageCredentials` never reads + // local config on the remote path, so a config-less workdir poses none of the risk the + // local-target hard-fail guards against. writeAncestorConfig(tmp.current, 'project_id = "test"\n[api]\nport = 65432\n'); const sub = join(tmp.current, "nested", "dir"); mkdirSync(sub, { recursive: true }); @@ -383,11 +374,9 @@ describe("storage ls", () => { it.live( "hints at the ancestor's --workdir when it genuinely has a project (shared helper propagation)", () => { - // Confirms `missingProjectConfigMessageEffect`'s "Did you mean" - // hint is not `config diff`-specific wiring — the full regression and - // its negative counterpart are pinned in - // config/diff/diff.integration.test.ts; this only proves the shared - // helper reaches storage's own missing-project message too. + // Confirms `missingProjectConfigMessageEffect`'s "Did you mean" hint isn't `config + // diff`-specific — the full regression is pinned in config/diff/diff.integration.test.ts; + // this only proves the shared helper reaches storage's message too. writeAncestorConfig(tmp.current, 'project_id = "test"\n[api]\nport = 65432\n'); const sub = join(tmp.current, "nested", "dir"); mkdirSync(sub, { recursive: true }); diff --git a/apps/cli/src/commands/storage/mv/mv.command.ts b/apps/cli/src/commands/storage/mv/mv.command.ts index 09678c5e5a..6c2576fed6 100644 --- a/apps/cli/src/commands/storage/mv/mv.command.ts +++ b/apps/cli/src/commands/storage/mv/mv.command.ts @@ -41,8 +41,7 @@ export const storageMvCommand = Command.make("mv", config).pipe( ]), Command.withHandler((flags) => Effect.gen(function* () { - // Gate before the mutex check below — order matters; see - // requireExperimental's doc comment for why. + // Gate before the mutex check below; see requireExperimental's doc comment for why. yield* requireExperimental; const cliArgs = yield* CliArgs; yield* assertStorageTargetsExclusive(cliArgs.args); @@ -53,9 +52,7 @@ export const storageMvCommand = Command.make("mv", config).pipe( "project-ref": flags.projectRef, }; return yield* storageMv(flags).pipe( - // TS-only flag with no Go telemetry-safety baseline; Go's nearest - // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most - // others) are unmarked, so it stays redacted. + // Not in the safe-flags allowlist, so `--project-ref` stays redacted. withCommandTelemetry({ flags: telemetryFlags }), ); }).pipe(withJsonErrorHandling), diff --git a/apps/cli/src/commands/storage/mv/mv.handler.ts b/apps/cli/src/commands/storage/mv/mv.handler.ts index e114ca526e..a10f4c7a13 100644 --- a/apps/cli/src/commands/storage/mv/mv.handler.ts +++ b/apps/cli/src/commands/storage/mv/mv.handler.ts @@ -43,9 +43,7 @@ export const storageMv = Effect.fn("storage.mv")(function* (flags: StorageMvFlag yield* Effect.gen(function* () { yield* assertStorageWorkdir(cliSettings.workdir); - // `--project-ref` never implies `--linked` and must not be silently - // discarded on the local target — see push.handler.ts's identical guard - // (db push) for the full TS-only rationale. + // `--project-ref` only applies to the linked project; it never implies `--linked`. if (Option.isSome(flags.projectRef) && flags.local) { return yield* Effect.fail( new StorageMutuallyExclusiveFlagsError({ @@ -62,8 +60,8 @@ export const storageMv = Effect.fn("storage.mv")(function* (flags: StorageMvFlag yield* output.raw(`Loading config override: [remotes.${loaded.appliedRemote}]\n`, "stderr"); } - // Parse + validate BEFORE building the client (Go `mv.go:24-39`): both must be - // ss://, at least one prefix non-empty, and the same bucket. + // Parse and validate both paths before building the client: both must be ss://, at + // least one prefix non-empty, and the same bucket. const srcParsed = yield* parseStorageUrlEffect(flags.src); const dstParsed = yield* parseStorageUrlEffect(flags.dst); const [srcBucket, srcPrefix] = splitBucketPrefix(srcParsed); @@ -92,7 +90,6 @@ export const storageMv = Effect.fn("storage.mv")(function* (flags: StorageMvFlag ); if (result.moved) { - // Go prints the move response message on success. yield* output.raw(`${result.message}\n`, "stderr"); if (output.format !== "text") { yield* output.success("", { message: result.message }); @@ -116,9 +113,8 @@ export const storageMv = Effect.fn("storage.mv")(function* (flags: StorageMvFlag }); /** - * Go `MoveStorageObjectAll` (`mv.go:55-88`): BFS over the source tree (LIFO), - * moving each object with its `srcPrefix`→`dstPrefix` rewrite. `srcPath` is - * terminated by `/`. Fails with `Object not found: <srcPath>` when nothing moved. + * BFS over the source tree (LIFO), moving each object with its `srcPrefix`→`dstPrefix` rewrite. + * `srcPath` is terminated by `/`. Fails with `Object not found: <srcPath>` when nothing moved. */ const moveStorageObjectAll = ( gateway: StorageGateway, From 3dd47f3b05d57af9b80fe086be25823a0ef0fc0d Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:21:38 +0100 Subject: [PATCH 54/56] chore(cli): tighten stop teardown comment --- apps/cli/src/commands/stop/stop.handler.ts | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/apps/cli/src/commands/stop/stop.handler.ts b/apps/cli/src/commands/stop/stop.handler.ts index 28d58fc5cf..869e6e0fd1 100644 --- a/apps/cli/src/commands/stop/stop.handler.ts +++ b/apps/cli/src/commands/stop/stop.handler.ts @@ -119,20 +119,13 @@ export const stop = Effect.fn("stop")(function* (flags: StopFlags) { yield* output.raw("Stopping containers...\n"); } - // `dockerRemoveAll` runs list -> stop -> container prune -> conditional volume prune -> - // network prune (see `docker-remove-all.ts`); its 5 stage-tagged failures are remapped into - // `stop`'s own tagged errors below. + // Stages: list -> stop -> container prune -> volume prune (when requested) -> network prune; + // each stage's failure is remapped to a `stop` error below. // - // `onContainersRemoved` fires only once container prune confirms removal, so - // `cleanupStartSecrets` reclaims exactly the directories for containers this run tore down. - // Each container's own `CLI_WORKDIR_LABEL` locates its directory, since `stop --all`/ - // `--project-id <other>` may be tearing down a different project's containers than this - // invocation's own `--workdir`. - // - // Runs via `Effect.ensuring` (in `Effect.suspend`, so `removedContainers` is read at - // finalizer-run time) because a later prune stage can still fail after containers are - // already confirmed removed — a plain statement here would skip cleanup and leak secret - // directories. + // `onContainersRemoved` fires only after container prune confirms removal, and each container's + // own `CLI_WORKDIR_LABEL` locates its secrets directory (`--all`/`--project-id` may tear down + // another project's containers). The cleanup runs via `Effect.ensuring` so a later prune-stage + // failure can't skip it and leak secret directories. let removedContainers: ReadonlyArray<ContainerIdName> = []; yield* dockerRemoveAll( spawner, From 147280ab523e21ea522ac3c2ac85aa986f4e2853 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:25:18 +0100 Subject: [PATCH 55/56] chore(repo): trim comments, shard 45 (1711 -> 744 comment lines) --- .../project-config.unit.test.ts | 721 ++++++----------- .../src/project-config/project-schema.ts | 184 +---- .../project-schema.unit.test.ts | 112 +-- .../src/project-config/registry-auth.ts | 743 ++++-------------- .../registry-integrity.unit.test.ts | 104 +-- .../config/src/project-config/registry-row.ts | 215 ++--- 6 files changed, 548 insertions(+), 1531 deletions(-) diff --git a/packages/config/src/project-config/project-config.unit.test.ts b/packages/config/src/project-config/project-config.unit.test.ts index f5c1bae8d5..f3a8cb7919 100644 --- a/packages/config/src/project-config/project-config.unit.test.ts +++ b/packages/config/src/project-config/project-config.unit.test.ts @@ -23,12 +23,9 @@ import { const decodeCliConfig = Schema.decodeUnknownSync(CliConfigSchema); /** - * A realistic v2 `data.attributes` payload exercising every mapped section - * plus, in each section, at least one known-but-unmapped field, and — at the - * top level — one whole unmapped section (`new_service`) and two metadata- - * shaped keys (`$weird`, `_private`) inside `api`. Values were chosen and - * hand-traced against `./registry.ts`/`./registry-auth.ts` (see the sibling - * describe blocks below for the derivation of each expected output). + * A realistic v2 `data.attributes` payload exercising every mapped section plus at least one + * known-but-unmapped field per section, one whole unmapped section (`new_service`), and two + * metadata-shaped keys (`$weird`, `_private`) inside `api`. */ const fullAttributesFixture: Record<string, unknown> = { database: { @@ -156,16 +153,9 @@ function apiEnvelope(attributes: Record<string, unknown>): unknown { describe("fromConfigDocument", () => { test("projecting the default config keeps exactly the hosted sections", () => { const projected = fromConfigDocument(getDefaultCliConfig()); - // `workers` survives as `{}`: the prune removes only containers the copy - // itself EMPTIED (secret stripping) — an originally-empty container is - // declared data (a record entry's value can be an empty struct by schema - // design, e.g. `storage.analytics.buckets` entries, where the key is the - // information). `realtime` is absent, not present-as-`{}` like `workers`: - // all 3 of its config-side fields are `DOCUMENT_ONLY_LOCAL_PATHS` entries - // (CLI-2316), all 3 are always-materialized (not `optionalKey`) so the - // default config always declares them, and the emptied-by-exclusion - // section prune (same rule as the secret-stripped case) removes it - // entirely — see the dedicated describe block below. + // `workers` survives as `{}` (an originally-empty container isn't pruned); `realtime` is + // absent entirely since all of its fields are `DOCUMENT_ONLY_LOCAL_PATHS` entries, always + // materialized, so the section-emptied-by-exclusion prune removes it. expect(Object.keys(projected).sort()).toEqual([ "api", "auth", @@ -213,14 +203,9 @@ describe("fromConfigDocument", () => { test("deep-copies rather than sharing the subtree reference", () => { const config = getDefaultCliConfig(); const projected = fromConfigDocument(config); - // The top-level `api`/`db` containers are still freshly built (never the - // same object as `config`'s), even though `toEqual`-comparing them WHOLE - // against `config.api`/`config.db` would now fail: CLI-2316 strips - // several of their fields (`api.port`/`tls`/`external_url`, - // `db.port`/`shadow_port`/`health_timeout`/`major_version`/`pooler`/ - // `migrations`/`seed` — see the dedicated describe block below) from the - // projection. `storage.s3_protocol` — an always-materialized nested - // object none of those exclusions touch — is the equality probe instead. + // `api`/`db` are freshly built but no longer whole-equal to `config`'s (several fields get + // stripped — see the dedicated describe block below), so `storage.s3_protocol`, an + // always-materialized nested object none of those exclusions touch, is the equality probe. expect(projected.api).not.toBe(config.api); expect(projected.db).not.toBe(config.db); expect(projected.storage?.s3_protocol).not.toBe(config.storage.s3_protocol); @@ -256,8 +241,7 @@ describe("fromConfigDocument", () => { expect(projected.auth?.email?.smtp?.host).toBe("smtp.example.com"); expect(projected.auth?.external?.github?.secret).toBeUndefined(); expect(projected.auth?.external?.github?.client_id).toBe("id"); - // Both experimental S3 fields are `secret()`-annotated (`../experimental.ts`), - // not just `s3_secret_key`. + // Both experimental S3 fields are `secret()`-annotated, not just `s3_secret_key`. expect(projected.experimental?.s3_secret_key).toBeUndefined(); expect(projected.experimental?.s3_access_key).toBeUndefined(); @@ -265,13 +249,8 @@ describe("fromConfigDocument", () => { expect(Object.hasOwn(projected.auth?.captcha ?? {}, "secret")).toBe(false); }); - // Drift-audit fix (round 30, ADR 0021): the schema types `smtp.port` as an - // unrestricted number, but the push wrapper stringifies it - // (`String(local.email.smtp.port)`, auth.sync.ts:2390) and the API arm's - // own row only ever reports a value `parseUint16` accepts — so without a - // matching document-side round trip, a fractional/out-of-range document - // port would disagree with what the API arm reports for the same pushed - // state. + // Without this round trip, a fractional/out-of-range port would disagree with what the API arm + // reports for the same pushed state. test("a fractional smtp.port is omitted (String->parseUint16 round trip), the rest of the block survives", () => { const projected = fromConfigDocument({ auth: { @@ -311,16 +290,11 @@ describe("fromConfigDocument", () => { }); test("prunes an empty container left behind by secret stripping, rather than keeping it as litter", () => { - // `auth.captcha` here declares nothing but its secret leaf — a sparse - // `EffectiveConfig` literal, not a decoded document (decoding would - // materialize `enabled`/`provider` defaults alongside it and mask this - // case). `auth.site_url` keeps the surrounding `auth` section itself - // non-empty, isolating the nested prune. Once `secret` is stripped, - // `captcha` is left with zero keys and must be pruned rather than - // surviving as `{}` litter (CLI-2230's secret-strip empty-container - // finding) — an empty container carries no comparable information, but - // `subtractCliConfig` would otherwise keep it forever as phantom drift - // against any baseline that never declared `captcha` at all. + // `auth.captcha` here is a sparse literal (a decoded document would materialize + // `enabled`/`provider` defaults and mask this case); `auth.site_url` keeps `auth` itself + // non-empty. Once `secret` is stripped, `captcha` has zero keys left and must be pruned rather + // than surviving as `{}` litter, which `subtractCliConfig` would otherwise keep forever as + // phantom drift against a baseline that never declared `captcha` at all. const projected = fromConfigDocument({ auth: { site_url: "https://example.com", captcha: { secret: "captcha-secret" } }, }); @@ -329,11 +303,9 @@ describe("fromConfigDocument", () => { }); test("a section that only contains a secret disappears entirely from the projection", () => { - // Unlike the nested case above, here the ENTIRE `experimental` section - // (both of whose declared fields are `secret()`-annotated, - // `../experimental.ts`) has nothing left after stripping — pruning must - // bubble all the way up through `fromConfigDocument`'s own per-section - // loop, not just `copyHostedValueWithoutSecrets`'s internal recursion. + // Unlike the nested case above, the entire `experimental` section (both declared fields are + // `secret()`-annotated) has nothing left after stripping, so pruning must bubble all the way + // up through the per-section loop, not just the internal recursion. const projected = fromConfigDocument({ experimental: { s3_access_key: "access-key", s3_secret_key: "s3-secret" }, }); @@ -341,21 +313,13 @@ describe("fromConfigDocument", () => { expect(projected).toEqual({}); }); - // Schema-derived, exhaustive counterpart to the 5-hand-picked-field test - // above (CLI-2230's review): rather than trusting a hand-picked field list - // to stay in sync with `CliConfigSchema`'s actual `x-secret` annotations, - // this enumerates every `x-secret` path pattern the schema declares - // (`secretPathPatterns`, `../lib/secret-paths.ts` — the same source of - // truth `isSecretPath` itself is built from), builds one probe document - // that populates every pattern reachable through a hosted section, and - // asserts none of them survive `fromConfigDocument`. This is the real - // "no x-secret path survives" contract; the hand-picked test above stays - // as a readable, minimal illustration of the same guarantee. + // Schema-derived, exhaustive counterpart to the hand-picked test above: enumerates every + // `x-secret` path pattern the schema declares, builds one probe document that populates every + // pattern reachable through a hosted section, and asserts none of them survive + // `fromConfigDocument`. test("no x-secret path from the schema's own pattern list survives fromConfigDocument, exhaustively", () => { - // Mirrors `HOSTED_SECTION_KEYS` (`./project-config.ts`): `fromConfigDocument` - // only ever copies these seven sections, so a secret pattern rooted - // anywhere else (`remotes.*`, `studio.*`, `edge_runtime.secrets.*`) is - // unreachable through it and deliberately excluded from this probe. + // `fromConfigDocument` only ever copies these seven sections, so a secret pattern rooted + // anywhere else (`remotes.*`, `studio.*`, …) is unreachable through it and excluded here. const HOSTED_TOP_LEVEL_KEYS = new Set([ "api", "auth", @@ -369,8 +333,6 @@ describe("fromConfigDocument", () => { const reachablePatterns = secretPathPatterns.filter((pattern) => HOSTED_TOP_LEVEL_KEYS.has(pattern[0] ?? ""), ); - // Guards the loop below against passing vacuously if the schema-derived - // pattern list is ever empty due to a broken import. expect(reachablePatterns.length).toBeGreaterThan(0); const WILDCARD_KEY = "probe_key"; @@ -378,9 +340,6 @@ describe("fromConfigDocument", () => { pattern.map((segment) => (segment === "*" ? WILDCARD_KEY : segment)), ); - // Every concrete path must actually be recognized as secret by the same - // predicate `copyHostedValueWithoutSecrets` consults — otherwise this - // probe would be asserting nothing. for (const path of concretePaths) { expect(isSecretPath(path)).toBe(true); } @@ -410,14 +369,10 @@ describe("fromConfigDocument", () => { } const probeDocument: Record<string, unknown> = {}; - // A benign sibling one level up keeps its parent container non-empty - // regardless of pruning, so a passing assertion below actually proves - // the SECRET leaf was removed rather than the whole subtree - // disappearing for an unrelated reason (e.g. a bug that wipes the - // projection entirely). Skipped when the sibling path would itself be - // secret-shaped — `db.vault.*` matches every key under `db.vault`, so no - // sibling there can ever prove non-vacuousness; other sections' siblings - // still do. + // A benign sibling one level up keeps its parent container non-empty, so a passing assertion + // below proves the secret leaf was removed rather than the whole subtree disappearing for an + // unrelated reason. Skipped when the sibling path is itself secret-shaped (e.g. `db.vault.*` + // matches every key under `db.vault`). const survivingSiblingPaths: Array<ReadonlyArray<string>> = []; for (const path of concretePaths) { setAtPath(probeDocument, path); @@ -509,11 +464,9 @@ describe("fromConfigDocument — CLI-only field exclusion (CLI-2316)", () => { expect(Object.hasOwn(projected.db?.pooler ?? {}, "port")).toBe(false); expect(Object.hasOwn(projected.db ?? {}, "migrations")).toBe(false); expect(Object.hasOwn(projected.db ?? {}, "seed")).toBe(false); - // Siblings prove the exclusion is targeted, not a section-wide wipe — - // `pool_mode`/`default_pool_size`/`max_client_conn` are real, - // `v2GetProjectConfig`-reported hosted facts (PR #6451 review round) and - // must stay comparable for `config diff`/`config pull`, unlike `enabled`/ - // `port` right above, which the API never reports at all. + // Siblings prove the exclusion is targeted, not a section-wide wipe: `pool_mode`/ + // `default_pool_size`/`max_client_conn` are real hosted facts that must stay comparable, + // unlike `enabled`/`port`, which the API never reports at all. expect(projected.api?.max_rows).toBe(42); expect(projected.db?.settings?.max_connections).toBe(5); expect(projected.db?.pooler).toEqual({ @@ -528,9 +481,8 @@ describe("fromConfigDocument — CLI-only field exclusion (CLI-2316)", () => { realtime: { enabled: false, ip_version: "IPv6", max_header_length: 1 }, }); const projected = fromConfigDocument(document); - // Every config-side `realtime` field is excluded, so — unlike `workers`, - // which survives as `{}` — the section disappears entirely: it was - // emptied BY this exclusion, the same prune rule as a secret-stripped + // Unlike `workers`, which survives as `{}`, this section disappears entirely: every field is + // excluded, so it was emptied by this exclusion, the same prune rule as a secret-stripped // section. expect(Object.hasOwn(projected, "realtime")).toBe(false); }); @@ -558,23 +510,16 @@ describe("fromConfigDocument — CLI-only field exclusion (CLI-2316)", () => { }); test("db.major_version and db.pooler.{pool_mode,default_pool_size,max_client_conn} populate on BOTH arms (PR #6451 correction)", () => { - // These 4 fields are real, `v2GetProjectConfig`-reported hosted facts - // with no `config push` write path — but `ProjectConfig`'s actual - // current consumers are `config diff`/`config pull` (`v2GetProjectConfig`), - // not `config push` (still the legacy v1 `config-sync` mappers, with zero - // `ProjectConfig` involvement). Excluding them from the document arm — - // this test's ORIGINAL, incorrect assertion — made them permanently - // `unmanaged` for every stock project (the `supabase init` template - // declares all four), which blocked `config pull` from ever syncing the + // These 4 fields have no `config push` write path, but `ProjectConfig`'s real consumers are + // `config diff`/`config pull`; excluding them from the document arm would make them + // permanently unmanaged for every stock project, blocking `config pull` from ever syncing the // platform's real values down. They must stay symmetric across both arms. const documentSide = fromConfigDocument( decodeCliConfig({ db: { major_version: 15, pooler: { pool_mode: "session" } } }), ); expect(documentSide.db?.major_version).toBe(15); - // `default_pool_size`/`max_client_conn` are schema-decoded defaults here - // (20/100, `../db.ts`) — present because the whole `pooler` struct - // materializes on decode, not because this document declared them. - // `enabled`/`port` are excluded regardless (the next test covers that). + // `default_pool_size`/`max_client_conn` are schema-decoded defaults (20/100), present because + // the whole `pooler` struct materializes on decode, not because this document declared them. expect(documentSide.db?.pooler).toEqual({ pool_mode: "session", default_pool_size: 20, @@ -597,15 +542,14 @@ describe("fromConfigDocument — CLI-only field exclusion (CLI-2316)", () => { const documentSide = fromConfigDocument( decodeCliConfig({ db: { pooler: { enabled: true, port: 54329 } } }), ); - // `pooler` itself survives (its other 3 fields decode to their schema - // defaults) — only `enabled`/`port` are excluded from it. + // `pooler` itself survives (other fields decode to schema defaults); only `enabled`/`port` + // are excluded from it. expect(Object.hasOwn(documentSide.db?.pooler ?? {}, "enabled")).toBe(false); expect(Object.hasOwn(documentSide.db?.pooler ?? {}, "port")).toBe(false); expect(documentSide.db?.pooler?.pool_mode).toBe("transaction"); - // The API arm never populates these either: no registry row maps them, - // since `v2GetProjectConfig`'s `pooler` struct has no `enabled`/`port` - // field to map from in the first place. + // The API arm never populates these either: `v2GetProjectConfig`'s `pooler` struct has no + // `enabled`/`port` field to map from. const apiSide = fromApiProjectConfig({ pooler: { enabled: true, port: 54329, pool_mode: "session" }, }); @@ -614,27 +558,12 @@ describe("fromConfigDocument — CLI-only field exclusion (CLI-2316)", () => { expect(apiSide.db?.pooler).toEqual({ pool_mode: "session" }); }); - // Exhaustive counterpart to the hand-picked tests above, iterating - // `DOCUMENT_ONLY_LOCAL_PATHS` itself rather than a second hand-picked field - // list. Unlike the x-secret exhaustiveness test just above — which builds - // its OWN probe programmatically from `secretPathPatterns`, so it can never - // go vacuous — this probe is still hand-written (`DOCUMENT_ONLY_LOCAL_PATHS` - // mixes whole-subtree and scalar-leaf entries of different value types, so - // one generic "put a marker at every path" builder can't populate it the - // way the all-string x-secret patterns allow). The `toBeDefined` check - // below on `document` is a PARTIAL guard, not a complete one (PR #6451 - // review round): it always catches a typo'd or renamed path in the - // CONSTANT itself (`decodeCliConfig` only ever produces the schema's own - // spelling, never a typo'd one, so `readAtPath` finds nothing regardless of - // defaults). What it does NOT reliably catch is the probe below simply - // forgetting to set a value at a CORRECTLY-spelled listed path: 12 of - // these 18 paths are always-materialized (`withDecodingDefaultKey`, never - // `optionalKey`), so `decodeCliConfig` fills a schema default for them even - // when the probe omits an explicit value — `toBeDefined` passes on that - // default either way. Only the other 6 — `api.external_url` and the 5 - // `experimental.*` entries, every one `optionalKey` — stay genuinely - // undefined unless the probe sets them explicitly, so only THOSE 6 catch a - // probe that forgot to populate a listed path. + // Exhaustive counterpart to the hand-picked tests above, iterating `DOCUMENT_ONLY_LOCAL_PATHS` + // itself; hand-written (not probe-generated like the x-secret exhaustiveness test above) since + // these paths mix whole-subtree and scalar-leaf entries of different types. The `toBeDefined` + // check below on `document` is a partial guard: it catches a typo'd or renamed path in the + // constant, but not the probe forgetting to set a correctly-spelled one, since most entries are + // always-materialized with a schema default regardless. test("no DOCUMENT_ONLY_LOCAL_PATHS entry survives fromConfigDocument, exhaustively", () => { expect(DOCUMENT_ONLY_LOCAL_PATHS.length).toBeGreaterThan(0); @@ -670,8 +599,6 @@ describe("fromConfigDocument — CLI-only field exclusion (CLI-2316)", () => { const projected = fromConfigDocument(document); for (const path of DOCUMENT_ONLY_LOCAL_PATHS) { - // The probe actually populated this path — otherwise the assertion - // below would pass whether or not the exclusion code does anything. expect(readAtPath(document, path)).toBeDefined(); expect(readAtPath(projected, path)).toBeUndefined(); } @@ -679,25 +606,15 @@ describe("fromConfigDocument — CLI-only field exclusion (CLI-2316)", () => { expect(projected.api?.max_rows).toBe(42); expect(projected.db?.settings?.max_connections).toBe(5); expect(projected.experimental?.webhooks).toEqual({ enabled: true }); - // `major_version`/`pool_mode` are declared right alongside the excluded - // `pooler.enabled`/`pooler.port` above — proving the exclusion is - // per-field, not a `db.pooler`- or `db`-wide wipe (PR #6451 correction). + // Proves the exclusion is per-field, not a `db.pooler`- or `db`-wide wipe: these are declared + // right alongside the excluded `pooler.enabled`/`pooler.port` above. expect(projected.db?.major_version).toBe(15); expect(projected.db?.pooler?.pool_mode).toBe("session"); }); - // Integrity guard (PR #6451 review round): the bug this whole file's - // review round caught was exactly this overlap — `db.major_version` and 3 - // of `db.pooler`'s fields were BOTH `comparableProjectConfigPaths` members - // (real registry rows, `./registry.ts`) AND `DOCUMENT_ONLY_LOCAL_PATHS` - // entries, which permanently blocked `config diff`/`config pull` from ever - // comparing or pulling them (see the corrected tests above). This test - // pins the invariant going forward: no comparable (registry-mapped) path, - // nor any of its ancestors, may ever be a `DOCUMENT_ONLY_LOCAL_PATHS` - // member — a future registry row added beneath an excluded prefix (e.g. - // under `db.migrations`, which today has none) would silently become - // uncomparable exactly like `major_version`/`pooler` did, and this test - // would catch it the moment that row is added. + // Guards against a comparable (registry-mapped) path, or any of its ancestors, ever being a + // `DOCUMENT_ONLY_LOCAL_PATHS` member: a future registry row added beneath an excluded prefix + // would silently become uncomparable the same way `major_version`/`pooler` once did. test("no comparableProjectConfigPaths entry (or its ancestors) is ever a DOCUMENT_ONLY_LOCAL_PATHS member", () => { expect(comparableProjectConfigPaths.length).toBeGreaterThan(0); @@ -771,10 +688,8 @@ describe("fromApiProjectConfig — envelope unwrapping", () => { expect(thrown).toBeInstanceOf(ProjectConfigParseError); const error = thrown as ProjectConfigParseError; expect(error.cause).toBeDefined(); - // `api.max_rows` is schema-concrete (mapped), so this fails at the - // lenient-schema decode itself — before any registry `transform` runs — - // and the message is built from the v4 schema-error formatter, not the - // registry's own `expectNumber` wording. + // Fails at the lenient-schema decode itself, before any registry `transform` runs, so the + // message comes from the schema-error formatter, not the registry's own `expectNumber` wording. expect(error.apiPath).toEqual(["api", "max_rows"]); expect(error.message).toContain( "Could not read the project config from the Management API response: at data.attributes.api.max_rows:", @@ -782,9 +697,7 @@ describe("fromApiProjectConfig — envelope unwrapping", () => { expect(error.detail).toBeDefined(); }); - // A malformed envelope must throw rather than silently fall through to - // "bare attributes" — see unwrapApiResponse's docstring in - // `./project-config.ts`. + // A malformed envelope must throw rather than silently fall through to "bare attributes". test.each([ [{ data: { attributes: 5 } }, "data.attributes is not an object"], [{ data: 5 }, "data is not an object"], @@ -809,12 +722,8 @@ describe("fromApiProjectConfig — api section", () => { expect(result.api).toEqual({ schemas: ["public", "graphql_public"], enabled: true }); }); - // An explicit `db_schema: ""` is the remote's disable sentinel - // (api.sync.ts:84-87 early-returns without applying anything else from the - // section), so ONLY `enabled: false` is reported — the sibling fields' - // remote values are meaningless while the service is off. An *absent* - // `db_schema` does not gate the siblings (see the minimal fixtures in the - // surrounding tests, which map `max_rows` without one). + // An explicit `db_schema: ""` is the remote's disable sentinel, so only `enabled: false` is + // reported; an absent `db_schema` does not gate the siblings. test("an empty db_schema disables the Data API and omits the sibling fields", () => { const result = fromApiProjectConfig({ api: { db_schema: "", db_extra_search_path: "public", max_rows: 100 }, @@ -827,10 +736,8 @@ describe("fromApiProjectConfig — api section", () => { expect(result.api?.max_rows).toBe(0); }); - // NaN has no JSON literal — JSON.parse can only ever produce ±Infinity from - // an overflowing numeral (e.g. `1e400`), never NaN — so a NaN in the raw - // response can only be programmatic input; the pre-decode walk rejects it - // with the caller-misuse reason before any row's narrowing runs. + // NaN has no JSON literal, so it can only be programmatic caller input, not a real API response; + // the pre-decode walk rejects it with the caller-misuse reason before any row narrows it. test("a NaN max_rows is rejected pre-decode as a non-JSON primitive", () => { let thrown: unknown; try { @@ -843,11 +750,8 @@ describe("fromApiProjectConfig — api section", () => { expect((thrown as ProjectConfigParseError).message).toContain("non-JSON primitive"); }); - // ±Infinity IS JSON-reachable (drift-audit fix, ADR 0019's 2026-08-27 - // addendum) and so passes the pre-decode walk — but on a MAPPED field like - // `max_rows`, `expectInteger`/`expectNumber`'s own finite check still - // rejects it, this time with the api_response reason (a platform-response - // problem, not caller misuse). + // ±Infinity is JSON-reachable, so it passes the pre-decode walk, but `expectInteger`'s own + // finite check still rejects it on a mapped field, this time with the api_response reason. test("an Infinity max_rows decodes past the pre-decode walk but still throws api_response via expectInteger", () => { let thrown: unknown; try { @@ -856,9 +760,8 @@ describe("fromApiProjectConfig — api section", () => { thrown = error; } expect(thrown).toBeInstanceOf(ProjectConfigParseError); - // `reason` is absent here — per ProjectConfigParseError's own docstring - // ("api_response" is the default when absent), that IS the api_response - // classification, not merely "not caller_misuse". + // `reason` is absent here, which is itself the api_response classification (the documented + // default), not merely "not caller_misuse". expect((thrown as ProjectConfigParseError).reason ?? "api_response").toBe("api_response"); expect((thrown as ProjectConfigParseError).apiPath).toEqual(["api", "max_rows"]); }); @@ -927,12 +830,9 @@ describe("fromApiProjectConfig — db section", () => { }); }); - // Codex round 31, THREAD B — deliberate, not a gap: a consumed path prunes - // its WHOLE subtree at apiPath granularity, so a platform-added key nested - // inside a consumed array's element (here, `comment` on a cidr entry) is - // never itemized by unmappedApiFields — full fidelity lives in - // `_apiResponse` instead (unmappedApiFields's own docstring, and the - // alsoConsumes design comment above consumedApiPathKeys). + // Not a gap: a consumed path prunes its whole subtree at apiPath granularity, so a + // platform-added key nested inside a consumed array element is never itemized by + // `unmappedApiFields` — full fidelity lives in `_apiResponse` instead. test("an extra key inside a consumed allowed_cidrs entry maps fine, is absent from unmappedApiFields, and survives in _apiResponse", () => { const result = fromApiProjectConfig({ database: { @@ -941,9 +841,8 @@ describe("fromApiProjectConfig — db section", () => { }, }, }); - // Both allowed_cidrs/allowed_cidrs_v6 rows read this same apiPath and - // filter by `type` (registry.ts's own `filterCidrAddresses`), so a - // v4-only entry still produces an (empty) v6 array. + // Both allowed_cidrs/allowed_cidrs_v6 rows read the same apiPath and filter by `type`, so a + // v4-only entry still produces an empty v6 array. expect(result.db?.network_restrictions).toEqual({ allowed_cidrs: ["1.2.3.4/32"], allowed_cidrs_v6: [], @@ -991,10 +890,8 @@ describe("fromApiProjectConfig — db section", () => { expect(result.db?.settings).toEqual({ session_replication_role: "origin" }); }); - // Mirrors the pool_mode "statement" case above: guarded to the enum the - // config schema accepts, but — unlike pool_mode — this path IS consumed by - // a registry row (`sessionReplicationRoleRow`), so an out-of-enum value is - // omitted from typed output but does NOT surface in unmappedApiFields. + // Mirrors the pool_mode "statement" case, but this path is consumed by a registry row, so an + // out-of-enum value is omitted from typed output without surfacing in unmappedApiFields. test("an unrecognized session_replication_role is omitted from typed output and from unmappedApiFields, but stays in _apiResponse", () => { const result = fromApiProjectConfig({ database: { postgres_settings: { session_replication_role: "weird" } }, @@ -1013,8 +910,7 @@ describe("fromApiProjectConfig — storage section", () => { expect(result.storage?.file_size_limit).toBe("50MiB"); }); - // Significant-digit (`%.4g`-equivalent) formatting cases, verified against - // the real `bytesSize` implementation before writing these assertions. + // Significant-digit (`%.4g`-equivalent) formatting cases. test.each([ [1500, "1.465KiB"], [1234567890, "1.15GiB"], @@ -1119,10 +1015,7 @@ describe("fromApiProjectConfig — auth section", () => { }); test("sms_autoconfirm maps to auth.sms.enable_confirmations WITHOUT inverting", () => { - // Deliberate, per registry-auth.ts's smsBaseRows comment: unlike - // mailer_autoconfirm/email.enable_confirmations, this GoTrue key maps - // identically on both the pull (auth.sync.ts:1677) and push - // (auth.sync.ts:2485) sides. + // Unlike mailer_autoconfirm/email.enable_confirmations, this key isn't inverted. const result = fromApiProjectConfig({ auth: { sms_autoconfirm: true } }); expect(result.auth?.sms?.enable_confirmations).toBe(true); }); @@ -1133,16 +1026,11 @@ describe("fromApiProjectConfig — auth section", () => { }); test("sessions_timebox (hours) converts to a Go duration string", () => { - // hoursToDurationString(2) => durationString(2 * 3_600_000_000_000) - // => hours=2, minutes=0, secs=0 => "2h0m0s" (verified against - // registry-auth.ts's durationString before writing this assertion). const result = fromApiProjectConfig({ auth: { sessions_timebox: 2 } }); expect(result.auth?.sessions?.timebox).toBe("2h0m0s"); }); test("smtp_max_frequency (seconds) converts to a Go duration string", () => { - // secondsToDurationString(60) => durationString(60_000_000_000) - // => hours=0, minutes=1, secs=0 => "1m0s". const result = fromApiProjectConfig({ auth: { smtp_max_frequency: 60 } }); expect(result.auth?.email?.max_frequency).toBe("1m0s"); }); @@ -1158,8 +1046,7 @@ describe("fromApiProjectConfig — auth section", () => { }); test("smtp_port parses a numeric string (when SMTP is enabled)", () => { - // The port is gated on an enabled smtp_host like every SMTP sibling — - // the push writes only smtp_host: "" when disabling. + // Gated on an enabled smtp_host, like every SMTP sibling. const result = fromApiProjectConfig({ auth: { smtp_host: "smtp.example.com", smtp_port: "2500" }, }); @@ -1232,10 +1119,6 @@ describe("fromApiProjectConfig — auth section", () => { expect(result.auth?.sms?.test_otp).toEqual({ "15551234567": "123456" }); }); - // CLI-2316 follow-up: figma is now a real provider (../auth/providers.ts), - // mirroring github's shape (no url, has email_optional, no skip_nonce_check - // — verified against the real V1GetAuthServiceConfigOutput contract, which - // has no `external_figma_skip_nonce_check` field at all). test("figma maps like any other non-apple/google provider", () => { const apiSide = fromApiProjectConfig({ auth: { @@ -1260,9 +1143,8 @@ describe("fromApiProjectConfig — auth section", () => { }, }), ); - // The document arm copies the whole decoded provider struct (minus the - // secret leaf) verbatim — every schema-materialized field, not just the - // two declared above. + // The document arm copies the whole decoded provider struct (minus the secret leaf) verbatim, + // not just the two fields declared above. expect(documentSide.auth?.external?.figma).toEqual({ enabled: true, client_id: "figma-id", @@ -1273,10 +1155,6 @@ describe("fromApiProjectConfig — auth section", () => { }); }); - // CLI-2316 follow-up: `sms.otp_length`/`sms.otp_expiry` are new config-schema - // fields for pre-existing real GoTrue fields (`sms_otp_length`/`sms_otp_exp`) - // that the CLI's config-sync never modeled and neither did Go's own - // `sms` struct — not a Go-parity gap, a genuinely new mapping. test("sms_otp_length/sms_otp_exp map to auth.sms.otp_length/otp_expiry", () => { const apiSide = fromApiProjectConfig({ auth: { sms_otp_length: 6, sms_otp_exp: 60 } }); expect(apiSide.auth?.sms?.otp_length).toBe(6); @@ -1289,11 +1167,7 @@ describe("fromApiProjectConfig — auth section", () => { expect(documentSide.auth?.sms?.otp_expiry).toBe(120); }); - // CLI-2316 follow-up: `sms.twilio.content_sid` is a new config-schema field - // for a pre-existing real GoTrue field (`sms_twilio_content_sid`), - // Twilio-only (no `sms_twilio_verify_content_sid` API counterpart) — same - // "omitted when the SMS provider is explicitly unset" gating as - // `account_sid`/`message_service_sid` (`smsCredentialStringRow`). + // Twilio-only: no `sms_twilio_verify_content_sid` API counterpart. test("sms_twilio_content_sid maps to auth.sms.twilio.content_sid, gated the same as its siblings", () => { const apiSide = fromApiProjectConfig({ auth: { sms_provider: "twilio", sms_twilio_content_sid: "HXreal00000000000000000000000000" }, @@ -1343,10 +1217,8 @@ describe("fromApiProjectConfig — secrets (ADR 0019 rule 5)", () => { }, ); - // None of these four have a registry row at all (no config-schema - // counterpart), so they would otherwise leak their HMAC digest into - // `unmappedApiFields` — the `unmappedSecretApiPaths` orphan list - // (`./registry-auth.ts`) treats each as consumed anyway. + // None of these four have a registry row at all, so they'd otherwise leak their HMAC digest into + // `unmappedApiFields`; the orphan list treats each as consumed anyway. test.each([ "external_figma_secret", "external_slack_secret", @@ -1365,9 +1237,8 @@ describe("fromApiProjectConfig — secrets (ADR 0019 rule 5)", () => { }); describe("fromApiProjectConfig — null convention", () => { - // `stringRow` (registry-auth.ts) declares a `transform` specifically so it - // can treat `null` as "omit" itself, rather than throwing via - // `expectString` — see the null-safety note on the row factories. + // `stringRow` declares a `transform` specifically so it can treat `null` as "omit" itself, + // rather than throwing via `expectString`. test("auth.site_url null is omitted via stringRow's own transform", () => { const result = fromApiProjectConfig({ auth: { site_url: null } }); expect(result.auth).toBeUndefined(); @@ -1443,10 +1314,8 @@ describe("fromApiProjectConfig — _apiResponse (ADR 0019 rules 1/3/4)", () => { }); describe("fromApiProjectConfig — clone/freeze robustness (CLI-2230)", () => { - // Attaching `_apiResponse` clones and freezes the raw attributes - // (`attachFrozenApiResponse`) BEFORE any depth check existed; each of these - // three payload shapes used to escape this package's documented - // `ProjectConfigParseError` contract with a raw, uncaught failure instead. + // Attaching `_apiResponse` clones and freezes the raw attributes; these payload shapes must + // throw the package's own `ProjectConfigParseError`, not a raw uncaught error. test("a pathologically deep raw attributes payload throws ProjectConfigParseError, not an uncaught RangeError", () => { let deeplyNested: Record<string, unknown> = { leaf: "value" }; for (let i = 0; i < 100; i += 1) { @@ -1611,18 +1480,12 @@ describe("comparableProjectConfigPaths / isComparableProjectConfigPath", () => { }); test("comparableProjectConfigPaths does NOT rescue a diff against a document operand that never declared the sub-section at all", () => { - // The API side maps `email.smtp.enabled` unconditionally, even when the - // *document* operand's `auth` section is genuinely present (it declares - // `site_url`) but never mentions `[auth.email.smtp]` at all (CLI-2230's - // granularity finding). This pins the case `comparableProjectConfigPaths` - // does NOT cover: `auth.email.smtp.enabled` IS a comparable leaf path, - // yet it still survives `subtractCliConfig` as phantom drift, because the - // baseline has no `smtp` key at that depth to compare against - // (`subtractValue` keeps a value verbatim whenever its baseline - // counterpart is absent). Restricting to comparableProjectConfigPaths - // only removes the WHOLE-SECTION-granularity false positives (e.g. - // `realtime`); it cannot rescue this finer-grained one — see - // `comparableProjectConfigPaths`'s docstring. + // The API side maps `email.smtp.enabled` unconditionally, even when the document operand's + // `auth` section is present but never mentions `[auth.email.smtp]` at all. + // `auth.email.smtp.enabled` is a comparable leaf, yet it still survives as phantom drift, + // since `subtractCliConfig` keeps a value verbatim whenever its baseline counterpart is + // absent — restricting to comparable paths only catches whole-section false positives, not + // this finer-grained one. const apiSide = fromApiProjectConfig({ auth: { smtp_host: "" } }); const documentSide = fromConfigDocument({ auth: { site_url: "https://example.com" } }); @@ -1675,14 +1538,10 @@ describe("unmappedApiFields", () => { expect(unmappedApiFields(result)).toEqual({}); }); - // `walkUnmapped`'s own `MAX_UNMAPPED_WALK_DEPTH` guard is no longer - // reachable through the public API on its own: every path that attaches - // `_apiResponse` (`fromApiProjectConfig`, `attachApiResponse`) now shares - // the same bound at construction time (`assertRawAttributesDepthWithinBound`, - // CLI-2230's clone/freeze finding), so a payload deep enough to trip - // `walkUnmapped`'s check always fails earlier, at construction — see - // "fromApiProjectConfig — clone/freeze robustness (CLI-2230)" above. - // `walkUnmapped`'s own guard remains as defense in depth. + // `walkUnmapped`'s own depth guard is no longer reachable through the public API on its own: + // every path that attaches `_apiResponse` now shares the same depth bound at construction time, + // so a payload deep enough always fails earlier — see the clone/freeze robustness tests above. + // The guard remains as defense in depth. }); describe("review round: numeric and provider narrowing (CLI-2230)", () => { @@ -1700,9 +1559,7 @@ describe("review round: numeric and provider narrowing (CLI-2230)", () => { test("fractional session hours map faithfully (no whole-hour rounding)", () => { const result = fromApiProjectConfig({ auth: { sessions_timebox: 1.5 } }); - // Deliberate divergence from the legacy apply's Math.round - // (auth.sync.ts:1402-1407): a standalone mapping must represent the - // hosted value, not change it. + // A standalone mapping must represent the hosted value, not round it. expect(result.auth?.sessions?.timebox).toBe("1h30m0s"); }); @@ -1746,19 +1603,9 @@ describe("review round: numeric and provider narrowing (CLI-2230)", () => { describe("review round: aliasing, unknown-empty sections, path encoding (CLI-2230)", () => { test("object elements inside hosted arrays are copied, not aliased", () => { - // `experimental.inspect.rules` was the only schema field shaped as an - // array of objects (this file's own `copyHostedValueWithoutSecrets` - // docstring used it as its example) — CLI-2316 excludes the whole - // `experimental.inspect` subtree as CLI-only, so it can no longer probe - // this. But `fromConfigDocument` runs no schema validation on its input - // (see that same docstring), so the object-in-array copy path is still - // reachable through any surviving array field — `api.schemas` (real - // schema type `string[]`) is used here as a structurally-typed carrier: a - // `Record<string, unknown>` operand is assignable to the exported - // `EffectiveConfig` parameter (every `EffectiveConfig` property is - // optional, so nothing named on it needs to reconcile against the - // index-signature type), with no cast, while still reaching this - // function's fully untyped runtime behavior. + // `api.schemas` is used as a probe for the array-of-objects copy path: a `Record<string, + // unknown>` operand is assignable to `EffectiveConfig` (every property optional) without a + // cast, and `fromConfigDocument` itself runs no schema validation on its input. const element = { nested: "value" }; const probe: Record<string, unknown> = { api: { schemas: [element] } }; const projected = fromConfigDocument(probe); @@ -1773,9 +1620,8 @@ describe("review round: aliasing, unknown-empty sections, path encoding (CLI-223 }); test("a raw key that would collide with a registry path under join-encoding stays unmapped", () => { - // One key containing a NUL between "auth" and "site_url" must not collide - // with the consumed two-segment path ["auth", "site_url"] — pathKey - // JSON-encodes the segment array instead of joining on a delimiter. + // A NUL between "auth" and "site_url" must not collide with the consumed path ["auth", + // "site_url"]: pathKey JSON-encodes the segment array instead of joining on a delimiter. const collidingKey = ["auth", "site_url"].join(String.fromCharCode(0)); const result = fromApiProjectConfig({ [collidingKey]: "x" }); expect(unmappedApiFields(result)).toEqual({ [collidingKey]: "x" }); @@ -1796,9 +1642,8 @@ describe("review round: aliasing, unknown-empty sections, path encoding (CLI-223 }); test("an unrecognized password character-class string still omits the field", () => { - // An enum member this package version doesn't model — tolerable skew, - // same bucket as pool_mode "statement": absent from typed output and - // (path consumed) from unmappedApiFields; reachable via _apiResponse. + // An enum member this package version doesn't model — same bucket as pool_mode "statement": + // absent from typed output and unmappedApiFields, reachable via _apiResponse. const result = fromApiProjectConfig({ auth: { password_required_characters: "abc" } }); expect(Object.hasOwn(result, "auth")).toBe(false); expect(unmappedApiFields(result)).toEqual({}); @@ -1827,10 +1672,9 @@ describe("review round: oauth_server rows, known-empty pruning, DAG walk (CLI-22 }); test("a shared-reference DAG is rejected in bounded time with a typed error", () => { - // ~40 shared levels × 2 properties = ~2^41 tree paths within the depth - // bound — the visit cap must reject it as pathological (typed, fast) - // instead of hanging. Real JSON off the network can never share - // references, so nothing legitimate hits this. + // ~40 shared levels × 2 properties ≈ 2^41 tree paths within the depth bound; the visit cap + // must reject this as pathological instead of hanging. Real JSON from the network never + // shares references. let node: Record<string, unknown> = { leaf: true }; for (let level = 0; level < 40; level++) { node = { a: node, b: node }; @@ -1936,10 +1780,9 @@ describe("review round: pre-decode depth guard, caller-misuse reason, readonly m describe("review round: deep-readonly metadata, integer frequencies, provider narrowing (CLI-2230)", () => { test("nested _apiResponse arrays are readonly under a readonly-preserving guard and frozen at runtime", () => { - // The lib's own Array.isArray narrows to a MUTABLE any[] view - // (microsoft/TypeScript#17002) — the type's docstring directs consumers - // to a readonly-preserving guard like this one, under which mutation - // does not compile. Runtime deep-freeze backstops the lib-guard path. + // `Array.isArray` narrows to a mutable `any[]` view (microsoft/TypeScript#17002); a + // readonly-preserving guard like this one is needed for mutation to not compile. Runtime + // deep-freeze backstops the guard. const isReadonlyJsonArray = ( value: ReadonlyJsonValue | undefined, ): value is ReadonlyArray<ReadonlyJsonValue> => Array.isArray(value); @@ -2024,11 +1867,8 @@ describe("review round: operand guards, empty-entry preservation, duration bound }); test("negative session hours render with their sign, like the legacy apply", () => { - // auth.sync.ts:1402-1404 renders sessions_timebox: -1 as "-1h0m0s" (the - // shared durationString prepends the sign), the document schema keeps - // timebox a plain string, and the push parser reads the leading "-" back - // (config-sync.duration.ts:101-104) — so a signed hosted value maps - // instead of throwing. + // `durationString` prepends the sign and the document schema keeps timebox a plain string, so + // a signed hosted value maps instead of throwing. expect(fromApiProjectConfig({ auth: { sessions_timebox: -1 } }).auth?.sessions?.timebox).toBe( "-1h0m0s", ); @@ -2057,7 +1897,7 @@ describe("review round: sibling validation, formatter overflow, prototype lookup "auth", "external_apple_additional_client_ids", ]); - // A null anchor with a VALID sibling still omits the field entirely. + // A null anchor with a valid sibling still omits the field entirely. const omitted = fromApiProjectConfig({ auth: { external_apple_client_id: null, external_apple_additional_client_ids: "b,c" }, }); @@ -2113,13 +1953,11 @@ describe("review round: duration/size bounds and freeze failures (CLI-2230)", () sessions: { timebox: "1h0.5s", inactivity_timeout: "1m0.5s" }, }, }); - // The push pipeline normalizes through the truncating legacy formatter - // (normalizeDurationStr, auth.sync.ts:986-987; config-sync.duration.ts: - // 39-45) BEFORE durationToHours converts — "1h0.5s" stores exactly one - // hour, so the canonical document spelling predicts that reading. + // The push pipeline truncates sub-second remainders before converting to hours, so "1h0.5s" + // stores exactly one hour and the canonical spelling predicts that reading. expect(projected.auth?.sessions?.timebox).toBe("1h0m0s"); expect(projected.auth?.sessions?.inactivity_timeout).toBe("1m0s"); - // Sub-minute magnitudes keep their fraction (legacy seconds branch does). + // Sub-minute magnitudes keep their fraction. const subMinute = fromConfigDocument({ auth: { sessions: { timebox: "59.5s" } } }); expect(subMinute.auth?.sessions?.timebox).toBe("59.5s"); }); @@ -2149,8 +1987,8 @@ describe("review round: absent anchors, exponent-free seconds, non-plain values "auth", "external_google_additional_client_ids", ]); - // A VALID sibling with an absent anchor still omits the field (nothing - // to fold into) and stays consumed. + // A valid sibling with an absent anchor still omits the field (nothing to fold into) and + // stays consumed. const omitted = fromApiProjectConfig({ auth: { external_google_additional_client_ids: "b,c" }, }); @@ -2214,7 +2052,7 @@ describe("review round: clone taxonomy, precision bound, type discriminator, sec thrown = error; } expect(thrown).toBeInstanceOf(ProjectConfigParseError); - // An envelope WITHOUT a type stays tolerated. + // An envelope without a type stays tolerated. const lenient = fromApiProjectConfig({ data: { attributes: { api: { max_rows: 5 } } } }); expect(lenient.api?.max_rows).toBe(5); }); @@ -2249,11 +2087,8 @@ describe("review round: safe integers, Go truncation, bigint, fractional-hour bo }); test("fractional nanoseconds round like the push parser (the pipeline authority)", () => { - // The push parser (config-sync.duration.ts:155) ROUNDS fractional - // nanoseconds — it is what actually processes the document on push, so - // canonicalization predicts its reading. (Go itself truncates; matching - // Go would canonicalize toward a hosted value the pipeline never - // produces.) + // The push parser rounds fractional nanoseconds; canonicalization predicts its reading rather + // than Go's own truncation, which would target a value push never produces. const projected = fromConfigDocument({ auth: { sessions: { timebox: "1.0000000005s" } } }); expect(projected.auth?.sessions?.timebox).toBe("1.000000001s"); }); @@ -2276,10 +2111,8 @@ describe("review round: safe integers, Go truncation, bigint, fractional-hour bo }); describe("review round: non-JSON primitives, tiny hours, readonly report, whole-second frequencies, disabled sentinel (CLI-2230)", () => { - // undefined and NaN have no JSON.parse-reachable spelling, so both stay - // caller-misuse. Drift-audit fix: ±Infinity is JSON-reachable - // (`JSON.parse('{"x":1e400}')` yields `Infinity`) and was wrongly rejected - // here too — see the sibling test below. + // `undefined` and `NaN` have no JSON.parse-reachable spelling, so both stay caller-misuse; + // ±Infinity is JSON-reachable (`1e400` overflows to it) and is handled by the sibling test below. test("undefined and NaN raw values throw the typed caller-misuse error", () => { for (const bad of [{ x: undefined }, { x: Number.NaN }]) { let thrown: unknown; @@ -2334,26 +2167,21 @@ describe("review round: non-JSON primitives, tiny hours, readonly report, whole- const leaf = report["brand_new"]; expect(Array.isArray(leaf)).toBe(true); expect(Object.isFrozen(leaf)).toBe(true); - // No non-finite value anywhere inside — the sanitizing walk must be a - // no-op and hand back the SAME array `_apiResponse` already holds, - // rather than a fresh (unfrozen) copy. + // No non-finite value anywhere inside, so the sanitizing walk is a no-op and hands back the + // same array `_apiResponse` already holds, not a fresh copy. expect(leaf).toBe(result._apiResponse?.["brand_new"]); expect(() => { // @ts-expect-error — the report's index is readonly. report["brand_new"] = null; - // The rebuilt top-level container is NOT frozen, so pin the compile - // error via the runtime no-op-or-throw distinction: assignment on the - // fresh record succeeds at runtime, which is why the compile-level - // readonly matters. Throw manually to keep the expectation uniform. + // The rebuilt container isn't frozen, so assignment would succeed at runtime; only the + // compile-time readonly guards it. Throws manually to keep the expectation uniform. throw new TypeError("compile-only guard"); }).toThrow(TypeError); }); - // Drift-audit follow-up to Fix 1: `walkUnmapped` returns an unmapped array - // leaf wholesale (never walked element-by-element the way an object is), - // so a non-finite number hiding inside one — even nested inside a plain - // object inside the array — would otherwise reach `unmappedApiFields`'s - // return unsanitized and violate its own ReadonlyJsonValue contract. + // `walkUnmapped` returns an unmapped array leaf wholesale rather than walking it + // element-by-element, so a non-finite number nested inside one must still be sanitized before + // it reaches `unmappedApiFields`'s return. test("a non-finite number hiding inside an unmapped array leaf (including nested in an object) surfaces as null via a sanitized copy", () => { const parsed: { brand_new: ReadonlyArray<unknown> } = JSON.parse( '{"brand_new":[1e400,{"x":-1e400,"y":1}]}', @@ -2362,15 +2190,12 @@ describe("review round: non-JSON primitives, tiny hours, readonly report, whole- const result = fromApiProjectConfig(parsed); const report = unmappedApiFields(result); expect(report["brand_new"]).toEqual([null, { x: null, y: 1 }]); - // Sanitizing produces a FRESH copy — unlike the all-finite case above, - // this is no longer the shared frozen `_apiResponse` reference. + // Sanitizing produces a fresh copy here, unlike the all-finite case above. expect(report["brand_new"]).not.toBe(result._apiResponse?.["brand_new"]); }); - // Codex round 31, THREAD C — unmappedApiFields must guard its own input - // boundary the same way the other public entry points do (toProjectConfig, - // attachApiResponse), rather than reading `config._apiResponse` directly - // and leaking a raw TypeError/Error past this package's typed contract. + // `unmappedApiFields` must guard its own input boundary like the other public entry points, + // rather than leaking a raw error past this package's typed contract. test("a non-object operand throws the typed caller-misuse error", () => { let thrown: unknown; try { @@ -2408,11 +2233,9 @@ describe("review round: non-JSON primitives, tiny hours, readonly report, whole- test("document frequency durations quantize to whole seconds like the legacy push", () => { const projected = fromConfigDocument({ auth: { email: { max_frequency: "1.5s" } } }); - // auth.sync.ts:2611-2616 floors to integer seconds on push — the hosted - // value can only ever be whole seconds, so the document converges on it. + // The hosted value can only ever be whole seconds, so the document converges on it. expect(projected.auth?.email?.max_frequency).toBe("1s"); - // Session durations quantize through the push formatter's h/m - // truncation instead (normalizeDurationStr runs before durationToHours). + // Session durations quantize through the push formatter's h/m truncation instead. const sessions = fromConfigDocument({ auth: { sessions: { timebox: "1h0.5s" } } }); expect(sessions.auth?.sessions?.timebox).toBe("1h0m0s"); }); @@ -2430,10 +2253,8 @@ describe("review round: fraction exactness, hour round-trip, bigint discriminato const projected = fromConfigDocument({ auth: { sessions: { timebox: "0.999999999999999999s" } }, }); - // The push parser's float accumulation reads this as exactly 1s — and - // ITS reading is the pipeline authority the canonical spelling predicts. - // (Go itself would truncate to 999999999ns; see parseDuration's - // authority-scoping note for why push wins for fractional arithmetic.) + // The push parser's float accumulation reads this as exactly 1s, and that reading is what the + // canonical spelling predicts, not Go's own truncation to 999999999ns. expect(projected.auth?.sessions?.timebox).toBe("1s"); }); @@ -2460,15 +2281,14 @@ describe("review round: fraction exactness, hour round-trip, bigint discriminato describe("review round: Go fraction order, mu units, realms, disabled-gate validation (CLI-2230)", () => { test("short decimal fractions scale exactly like Go", () => { const projected = fromConfigDocument({ auth: { sessions: { timebox: "0.2593ms" } } }); - // Go computes int64(f * (unit/scale)) = 2593 * 100 = 259300ns exactly; - // the old operand order truncated a nanosecond short (259.299µs). + // Go computes int64(f * (unit/scale)) = 2593 * 100 = 259300ns exactly; the reverse operand + // order truncates a nanosecond short (259.299µs). expect(projected.auth?.sessions?.timebox).toBe("259.3µs"); }); test("the Greek small mu spelling stays verbatim (the push parser rejects it)", () => { - // Go accepts U+03BC, but the push parser takes only us/µs - // (config-sync.duration.ts:134) — canonicalizing "1μs" into a pushable - // spelling would fabricate a reading the pipeline never performs. + // Go accepts U+03BC, but the push parser only takes us/µs; canonicalizing "1μs" into a + // pushable spelling would fabricate a reading the pipeline never performs. const projected = fromConfigDocument({ auth: { sessions: { timebox: "1μs" } } }); expect(projected.auth?.sessions?.timebox).toBe("1μs"); }); @@ -2502,9 +2322,8 @@ describe("review round: Go fraction order, mu units, realms, disabled-gate valid describe("review round: Go-range sessions, SMTP/provider/storage disabled sentinels (CLI-2230)", () => { test("year-scale whole-hour durations parse and map inside Go's range", () => { - // "8760h" is a valid push-side value (sent as 8760 hours); the earlier - // float-precision ceiling wrongly rejected it. Single whole-unit - // components stay exact at any magnitude inside Go's range. + // "8760h" is a valid push-side value; single whole-unit components stay exact at any + // magnitude inside Go's range. const projected = fromConfigDocument({ auth: { sessions: { timebox: "8760h" } } }); expect(projected.auth?.sessions?.timebox).toBe("8760h0m0s"); const mapped = fromApiProjectConfig({ auth: { sessions_timebox: 8760 } }); @@ -2530,10 +2349,8 @@ describe("review round: Go-range sessions, SMTP/provider/storage disabled sentin ); }); - // Three-state fix (drift audit of CLI-2230/PR #6339): an ABSENT smtp_host - // says nothing about SMTP status — unlike the explicit "" sentinel above, - // it must not suppress the siblings. Mirrors smsProviderExplicitlyUnset's - // own absent-vs-sentinel rule a few rows below in registry-auth.ts. + // An absent smtp_host says nothing about SMTP status, unlike the explicit "" sentinel above, so + // it must not suppress the siblings. test("SMTP siblings map normally when smtp_host is ABSENT, not explicitly disabled", () => { const sparse = fromApiProjectConfig({ auth: { smtp_user: "postmaster", smtp_admin_email: "a@b.c" }, @@ -2545,11 +2362,9 @@ describe("review round: Go-range sessions, SMTP/provider/storage disabled sentin expect(unmappedApiFields(sparse)).toEqual({}); }); - // `enabled=false` means no Iceberg/Vector catalog is provisioned, so the - // quota fields are retained-but-inert ceilings on a non-existent resource - // — DISABLED_SENTINEL_PRUNES drops them on BOTH arms, leaving - // `{enabled: false}` either way (CLI-2314: the document arm no longer - // omits the container entirely — see this file's own docstring). + // `enabled: false` means no catalog is provisioned, so the quota fields are inert ceilings on a + // non-existent resource; `DISABLED_SENTINEL_PRUNES` drops them on both arms, leaving + // `{enabled: false}` either way. test("a disabled storage.analytics/vector container projects only its toggle on both arms", () => { const projected = fromConfigDocument({ storage: { @@ -2628,11 +2443,8 @@ describe("review round: exactness parsing, cross-arm disabled sentinels (CLI-223 }); test("null gating discriminators read as disabled and still prune their siblings", () => { - // The GET contract permits null for these booleans, and the legacy - // reconciliation reads a null discriminator as disabled (auth.sync.ts: - // 1315 captcha, :1336 hooks, :1789 external providers) — so the gated - // rows map null to false, letting the sentinel sweep prune the retained - // siblings instead of projecting them with no `enabled` key. + // The gated rows map null to false, letting the sentinel sweep prune the retained siblings + // instead of projecting them with no `enabled` key. const result = fromApiProjectConfig({ auth: { external_github_enabled: null, @@ -2663,10 +2475,9 @@ describe("review round: exactness parsing, cross-arm disabled sentinels (CLI-223 }); test("CSV-backed arrays canonicalize to their push round-trip on the document side", () => { - // The push mapper joins these arrays with "," (auth.sync.ts:2294, - // api.sync.ts:138,140) and the pull direction re-splits, so an element - // holding a literal comma round-trips into a different array — the - // document projection converges on the value that actually exists hosted. + // The push mapper joins these arrays with "," and the pull direction re-splits, so an + // element holding a literal comma round-trips into a different array; the document + // projection converges on what actually exists hosted. const doc = fromConfigDocument({ api: { schemas: ["public,graphql_public"], extra_search_path: [" public", "extensions "] }, auth: { additional_redirect_urls: ["https://example.com/callback?a=1,2"] }, @@ -2685,10 +2496,8 @@ describe("review round: exactness parsing, cross-arm disabled sentinels (CLI-223 }); test("an explicitly-unset SMS provider omits retained credentials entirely", () => { - // Legacy touches neither the flags nor the credentials on a null/empty - // sms_provider (auth.sync.ts:1664-1666, :1574-1655) — so nothing about - // the providers projects: no fabricated enabled flags, no retained - // credentials surviving as unmanaged phantom entries. + // Nothing about the providers projects when sms_provider is null/empty: no fabricated + // enabled flags, no retained credentials surviving as unmanaged phantom entries. const nullProvider = fromApiProjectConfig({ auth: { sms_provider: null, sms_messagebird_originator: "retained" }, }); @@ -2697,7 +2506,7 @@ describe("review round: exactness parsing, cross-arm disabled sentinels (CLI-223 auth: { sms_provider: "", sms_twilio_account_sid: "AC1" }, }); expect(Object.hasOwn(emptyProvider, "auth")).toBe(false); - // An ABSENT provider key says nothing — the credential still maps. + // An absent provider key says nothing — the credential still maps. const absentProvider = fromApiProjectConfig({ auth: { sms_messagebird_originator: "retained" }, }); @@ -2719,10 +2528,9 @@ describe("review round: exactness parsing, cross-arm disabled sentinels (CLI-223 }); test("the sessions floor includes int64's own minimum, asymmetrically", () => { - // -2^63 ns IS a valid Go duration (the int64 minimum); its hours spelling - // rounds back to exactly -2^63 through magnitude-then-sign. The next - // more-negative float already products past 2^63, and the POSITIVE - // mirror of the endpoint stays rejected (+2^63 is one past max int64). + // -2^63 ns is a valid Go duration (the int64 minimum); its hours spelling rounds back to + // exactly -2^63 through magnitude-then-sign. The positive mirror of the endpoint stays + // rejected, since +2^63 is one past max int64. const endpoint = fromApiProjectConfig({ auth: { sessions_timebox: -(2 ** 63) / 3_600_000_000_000 }, }); @@ -2735,10 +2543,9 @@ describe("review round: exactness parsing, cross-arm disabled sentinels (CLI-223 }); test("test_otp records canonicalize to their push round-trip on the document side", () => { - // The push wrapper serializes k=v pairs joined by commas (mapToEnv, - // auth.sync.ts:2603-2609) and the pull direction re-parses by splitting - // on every comma — a value holding a literal comma converges on the - // post-push hosted record. + // The push wrapper serializes k=v pairs joined by commas and the pull direction re-parses by + // splitting on every comma, so a value holding a literal comma converges on the post-push + // hosted record. const doc = fromConfigDocument({ auth: { sms: { test_otp: { "15551234567": "123,456" } } }, }); @@ -2796,9 +2603,8 @@ describe("review round: exactness parsing, cross-arm disabled sentinels (CLI-223 }); test("an empty test_otp map normalizes to unmanaged absence", () => { - // The push wrapper omits sms_test_otp when the serialized map is empty - // (auth.sync.ts:2487-2495), so an explicit {} can never clear a retained - // remote value — projecting it would fabricate permanent drift. + // The push wrapper omits sms_test_otp when the serialized map is empty, so an explicit {} + // can never clear a retained remote value; projecting it would fabricate permanent drift. const empty = fromConfigDocument({ auth: { sms: { test_otp: {} } } }); expect(Object.hasOwn(empty, "auth")).toBe(false); // A record whose entries all dissolve in the round-trip empties too. @@ -2822,27 +2628,23 @@ describe("review round: exactness parsing, cross-arm disabled sentinels (CLI-223 }); test("the document parser rejects the positive int64 endpoint the API arm rejects", () => { - // +2^63 ns is one past Go's maximum; a non-canonical spelling summing to - // exactly 2^63 stays verbatim instead of canonicalizing into a duration - // fromApiProjectConfig would reject. + // +2^63 ns is one past Go's maximum, so a non-canonical spelling summing to exactly 2^63 + // stays verbatim rather than canonicalizing into a duration the API arm would reject. const positive = fromConfigDocument({ auth: { sessions: { timebox: "2562047h47m16s854775808ns" } }, }); expect(positive.auth?.sessions?.timebox).toBe("2562047h47m16s854775808ns"); - // The negative endpoint IS valid int64 and still canonicalizes. + // The negative endpoint is valid int64 and still canonicalizes. const negative = fromConfigDocument({ auth: { sessions: { timebox: "-2562047h47m16s854775808ns" } }, }); - // The push-formatter truncation drops the sub-second tail on the - // document side; the API arm's faithful endpoint render is pinned above. + // The push-formatter truncation drops the sub-second tail on the document side. expect(negative.auth?.sessions?.timebox).toBe("-2562047h47m16s"); }); test("multiple enabled SMS providers converge on the push switch's first-enabled precedence", () => { - // The push switch selects the FIRST enabled provider in its fixed order - // and sends only that one (auth.sync.ts:2498-2539) — later enabled flags - // flip to false and their siblings prune, matching the API arm's report - // of the post-push hosted state. + // The push switch selects the first enabled provider in its fixed order and sends only that + // one; later enabled flags flip to false and their siblings prune. const doc = fromConfigDocument({ auth: { sms: { @@ -2871,13 +2673,9 @@ describe("review round: exactness parsing, cross-arm disabled sentinels (CLI-223 }); test("negative unsigned-style document values clamp like the pull direction", () => { - // The push mapper sends the local value unchanged (auth.sync.ts: - // 2304-2309) and the pull direction clamps what the API reports — a - // pushed -1 projects back as 0, so the document spelling converges. - // `api.max_rows` is the one exception (thread 2, human review round on - // PR #6339): push OMITS max_rows entirely when non-positive - // (api.sync.ts:141), so the document arm omits rather than clamps — - // see the dedicated max_rows tests below for the full omit/keep matrix. + // A pushed -1 projects back as 0, so the document spelling converges. `api.max_rows` is the + // one exception: push omits it entirely when non-positive, so the document arm omits rather + // than clamps — see the dedicated max_rows tests below. const doc = fromConfigDocument({ auth: { rate_limit: { anonymous_users: -1 } }, api: { enabled: true, max_rows: -5 }, @@ -2893,21 +2691,17 @@ describe("review round: exactness parsing, cross-arm disabled sentinels (CLI-223 expect(positive.auth?.rate_limit?.anonymous_users).toBe(30); }); - // Thread 2 (human review round on PR #6339): api.sync.ts:141 only sends - // max_rows when strictly positive — the document arm mirrors that by - // omitting rather than clamping. The API arm is unaffected (hosted `0` is - // real, reported state). + // The push side only sends max_rows when strictly positive, so the document arm mirrors that + // by omitting rather than clamping; the API arm is unaffected since hosted `0` is real, + // reported state. test.each([ ["0", 0], ["-0", -0], ["negative", -5], ["-Infinity", Number.NEGATIVE_INFINITY], - // TOML's `nan` literal is a real reachable document value here — - // `smol-toml` (this package's TOML parser, `io.ts`) parses - // `max_rows = nan` to `Number.NaN` — and `NaN <= 0` is `false`, which - // would have let a NaN slip past a naive non-positive check (engineer - // review round on PR #6339): `!(value > 0)` catches it because - // `NaN > 0` is also `false`. + // TOML's `nan` literal is a real reachable document value (`smol-toml` parses + // `max_rows = nan` to `Number.NaN`); `NaN <= 0` is `false`, so a naive non-positive check + // would miss it, but `!(value > 0)` catches it since `NaN > 0` is also `false`. ["NaN", Number.NaN], ])("api.max_rows: %s is omitted on the document arm", (_description, value) => { const doc = fromConfigDocument({ api: { enabled: true, max_rows: value } }); @@ -2940,12 +2734,8 @@ describe("review round: exactness parsing, cross-arm disabled sentinels (CLI-223 throw new Error("boom"); }, }, - // The toProjectConfig-nested variant (engineer review round on PR - // #6339, item 2): the dispatcher's own `cliConfig` read succeeds fine - // (it just returns this plain object reference) — the throw happens - // one level deeper, inside fromConfigDocument's own { config, - // document } unwrapping, which used to read `input["config"]"`/ - // `input["document"]` unguarded. + // The dispatcher's own `cliConfig` read succeeds fine; the throw happens one level deeper, + // inside `fromConfigDocument`'s own { config, document } unwrapping. { cliConfig: { get config(): EffectiveConfig { @@ -2966,11 +2756,8 @@ describe("review round: exactness parsing, cross-arm disabled sentinels (CLI-223 } }); - // Direct fromConfigDocument calls (not routed through the toProjectConfig - // dispatcher above) — engineer review round on PR #6339, item 2: both the - // "config" and "document" properties of the { config, document } pair - // shape must be read through the same guarded boundary as every other - // accessor-backed operand this file handles. + // Both the "config" and "document" properties of the { config, document } pair must be read + // through the same guarded boundary as every other accessor-backed operand. test("a throwing config or document getter on the { config, document } pair surfaces as caller misuse", () => { const throwingConfig = { get config(): EffectiveConfig { @@ -3003,9 +2790,8 @@ describe("review round: exactness parsing, cross-arm disabled sentinels (CLI-223 }); test("an explicitly empty schemas array normalizes to unmanaged absence", () => { - // Push only sends db_schema when the array is non-empty (api.sync.ts: - // 137-139, "" being the disable sentinel), and the pull side reads "" - // as disabled — the API arm can never project [], so keeping it would + // Push only sends db_schema when the array is non-empty ("" is the disable sentinel), and + // the pull side reads "" as disabled, so the API arm can never project []; keeping it would // fabricate permanent drift. const empty = fromConfigDocument({ api: { enabled: true, schemas: [] } }); expect(empty.api?.schemas).toBeUndefined(); @@ -3033,9 +2819,8 @@ describe("review round: exactness parsing, cross-arm disabled sentinels (CLI-223 }); test("sub-minute fractional seconds quantize to the push formatter's toPrecision(10)", () => { - // The legacy seconds branch renders toPrecision(10) (config-sync. - // duration.ts:47-55) — "59.123456789s" pushes as "59.12345679s", so the - // canonical document spelling predicts that reading. + // The push formatter renders toPrecision(10): "59.123456789s" pushes as "59.12345679s", so + // the canonical spelling predicts that reading. const doc = fromConfigDocument({ auth: { sessions: { timebox: "59.123456789s" } } }); expect(doc.auth?.sessions?.timebox).toBe("59.12345679s"); // Cross-arm equality for the post-push hosted value. @@ -3075,10 +2860,8 @@ describe("review round: exactness parsing, cross-arm disabled sentinels (CLI-223 }); test("session canonicalization rides the push payload's hours round-trip", () => { - // Sessions travel as fractional hours (durationToHours = parse / 3.6e12, - // auth.sync.ts:2621-2627) and map back via Math.round(|hours| * 3.6e12) — - // "1024h4s" comes back one nanosecond high, so the canonical document - // spelling predicts that exact post-push reading. + // Sessions travel as fractional hours and map back via rounding; "1024h4s" comes back one + // nanosecond high, so the canonical spelling predicts that exact post-push reading. const doc = fromConfigDocument({ auth: { sessions: { timebox: "1024h4s" } } }); expect(doc.auth?.sessions?.timebox).toBe("1024h0m4.000000001s"); // Cross-arm equality with the hosted hours value the push would store. @@ -3092,9 +2875,8 @@ describe("review round: exactness parsing, cross-arm disabled sentinels (CLI-223 }); test("orphan secret paths validate like isSecret rows before being suppressed", () => { - // The four unmappedSecretApiPaths are in the consumed set, so without - // validation a contract-invalid value (string-or-null only) would vanish - // completely — never emitted AND hidden from unmappedApiFields. + // These paths are in the consumed set, so without validation a contract-invalid value would + // vanish completely, never emitted and hidden from unmappedApiFields. let thrown: unknown; try { fromApiProjectConfig({ auth: { external_slack_secret: 123 } }); @@ -3111,10 +2893,8 @@ describe("review round: exactness parsing, cross-arm disabled sentinels (CLI-223 }); test("documents with auth or storage disabled still project their other declared fields (CLI-2314)", () => { - // `auth.enabled`/`storage.enabled` are local Docker toggles with no - // hosted counterpart — `DISABLED_SENTINEL_PRUNES` no longer treats them - // as a "drop every other declared field" gate, so a document that - // declares `enabled = false` alongside a real hosted field keeps it. + // `auth.enabled`/`storage.enabled` are local Docker toggles with no hosted counterpart, so a + // document that declares `enabled = false` alongside a real hosted field keeps that field. const projected = fromConfigDocument({ auth: { enabled: false, site_url: "http://localhost:3000" }, storage: { enabled: false, file_size_limit: "50MiB" }, @@ -3132,14 +2912,13 @@ describe("review round: exactness parsing, cross-arm disabled sentinels (CLI-223 }); expect(doc.auth?.rate_limit).toEqual({ sms_sent: 30 }); - // API arm, smtp_host ABSENT: says nothing (same sibling rule as the SMTP - // three-state fix) — email_sent must NOT be pruned. + // API arm, smtp_host absent: says nothing about SMTP status, so email_sent must not be pruned. const apiAbsent = fromApiProjectConfig({ auth: { smtp_user: "u", smtp_admin_email: "a@b.c", rate_limit_email_sent: 5 }, }); expect(apiAbsent.auth?.rate_limit).toEqual({ email_sent: 5 }); - // API arm, smtp_host EXPLICITLY "" (disabled sentinel): still pruned. + // API arm, smtp_host explicitly "" (disabled sentinel): still pruned. const apiDisabled = fromApiProjectConfig({ auth: { smtp_host: "", rate_limit_email_sent: 30 }, }); @@ -3161,14 +2940,10 @@ describe("review round: oauth_server disabled sentinel (CLI-2230)", () => { expect(api.auth?.oauth_server).toEqual({ enabled: false }); }); - // `oauth_server_enabled=false` means the platform serves no OAuth - // consent-UI/dynamic-registration behavior, but genuinely retains - // `allow_dynamic_registration`/`authorization_url_path` as - // stored-but-inert state — DISABLED_SENTINEL_PRUNES drops them on BOTH - // arms, leaving `{enabled: false}` either way (CLI-2314: the document arm - // no longer omits the container entirely — see this file's own - // docstring). An enabled container survives with every field intact on - // the document arm. + // `oauth_server_enabled: false` means the platform serves no OAuth consent-UI/dynamic- + // registration behavior, but genuinely retains its other fields as stored-but-inert state; + // `DISABLED_SENTINEL_PRUNES` drops them on both arms, leaving `{enabled: false}` either way. An + // enabled container survives with every field intact on the document arm. test("a disabled auth.oauth_server container projects only its toggle on both arms", () => { const disabled = fromConfigDocument({ auth: { oauth_server: { enabled: false, authorization_url_path: "/stale" } }, @@ -3194,16 +2969,11 @@ describe("review round: oauth_server disabled sentinel (CLI-2230)", () => { describe("DISABLED_SENTINEL_PRUNES — cross-arm symmetry re-derived from the data model (CLI-2314)", () => { /** - * One disabled-state fixture pair per {@link DISABLED_SENTINEL_PRUNES} - * entry: a document declaring the container disabled with every dropKey - * populated with a stale, non-default value, and the equivalent API - * attributes representing that same hosted state with the same sibling - * values. Machine-checks the claim behind each entry's own docstring — that - * the rule is re-derived from something the platform's data model already - * enforces (or retains) on both arms, not push-shaped reasoning the API arm - * doesn't independently share. A future entry added on push-only reasoning - * would need its own fixture here and would fail the loop below the moment - * the two arms disagree. + * One disabled-state fixture pair per {@link DISABLED_SENTINEL_PRUNES} entry, machine-checking + * that each rule is re-derived from something the platform's data model already enforces on + * both arms, not push-only reasoning the API arm doesn't independently share. A future entry + * added on push-only reasoning would need its own fixture here and would fail the loop below + * the moment the two arms disagree. */ const fixturesByContainerPath: Record< string, @@ -3316,15 +3086,10 @@ describe("DISABLED_SENTINEL_PRUNES — cross-arm symmetry re-derived from the da for (const rule of DISABLED_SENTINEL_PRUNES) { const key = rule.containerPath.join("."); - // Skipped, not weakened: `db.network_restrictions.enabled` has no v2 API - // contract field at all (registry.ts:332-337) — it's a document-only - // management toggle. `applyDisabledSentinels` can therefore never fire on - // the API arm for this container (its mapped shape never carries an - // `enabled` key to compare against `false`), so there is no "equivalent - // API response representing the same disabled state" this generic scheme - // could build. The asymmetry itself is the documented, correct behavior - // for this entry (see its own comment above `DISABLED_SENTINEL_PRUNES`), - // not a gap this test should paper over. + // Skipped, not weakened: `db.network_restrictions.enabled` has no v2 API contract field at + // all, so `applyDisabledSentinels` can never fire on the API arm for this container. The + // asymmetry is documented, correct behavior for this entry, not a gap this test should paper + // over. if (key === "db.network_restrictions") { continue; } @@ -3353,11 +3118,10 @@ describe("DISABLED_SENTINEL_PRUNES — cross-arm symmetry re-derived from the da describe("review round: clone-snapshot validation, provenance, digit exactness (CLI-2230)", () => { test("a getter that changes answers cannot desynchronize validation from the attached snapshot", () => { - // Clone-first ordering: structuredClone reads the getter exactly once, - // and the VALIDATED value is the CLONE — so either the snapshot is plain - // JSON and attaches coherently (this case: first read returns 1), or the - // snapshot itself fails validation typed. No ordering lets a value that - // wasn't validated get attached. + // Clone-first ordering: structuredClone reads the getter exactly once, and the validated + // value is the clone, so either the snapshot is plain JSON and attaches coherently (this + // case: first read returns 1), or the snapshot itself fails validation typed — no ordering + // lets an unvalidated value get attached. let reads = 0; const sneaky: Record<string, unknown> = {}; Object.defineProperty(sneaky, "flip", { @@ -3416,8 +3180,8 @@ describe("review round: unified snapshot, exact scaling, signed frequencies, end }, }); const result = fromApiProjectConfig({ api: flippy }); - // Whatever the first (and only) read produced is BOTH the mapped value - // and the metadata value — no desync possible. + // Whatever the first (and only) read produced is both the mapped value and the metadata + // value — no desync possible. expect(result.api?.max_rows).toBe(1); expect(result._apiResponse?.["api"]).toEqual({ max_rows: 1 }); }); @@ -3452,14 +3216,11 @@ describe("review round: fractional-addition exactness (CLI-2230)", () => { }); describe("fromConfigDocument — raw-presence masking (CliConfigWithRawPresence, thread 1, human review round on PR #6339)", () => { - // Engineer review round on PR #6339, item 6: pins the collision - // `unwrapConfigDocumentSource`'s shape-sniffing relies on — no top-level - // `CliConfig` field is literally named "config" or "document", so a real - // decoded document can never be misread as the { config, document } pair - // shape. A future top-level `[config]`/`[document]` section would - // silently reroute every bare-operand call into the pair arm; this test - // fails loudly the moment one is added, rather than the collision - // surfacing as a confusing runtime misread. + // Pins the collision `unwrapConfigDocumentSource`'s shape-sniffing relies on: no top-level + // `CliConfig` field is named "config" or "document", so a real decoded document can never be + // misread as the { config, document } pair shape. This fails loudly if a future top-level + // `[config]`/`[document]` section is ever added, rather than surfacing as a confusing runtime + // misread. test("no top-level CliConfig field is named config or document (the shape-sniffing collision this relies on)", () => { const topLevelKeys = Object.keys(CliConfigSchema.fields); expect(topLevelKeys).not.toContain("config"); @@ -3469,8 +3230,8 @@ describe("fromConfigDocument — raw-presence masking (CliConfigWithRawPresence, test("without a document, decode-materialized defaults leak through (the presence-relativity limit ADR 0021 documents)", () => { const config = decodeCliConfig({}); const projected = fromConfigDocument(config); - // All 19 providers present with their schema-defaulted shape — this is - // exactly the limit CliConfigWithRawPresence exists to close. + // All 19 providers present with their schema-defaulted shape — the limit + // `CliConfigWithRawPresence` exists to close. expect(Object.keys(projected.auth?.external ?? {}).length).toBeGreaterThan(1); }); @@ -3481,13 +3242,12 @@ describe("fromConfigDocument — raw-presence masking (CliConfigWithRawPresence, const config = decodeCliConfig(document); const projected = fromConfigDocument({ config, document }); expect(Object.keys(projected.auth?.external ?? {}).sort()).toEqual(["apple", "google"]); - // Declared with only client_id — the rest still comes from the DECODED - // schema-defaulted shape, masking only decides presence, not values. + // Declared with only client_id — the rest still comes from the decoded schema-defaulted + // shape; masking only decides presence, not values. expect(projected.auth?.external?.google).toEqual(config.auth.external.google); - // Apple is schema-defaulted `enabled: false` here (never raw-declared), - // so the pre-existing disabled-sentinel sweep (unrelated to presence - // masking) still prunes its siblings down to the toggle alone — apple - // "always sent" means always PRESENT, not exempt from that sweep. + // Apple is schema-defaulted `enabled: false` here (never raw-declared), so the + // disabled-sentinel sweep still prunes its siblings down to the toggle alone — "always sent" + // means always present, not exempt from that sweep. expect(projected.auth?.external?.apple).toEqual({ enabled: false }); }); @@ -3499,15 +3259,12 @@ describe("fromConfigDocument — raw-presence masking (CliConfigWithRawPresence, expect(Object.hasOwn(projected.auth ?? {}, "captcha")).toBe(false); }); - // Engineer review round on PR #6339, item 3: an own key set to an - // EXPLICIT `undefined` must read as absent, matching `presenceIn`'s - // own `x?.["key"] !== undefined` predicate exactly (a value comparison, - // not `Object.hasOwn`) — the degenerate case a naive `Object.hasOwn` - // check would get wrong. + // An own key set to explicit `undefined` must read as absent, matching `presenceIn`'s own + // `x?.["key"] !== undefined` predicate (a value comparison, not `Object.hasOwn`) — the + // degenerate case a naive `Object.hasOwn` check would get wrong. test("an own key set to explicit undefined reads as absent, same as omitted entirely", () => { - // `document` need not itself be schema-decodable — it's the raw - // presence signal, independent of `config` — so this deliberately - // malformed-looking `{ captcha: undefined }` shape is paired with an + // `document` need not itself be schema-decodable — it's the raw presence signal, independent + // of `config` — so this malformed-looking `{ captcha: undefined }` shape is paired with an // ordinary fully-defaulted decoded config instead of decoding itself. const document = { auth: { captcha: undefined } }; expect(Object.hasOwn(document.auth, "captcha")).toBe(true); @@ -3582,11 +3339,9 @@ describe("fromConfigDocument — raw-presence masking (CliConfigWithRawPresence, expect(Object.hasOwn(projected.auth ?? {}, "captcha")).toBe(false); }); - // Engineer review round on PR #6339, item 4: absent/explicit-undefined - // `document` is legal (no masking, asserted elsewhere in this file); a - // PRESENT but non-object `document` is a different, caller-error case — - // silently disabling masking with no signal would be asymmetric with the - // throwing guard `config` already gets. + // Absent/explicit-undefined `document` is legal (no masking, asserted elsewhere in this file); + // a present but non-object `document` is a caller-error case — silently disabling masking with + // no signal would be asymmetric with the throwing guard `config` already gets. test.each([ ["null", null], ["a string", "oops"], @@ -3597,10 +3352,7 @@ describe("fromConfigDocument — raw-presence masking (CliConfigWithRawPresence, const config = decodeCliConfig({}); let thrown: unknown; try { - // A JavaScript caller can hand a non-object `document` despite the - // compile-time type — same rationale as this file's other - // `as unknown as` runtime-misuse pins (e.g. `unmappedApiFields(null as - // unknown as ProjectConfig)` above). + // A JavaScript caller can hand a non-object `document` despite the compile-time type. fromConfigDocument({ config, document } as unknown as EffectiveConfig); } catch (error) { thrown = error; @@ -3611,9 +3363,8 @@ describe("fromConfigDocument — raw-presence masking (CliConfigWithRawPresence, ); test("saveCliConfig's LoadedCliConfig shape (no document field at all) falls back to the un-remedied, unmasked behavior", () => { - // Mirrors io.ts's saveCliConfig return literal exactly: no `document` - // key at all, not even `undefined` — there is no raw file to re-read on - // a save. + // Mirrors `saveCliConfig`'s return shape: no `document` key at all, not even `undefined` — + // there is no raw file to re-read on a save. const config = decodeCliConfig({}); const saved = { path: "supabase/config.toml", format: "toml", config, ignoredPaths: [] }; const projected = fromConfigDocument(saved); diff --git a/packages/config/src/project-config/project-schema.ts b/packages/config/src/project-config/project-schema.ts index e3f53a1ef2..dc0116aca9 100644 --- a/packages/config/src/project-config/project-schema.ts +++ b/packages/config/src/project-config/project-schema.ts @@ -1,100 +1,9 @@ /** - * Runtime companion to {@link ProjectConfig} (`./project-config.ts`) — a - * schema that VALIDATES the same sparse hosted-section overlay - * `ProjectConfig` only describes at compile time. Derived from - * {@link CliConfigSchema} (`../base.ts`), never hand-declared, so the two can - * never independently drift: every leaf type, annotation, and leaf-level - * check traces back to the exact schema `base.ts` decodes a config document - * with. - * - * Derivation, in order: - * - * 1. {@link hostedSectionsStruct} picks the seven {@link HOSTED_SECTION_KEYS} - * fields off `CliConfigSchema.fields` and rebuilds a fresh `Schema.Struct` - * from them — the same field schemas `CliConfigSchema` itself embeds, not - * copies. - * 2. `SchemaAST.toType` strips every encoding/transformation (decoding - * defaults, `env()` deferred substitution, …), leaving the DECODED shape — - * exactly what `ProjectConfig` describes; a `ProjectConfig` value is never - * re-encoded. - * 3. {@link toDeepOptionalHostedAst} then recursively rebuilds the result: - * - In every `Objects` node (struct OR record), drops any - * `PropertySignature`/`IndexSignature` whose value AST carries the - * `x-secret` annotation (ADR 0019 rule 5 — `fromConfigDocument`/ - * `fromApiProjectConfig` never populate a secret leaf either), the same - * detection `../lib/secret-paths.ts`'s own walk uses. When that - * stripping empties out an `Objects` node that ORIGINALLY had at least - * one property/index signature — a container whose value type consists - * ENTIRELY of secret leaves, e.g. `db.vault` (a `Record<string, - * secret()>`) — the walk drops that property/index signature from its - * PARENT entirely instead of keeping an empty, accept-anything - * `Objects` node: an all-secret container is itself secret-shaped, the - * same as a single secret leaf, so `db.vault` never appears anywhere in - * `ProjectConfigSchema` at all. This is distinct from an `Objects` node - * that was ALREADY empty at the SOURCE level before any stripping — - * `storage.analytics.buckets.*` and `storage.vector.buckets.*` are - * genuinely empty `Schema.Struct({})`s (`../storage.ts`), untouched by - * this walk, and still pass through as accept-anything leaves — the - * derived schema must not be stricter than `CliConfigSchema` itself, - * which behaves identically for those two, genuinely-empty structs. - * - Wraps every SURVIVING property in `optionalKey` (via - * {@link toOptionalAst}), recursing into its type — mirroring - * `DeepPartial`'s `{ readonly [K in keyof T]?: DeepPartial<T[K]> }` - * mapped type (`../sparse.ts`) at every object level reached, and - * recursing the same way into index-signature VALUE types (matching - * `DeepPartial`'s recursion into a `Record`'s value type — - * `Record<string, X>` deep-partializes to `Record<string, - * DeepPartial<X>>`, not `X` verbatim). - * - Leaves an `Arrays` node completely untouched, INCLUDING its element - * types: `DeepPartial` special-cases arrays to pass `T` through - * verbatim rather than partializing element types (`../sparse.ts`), and - * no `x-secret` leaf sits inside an array anywhere in this schema - * (`../lib/secret-paths.ts`'s own docstring), so there is nothing this - * walk would otherwise need to change there anyway. - * - Strips every `checks` array attached DIRECTLY to an `Objects` node — - * the cross-field business-rule refinements this repo attaches with - * `.check()` on a whole struct (`requiredWhenEnabled` in - * `../auth/email.ts`/`../auth/providers.ts`, `validateSmsProviderSwitch` - * in `../auth/sms.ts`) encode invariants a deliberately sparse overlay - * cannot generally satisfy — e.g. `{ auth: { email: { smtp: { enabled: - * true } } } }` with no `host` yet is a legal, if incomplete, - * `ProjectConfig` fragment, but `requiredWhenEnabled("host", ...)` would - * reject it. Every LEAF-level check survives untouched, since it lives - * on a non-`Objects` node — today that's only `workers.*.instances`'s - * `Schema.Number.check(isInt(), isGreaterThanOrEqualTo(0))` and the - * `[workers]` record's own key pattern (`Schema.isPattern(...)` on - * `workerName`, `../workers.ts`). There is no port-range (or other - * numeric-bound) leaf check anywhere in this schema today. - * - Recurses into `Union` members (e.g. `storage.file_size_limit`'s - * `Schema.Union([String, Number])`, and every `Schema.Literals`-backed - * enum, which V4 also compiles to a `Union`), so a secret-bearing or - * object-shaped member nested inside one would still be reached. Every - * other node kind (every leaf: `String`, `Number`, `Boolean`, - * `Literal`, …) is returned unchanged — there is nothing further to - * drop or partialize on a leaf. This module's own AST node kinds are - * enumerated explicitly, via each class's PUBLIC constructor, rather - * than through a generic `.recur()`-style mechanism: unlike - * `.repos/effect`'s vendored source, the installed `effect` release's - * own `AST#recur` is `@internal` (absent from its published `.d.ts`), - * so a truly generic fallback isn't available through the public API - * surface this package is allowed to depend on. - * `./project-schema.unit.test.ts`'s AST-walk exhaustiveness guard walks - * the derived AST and fails loudly if a node kind outside this - * enumerated set (or a reintroduced `Suspend`, deliberately unhandled - * here — see {@link toDeepOptionalHostedAst}) ever appears, rather than - * silently mishandling it. - * - * `_apiResponse` (ADR 0019) is deliberately NOT part of this schema: it's - * attached as a non-enumerable property that ordinary decode/validation can - * never see, so there is nothing here for a schema to describe. - * - * Never `additionalProperties: false` ({@link toProjectConfigJsonSchema} - * passes `{ additionalProperties: true }` to `Schema.toJsonSchemaDocument`, - * and `ProjectConfigSchema` itself is never decoded with - * `onExcessProperty: "error"`): a `ProjectConfig` value can carry extra own - * keys a given schema VERSION doesn't yet model (a registry-mapped field a - * future release adds), and JSON Schema's own default is permissive — this - * derivation matches that norm rather than rejecting anything unrecognized. + * Runtime schema for {@link ProjectConfig}, derived from {@link CliConfigSchema} so the two shapes + * cannot drift independently: picks the seven hosted-section fields, strips down to the decoded + * shape, then makes every property deep-optional while dropping secret-only (`x-secret`) leaves + * and containers and struct-level `.check()` refinements a sparse overlay can't satisfy. Always + * permissive (`additionalProperties: true`); `_apiResponse` is non-enumerable, so it's excluded. */ import type { StandardSchemaV1 } from "@standard-schema/spec"; import { Schema, SchemaAST } from "effect"; @@ -113,15 +22,9 @@ function hasObjectMembers(ast: SchemaAST.AST): boolean { } /** - * True when `original` was an `Objects` node with at least one member - * (property or index signature) before secret-stripping, and `transformed` — - * the same node's {@link toDeepOptionalHostedAst} result — ended up with - * none: every member was secret-shaped and got dropped, so the container - * itself is now secret-shaped too. Distinguishes that case from an `Objects` - * node that was ALREADY empty at the source level (`storage.analytics. - * buckets.*`/`storage.vector.buckets.*` — see this module's own doc - * comment), which must pass through unchanged rather than being treated as - * secret-shaped. + * True when every member of `original` was secret-shaped and got stripped from `transformed`, + * leaving it empty — as opposed to an `Objects` node that was already empty in the source schema, + * which must pass through as a permissive leaf rather than being treated as secret-shaped. */ function isAllSecretCollapsedContainer( original: SchemaAST.AST, @@ -131,29 +34,16 @@ function isAllSecretCollapsedContainer( } /** - * Marks `ast` optional through the PUBLIC `Schema.optionalKey` combinator - * (`Schema.optionalKey(Schema.make(ast)).ast`) rather than the internal - * `SchemaAST.optionalKey` this repo's vendored `.repos/effect` snapshot - * exposes publicly but the installed `effect` release does not — see this - * module's own doc comment. Conscious exception to this repo's `as`-cast - * policy's spirit (a typed-constructor call standing in for one): `Schema.make` - * performs no structural check against the throwaway `unknown` `Codec` - * parameter here; only `ast` itself (read straight back off the wrapped - * schema) is used. + * Marks `ast` optional through the public `Schema.optionalKey` combinator; the installed `effect` + * release doesn't export `SchemaAST.optionalKey` directly. */ function toOptionalAst(ast: SchemaAST.AST): SchemaAST.AST { return Schema.optionalKey(Schema.make<Schema.Codec<unknown>>(ast)).ast; } /** - * `Suspend` is deliberately UNHANDLED here (falls through to the final - * `return ast` below, verbatim, untouched) rather than recursed into: no - * `Schema.suspend`-backed recursive type is reachable from the seven hosted - * sections today, so this is unreachable in practice, and - * `./project-schema.unit.test.ts`'s AST-walk exhaustiveness guard fails - * loudly the moment one is introduced — a reviewable prompt to design real - * `Suspend` handling (thunk identity/`$defs` implications included) instead - * of silently mishandling recursion. + * `Suspend` nodes fall through untouched; no recursive schema reaches this walk today. A test + * fails loudly if one is introduced, rather than this silently mishandling the recursion. */ function toDeepOptionalHostedAst(ast: SchemaAST.AST): SchemaAST.AST { if (SchemaAST.isObjects(ast)) { @@ -204,12 +94,8 @@ function toDeepOptionalHostedAst(ast: SchemaAST.AST): SchemaAST.AST { return ast; } -// A literal field-picking object, not a `HOSTED_SECTION_KEYS.map(...)` -// reflection: `Schema.Struct`'s field type is inferred per-property from a -// literal object type, which a programmatic pick loses without an `as` cast -// (disallowed by this repo's typing policy) to restore. Each field schema -// below is still the exact one `CliConfigSchema` itself embeds (`../base.ts`), -// never a copy. +// Field-picked literally rather than derived from `HOSTED_SECTION_KEYS`: `Schema.Struct`'s field +// type is inferred per-property from a literal object, which a programmatic pick would lose. const hostedSectionsStruct = Schema.Struct({ api: CliConfigSchema.fields.api, auth: CliConfigSchema.fields.auth, @@ -220,46 +106,22 @@ const hostedSectionsStruct = Schema.Struct({ experimental: CliConfigSchema.fields.experimental, }); -// The literal pick above still names the same seven keys as -// `HOSTED_SECTION_KEYS` by hand, since a type-safe `Schema.Struct` field -// object can't be built from an array without an `as` cast. The two lists -// drifting apart (an edit to one without the other) is caught by -// `./project-schema.unit.test.ts`'s own assertion against -// `ProjectConfigSchema.ast`'s top-level property names vs. -// `HOSTED_SECTION_KEYS`, not by an import-time throw here (CLI-2234) — a -// schema-module import should never be able to crash a consumer's process -// for a condition a test already covers. - +// Must name the same seven keys as `HOSTED_SECTION_KEYS`; a test asserts they match rather than +// an import-time check, so a config-module import can't crash a consumer's process. const projectConfigAst = toDeepOptionalHostedAst(SchemaAST.toType(hostedSectionsStruct.ast)); /** - * The runtime shape {@link projectConfigAst} validates: {@link ProjectConfig} - * minus `_apiResponse`, which — being non-enumerable and never serialized — - * has no runtime representation for a schema to check. `Schema.make` performs - * no structural verification against this annotation (the same trust-the- - * caller contract as effect's own `Json: Codec<Json> = make(SchemaAST.Json)` - * precedent); the type-level pin in `./project-schema.unit.test.ts` cross- - * checks this exact type expression against `ProjectConfig` independently, so - * a future edit to either side that silently drifts fails to compile there. + * The runtime shape {@link projectConfigAst} validates: {@link ProjectConfig} minus + * `_apiResponse`, which is non-enumerable and has no runtime representation to check. */ type ProjectConfigSchemaType = Omit<ProjectConfig, "_apiResponse">; /** - * Runtime validation for {@link ProjectConfig} — both an Effect-native schema - * (decode/encode, `.ast`, …) and a spec-compliant Standard Schema - * (`~standard`), since {@link Schema.toStandardSchemaV1} augments and returns - * the SAME object rather than wrapping it in a second value. + * Runtime validator for {@link ProjectConfig}, exposed as both an Effect `Schema.Codec` and a + * spec-compliant Standard Schema (`~standard`) on the same object. * - * Annotated explicitly (rather than left inferred) because the inferred type - * names `StandardSchemaV1` from `@standard-schema/spec` — a package reachable - * only transitively through `effect` under pnpm's strict `node_modules` - * isolation — which tsc's declaration emit refuses to synthesize into - * `project-schema.d.ts` as non-portable. Explicitly importing the type here - * pins `@standard-schema/spec` as a direct dependency instead. Conscious - * exception to this repo's `as`-cast policy's spirit: `Schema.make`'s type - * parameter here is asserted, not verified, against `projectConfigAst` — see - * {@link ProjectConfigSchemaType}'s doc comment for the independent - * compile-time cross-check that catches drift instead. + * Annotated explicitly because the inferred type names `@standard-schema/spec` transitively, + * which tsc can't portably emit into a declaration file unless it's a direct dependency. */ export const ProjectConfigSchema: StandardSchemaV1< ProjectConfigSchemaType, @@ -269,7 +131,7 @@ export const ProjectConfigSchema: StandardSchemaV1< Schema.make<Schema.Codec<ProjectConfigSchemaType>>(projectConfigAst), ); -/** JSON Schema (draft 2020-12) rendering of {@link ProjectConfigSchema}, mirroring `../base.ts`'s `toCliConfigJsonSchema`. */ +/** JSON Schema (draft 2020-12) rendering of {@link ProjectConfigSchema}. */ export function toProjectConfigJsonSchema() { const document = Schema.toJsonSchemaDocument(ProjectConfigSchema, { additionalProperties: true, diff --git a/packages/config/src/project-config/project-schema.unit.test.ts b/packages/config/src/project-config/project-schema.unit.test.ts index 53e2dced7c..d9941fe541 100644 --- a/packages/config/src/project-config/project-schema.unit.test.ts +++ b/packages/config/src/project-config/project-schema.unit.test.ts @@ -36,10 +36,8 @@ describe("ProjectConfigSchema acceptance", () => { }); test("a sparse overlay leaving required-looking siblings unset still validates", () => { - // `db.pooler`'s own fields (`pool_mode`, `default_pool_size`, …) are all - // present in `CliConfigSchema`, but this schema wraps every one of them - // `optionalKey` — a fragment naming only `enabled` must not fail just - // because it says nothing about the rest of the section. + // `db.pooler`'s sibling fields are all wrapped `optionalKey`, so naming only `enabled` must + // not fail. expect(() => decodeProjectConfig({ db: { pooler: { enabled: true } } })).not.toThrow(); }); @@ -73,13 +71,6 @@ describe("ProjectConfigSchema acceptance", () => { expect(() => decodeProjectConfig(projected)).not.toThrow(); }); - // `db.vault` is dropped from the schema entirely (an all-secret - // `Record<string, secret()>` container, project-schema.ts's - // `isAllSecretCollapsedContainer`) rather than kept as an empty, - // accept-anything node — so under this schema's permissive-excess design - // (never `additionalProperties: false`, never `onExcessProperty: "error"`), - // a `db.vault` of ANY shape is simply excess input: it validates, but is - // silently dropped from the decoded result rather than rejected. test("db.vault of any shape validates but is dropped, since the schema no longer knows the key", () => { expect(decodeProjectConfig({ db: { vault: 42 } })).toEqual({ db: {} }); expect(decodeProjectConfig({ db: { vault: {} } })).toEqual({ db: {} }); @@ -105,12 +96,8 @@ describe("ProjectConfigSchema rejection", () => { }); describe("ProjectConfigSchema secret-strip exhaustiveness", () => { - // Schema-derived, exhaustive counterpart to a hand-picked field list - // (matching `project-config.unit.test.ts`'s own exhaustive-probe - // precedent): every `x-secret` path pattern the schema declares, rooted in - // one of the seven hosted sections, must be structurally absent from - // `ProjectConfigSchema`'s own AST — not merely absent from one hand-picked - // example. + // Exhaustive counterpart to a hand-picked field list: every `x-secret` path pattern rooted in a + // hosted section must be structurally absent from `ProjectConfigSchema`'s own AST. const reachablePatterns = secretPathPatterns.filter((pattern) => HOSTED_SECTION_KEYS.some((key) => key === (pattern[0] ?? "")), ); @@ -124,12 +111,9 @@ describe("ProjectConfigSchema secret-strip exhaustiveness", () => { }); /** - * Walks {@link ProjectConfigSchema}'s own AST along `pattern`, treating a - * `"*"` segment as "descend into the node's own index signature" and every - * other segment as "descend into the property signature of that name" — - * returns `undefined` the moment the path can no longer be followed, which - * is exactly the outcome a dropped secret property/index-signature - * produces. + * Walks {@link ProjectConfigSchema}'s AST along `pattern` (`"*"` descends into an index + * signature, anything else into a same-named property signature); returns `undefined` once the + * path can no longer be followed. */ function findAtPattern( ast: SchemaAST.AST, @@ -154,19 +138,11 @@ describe("ProjectConfigSchema secret-strip exhaustiveness", () => { } }); - // Guards against a vacuous pass: if an ANCESTOR of `pattern` vanished - // (e.g. a whole section got dropped by an unrelated bug), `findAtPattern` - // for the full secret path also returns `undefined` — indistinguishable, - // from that assertion alone, from the secret leaf being correctly - // stripped. Asserting the parent path is still reachable rules that out — - // EXCEPT for a known all-secret collapsed container (`db.vault`, a - // `Record<string, secret()>` — project-schema.ts's - // `isAllSecretCollapsedContainer`), whose own immediate parent is dropped - // entirely rather than kept as an empty node. That one case is accepted - // explicitly (checking the GRANDPARENT is reachable instead, and that the - // container's own name no longer survives as a property there) rather - // than by walking arbitrarily far up the ancestor chain, which would mask - // an unrelated regression dropping some other, unexpected ancestor. + // Guards against a vacuous pass: if an ancestor of `pattern` vanished entirely, the leaf lookup + // also returns `undefined`, indistinguishable from a correctly-stripped secret. Asserting the + // immediate parent is still reachable rules that out, except for a known all-secret collapsed + // container (`db.vault`), whose own parent is dropped entirely — checked via its grandparent + // instead. const KNOWN_ALL_SECRET_COLLAPSED_CONTAINER_PARENTS: ReadonlyArray<ReadonlyArray<string>> = [ ["db", "vault"], ]; @@ -210,14 +186,9 @@ describe("ProjectConfigSchema secret-strip exhaustiveness", () => { }); /** - * Recursively collects the dotted path of every reachable `Objects` node - * with zero properties AND zero index signatures — the shape both a - * genuinely source-empty struct (`storage.analytics.buckets.*`, - * `storage.vector.buckets.*` — see `project-schema.ts`'s own doc comment) - * and (before CLI-2234's fix) an all-secret collapsed container would - * produce. `db.vault` is dropped entirely rather than emptied now, so it - * must NOT appear in this list — this is the "double-check no OTHER - * container becomes stripped-empty besides vault" guard. + * Recursively collects the dotted path of every reachable `Objects` node with zero properties + * and zero index signatures — the shape a genuinely source-empty struct produces, and what an + * all-secret collapsed container would also produce if it weren't dropped entirely instead. */ function collectEmptyObjectPaths( ast: SchemaAST.AST, @@ -262,11 +233,8 @@ describe("ProjectConfigSchema secret-strip exhaustiveness", () => { }); describe("ProjectConfigSchema hosted-section keys", () => { - // Moved from an import-time throw in `project-schema.ts` (CLI-2234): a - // schema-module import should never be able to crash a consumer's - // process for a condition a test already covers. Asserts against the - // PUBLIC, observable `ProjectConfigSchema.ast` rather than reaching into - // the module's private `hostedSectionsStruct`. + // Asserts against the schema's own public AST rather than the module's private struct, so a + // schema-module import can't crash a consumer for a condition this test already covers. test("the schema's own top-level property names are exactly HOSTED_SECTION_KEYS", () => { if (!SchemaAST.isObjects(ProjectConfigSchema.ast)) { throw new Error("expected ProjectConfigSchema.ast to be an Objects node"); @@ -279,15 +247,9 @@ describe("ProjectConfigSchema hosted-section keys", () => { }); describe("ProjectConfigSchema derivation AST-walk exhaustiveness", () => { - // CLI-2234 group 7c/7d: `toDeepOptionalHostedAst` (`project-schema.ts`) - // enumerates AST node kinds explicitly rather than through a generic - // recursion helper (see that module's doc comment for why) and - // deliberately leaves `Suspend` unhandled. This walks the ACTUAL derived - // `ProjectConfigSchema.ast` and fails loudly the moment a node kind - // outside the set that derivation is written to understand appears, - // rather than letting a future schema addition silently fall through - // `toDeepOptionalHostedAst`'s final `return ast` (correct for a true - // leaf, silently wrong for an unhandled container/recursive kind). + // `toDeepOptionalHostedAst` (project-schema.ts) enumerates AST node kinds explicitly and leaves + // `Suspend` unhandled; this walks the actual derived AST and fails loudly if a node kind outside + // that set appears, instead of silently falling through to the leaf case. const HANDLED_CONTAINER_TAGS = new Set(["Objects", "Arrays", "Union"]); const HANDLED_LEAF_TAGS = new Set(["String", "Number", "Boolean", "Literal"]); @@ -377,13 +339,9 @@ describe("ProjectConfigSchema Standard Schema interop", () => { describe("toProjectConfigJsonSchema", () => { const typedDocument = toProjectConfigJsonSchema(); - // `JsonSchema.JsonSchema` (`effect`) is an open `[x: string]: unknown` - // record with no named properties, so TypeScript can't statically type - // `typedDocument`'s nested `properties`/`required`/… fields — the same - // reason `io.unit.test.ts`'s own `toCliConfigJsonSchema` coverage asserts - // through a stringified rendering rather than typed property access. A - // JSON round trip gives every assertion below a plainly-navigable value - // without an `as` cast. + // `JsonSchema.JsonSchema` has no named properties, so TypeScript can't statically type the + // nested fields; round-tripping through JSON gives a plainly-navigable value without an `as` + // cast. const document = JSON.parse(JSON.stringify(typedDocument)); test("declares the draft 2020-12 dialect", () => { @@ -411,25 +369,11 @@ describe("toProjectConfigJsonSchema", () => { }); describe("ProjectConfigSchema type-level pin", () => { - // Compile-time drift guard (CLI-2234 design requirement, mirroring - // `apps/cli/src/shared/config/project-config-api-drift.unit.test.ts`'s - // `_typeDriftGuard`/`AssertNever` style): `ProjectConfigSchema`'s own - // generic annotation (`project-schema.ts`) and `ProjectConfig` - // (`project-config.ts`) are independent expressions of the same shape — - // this file re-derives the expected shape from `ProjectConfig` itself - // (rather than importing `project-schema.ts`'s private type alias) so a - // future edit to either side that silently drifts fails to compile here. - // - // Both directions hold because the only structural difference between the - // two sides is optional-property PRESENCE: `ProjectConfigSchema`'s Type - // never carries an `_apiResponse` key at all (never modeled, ADR 0019), and - // `ProjectConfig` types every `x-secret` leaf as present-but-optional even - // though the runtime derivation drops those keys entirely from the schema. - // TypeScript's structural assignability does not require a source type to - // have (or lack) an optional property the target also lacks (or has), so a - // missing or extra OPTIONAL property never blocks assignability in either - // direction — verified by actually compiling both functions below, not - // merely asserted in prose. + // Compile-time drift guard: re-derives the expected shape from `ProjectConfig` itself, so a + // future edit to either side that silently drifts fails to compile here. Both directions hold + // because the only structural difference is optional-property presence (`_apiResponse` vs. + // `x-secret` leaves), and TypeScript's structural assignability doesn't require a source to have + // or lack an optional property the target lacks or has. type ExpectedProjectConfigSchemaType = Omit<ProjectConfig, "_apiResponse">; type DerivedProjectConfigSchemaType = typeof ProjectConfigSchema.Type; diff --git a/packages/config/src/project-config/registry-auth.ts b/packages/config/src/project-config/registry-auth.ts index 5ce62cc52c..1e0e0c4db6 100644 --- a/packages/config/src/project-config/registry-auth.ts +++ b/packages/config/src/project-config/registry-auth.ts @@ -12,37 +12,15 @@ import { } from "./registry-row.ts"; /** - * GoTrue-key rows for the `auth` section of the v2 project-config - * `data.attributes` — a flat `Record<string, Json>` keyed by lowercased - * GoTrue setting name (e.g. `disable_signup`, `mfa_totp_enroll_enabled`). - * Every row's `apiPath` therefore starts with `["auth", "<gotrue_key>"]` and - * every `configPath` starts with `["auth", ...]`. + * GoTrue-key rows for the `auth` section of the v2 project-config `data.attributes` — a flat + * `Record<string, Json>` keyed by lowercased GoTrue setting name. Every row's `apiPath` starts + * with `["auth", "<gotrue_key>"]` and every `configPath` starts with `["auth", ...]`. * - * Mined from the push-direction sync helpers in - * `apps/cli/src/commands/config/push/config-sync/auth.sync.ts` - * (`applyRemoteAuthConfig` and its `applyRemoteHook`/`applyRemoteProvider` - * helpers for the pull direction, `authToUpdateBody` for the push direction) - * — cited per row below — and verified against the config schema files under - * `../auth/*.ts`. Local helpers below replicate the CLI's duration, - * password-character, and env-map conversions since `packages/config` cannot - * import from `apps/cli`. + * Local helpers below replicate the CLI's duration, password-character, and env-map conversions, + * since `packages/config` cannot import from `apps/cli`. */ -// Local helpers replicated from the CLI (see each citation). - -/** - * Port of Go `time.Duration.String()`, based on the port at - * `apps/cli/src/commands/config/push/config-sync/config-sync.duration.ts:18-82`, - * with one DELIBERATE divergence: the port truncates sub-second - * remainders in its hours/minutes branches (its :39-45), where Go itself - * prints fractional seconds (`"1h0m0.5s"`). This copy matches Go because it - * renders the HOSTED value (the API arm must show sub-second bits a hosted - * value can genuinely carry); the document-side canonicalizers instead apply - * {@link truncateLikePushFormatter} first, since the push pipeline runs the - * truncating formatter before converting (`normalizeDurationStr`, - * auth.sync.ts:986-987) — the two arms then agree exactly on every value a - * push can actually produce. - */ +/** Formats nanoseconds in Go duration syntax (`1h0m0.5s`), keeping fractional seconds. */ function durationString(ns: number): string { if (ns === 0) return "0s"; @@ -65,10 +43,8 @@ function durationString(ns: number): string { ns -= us * 1_000; const subSecondNs = ms * 1_000_000 + us * 1_000 + ns; - // toFixed(9), not toPrecision: a sub-microsecond fraction under a whole - // second (e.g. "1h1ns" => 1e-9 s) stringifies in exponent notation under - // toPrecision, which Go duration syntax does not accept — Go prints - // "1h0m0.000000001s" (up to nine decimals, trailing zeros trimmed). + // toFixed(9), not toPrecision: a sub-microsecond fraction under a whole second would stringify + // in exponent notation under toPrecision, which Go duration syntax doesn't accept. const secondsText = subSecondNs > 0 ? ((secs * 1_000_000_000 + subSecondNs) / 1_000_000_000) @@ -112,19 +88,12 @@ function durationString(ns: number): string { return result; } -/** - * Go's maximum time.Duration (max int64 nanoseconds, ~292 years); 2^63 is - * the nearest exactly-representable float64, one nanosecond above it — an - * approximate guard whose only job is keeping values inside Go's domain. - */ +/** Go's maximum `time.Duration` (max int64 nanoseconds, ~292 years); 2^63 is the nearest exactly-representable float64 above it. */ const MAX_GO_DURATION_NS = 2 ** 63; /** - * Go's maximum duration in whole seconds, for the `*_max_frequency` rows. - * Values this large are single whole-unit components, which stay float-exact - * at any magnitude inside Go's range (see {@link parseDuration}'s - * precision rule) — only MIXED or FRACTIONAL components past - * `Number.MAX_SAFE_INTEGER` nanoseconds lose precision. + * Go's maximum duration in whole seconds, for the `*_max_frequency` rows: a single whole-unit + * component stays float-exact at any magnitude in this range. */ const MAX_CANONICAL_DURATION_SECONDS = 9_223_372_036; @@ -135,23 +104,11 @@ const NS_PER_MS = 1_000_000; const NS_PER_US = 1_000; /** - * Port of Go `time.ParseDuration`, based on the push parser at - * `apps/cli/src/commands/config/push/config-sync/config-sync.duration.ts:95-159` - * (the same file `durationString` above is ported from). Returns nanoseconds; - * throws on invalid input — used only by the canonicalizers below, which - * never let a throw escape (unparsable document values stay verbatim). - * - * SETTLED AUTHORITY SCOPING (after several review rounds pulled in opposite - * directions): for VALID inputs the fractional-nanosecond arithmetic - * replicates the push parser verbatim — its float rounding is the pipeline's - * real reading, and canonicalization exists to predict the hosted value, so - * a more "exact" result push would never produce is the wrong target. The - * MAGNITUDE guards (digit-accumulation and whole-component exactness, the - * Go-range bound) instead keep verbatim-on-loss semantics: there the - * discrepancy changes a user-visible value by whole units, which - * canonicalization must never do silently. Malformed inputs (digit-less - * components, unknown units) throw here and stay verbatim, even though the - * legacy parser tolerates some of them. + * Ports Go's `time.ParseDuration`. Returns nanoseconds; throws on invalid input, and callers below + * never let that throw escape (unparsable document values stay verbatim). For valid inputs the + * fractional arithmetic matches the push pipeline's own float rounding, since canonicalization + * exists to predict the value push actually produces, not a more "exact" reading it never performs. + * Magnitude guards instead reject anything that would silently change a value by whole units. */ function parseDuration(s: string): number { if (s === "0") return 0; @@ -187,21 +144,15 @@ function parseDuration(s: string): number { if (i < s.length && s.charAt(i) === ".") { i++; while (i < s.length && s.charAt(i) >= "0" && s.charAt(i) <= "9") { - // Unbounded float accumulation, exactly like the push parser - // (config-sync.duration.ts:112-123): its rounding IS the pipeline's - // reading of long fractions, so the canonicalizer must reproduce it - // rather than a more exact result push would never produce. + // Unbounded float accumulation, matching the push parser: its rounding of long fractions + // is the pipeline's actual reading, so the canonicalizer reproduces it verbatim. frac = frac * 10 + parseInt(s.charAt(i), 10); post *= 10; i++; } } - // Go's ParseDuration rejects a component with no digits at all (`!pre && - // !post`, e.g. "s" or ".h") — the port at - // config-sync.duration.ts:107-125 omits that check and reads such input - // as zero, which would let `canonicalizeDurationString` silently rewrite - // a malformed document value like "s" into "0s". Failing here instead - // leaves the document value verbatim (normalizeDocument's contract). + // Rejects a component with no digits at all (e.g. "s" or ".h"), so a malformed document value + // stays verbatim instead of silently canonicalizing into "0s". if (integerDigits === 0 && post === 1) { throw new Error(`time: invalid duration "${orig}"`); } @@ -214,11 +165,8 @@ function parseDuration(s: string): number { unitNs = 1; s = s.slice(2); } else if (s.startsWith("us") || s.startsWith("µs")) { - // Only the two spellings the PUSH parser accepts (config-sync. - // duration.ts:134): Go itself also takes Greek small mu (U+03BC), but - // push throws on it, so canonicalizing "1μs" into a pushable "1µs" - // would fabricate a reading the pipeline never performs — per the - // authority scoping above, it stays verbatim instead. + // Only the two spellings the push pipeline accepts; Go itself also takes Greek small mu + // (U+03BC), but push throws on it, so that spelling stays verbatim instead. unitNs = NS_PER_US; s = s.slice(2); } else if (s.startsWith("ms")) { @@ -237,42 +185,27 @@ function parseDuration(s: string): number { throw new Error(`time: unknown unit in duration "${orig}"`); } - // Plain integer multiplication, with no rounding decision of its own — - // any imprecision at large magnitudes is REJECTED below (the BigInt - // exactness check), never rounded. Rounding only applies to the - // fractional remainder handled next (`fracNs`), whose authority is the - // legacy PUSH parser (`apps/cli/src/commands/config/push/ - // config-sync/config-sync.duration.ts:155`), not Go's own - // `time.ParseDuration`. + // Any imprecision here is rejected below via the BigInt exactness check, never rounded; + // rounding only applies to the fractional remainder (`fracNs`) next. const wholeContribution = n * unitNs; - // A safe integer component can still round through the unit - // multiplication ("9007199254740ms" × 1e6 lands above MAX_SAFE, and the - // rounding can even divide back clean) — BigInt exactness is the only - // reliable detector; on loss the value stays verbatim. Representable - // floats at these magnitudes are integers, so BigInt() is total here. + // A safe-integer component can still round through the unit multiplication; BigInt exactness + // is the only reliable detector, and floats at these magnitudes are integers, so BigInt() is + // total here. if (n !== 0 && BigInt(wholeContribution) !== BigInt(n) * BigInt(unitNs)) { throw new Error(`time: invalid duration "${orig}" (value out of range)`); } - // The fractional arithmetic replicates the PUSH parser verbatim - // (config-sync.duration.ts:155, `Math.round((frac / post) * unitNs)`): - // that parser is what actually processes the document on push, so the - // canonical spelling must predict ITS reading — Go's own ParseDuration - // truncates and scales in the other operand order, but matching Go here - // would canonicalize toward a hosted value the pipeline never produces. + // Replicates the push parser's rounding exactly, since that's what actually processes the + // document on push; Go's own ParseDuration rounds differently, toward a value push never + // produces. const fracNs = Math.round((frac / post) * unitNs); - // The frac addition itself can round onto a large exactly-scaled whole - // ("9000000000000.001ms": 9e18 + 1000 lands between float ticks) — on - // loss the value stays verbatim. + // The addition itself can round onto a large exactly-scaled whole; on loss the value stays + // verbatim. const contribution = wholeContribution + fracNs; if (fracNs !== 0 && contribution - wholeContribution !== fracNs) { throw new Error(`time: invalid duration "${orig}" (value out of range)`); } - // Two bounds: Go's own int64 range, and float64 EXACTNESS — the addition - // must not round ("2502h1ns" adds 1ns to a total whose float spacing is - // already >1ns, so next - total comes back 0, not 1, and the value stays - // verbatim rather than silently losing its tail), while exact additions - // parse at any magnitude inside Go's range ("8760h", "8760h0m", - // "8760h30m" — zero or coarse-grained components stay exact). + // Enforces two bounds: Go's int64 range, and float64 exactness — the running total must not + // round, or the value stays verbatim rather than silently losing precision. const next = total + contribution; if (!Number.isFinite(next) || next > MAX_GO_DURATION_NS || next - total !== contribution) { throw new Error(`time: invalid duration "${orig}" (value out of range)`); @@ -280,12 +213,8 @@ function parseDuration(s: string): number { total = next; } - // int64's own asymmetry: +2^63 is one nanosecond PAST Go's maximum while - // -2^63 IS the valid minimum. The in-loop bound is strict (`>`), which - // rightly lets the accumulation land exactly on 2^63 for the negative - // endpoint — so the positive case must be rejected here, keeping the - // document side in agreement with the API-side session ceiling (which - // stops short of +2^63). + // int64's asymmetry: +2^63 is one nanosecond past Go's maximum, while -2^63 is the valid + // minimum, so only the positive case is rejected here. if (!neg && total === MAX_GO_DURATION_NS) { throw new Error(`time: invalid duration "${orig}" (value out of range)`); } @@ -294,15 +223,10 @@ function parseDuration(s: string): number { } /** - * DOCUMENT-side duration canonicalization (CLI-2230's duration/byte-size - * finding): a config document legally spells a duration as `"1m"`, `"24h"`, - * or `"60s"` (the schema keeps every duration field a plain `Schema.String`), - * while {@link secondsToDurationString}/{@link hoursToDurationString} always - * emit the canonical Go form (`"1m0s"`). Reparsing and re-emitting through - * `durationString`/`parseDuration` makes both sides converge on one spelling - * for one logical duration. Never throws: a document value has already - * passed schema validation, so an unparsable value (which should not occur) - * is returned verbatim rather than failing `fromConfigDocument`. + * Document-side duration canonicalization: a config document legally spells a duration as `"1m"`, + * `"24h"`, or `"60s"`, while the API-side rows always emit the canonical Go form (`"1m0s"`). + * Reparsing and re-emitting makes both sides converge on one spelling for one logical duration. + * Never throws; an unparsable value is returned verbatim. */ function canonicalizeDurationString(value: unknown): unknown { if (typeof value !== "string") { @@ -318,16 +242,9 @@ function canonicalizeDurationString(value: unknown): unknown { } /** - * The push payload's OWN float quantization, applied after - * {@link truncateLikePushFormatter}: the session fields travel as fractional - * HOURS — `durationToHours` is a bare `parseDuration(s) / 3.6e12` - * (auth.sync.ts:2621-2627) — and {@link hoursToDurationString} maps the - * hosted float back with `Math.round(|hours| * 3.6e12)`. That ns→hours→ns - * trip is not always exact ("1024h4s" = 3,686,404,000,000,000 ns comes back - * 1 ns high, rendering "1024h0m4.000000001s"), so the canonical document - * spelling must ride the same round trip to land on the value the API arm - * will actually report after a push. The arithmetic here mirrors - * `hoursToDurationString` exactly (magnitude first, sign second). + * Replicates the push payload's own float quantization: session fields travel as fractional hours, + * and the nanoseconds→hours→nanoseconds round trip isn't always exact, so the canonical document + * spelling must ride the same round trip to match what the API arm reports after a push. */ function roundTripThroughHoursPayload(ns: number): number { const hours = ns / NS_PER_HOUR; @@ -336,25 +253,12 @@ function roundTripThroughHoursPayload(ns: number): number { } /** - * The push pipeline's OWN quantization of session durations: the local - * subset is built with `normalizeDurationStr` (auth.sync.ts:986-987), whose - * formatter drops the sub-second remainder in its hours/minutes branches - * (config-sync.duration.ts:39-45) before `durationToHours` converts what - * remains (auth.sync.ts:2374-2375) — so a document `"1h0.5s"` stores exactly - * one hour, and the canonical document spelling must predict that reading - * (same convergence rule as the whole-second flooring for frequencies). - * Sub-minute magnitudes with at least a whole second follow the legacy - * SECONDS branch instead, which renders `toPrecision(10)` (config-sync. - * duration.ts:47-55) — ten significant digits, not nine fixed decimals — so - * `"59.123456789s"` pushes as `"59.12345679s"`; the quantized nanoseconds - * are recovered by re-parsing that exact rendering through - * {@link parseDuration}, which replicates the push parser's fractional - * arithmetic verbatim. Below one second the two formatters' branches are - * identical (both `toPrecision(10)`), so the value passes through. The - * API-arm formatter ({@link durationString}) stays Go-faithful — a hosted - * value set out-of-band CAN carry sub-second bits under an hour/minute - * magnitude, and rendering them faithfully is what makes the resulting - * drift honest (a push would quantize it away). + * Replicates the push pipeline's own quantization of session durations: it drops the sub-second + * remainder for magnitudes of a minute or more, and re-renders sub-minute-but-whole-second-or-more + * values through the same `toPrecision(10)` rounding the push formatter uses. Below one second both + * formatters agree, so the value passes through unchanged. {@link durationString} (the API arm) + * stays Go-faithful instead, since a hosted value set out-of-band can genuinely carry sub-second + * bits there. */ function truncateLikePushFormatter(ns: number): number { const magnitude = Math.abs(ns); @@ -374,11 +278,9 @@ function truncateLikePushFormatter(ns: number): number { } /** - * {@link canonicalizeDurationString}, additionally floored to whole seconds — - * for the `*_max_frequency` rows, whose legacy push wrapper floors to integer - * seconds (auth.sync.ts:2611-2616): the hosted value can only ever be whole - * seconds, so the document spelling converges on what a push would actually - * produce. Unparsable values stay verbatim, like the base canonicalizer. + * {@link canonicalizeDurationString}, additionally floored to whole seconds, for the + * `*_max_frequency` rows: the hosted value can only ever be whole seconds, so the document + * spelling converges on what a push would actually produce. */ function canonicalizeWholeSecondsDurationString(value: unknown): unknown { if (typeof value !== "string") { @@ -392,49 +294,28 @@ function canonicalizeWholeSecondsDurationString(value: unknown): unknown { } } -/** - * Seconds (integer, as reported by the API) → Go duration string. Used for - * `email.max_frequency`, `mfa.phone.max_frequency`, and `sms.max_frequency`, - * mirroring `secondsToDurationString` in `config-sync.duration.ts:161-167`. - */ +/** Seconds (integer, as reported by the API) → Go duration string. */ function secondsToDurationString(seconds: number): string { return durationString(seconds * 1_000_000_000); } /** - * Hours (float, as reported by the API) → Go duration string. Used for - * `sessions.timebox`/`sessions.inactivity_timeout`. DELIBERATE divergence - * from the legacy apply, which rounds to whole hours - * (`Math.round(hours) * 3_600_000_000_000`, auth.sync.ts:1402-1407): a - * standalone mapping must represent the hosted value faithfully — rounding - * `1.5` hours to `"2h0m0s"` would change the setting, break the push-side - * round-trip (which converts back to fractional hours), and hide real drift. + * Hours (float, as reported by the API) → Go duration string, rendered faithfully rather than + * rounded to whole hours: rounding would change the setting, break the push-side round trip + * (which converts back to fractional hours), and hide real drift. */ function hoursToDurationString(hours: number): string { - // Go durations are integer nanoseconds by definition, so the float product - // resolves to the NEAREST integer nanosecond: an hour value that itself - // came from quantizing an integer-nanosecond duration (pushing "65s" - // stores 65e9/3.6e12 hours) can land a hair below the original, and - // truncation would shave a nanosecond ("1m4.999999999s"); rounding repairs - // that representation error while sub-nanosecond noise (sessions_timebox: - // 1e-20 → exponent-notation "3.6e-8ns" under raw decomposition) still - // collapses to "0s". - // Magnitude first, sign second: both duration parsers round the absolute - // value and then negate (parseDuration above; config-sync.duration.ts: - // 101-104,155,158), while a raw Math.round rounds half toward +∞ — the two - // disagree on negative half-nanosecond boundaries. + // Rounds to the nearest integer nanosecond rather than truncating, since a value that quantized + // from an integer-nanosecond duration can land a hair below the original. + // Magnitude first, sign second, matching `parseDuration`: a raw `Math.round` rounds half toward + // +∞, which disagrees with that convention on negative half-nanosecond boundaries. const magnitudeNs = Math.round(Math.abs(hours) * 3_600_000_000_000); return durationString(hours < 0 ? -magnitudeNs : magnitudeNs); } /** - * Mirrors Go `strconv.ParseUint(s, 10, 16)`, replicated from `auth.sync.ts: - * 2592-2601`: base-10 digits only, no sign, no suffix, value <= 65535. - * Returns `undefined` on any parse error. Used for `email.smtp.port`, which - * the API reports as a string. Unlike the legacy pull direction (which keeps - * the previous local value on a parse failure, since it is merging into a - * local document), this sparse mapping has no local value to fall back to, - * so an unparsable port simply omits the field. + * Parses a base-10, unsigned 16-bit port string (`email.smtp.port` arrives as a string); returns + * `undefined` on any parse error, which the caller treats as omitting the field. */ function parseUint16(s: string): number | undefined { if (!/^\d+$/.test(s)) return undefined; @@ -442,32 +323,13 @@ function parseUint16(s: string): number | undefined { return n > 65535 ? undefined : n; } +/** Splits on `,` then each entry on the first `=`; entries without `=` are dropped. */ /** - * Port of Go `sms.fromAuthConfig`'s `envToMap`, replicated from - * `auth.sync.ts:1736-1747`: splits on `,` (empty string → no entries, no - * trimming — same as the shared `strToArr`, - * `apps/cli/src/command-internal/local-config-values.ts:2790-2792`) then - * each entry on the first `=`; entries without a `=` (or with `=` at index 0) - * are dropped. Used for `sms.test_otp`. - */ -/** - * DOCUMENT-side canonicalization for `sms.test_otp` (same convergence rule - * as the CSV-backed array rows): the push wrapper serializes the record as - * `k=v` pairs joined by commas (`mapToEnv`, auth.sync.ts:2603-2609, used at - * :2487) and the pull direction re-parses with {@link envToMap}, which - * splits on EVERY comma and drops `=`-less fragments — so a key or value - * holding a literal comma round-trips into a different record. Replaying - * serialize-then-parse converges the document projection on the value that - * actually exists hosted after a push. Non-record values or non-string - * entries stay verbatim (document input has passed schema validation; never - * throw here). - * - * A record that is (or parses back) EMPTY normalizes to `undefined` — - * unmanaged absence: the push wrapper omits `sms_test_otp` entirely when the - * serialized map is empty (auth.sync.ts:2487-2495), so an explicit - * `test_otp: {}` can never clear a retained remote value; projecting `{}` - * would fabricate permanent drift against the API arm (whose transform - * likewise omits an empty map). + * Document-side canonicalization for `sms.test_otp`: serializes then re-parses through + * {@link envToMap} so a key or value holding a literal comma converges on the value that + * actually exists hosted after a push, instead of round-tripping into a different record. + * Non-record or non-string-valued input passes through verbatim. An empty (or now-empty) record + * normalizes to `undefined`, since the push wrapper omits the field entirely when the map is empty. */ function canonicalizeTestOtpMap(value: unknown): unknown { if (!isObject(value)) { @@ -494,11 +356,8 @@ function envToMap(input: string): Record<string, string> { } /** - * Local config `password_requirements` enum → API `password_required_characters` - * value, replicated verbatim from `auth.sync.ts:1241-1246` (Go - * `PasswordRequirements.ToChar`) — the `:` separators between character-class - * groups are significant, matching the `@supabase/api` generated client's - * literals. + * Local config `password_requirements` enum → API `password_required_characters` value; the `:` + * separators between character-class groups are significant. */ const PASSWORD_REQUIREMENTS_TO_CHAR: Record<string, string> = { letters_digits: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", @@ -507,16 +366,14 @@ const PASSWORD_REQUIREMENTS_TO_CHAR: Record<string, string> = { "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\\\:\"|<>?,./`~", }; -/** Inverse of {@link PASSWORD_REQUIREMENTS_TO_CHAR} (`auth.sync.ts:1248-1251`, Go `NewPasswordRequirement`). */ +/** Inverse of {@link PASSWORD_REQUIREMENTS_TO_CHAR}. */ const CHAR_TO_PASSWORD_REQUIREMENTS: Record<string, string> = Object.fromEntries( Object.entries(PASSWORD_REQUIREMENTS_TO_CHAR).map(([requirement, char]) => [char, requirement]), ); /** - * Reads a sibling key from the flat `auth` attributes record for rows whose - * `transform` combines more than one GoTrue key (declared via `alsoConsumes`). - * `attributes` is the full `data.attributes` object, so this drills into its - * `auth` sub-record first. + * Reads a sibling key from the flat `auth` attributes record for rows whose `transform` combines + * more than one GoTrue key (declared via `alsoConsumes`). */ function readAuthAttribute(attributes: Record<string, unknown>, key: string): unknown { const authAttributes = attributes["auth"]; @@ -524,20 +381,13 @@ function readAuthAttribute(attributes: Record<string, unknown>, key: string): un return Object.hasOwn(authAttributes, key) ? authAttributes[key] : undefined; } -// Row factories — see ./registry-row.ts for the null convention: `undefined` -// always skips a row, `null` skips unless the row has a `transform`. Every -// factory below (and every one-off row further down this file) therefore -// treats `null` as "omit" *before* narrowing the value with an `expect*` -// helper — narrowing a `null` first would throw `ProjectConfigParseError` for -// a value GoTrue legitimately reports, rather than skipping the field. +// Every factory treats `null` as "omit" before narrowing the value with an `expect*` helper; +// narrowing a `null` first would throw for a value GoTrue legitimately reports, rather than +// skipping the field. /** - * Plain string passthrough. Needs its own `transform` (rather than none, as a - * true passthrough would use) specifically so `null` is handled explicitly: - * without a `transform`, the engine already omits a `null` row, but 54 GoTrue - * keys route through this factory, and a future non-string, non-null value - * (e.g. a nested object) must still throw via `expectString` rather than land - * verbatim in the typed output. + * Plain string passthrough. Declares its own `transform` (rather than none) so a non-string, + * non-null value still throws via `expectString` instead of landing verbatim in typed output. */ function stringRow(configPath: ReadonlyArray<string>, apiKey: string): ProjectConfigMappingRow { const apiPath = ["auth", apiKey]; @@ -563,16 +413,10 @@ function boolRow(configPath: ReadonlyArray<string>, apiKey: string): ProjectConf } /** - * A GATING boolean — one that anchors a disabled-sentinel prune (captcha, - * hooks, external providers). Unlike {@link boolRow}'s null-skip, `null` maps - * to `false` here: the legacy reconciliation reads a null discriminator as - * disabled (`valOrDefault(remote.security_captcha_enabled, false)`, - * auth.sync.ts:1315; hooks `:1336`; providers `:1789`), the push path only - * manages the sibling fields while enabled, and the sentinel sweep - * (`applyDisabledSentinels`) only fires on a literal `false` — dropping the - * null would leave a retained client_id/URI in the projection with no - * `enabled` key, exactly the phantom-drift shape the sweep exists to prune. - * Same shape as the SMTP anchor's `smtp_host: null → enabled: false`. + * A gating boolean anchoring a disabled-sentinel prune (captcha, hooks, external providers). + * Unlike {@link boolRow}, `null` maps to `false` here rather than being skipped: a disabled + * feature's sentinel sweep only fires on a literal `false`, so dropping the `null` would leave a + * retained sibling field in the projection with no `enabled` key. */ function gatedBoolRow(configPath: ReadonlyArray<string>, apiKey: string): ProjectConfigMappingRow { const apiPath = ["auth", apiKey]; @@ -583,12 +427,7 @@ function gatedBoolRow(configPath: ReadonlyArray<string>, apiKey: string): Projec }; } -/** - * Boolean field whose GoTrue name is the negation of the config field, e.g. - * `disable_signup` → `enable_signup` (`auth.sync.ts:1272`, push inverse at - * `:2299`) and `mailer_autoconfirm` → `email.enable_confirmations` - * (`:1551`, push inverse at `:2379`). - */ +/** Boolean field whose GoTrue name is the negation of the config field, e.g. `disable_signup` → `enable_signup`. */ function invertedBoolRow( configPath: ReadonlyArray<string>, apiKey: string, @@ -603,12 +442,9 @@ function invertedBoolRow( } /** - * Signed API integer clamped to the schema's unsigned domain (`intToUint`). - * The DOCUMENT side clamps too: the config schema's `Schema.Number` accepts - * a negative value and the push mapper sends it unchanged (e.g. - * auth.sync.ts:2304-2309), but the pull direction clamps whatever the API - * reports — so a pushed `-1` projects back as `0`, and the document spelling - * must converge on that same reading. + * Signed API integer clamped to the schema's unsigned domain. The document side clamps too: the + * config schema accepts a negative value, but a pushed `-1` projects back as `0`, so the document + * spelling must converge on that same reading. */ function uintRow(configPath: ReadonlyArray<string>, apiKey: string): ProjectConfigMappingRow { const apiPath = ["auth", apiKey]; @@ -621,13 +457,8 @@ function uintRow(configPath: ReadonlyArray<string>, apiKey: string): ProjectConf } /** - * Integer seconds (API) → Go duration string (config), e.g. `"5s"`. Every - * call site is one of the five duration rows CLI-2230's finding names, so - * `normalizeDocument` is wired unconditionally here rather than per call - * site. Narrowed with `expectInteger`, not `expectNumber`: the generated - * contract declares all three `*_max_frequency` fields `isInt()`, so a - * fractional value is a malformed platform response — only the session-hour - * rows below are genuinely fractional. + * Integer seconds (API) → Go duration string (config), e.g. `"5s"`. Narrowed with `expectInteger`, + * not `expectNumber`: only the session-hour rows below are genuinely fractional. */ function secondsDurationRow( configPath: ReadonlyArray<string>, @@ -648,46 +479,25 @@ function secondsDurationRow( MAX_CANONICAL_DURATION_SECONDS, ), ), - // Whole-second quantization, not just respelling: the legacy push - // wrapper floors these three durations to integer seconds - // (auth.sync.ts:2611-2616), so the hosted value can only ever be whole - // seconds — a document "1.5s" pushes as 1s, and canonicalizing it to - // "1s" makes the two sides converge on the value that actually exists. + // Floors to whole seconds: the hosted value for these fields can only ever be whole seconds, + // so the document spelling converges on what a push would actually produce. normalizeDocument: canonicalizeWholeSecondsDurationString, unit: "seconds → duration string", }; } +/** Float hours (API) → Go duration string (config), e.g. `"1h0m0s"`. */ /** - * Float hours (API) → Go duration string (config), e.g. `"1h0m0s"`. Every - * call site is one of the five duration rows CLI-2230's finding names, so - * `normalizeDocument` is wired unconditionally here rather than per call - * site. - */ -/** - * Bound for the session-hour fields: Go's maximum duration expressed in - * (fractional) hours — a valid year-long "8760h" session bound pushes as - * 8760 and must map back, so the ceiling is Go's range, not float precision - * (whole-hour products stay exact at any magnitude inside it). Values past - * it overflow the formatter ("InfinityhNaNmNaNs", exponent notation). - * SIGNED: the strict contract only requires these fields finite, and the - * legacy apply renders a negative value faithfully (`sessions_timebox: -1` → - * `"-1h0m0s"`, auth.sync.ts:1402-1404 via durationString's sign handling), - * with the push parser reading the leading `-` back (config-sync.duration. - * ts:101-104,158) — like the signed `*_max_frequency` rows above, except the - * floor reaches one nanosecond-equivalent further (int64's own asymmetry, - * {@link MIN_SESSION_DURATION_HOURS} below). + * Bound for the session-hour fields: Go's maximum duration expressed in hours, so a value at the + * ceiling still maps back exactly (whole-hour products stay float-exact at any magnitude in Go's + * range). Signed, since these fields allow negative values, which render as `-Nh0m0s`. */ const MAX_SESSION_DURATION_HOURS = (MAX_GO_DURATION_NS - 2 ** 10) / NS_PER_HOUR; -// ^ 2^63 - 1024 (exactly representable at that float spacing) keeps the -// INCLUSIVE bound below Go's maximum duration — 2^63 itself is one -// nanosecond past max int64. - -// Asymmetric like int64 itself: -2^63 ns IS a valid Go duration (the -// minimum), and the hours spelling of that endpoint rounds back to exactly -// -2^63 through the magnitude-then-sign conversion below (verified: the next -// more-negative float already products past 2^63 and stays rejected) — so -// the floor includes it while the ceiling stops short of +2^63. +// 2^63 - 1024 is exactly representable at that float spacing, keeping the inclusive bound below +// Go's maximum duration (2^63 itself is one nanosecond past it). + +// Asymmetric like int64 itself: -2^63 ns is a valid Go duration (the minimum), so the floor +// includes it while the ceiling stops one nanosecond short of +2^63. const MIN_SESSION_DURATION_HOURS = -(MAX_GO_DURATION_NS / NS_PER_HOUR); function hoursDurationRow( @@ -714,16 +524,9 @@ function hoursDurationRow( }; } -// Legacy-handled but deliberately unmapped: the 4 passkey/webauthn keys -// (auth.sync.ts:281-285) — `passkey_enabled`, `webauthn_rp_display_name`, -// `webauthn_rp_id`, `webauthn_rp_origins`. `RemoteAuthConfig` carries them -// (the CLI's own `authSubsetFromConfig` sets its `passkey`/ -// `webauthn` subset fields to `undefined` unconditionally, auth.sync.ts: -// 918-920, "not in @supabase/config schema"), but there is no -// `../auth/*.ts` section for passkey/WebAuthn at all, so no row can target -// either side. Still reachable via `_apiResponse`. - -// CORE (auth.sync.ts:1263-1276, applyRemoteAuthConfig's base scalar fields) +// No `../auth/*.ts` section covers passkey/WebAuthn (`passkey_enabled`, `webauthn_rp_display_name`, +// `webauthn_rp_id`, `webauthn_rp_origins`), so no row targets either side; still reachable via +// `_apiResponse`. const coreRows: ReadonlyArray<ProjectConfigMappingRow> = [ { ...stringRow(["auth", "site_url"], "site_url"), dualScope: true }, @@ -735,9 +538,8 @@ const coreRows: ReadonlyArray<ProjectConfigMappingRow> = [ ? undefined : splitCommaSeparated(expectString(value, ["auth", "uri_allow_list"])), normalizeDocument: canonicalizeCommaJoinedArray, - // GoTrue treats the allow list as membership only — reordering the URLs - // changes nothing at runtime, unlike the sequence-semantics CSV arrays - // (`api.schemas`, `api.extra_search_path`). + // GoTrue treats the allow list as membership only; reordering the URLs changes nothing at + // runtime. arrayEquality: "set", unit: "csv → string[]", dualScope: true, @@ -752,21 +554,14 @@ const coreRows: ReadonlyArray<ProjectConfigMappingRow> = [ { configPath: ["auth", "password_requirements"], apiPath: ["auth", "password_required_characters"], - // "" is a legitimate value (no character-class requirement) and an - // unrecognized character-class STRING omits the field — an enum member - // this package version doesn't model, tolerable API-ahead skew (ADR - // 0019 rule 2; see auth.sync.ts:1259-1261). A present non-string, - // however, is a malformed platform response and throws like every other - // mapped auth field — silently omitting it would also let - // `unmappedApiFields` hide the malformed value, since this path is - // consumed. `null` keeps the no-value-omits convention. + // "" means no character-class requirement; an unrecognized character-class string omits the + // field (API-ahead skew), while a non-string still throws like every other mapped field. transform: (value) => { if (value === null) return undefined; const characters = expectString(value, ["auth", "password_required_characters"]); if (characters === "") return ""; - // Own entries only: the key is API-controlled, and a bare lookup with - // e.g. "constructor" would return the inherited function instead of - // omitting the unrecognized charset. + // Own entries only: a bare lookup with e.g. "constructor" would return the inherited + // function instead of omitting the unrecognized charset. return Object.hasOwn(CHAR_TO_PASSWORD_REQUIREMENTS, characters) ? CHAR_TO_PASSWORD_REQUIREMENTS[characters] : undefined; @@ -774,28 +569,20 @@ const coreRows: ReadonlyArray<ProjectConfigMappingRow> = [ }, ]; -// RATE LIMIT (auth.sync.ts:1291-1301; sign_in_sign_ups/token_verifications are renames) - const rateLimitRows: ReadonlyArray<ProjectConfigMappingRow> = [ uintRow(["auth", "rate_limit", "anonymous_users"], "rate_limit_anonymous_users"), uintRow(["auth", "rate_limit", "token_refresh"], "rate_limit_token_refresh"), uintRow(["auth", "rate_limit", "sign_in_sign_ups"], "rate_limit_otp"), uintRow(["auth", "rate_limit", "token_verifications"], "rate_limit_verify"), uintRow(["auth", "rate_limit", "sms_sent"], "rate_limit_sms_sent"), - // Deliberate divergence from the legacy apply: auth.sync.ts:1298 only - // applies this field when local SMTP is enabled. A standalone mapping has - // no local document to gate on, so it maps unconditionally. + // Maps unconditionally: a standalone mapping has no local document to gate this on. uintRow(["auth", "rate_limit", "email_sent"], "rate_limit_email_sent"), uintRow(["auth", "rate_limit", "web3"], "rate_limit_web3"), ]; -// SESSIONS (auth.sync.ts:1400-1408) - const sessionsRows: ReadonlyArray<ProjectConfigMappingRow> = [ - // GoTrue reports 0 hours for a session bound that was never configured, and - // the transform canonicalizes that to the string "0s" — declare it here so - // the diff baseline recognizes the canonicalized form (a type-level zero - // check would miss it and flag every untouched project). + // GoTrue reports 0 hours for an unconfigured session bound, canonicalized to "0s"; declared as + // the baseline so the diff recognizes it instead of flagging every untouched project. { ...hoursDurationRow(["auth", "sessions", "timebox"], "sessions_timebox"), unconfiguredValue: "0s", @@ -806,8 +593,6 @@ const sessionsRows: ReadonlyArray<ProjectConfigMappingRow> = [ }, ]; -// EMAIL (auth.sync.ts:1548-1562) - const emailBaseRows: ReadonlyArray<ProjectConfigMappingRow> = [ boolRow(["auth", "email", "enable_signup"], "external_email_enabled"), boolRow(["auth", "email", "double_confirm_changes"], "mailer_secure_email_change_enabled"), @@ -821,28 +606,20 @@ const emailBaseRows: ReadonlyArray<ProjectConfigMappingRow> = [ secondsDurationRow(["auth", "email", "max_frequency"], "smtp_max_frequency"), ]; -// SMTP (auth.sync.ts:1410-1435; enabled/host share the smtp_host key) - const smtpHostPath = ["auth", "smtp_host"]; const smtpPortPath = ["auth", "smtp_port"]; const smtpRows: ReadonlyArray<ProjectConfigMappingRow> = [ { - // auth.sync.ts:1433 derives enabled from `smtp_host != null` (any non-null - // host, including ""). This sparse mapping instead treats a non-empty - // host as the signal, matching the push direction's own disable sentinel - // (`body["smtp_host"] = ""` at auth.sync.ts:2387) so "" round-trips to - // disabled on both sides of this registry. `null` keeps meaning - // "disabled"; any other non-string throws via `expectString` rather than - // silently reporting `enabled: false` for a value GoTrue never actually - // sends. + // A non-empty host is the "enabled" signal, matching the push direction's own disable + // sentinel (`smtp_host: ""`), so "" round-trips to disabled. `null` also means disabled; any + // other non-string throws via `expectString`. configPath: ["auth", "email", "smtp", "enabled"], apiPath: smtpHostPath, transform: (value) => (value === null ? false : expectString(value, smtpHostPath).length > 0), }, { - // Same null/non-string handling as `enabled` above — `null`/`""` omit the - // field (host is meaningless while SMTP is off), any other non-string + // `null`/`""` omit the field (host is meaningless while SMTP is off); any other non-string // throws. configPath: ["auth", "email", "smtp", "host"], apiPath: smtpHostPath, @@ -854,11 +631,9 @@ const smtpRows: ReadonlyArray<ProjectConfigMappingRow> = [ dualScope: true, }, { - // auth.sync.ts:1420-1425: the API reports smtp_port as a string. `null` - // omits the field; a non-string throws via `expectString`; a string that - // fails `parseUint16` (out of range, non-digits) still omits, per the - // legacy pull direction's own tolerance for an unparsable port. Gated on - // an enabled SMTP host (validation first), like the sibling rows below. + // The API reports smtp_port as a string. `null` omits the field; a non-string throws; a + // string `parseUint16` can't parse (out of range, non-digits) also omits. Gated on an enabled + // SMTP host, like the sibling rows below. configPath: ["auth", "email", "smtp", "port"], apiPath: smtpPortPath, transform: (value, attributes) => { @@ -866,16 +641,10 @@ const smtpRows: ReadonlyArray<ProjectConfigMappingRow> = [ const port = parseUint16(expectString(value, smtpPortPath)); return smtpExplicitlyDisabledInAttributes(attributes) ? undefined : port; }, - // DOCUMENT-side round-trip (ADR 0021 drift-audit fix): the config schema - // types `smtp.port` as an unrestricted `Schema.Number`, but the push - // wrapper stringifies it verbatim (`String(local.email.smtp.port)`, - // auth.sync.ts:2390) and the row above only ever produces a value - // `parseUint16` accepts — so a fractional or out-of-range document port - // (`25.5`, `70000`) would push as a string this API arm's own row omits, - // while the unmirrored document side kept it. Replaying the exact - // String→parseUint16 round trip predicts that: `25` survives unchanged, - // `25.5`/out-of-range omit the field (REMOVED, not left verbatim — an - // omitted key is what the API arm reports for the same pushed state). + // The config schema allows an unrestricted `Schema.Number` for port, but the push wrapper + // stringifies it verbatim while this row's transform only ever produces what `parseUint16` + // accepts. Replaying the same String→parseUint16 round trip here predicts that: a fractional + // or out-of-range document port omits the field, matching what the API arm reports post-push. normalizeDocument: (value) => (typeof value === "number" ? parseUint16(String(value)) : value), dualScope: true, }, @@ -886,16 +655,9 @@ const smtpRows: ReadonlyArray<ProjectConfigMappingRow> = [ ]; /** - * Whether the response EXPLICITLY reports SMTP disabled — a `null` or `""` - * `smtp_host`, the push disable sentinel (the push direction writes ONLY - * `smtp_host: ""` when disabling, auth.sync.ts:2384-2397). An ABSENT - * `smtp_host` normalizes to `undefined` here (`readAuthAttribute`), which - * fails both comparisons below and so does NOT gate the siblings — same - * absent-vs-sentinel rule, and same twin-function shape, as - * {@link smsProviderExplicitlyUnset} below: a sparse response that never - * mentioned the host must still map `smtp_user`/`smtp_admin_email`/… - * normally, rather than have them vanish untraceably (both from the mapped - * output AND from `unmappedApiFields`, since these paths are consumed). + * Whether the response explicitly reports SMTP disabled (a `null` or `""` `smtp_host`, the push + * disable sentinel). An absent `smtp_host` normalizes to `undefined`, which does not gate the + * sibling rows — a sparse response that never mentioned the host must still map them normally. */ function smtpExplicitlyDisabledInAttributes(attributes: Record<string, unknown>): boolean { const host = readAuthAttribute(attributes, "smtp_host"); @@ -920,8 +682,6 @@ function smtpSiblingStringRow( }; } -// Email templates ×6 (auth.sync.ts:1439-1461; content_path has no API key) - const EMAIL_TEMPLATE_NAMES = [ "invite", "confirmation", @@ -933,16 +693,11 @@ const EMAIL_TEMPLATE_NAMES = [ const templateRows: ReadonlyArray<ProjectConfigMappingRow> = EMAIL_TEMPLATE_NAMES.map((name) => ({ ...stringRow(["auth", "email", "template", name, "subject"], `mailer_subjects_${name}`), - // `mailer_subjects_*` is a platform-rendered string with no meaningful - // local default — a prior version of this row pinned the provisioning - // string recorded from a prod fixture as `unconfiguredValue`, which broke - // the moment the platform reworded its default (and can never be - // simultaneously correct for every environment platform-side). + // A platform-rendered string with no meaningful local default; pinning a baseline breaks the + // moment the platform rewords it. platformRendered: true, })); -// Email notifications ×7 (auth.sync.ts:1491-1525) - const EMAIL_NOTIFICATION_NAMES = [ "password_changed", "email_changed", @@ -960,10 +715,8 @@ const notificationRows: ReadonlyArray<ProjectConfigMappingRow> = EMAIL_NOTIFICAT ["auth", "email", "notification", name, "enabled"], `mailer_notifications_${name}_enabled`, ), - // Every account-change notification defaults to disabled (supabase/auth - // `NotificationsConfiguration`, `default:"false"` on each field) — the - // config schema declares no default, so the diff baseline needs the - // platform's own unconfigured reading here. + // Every account-change notification defaults to disabled on the platform; the config + // schema declares no default, so the diff baseline needs this reading. unconfiguredValue: false, }, { @@ -971,33 +724,15 @@ const notificationRows: ReadonlyArray<ProjectConfigMappingRow> = EMAIL_NOTIFICAT ["auth", "email", "notification", name, "subject"], `mailer_subjects_${name}_notification`, ), - // Same platform-rendered rationale as the template subjects above. platformRendered: true, }, ], ); -// Legacy-handled but deliberately unmapped: the 13 mailer template/ -// notification CONTENT keys (as opposed to the SUBJECT keys mapped above) — -// `mailer_templates_invite_content`, `mailer_templates_confirmation_content`, -// `mailer_templates_recovery_content`, `mailer_templates_magic_link_content`, -// `mailer_templates_email_change_content`, -// `mailer_templates_reauthentication_content` (the 6 templates, auth.sync.ts: -// 339-349), and `mailer_templates_password_changed_notification_content`, -// `mailer_templates_email_changed_notification_content`, -// `mailer_templates_phone_changed_notification_content`, -// `mailer_templates_identity_linked_notification_content`, -// `mailer_templates_identity_unlinked_notification_content`, -// `mailer_templates_mfa_factor_enrolled_notification_content`, -// `mailer_templates_mfa_factor_unenrolled_notification_content` (the 7 -// notifications, auth.sync.ts:353-371). The config schema stores -// `content_path` (a filesystem path to the template body, `../auth/email.ts`) -// for each of these, never the rendered `content` itself, so there is no -// config-side field a row could target — `content` only exists on the -// GoTrue/API side, loaded from `content_path` at push time -// (`authSubsetFromConfig`'s `emailContent` parameter, auth.sync.ts:989-999). - -// MFA (auth.sync.ts:1381-1398) +// The mailer template/notification CONTENT keys (as opposed to the SUBJECT keys mapped above, +// e.g. `mailer_templates_invite_content`) have no config-side field: the schema stores +// `content_path`, a filesystem path to the template body, never the rendered `content` itself, +// which exists only on the API side. const mfaRows: ReadonlyArray<ProjectConfigMappingRow> = [ uintRow(["auth", "mfa", "max_enrolled_factors"], "mfa_max_enrolled_factors"), @@ -1012,18 +747,11 @@ const mfaRows: ReadonlyArray<ProjectConfigMappingRow> = [ boolRow(["auth", "mfa", "web_authn", "verify_enabled"], "mfa_web_authn_verify_enabled"), ]; -// CAPTCHA (auth.sync.ts:1303-1317) - const captchaRows: ReadonlyArray<ProjectConfigMappingRow> = [ gatedBoolRow(["auth", "captcha", "enabled"], "security_captcha_enabled"), { - // Guarded to the schema enum (../auth/captcha.ts: "hcaptcha" | "turnstile"): - // an unrecognized STRING (including "") omits the field — an enum member - // this version doesn't model, tolerable API-ahead skew — and `null` keeps - // the no-value-omits convention, but a present non-string is a malformed - // platform response and throws like every other mapped auth field. - // auth.sync.ts:1309 has no guard at all because it merges into a local - // document instead of producing a standalone sparse one. + // Guarded to the schema enum ("hcaptcha" | "turnstile"): an unrecognized string (including + // "") omits the field (API-ahead skew), `null` also omits, and a non-string throws. configPath: ["auth", "captcha", "provider"], apiPath: ["auth", "security_captcha_provider"], transform: (value) => { @@ -1036,13 +764,8 @@ const captchaRows: ReadonlyArray<ProjectConfigMappingRow> = [ secretRow(["auth", "captcha", "secret"], "security_captcha_secret"), ]; -// OAUTH SERVER — no sync precedent (the section postdates the legacy -// mappers); name-matched against the generated contract -// (packages/api/src/generated/contracts.ts:3462-3464) and the config schema -// (../auth/index.ts:180-200). Note the rename: the GoTrue key is -// `oauth_server_authorization_path`, the config field +// The GoTrue key `oauth_server_authorization_path` maps to the config field // `authorization_url_path`. - const oauthServerRows: ReadonlyArray<ProjectConfigMappingRow> = [ boolRow(["auth", "oauth_server", "enabled"], "oauth_server_enabled"), boolRow( @@ -1052,40 +775,21 @@ const oauthServerRows: ReadonlyArray<ProjectConfigMappingRow> = [ stringRow(["auth", "oauth_server", "authorization_url_path"], "oauth_server_authorization_path"), ]; -// WEB3 (auth.sync.ts:1695-1704) - const web3Rows: ReadonlyArray<ProjectConfigMappingRow> = [ boolRow(["auth", "web3", "solana", "enabled"], "external_web3_solana_enabled"), boolRow(["auth", "web3", "ethereum", "enabled"], "external_web3_ethereum_enabled"), ]; -// SMS (auth.sync.ts:1674-1685) - const smsBaseRows: ReadonlyArray<ProjectConfigMappingRow> = [ boolRow(["auth", "sms", "enable_signup"], "external_phone_enabled"), - // Not inverted: unlike mailer_autoconfirm/email.enable_confirmations - // (auth.sync.ts:1551), sms_autoconfirm maps to sms.enable_confirmations - // identically on both the pull (auth.sync.ts:1677) and push - // (auth.sync.ts:2485) sides. + // Not inverted, unlike mailer_autoconfirm: sms_autoconfirm maps to enable_confirmations directly. boolRow(["auth", "sms", "enable_confirmations"], "sms_autoconfirm"), stringRow(["auth", "sms", "template"], "sms_template"), secondsDurationRow(["auth", "sms", "max_frequency"], "sms_max_frequency"), - // No sync precedent (CLI-2316 follow-up audit): `sms.otp_length`/ - // `otp_expiry` are new config-schema fields (`../auth/sms.ts`) added - // alongside this pair of rows — the CLI's `config-sync/auth.sync.ts` - // predates both and never read or wrote either, matching neither Go's own - // `sms` struct (`apps/cli-go/pkg/config/auth.go`, which also has no - // `OtpLength`/`OtpExpiry` on `sms` — only on the unrelated `email` and - // `mfa.phone` structs) — this is a genuinely new config surface for a - // pre-existing, real GoTrue field pair (`sms_otp_length`/`sms_otp_exp`, - // confirmed live via `apps/cli-e2e/fixtures/recorded/ - // GET_v1_projects___PROJECT_REF___config_auth`), not a Go-parity gap. uintRow(["auth", "sms", "otp_length"], "sms_otp_length"), uintRow(["auth", "sms", "otp_expiry"], "sms_otp_exp"), { - // auth.sync.ts:1679, 1736-1747 (envToMap). Null/empty/unparsed → omit; - // a present non-string is a malformed platform response and throws like - // every other mapped auth field. + // Null/empty/unparsed → omit; a present non-string throws like every other mapped field. configPath: ["auth", "sms", "test_otp"], apiPath: ["auth", "sms_test_otp"], transform: (value) => { @@ -1100,40 +804,19 @@ const smsBaseRows: ReadonlyArray<ProjectConfigMappingRow> = [ }, ]; -// SMS provider selection ×5 (auth.sync.ts:1663-1671, 1687: a single -// `sms_provider` string names exactly one active provider) -// -// Deliberate divergence from the legacy apply: auth.sync.ts:1643-1655 skips -// provider reconciliation entirely when the remote reports phone auth -// disabled and no local provider is already enabled. A standalone mapping -// has no local document to consult for "already enabled", so it reconciles -// unconditionally, for the same reason as the rate_limit.email_sent row -// above. -// -// An unrecognized `sms_provider` value (one that matches none of the five -// `=== provider` comparisons below) maps every provider's `enabled` to -// `false` — legacy-faithful (auth.sync.ts's switch is exactly these five -// `===` comparisons, with no fallback branch), not a bug. Unlike -// `pool_mode`'s `"statement"` case (`../registry.ts`), there is no single -// omitted field to point at: "phone auth is enabled with a provider this -// package version doesn't model" is invisible in the typed output entirely — -// every provider reading `false` looks identical to "no provider recognized" -// and to "phone auth genuinely uses none of these five". The raw string is -// still reachable at `_apiResponse.auth.sms_provider` — same bucket as -// `pool_mode`'s omitted enum member. A future report of unmapped/ -// unrepresentable *values* (as opposed to unmapped *fields*, which -// `unmappedApiFields` already covers) would need to special-case this row. - +// A single `sms_provider` string names exactly one active provider; reconciled unconditionally +// since a standalone mapping has no local document to consult for "already enabled". An +// unrecognized value maps every provider's `enabled` to `false` rather than surfacing as a bug — +// there's no single field to flag it against, but the raw string stays reachable at +// `_apiResponse.auth.sms_provider`. const SMS_PROVIDERS = ["twilio", "twilio_verify", "messagebird", "textlocal", "vonage"] as const; const smsProviderSelectionRows: ReadonlyArray<ProjectConfigMappingRow> = SMS_PROVIDERS.map( (provider) => ({ configPath: ["auth", "sms", provider, "enabled"], apiPath: ["auth", "sms_provider"], - // Null/empty → omit all five (no provider named); a present non-string - // is a malformed platform response and throws, like every other mapped - // auth field — silently omitting would also hide it from - // `unmappedApiFields`, since this shared path is consumed. + // Null/empty → omit all five (no provider named); a non-string throws like every other + // mapped field. transform: (value) => { if (value === null) return undefined; const named = expectString(value, ["auth", "sms_provider"]); @@ -1143,12 +826,9 @@ const smsProviderSelectionRows: ReadonlyArray<ProjectConfigMappingRow> = SMS_PRO ); /** - * Whether the response EXPLICITLY reports no active SMS provider — a `null` - * or `""` `sms_provider`, which legacy treats identically (`valOrDefault( - * remote.sms_provider, "")` then `provider.length > 0`, auth.sync.ts: - * 1664-1666). An ABSENT key does not gate: a sparse response that never - * mentioned the provider says nothing about it, same absent-vs-sentinel rule - * as `api.db_schema`'s `""` sentinel (`../registry.ts`). + * Whether the response explicitly reports no active SMS provider (a `null` or `""` + * `sms_provider`). An absent key does not gate — a sparse response that never mentioned the + * provider says nothing about it. */ function smsProviderExplicitlyUnset(attributes: Record<string, unknown>): boolean { const provider = readAuthAttribute(attributes, "sms_provider"); @@ -1157,14 +837,9 @@ function smsProviderExplicitlyUnset(attributes: Record<string, unknown>): boolea /** * A {@link stringRow} for a non-secret SMS provider credential, omitted when - * {@link smsProviderExplicitlyUnset} — validation still runs first. Legacy - * touches NEITHER the flags nor the credentials on a null/empty provider - * (flag reconciliation is gated at auth.sync.ts:1664-1666, credentials read - * only for the locally-selected provider, :1574-1655), so a retained - * credential under an explicitly-unset provider must not project: the five - * selection rows all omit on null/"" too, and with no `enabled: false` for - * the entry sweep to key on, the credential would otherwise survive as an - * unmanaged phantom entry. + * {@link smsProviderExplicitlyUnset}. Without this, a retained credential under an explicitly + * unset provider would survive as an unmanaged phantom entry, since the selection rows all omit + * on null/"" too and leave nothing for an entry sweep to key on. */ function smsCredentialStringRow( configPath: ReadonlyArray<string>, @@ -1182,20 +857,14 @@ function smsCredentialStringRow( }; } -// SMS provider credentials (auth.sync.ts:1574-1672; vonage.api_key is NOT a -// secret — ../auth/sms.ts:286-292 has no `secret()` wrapper on it) - +// vonage.api_key isn't a secret field, unlike the other provider credentials below. const smsCredentialRows: ReadonlyArray<ProjectConfigMappingRow> = [ smsCredentialStringRow(["auth", "sms", "twilio", "account_sid"], "sms_twilio_account_sid"), smsCredentialStringRow( ["auth", "sms", "twilio", "message_service_sid"], "sms_twilio_message_service_sid", ), - // No sync precedent (CLI-2316 follow-up audit): `twilio.content_sid` is a - // new config-schema field (`../auth/sms.ts`) for a pre-existing, real - // GoTrue field (`sms_twilio_content_sid`, confirmed live via - // `apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth`). - // Twilio-only: the API has no `sms_twilio_verify_content_sid` counterpart. + // Twilio-only: there's no `sms_twilio_verify_content_sid` counterpart. smsCredentialStringRow(["auth", "sms", "twilio", "content_sid"], "sms_twilio_content_sid"), secretRow(["auth", "sms", "twilio", "auth_token"], "sms_twilio_auth_token"), smsCredentialStringRow( @@ -1219,13 +888,10 @@ const smsCredentialRows: ReadonlyArray<ProjectConfigMappingRow> = [ secretRow(["auth", "sms", "vonage", "api_secret"], "sms_vonage_api_secret"), ]; -// HOOKS ×6 (auth.sync.ts:1319-1379; top-level config key is `hook`, singular -// — see ../auth/hooks.ts) +// The top-level config key is singular: `hook`, not `hooks`. -// Exported so `../project-config.ts`'s raw-presence mask (human review round -// on PR #6339, thread 1) can walk the same six names rather than keeping a -// second hand-copied list — mirrors `SMS_PROVIDER_PUSH_PRECEDENCE`'s own -// export/reuse for the same reason. +// Exported so `../project-config.ts` can walk the same six names instead of keeping a second +// hand-copied list. export const AUTH_HOOK_NAMES = [ "mfa_verification_attempt", "password_verification_attempt", @@ -1241,36 +907,11 @@ const hookRows: ReadonlyArray<ProjectConfigMappingRow> = AUTH_HOOK_NAMES.flatMap secretRow(["auth", "hook", name, "secrets"], `hook_${name}_secrets`), ]); -// EXTERNAL PROVIDERS (auth.sync.ts:1749-2000; provider set and per-field -// availability taken from ../auth/providers.ts and RemoteAuthConfig) -// -// Corrections against the mined field list: -// - `url` only exists as an API field for azure/gitlab/keycloak/workos -// (getProviderUrl, :1942-1955), even though the schema's `provider()` -// struct declares a `url` field (with a default) for every provider. -// - `email_optional` has no API field for workos specifically — absent from -// both RemoteAuthConfig (:471-474) and getProviderEmailOptional's switch -// (:1957-1998) — even though every other provider (including apple and -// google) has one. -// - `skip_nonce_check` has no API field for ANY provider except google -// (`googleSkipNonceCheckRow`, below) — verified against the generated -// contract's full `V1GetAuthServiceConfigOutput` field list (CLI-2316 -// follow-up audit), even though the schema's `provider()` struct declares -// a `skip_nonce_check` field for every provider. -// - "figma" IS representable now (`../auth/providers.ts`, CLI-2316 follow-up -// — was previously documented here as unrepresentable, since the schema -// had no `figma` member at all). -// - plain "slack" (as opposed to `slack_oidc`) has a real API field set -// (`external_slack_client_id`/`_enabled`/`_secret`/`_email_optional`) but -// is DELIBERATELY not a schema member and never will be without a -// separate decision to reverse it: `../auth/providers.ts`'s own comment -// records that Go's deprecated `linkedin`/`slack` provider ids are -// intentionally unmodeled, matching `(e external) validate()`'s -// unconditional deletion of those keys before decode — `../io.ts`'s -// `normalizeDeprecatedExternalProviders` strips a config's `[auth. -// external.slack]` table (warning on stderr when it was `enabled`) before -// this schema ever sees it. A real, live `external_slack_*` API surface -// existing does not by itself justify undoing that Go-parity deprecation. +// `url` exists as an API field only for azure/gitlab/keycloak/workos, and `email_optional` has no +// API field for workos specifically — both despite the schema declaring those fields for every +// provider. `skip_nonce_check` has no API field for any provider except google (handled +// separately below). Plain "slack" (unlike `slack_oidc`) has a real API field set but isn't a +// schema member: `../io.ts` strips `[auth.external.slack]` from a config before this schema sees it. interface ExternalProviderSpec { readonly id: string; @@ -1301,11 +942,7 @@ const EXTERNAL_PROVIDERS: ReadonlyArray<ExternalProviderSpec> = [ { id: "zoom", hasUrl: false, hasEmailOptional: true }, ]; -/** - * Apple/Google fold a sibling `external_<id>_additional_client_ids` GoTrue - * key into `client_id` (main + "," + additional, when the additional value - * is a non-empty string) — auth.sync.ts:1764-1774. - */ +/** Apple/Google fold a sibling `external_<id>_additional_client_ids` GoTrue key into `client_id` (main + "," + additional, when non-empty). */ function providerClientIdRow(id: string): ProjectConfigMappingRow { const additionalKey = `external_${id}_additional_client_ids`; const apiPath = ["auth", `external_${id}_client_id`]; @@ -1315,19 +952,15 @@ function providerClientIdRow(id: string): ProjectConfigMappingRow { apiPath, alsoConsumes: [additionalApiPath], transform: (value, attributes) => { - // The sibling is validated FIRST, even when the main ID is null: both - // paths are marked consumed, so a malformed additional value behind a - // null anchor would otherwise be hidden from `unmappedApiFields` too. - // Null keeps the no-value-omits convention for either key; any other - // non-string throws like every registry-mapped field. + // The sibling is validated first, even when the main ID is null, so a malformed additional + // value isn't hidden from `unmappedApiFields`. `null` omits either key; a non-string throws. const additional = readAuthAttribute(attributes, additionalKey); const additionalIds = additional === undefined || additional === null ? undefined : expectString(additional, additionalApiPath); - // Undefined = the anchor key is absent entirely — the engine still ran - // this transform because a consumed sibling is present (see - // applyMappingRows); the sibling was validated above, so bail like null. + // `undefined` means the anchor key is absent but a consumed sibling is present; the sibling + // was already validated above, so this bails like `null`. if (value === null || value === undefined) return undefined; const clientId = expectString(value, apiPath); return additionalIds !== undefined && additionalIds.length > 0 @@ -1364,7 +997,7 @@ const externalProviderRows: ReadonlyArray<ProjectConfigMappingRow> = EXTERNAL_PR }, ); -/** Google-only (auth.sync.ts:1783-1786). */ +/** Google-only. */ const googleSkipNonceCheckRow: ProjectConfigMappingRow = boolRow( ["auth", "external", "google", "skip_nonce_check"], "external_google_skip_nonce_check", @@ -1391,43 +1024,11 @@ export const authMappingRows: ReadonlyArray<ProjectConfigMappingRow> = [ ]; /** - * API-side GoTrue keys shaped like a secret (suffix `_secret`, `_secrets`, - * `_auth_token`, `_api_secret`, `_access_key`, or `_api_key`) that have no - * registry row at all, verified exhaustively against the generated - * Management API v1 auth-config contract - * (`packages/api/src/generated/contracts.ts`'s `V1GetAuthServiceConfigOutput` - * — the authority for this registry's key set, not the legacy hand-mined - * `auth.sync.ts` interface, which is missing `external_slack` and - * `nimbus_oauth` entirely) (CLI-2230's `unmappedApiFields` secret-leak - * finding). Every OTHER secret-shaped GoTrue key already has an `isSecret` - * row above and is therefore already excluded from `unmappedApiFields` on - * its own merit; this list exists only for the ones that don't, so an HMAC - * digest can't leak into that report just because this registry hasn't grown - * a row for the field yet. `walkUnmapped` (`./project-config.ts`) treats - * every path here as consumed, same as a row's `apiPath`/`alsoConsumes`. - * - * `sms_vonage_api_key` is deliberately excluded despite the `_api_key` - * suffix: it is NOT `x-secret` on the config side (`../auth/sms.ts:286-292` - * has no `secret()` wrapper on it — `smsCredentialRows`'s comment) and - * already has an ordinary `stringRow`. - * - * Three orphans found, none with a config-schema counterpart at all: - * - `external_figma_secret`: `figma` is a GoTrue provider with no - * config-schema counterpart at all (`externalProviderRows`'s comment - * above), so it never gets a row of its own, secret or otherwise. - * - `external_slack_secret`: distinct from the mapped `slack_oidc` provider - * (`EXTERNAL_PROVIDERS`) — plain `slack` has no config-schema counterpart - * either. - * - `hook_after_user_created_secrets`: distinct from the mapped - * `before_user_created` hook (`AUTH_HOOK_NAMES`) — there is no - * `hook.after_user_created` config-schema section to target. - * - `nimbus_oauth_client_secret`: there is no `nimbus`-named external - * provider in the config schema at all. - * - * Guarded against regrowing a fourth orphan by - * `apps/cli/src/shared/config/project-config-auth-contract.unit.test.ts`, - * which walks the same generated contract's full key set, not just this - * hand-maintained list. + * GoTrue keys shaped like a secret (`_secret`, `_secrets`, `_auth_token`, `_api_secret`, + * `_access_key`, or `_api_key`) with no registry row and no config-schema counterpart to give them + * one — `unmappedApiFields` treats every listed path as mapped so an HMAC digest never appears + * there just because a row doesn't exist. `sms_vonage_api_key` is excluded despite the suffix + * since it isn't `x-secret` and already has an ordinary `stringRow`. */ export const unmappedSecretApiPaths: ReadonlyArray<ReadonlyArray<string>> = [ ["auth", "external_figma_secret"], diff --git a/packages/config/src/project-config/registry-integrity.unit.test.ts b/packages/config/src/project-config/registry-integrity.unit.test.ts index 1747a48380..d32857265a 100644 --- a/packages/config/src/project-config/registry-integrity.unit.test.ts +++ b/packages/config/src/project-config/registry-integrity.unit.test.ts @@ -11,40 +11,18 @@ import { unmappedSecretApiPaths } from "./registry-auth.ts"; import { projectConfigMappingRows } from "./registry.ts"; /** - * Standing AST-walk drift guard (CLI-2230): every row's `configPath` must - * resolve against {@link CliConfigSchema}'s AST, and every row's `apiPath` - * (plus `alsoConsumes` and `./registry-auth.ts`'s `unmappedSecretApiPaths`) - * must resolve against {@link ProjectConfigApiAttributesSchema}'s AST. This - * is what keeps the 243 rows across `./registry.ts`/`./registry-auth.ts` - * true when either schema moves — a renamed or removed field fails a test - * here instead of silently producing a `ProjectConfig` that never populates - * (a wrong `configPath`) or a row that never reads a real API field (a - * wrong `apiPath`). + * AST-walk guard: every row's `configPath` must resolve against {@link CliConfigSchema}, and every + * row's `apiPath` (plus `alsoConsumes` and `unmappedSecretApiPaths`) must resolve against + * {@link ProjectConfigApiAttributesSchema}. A renamed or removed field fails a test here instead of + * silently producing a `ProjectConfig` that never populates, or a row that never reads a real API + * field. * - * The walker below mirrors `../lib/env.ts`'s `descendAst`/`../lib/ - * secret-paths.ts`'s `collectSecretPathPatterns`: Effect v4 represents both - * `Schema.Struct` and `Schema.Record` as an `"Objects"` AST node - * (`.repos/effect/packages/effect/src/SchemaAST.ts:2038-2090`), carrying - * named `propertySignatures` (struct fields) and/or `indexSignatures` - * (record key patterns) side by side on the same node type. Descending a - * path segment therefore tries an exact-name property signature first, then - * falls back to the first index signature's value type — the record - * fallback is what "the auth record accepts any second segment" means for - * `ProjectConfigApiAttributesSchema`'s `auth: Schema.Record(Schema.String, - * Schema.Json)` field: every row's two-segment `["auth", "<gotrue_key>"]` - * `apiPath` resolves through that index signature, not a named property. - * - * That same open-`Record` shape makes this file's `apiPath` check - * structurally vacuous for all 189 auth rows: ANY second segment resolves - * through the record's index signature, whether or not GoTrue actually has a - * key by that name, so this walker cannot catch a renamed or invented - * GoTrue key the way it catches a real `configPath`/`CliConfigSchema` - * mismatch. The real check for the auth half of the registry — every row's - * `apiPath` against the generated Management API v1 auth-config contract's - * actual key set — lives in - * `apps/cli/src/shared/config/project-config-auth-contract.unit.test.ts`, - * since `packages/config` cannot depend on `packages/api`'s generated - * client. + * Effect v4 represents both `Schema.Struct` and `Schema.Record` as an `"Objects"` AST node, so + * descending a path segment tries a named property signature first, then falls back to the first + * index signature — which is also why this check is vacuous for the auth rows: `auth` is an open + * `Schema.Record`, so any second segment resolves whether or not GoTrue actually has a key by that + * name. The real check for auth `apiPath`s against GoTrue's actual key set lives in `apps/cli`, + * since `packages/config` cannot depend on `packages/api`'s generated client. */ interface AstNode { @@ -109,13 +87,10 @@ function pathResolves(rootAst: unknown, path: ReadonlyArray<string>): boolean { } /** - * The named property-signature keys directly under `path` from `rootAst` — - * empty if `path` doesn't resolve at all, or resolves to a node with no named - * signatures (e.g. a bare `Schema.Record`). Used for the entry-sweep tables - * below, whose `entryKeys` is sometimes omitted (the sweep walks - * `Object.keys(container)` at runtime instead) — reading the schema's own - * property names is the only way to still assert something concrete about - * which entries that sweep can ever see. + * The named property-signature keys directly under `path` from `rootAst` — empty if `path` + * doesn't resolve, or resolves to a node with no named signatures (e.g. a bare `Schema.Record`). + * Used when a sweep's `entryKeys` is omitted, to still assert something concrete about which + * entries it can see. */ function structPropertyNames(rootAst: unknown, path: ReadonlyArray<string>): ReadonlyArray<string> { let current: unknown = rootAst; @@ -131,8 +106,6 @@ function structPropertyNames(rootAst: unknown, path: ReadonlyArray<string>): Rea describe("registry integrity: every row resolves against both schemas", () => { test("the registry actually has rows to check", () => { - // Guards against the loop below passing vacuously if the registry import - // is ever broken. expect(projectConfigMappingRows.length).toBeGreaterThan(100); }); @@ -163,19 +136,12 @@ describe("registry integrity: every row resolves against both schemas", () => { }); /** - * Standing AST-walk drift guard for the three hand-written disabled-sentinel - * tables in `./project-config.ts` (`DISABLED_SENTINEL_PRUNES`, - * `DISABLED_SENTINEL_ENTRY_SWEEPS`, `SMS_PROVIDER_PUSH_PRECEDENCE`): these - * tables were added after the registry-integrity walker above and carry no - * AST guard of their own — a renamed `CliConfigSchema` field would silently - * turn one of their rules into a no-op (the gating `enabled` check, a dropped - * sibling, or an entry sweep simply never firing again for that field) with - * no red test anywhere to catch it. + * AST-walk guard for the three hand-written disabled-sentinel tables in `./project-config.ts`. + * These carry no guard of their own — a renamed `CliConfigSchema` field would silently turn one + * of their rules into a no-op with nothing to catch it. */ describe("disabled-sentinel tables: every path/key resolves against CliConfigSchema", () => { test("DISABLED_SENTINEL_PRUNES actually has rows to check", () => { - // Guards against the loop below passing vacuously if the table is ever - // emptied out. expect(DISABLED_SENTINEL_PRUNES.length).toBeGreaterThan(5); }); @@ -208,10 +174,8 @@ describe("disabled-sentinel tables: every path/key resolves against CliConfigSch expect(pathResolves(CliConfigSchema.ast, sweep.containerPath)).toBe(true); }); - // A row without `entryKeys` sweeps `Object.keys(container)` at runtime — - // fall back to the schema's own property names so this guard still - // catches an "enabled" rename on any concrete entry the container can - // ever hold. + // Falls back to the schema's own property names when `entryKeys` is omitted, so this guard + // still catches an "enabled" rename on any entry the container can hold. const entryKeys = sweep.entryKeys ?? structPropertyNames(CliConfigSchema.ast, sweep.containerPath); @@ -230,11 +194,9 @@ describe("disabled-sentinel tables: every path/key resolves against CliConfigSch }); /** - * `SMS_PROVIDER_PUSH_PRECEDENCE` doubles as data (every entry must resolve as - * a real `auth.sms` provider) and as a pinned ORDER (it must keep matching - * the legacy push switch's fixed provider priority, since a reordering here - * would silently change which provider `fromConfigDocument` treats as "the" - * enabled one when a document enables more than one). + * `SMS_PROVIDER_PUSH_PRECEDENCE` doubles as data (every entry resolves as a real `auth.sms` + * provider) and as a pinned order: reordering it would silently change which provider + * `fromConfigDocument` treats as "the" enabled one when a document enables more than one. */ describe("SMS_PROVIDER_PUSH_PRECEDENCE: every provider resolves and matches the legacy push order", () => { test("SMS_PROVIDER_PUSH_PRECEDENCE actually has rows to check", () => { @@ -251,12 +213,7 @@ describe("SMS_PROVIDER_PUSH_PRECEDENCE: every provider resolves and matches the }); } - // Pinned against a hardcoded copy of the legacy switch's order (cited - // below) — this test file cannot itself see auth.sync.ts. The FIRST - // enabled provider wins and every later one is skipped entirely - // (apps/cli/src/commands/config/push/config-sync/auth.sync.ts:2498-2539's - // `switch (true)`: twilio (case at :2499), twilio_verify (:2507), - // messagebird (:2515), textlocal (:2522), vonage (:2529), default (:2537-2539)). + // The first enabled provider wins; every later one is skipped. test("order matches the legacy push switch's fixed provider priority", () => { expect(SMS_PROVIDER_PUSH_PRECEDENCE).toEqual([ "twilio", @@ -269,21 +226,14 @@ describe("SMS_PROVIDER_PUSH_PRECEDENCE: every provider resolves and matches the }); /** - * `dualScope` (CLI-2064): every flagged row's `configPath` resolves against - * `CliConfigSchema` (already exercised generically by the main loop above, - * since every `dualScope` row is a member of `projectConfigMappingRows` — - * asserted again here against the narrower subset so this describe block - * stands on its own) and the exact dual-scope path SET is pinned by an inline - * snapshot: adding (or removing) a `dualScope` flag anywhere in the registry - * must be a deliberate, reviewed act, not a silent side effect of an - * unrelated row edit. + * Every `dualScope`-flagged row's `configPath` resolves against `CliConfigSchema`, and the exact + * set of dual-scope paths is pinned by an inline snapshot: adding or removing a `dualScope` flag + * must be a reviewed act, not a silent side effect of an unrelated edit. */ describe("dualScope rows: configPath resolves against CliConfigSchema and the path list is pinned", () => { const dualScopeRows = projectConfigMappingRows.filter((row) => row.dualScope === true); test("dualScope rows actually exist to check", () => { - // Guards against the loop below passing vacuously if every `dualScope` - // flag is ever accidentally removed from the registry. expect(dualScopeRows.length).toBeGreaterThan(0); }); diff --git a/packages/config/src/project-config/registry-row.ts b/packages/config/src/project-config/registry-row.ts index 986c5e6d74..121672e56e 100644 --- a/packages/config/src/project-config/registry-row.ts +++ b/packages/config/src/project-config/registry-row.ts @@ -5,150 +5,91 @@ import { } from "../errors.ts"; /** - * Registry-driven mapping between the Management API v2 project-config - * resource (`data.attributes`) and the hosted subset of `CliConfig` — one - * table of rows so the pull-direction normalizer (`fromApiProjectConfig`) and - * the future push-direction `*ToUpdateBody` mappers derive from a single - * source of truth (CLI-2230). Rows are data, not behavior: the assembly - * engine lives in `project-config.ts`, and `unmappedApiFields` derives its - * mapped-path set from these same rows. + * Registry mapping between the Management API v2 project-config resource (`data.attributes`) and + * the hosted subset of `CliConfig`. Rows are data, not behavior — the assembly engine lives in + * `project-config.ts`. * - * Null convention: the legacy push-direction apply (`config-sync/*.sync.ts`) - * merges remote values into a local document, so it maps API `null` to a - * zero value (`valOrDefault`). This registry produces a *standalone sparse* - * config instead, where "no value" must stay absent: the engine skips a row - * whose API value is `undefined` (key not reported) — unless the row declares - * `alsoConsumes` and a consumed sibling IS present, in which case the - * transform runs with `undefined` so it can still validate the sibling — and - * skips `null` unless the row declares a `transform`; a transform receives - * `null` and decides (e.g. `smtp_host: null` still means "SMTP disabled"). + * Produces a sparse config where "no value" stays absent: a row is skipped when its API value is + * `undefined` (unless `alsoConsumes` names a present sibling, letting the transform still run) and + * skipped when `null` unless the row declares a `transform`, which receives `null` and decides + * what it means (e.g. `smtp_host: null` still means "SMTP disabled"). */ export interface ProjectConfigMappingRow { /** Path segments into the hosted subset of `CliConfig`, e.g. `["api", "max_rows"]`. */ readonly configPath: ReadonlyArray<string>; /** - * Path segments under v2 `data.attributes`, e.g. `["api", "db_schema"]` or - * `["auth", "site_url"]`. Several rows may share one `apiPath` when a - * single API field feeds multiple config fields (e.g. `api.db_schema` - * drives both `api.schemas` and the derived `api.enabled`). + * Path segments under v2 `data.attributes`, e.g. `["api", "db_schema"]` or `["auth", + * "site_url"]`. Several rows may share one `apiPath` when a single API field feeds multiple + * config fields. */ readonly apiPath: ReadonlyArray<string>; /** - * Maps the API-reported value to the config-side value; identity when - * absent. Receives the full decoded attributes object as a second argument - * for the rare row that combines sibling fields (declare those siblings in - * {@link alsoConsumes}). Returning `undefined` omits the field from the - * mapped output (e.g. an API enum member the config schema cannot - * represent). Narrowing failures throw `ProjectConfigParseError` via the - * `expect*` helpers. + * Maps the API-reported value to the config-side value; identity when absent. Receives the full + * decoded attributes object as a second argument for rows that combine sibling fields (declare + * those siblings in {@link alsoConsumes}). Returning `undefined` omits the field from the mapped + * output. Narrowing failures throw `ProjectConfigParseError` via the `expect*` helpers. */ readonly transform?: (value: unknown, attributes: Record<string, unknown>) => unknown; /** - * Additional `data.attributes` paths this row's `transform` reads beyond - * `apiPath` (e.g. Apple/Google `external_*_additional_client_ids`, folded - * into `client_id`). Listed so `unmappedApiFields` counts them as mapped. + * Additional `data.attributes` paths this row's `transform` reads beyond `apiPath`, counted as + * mapped for `unmappedApiFields`. */ readonly alsoConsumes?: ReadonlyArray<ReadonlyArray<string>>; /** - * Canonicalizes a DOCUMENT-sourced value at `configPath` so a value pulled - * from the API and the same logical value spelled locally converge on one - * representation (CLI-2230's duration/byte-size finding) — e.g. a document - * duration of `"24h"` and an API-derived `"24h0m0s"` denote the same - * duration but compare unequal textually unless one side is normalized. - * Applied by `fromConfigDocument` only, at `configPath`, after the - * secret-omitting copy; never applied by `fromApiProjectConfig` (its output - * is already canonical). Must return the canonical value, the input - * verbatim when it cannot be parsed (a document value has already passed - * schema validation, so this must never throw), or `undefined` to REMOVE - * the field — unmanaged absence, for a value the push wrapper would omit - * entirely (e.g. an empty `test_otp` map); the engine prunes containers - * the removal empties. + * Canonicalizes a document-sourced value at `configPath` so a value pulled from the API and the + * same logical value spelled locally converge on one representation (e.g. `"24h"` vs. + * `"24h0m0s"`). Applied by `fromConfigDocument` only; `fromApiProjectConfig`'s output is already + * canonical. Must return the canonical value, the input verbatim when it can't be parsed (never + * throw), or `undefined` to remove the field — the engine prunes containers the removal empties. */ readonly normalizeDocument?: (value: unknown) => unknown; /** - * Push-direction inverse (config value → API body value). Unused by - * `toProjectConfig` — carried so a future push mapper can derive from this - * registry instead of a second hand-maintained table. Absence does NOT mean - * identity: several rows have no faithful config→API inverse yet (every - * duration row, since `"1m0s"` must push as `60`; `BytesSize` strings; - * `email.smtp.port`'s number→string; `sms.test_otp`'s record→env string; - * the SMS provider selection rows). Absence means "not derived for this row - * yet" — zero rows currently define one; a push mapper must treat a missing - * `inverse` as unsupported for that row, never fall back to identity. Push - * derivation lands with the push-mapper work (CLI-2230 follow-up). + * Push-direction inverse of `transform` (config value → API body value). Unused today; carried + * so a future push mapper can derive from this registry instead of a second hand-maintained + * table. Absence does not mean identity — several rows have no faithful inverse yet, so a push + * mapper must treat a missing `inverse` as unsupported for that row, not a fallback to identity. */ readonly inverse?: (value: unknown) => unknown; /** - * `x-secret` field: the API reports an HMAC digest of the value, never the - * plaintext, so the mapping omits the value entirely and pull flows must - * source it from the local document (ADR 0019, rule 5). The path still + * Marks an `x-secret` field: the API reports only an HMAC digest, never the plaintext, so the + * mapping omits the value and pull flows must source it from the local document. The path still * counts as mapped for `unmappedApiFields`. */ readonly isSecret?: boolean; /** - * Equality semantics for an array-valued row when a diff consumer compares - * the two projections (`../config-diff.ts`). Whether an array is a set or a - * sequence is per-field wire knowledge, so it lives here with the rest of - * the field's semantics. `"sequence"` — the default when absent — treats - * element order as meaningful: `api.schemas`' first entry is PostgREST's - * default schema and `api.extra_search_path` is a literal `search_path` - * whose order is resolution order, so a reordering changes runtime behavior - * and must register as drift. `"set"` opts a row out for arrays whose wire - * semantics are order-free (`auth.additional_redirect_urls`). Defaulting to - * sequence over-reports rather than under-reports when a new array row - * forgets to choose. + * Equality semantics for an array-valued row when a diff consumer compares two projections. + * `"sequence"` (the default) treats element order as meaningful, e.g. `api.schemas`'s first + * entry is PostgREST's default schema; `"set"` opts out for order-free arrays like + * `auth.additional_redirect_urls`. Defaulting to sequence over-reports drift rather than + * missing it. */ readonly arrayEquality?: "set" | "sequence"; /** - * The value the platform reports at `configPath` for a project that never - * configured this feature, expressed in CONFIG-space (post-`transform`) — - * e.g. the `"0s"` an unset `sessions.timebox` canonicalizes to, or the - * provisioning-default mailer subjects (pinned by the recorded - * `GET /v1/projects/{ref}/config/auth` fixtures under - * `apps/cli-e2e/fixtures/recorded/`). `../config-diff.ts` uses this as the - * last `remote_only`-suppression baseline tier for paths the default config - * (and its convergence projection) is silent on: a remote report equal to - * this value is the platform's spelling of "unconfigured", not drift. A row - * without it (and without any other baseline) over-reports rather than - * guesses — "unconfigured" is never inferred from type-level zero values, - * because canonicalization can turn a platform zero into a non-zero shape - * (`sessions_timebox: 0` arrives as the string `"0s"`). + * The value the platform reports at `configPath` for a project that never configured this + * feature, in config-space (post-`transform`). A diff consumer treats a remote report equal to + * this value as "unconfigured", not drift. A row without it over-reports rather than guessing + * from a type-level zero value, since canonicalization can turn a platform zero into a + * non-zero shape (e.g. `0` arriving as the string `"0s"`). */ readonly unconfiguredValue?: unknown; /** - * The platform authors/renders this value itself; there is no local - * default. When the local projection carries no value at this path, any - * remote value is the platform's own rendering — never `remote_only` - * drift. A locally DECLARED value still classifies normally. Unlike - * {@link unconfiguredValue}, which suppresses one pinned baseline value, - * this suppresses unconditionally — the right choice for a rendered string - * with no fixed spelling (e.g. a mailer subject line), where pinning a - * baseline breaks the moment the platform rewords its own default. + * The platform renders this value itself; there is no local default. When the local projection + * carries no value here, any remote value is the platform's own rendering, never `remote_only` + * drift — a locally declared value still classifies normally. Unlike {@link unconfiguredValue}, + * which suppresses one pinned baseline, this suppresses unconditionally. */ readonly platformRendered?: boolean; - /** - * Unit/semantics note, e.g. `"csv → string[]"` or `"seconds → duration - * string"`. Documentation-only — never read at runtime. - */ + /** Unit/semantics note for documentation only, e.g. `"csv → string[]"`; never read at runtime. */ readonly unit?: string; /** - * A property with a legitimate DIFFERENT correct value for the local stack - * than the hosted project — pulling the hosted value into the config ROOT - * silently reconfigures `supabase start` (e.g. a hosted `db.major_version` - * or SMTP host has no business overwriting the local dev stack's own - * setting), so `config pull` warns before overwriting one at root. Writes - * into `[remotes.*]` blocks are unaffected — those are scoped to the - * remote project by construction and never feed `supabase start`. + * True when the hosted value has no business overwriting the local dev stack's own setting + * (e.g. `db.major_version`), so `config pull` warns before overwriting it at the config root. + * Writes into `[remotes.*]` blocks are unaffected. */ readonly dualScope?: boolean; } -/** - * Narrowing helpers for `transform` implementations. The non-auth attribute - * sections are schema-typed before rows run, so these mostly guard the `auth` - * record (typed `Record<string, Json>` by the API) and document each row's - * expectation at its use site. - */ +/** Narrows to a string API value, mostly guarding the loosely-typed `auth` attributes record. */ export function expectString(value: unknown, apiPath: ReadonlyArray<string>): string { if (typeof value !== "string") { throw parseErrorFor("a string", value, apiPath); @@ -157,11 +98,8 @@ export function expectString(value: unknown, apiPath: ReadonlyArray<string>): st } /** - * Also rejects non-finite numbers (`NaN`, `Infinity`, `-Infinity`) — no - * registry row expects one. The expectation text is "a finite number", not - * "a number": for a non-finite input, `typeof value` is already `"number"`, - * so the generic "a number" wording used to render the nonsensical "expected - * a number, got number". + * Also rejects non-finite numbers; the error names them "a finite number" rather than "a number" + * so a `NaN`/`Infinity` input doesn't render as "expected a number, got number". */ function expectNumber(value: unknown, apiPath: ReadonlyArray<string>): number { if (typeof value !== "number" || !Number.isFinite(value)) { @@ -171,21 +109,14 @@ function expectNumber(value: unknown, apiPath: ReadonlyArray<string>): number { } /** - * Narrows to a finite integer. The generated API contract types these fields - * `isInt`; the lenient mirror deliberately drops that check so API-ahead skew - * never fails the decode (ADR 0019 rule 2), so the rows for integer-typed - * config fields re-assert it here — a fractional value on an integer field is - * a malformed platform response, not tolerable skew. Only the session-hour - * durations stay on {@link expectNumber}: the contract types them as plain - * numbers and fractional hours are meaningful (the renderer rounds); every - * other numeric field — including the `*_max_frequency` seconds — is - * `isInt()` in the contract and narrows here. + * Narrows to a finite integer. The API's lenient decode doesn't enforce this itself, so + * integer-typed config fields re-assert it here; session-hour durations use {@link expectNumber} + * instead, since fractional hours are meaningful. */ export function expectInteger(value: unknown, apiPath: ReadonlyArray<string>): number { const numeric = expectNumber(value, apiPath); - // Safe integers only: the generated contract bounds every int field to - // Number.MAX_SAFE_INTEGER, and a JSON number past it has already been - // silently rounded during parsing — emitting it would launder the rounding. + // Rejects unsafe integers: a JSON number past Number.MAX_SAFE_INTEGER has already been + // silently rounded during parsing, so passing it through would launder that rounding. if (!Number.isSafeInteger(numeric)) { throw parseErrorFor("a safe integer", numeric, apiPath); } @@ -193,12 +124,9 @@ export function expectInteger(value: unknown, apiPath: ReadonlyArray<string>): n } /** - * Narrows to a finite number within `[min, max]` — for fields whose - * downstream formatter is only defined on a bounded range. The session-hour - * durations are the motivating case: the generated contract only requires - * them finite, but a huge-but-finite hours value overflows the nanosecond - * conversion into `"InfinityhNaNmNaNs"`, and a merely-large one stringifies - * in exponent notation (`"1e+22h0m0s"`) that no duration parser reads. + * Narrows to a finite number within `[min, max]`, for fields whose downstream formatter only + * handles a bounded range — e.g. session-hour durations, where an out-of-range value would + * overflow the nanosecond conversion or stringify in exponent notation no duration parser reads. */ export function expectNumberBetween( value: unknown, @@ -220,25 +148,12 @@ export function expectBoolean(value: unknown, apiPath: ReadonlyArray<string>): b return value; } -/** - * Clamps a signed API integer to the unsigned domain the config schema - * expects. Replicates the CLI's `intToUint` - * (`apps/cli/src/command-internal/size-units.ts`), applied by the sync - * mappers to every uint-typed field pulled from the API. - */ +/** Clamps a signed API integer to the unsigned domain the config schema expects. */ export function clampToUint(value: number): number { return value < 0 ? 0 : value; } -/** - * Splits an API comma-separated list field into the string array the config - * schema holds. Replicates the CLI's `strToArr` + per-element - * trim as applied in `config-sync/api.sync.ts:92-93` (`db_schema`, - * `db_extra_search_path`). The `auth.sync.ts:1265` `uri_allow_list` site uses - * `strToArr` without the trim; trimming there too is a deliberate, - * benign normalization — push-direction bodies are built with `join(",")`, so - * round-tripped data never carries the spaces the trim would remove. - */ +/** Splits a comma-separated API list field into a trimmed string array. */ export function splitCommaSeparated(value: string): ReadonlyArray<string> { if (value.length === 0) { return []; @@ -247,16 +162,10 @@ export function splitCommaSeparated(value: string): ReadonlyArray<string> { } /** - * DOCUMENT-side canonicalization for the three CSV-backed array rows - * (`api.schemas`, `api.extra_search_path`, `auth.additional_redirect_urls`): - * the push mapper joins the array with `","` (auth.sync.ts:2294, - * api.sync.ts:138,140) and the pull direction re-splits with - * {@link splitCommaSeparated}, so an element containing a literal comma (or - * padded with whitespace) round-trips into a DIFFERENT array — replaying - * join-then-split makes the document projection converge on the value that - * actually exists hosted after a push, same as the whole-second duration - * flooring. Non-array/non-string-element values stay verbatim (a document - * value has already passed schema validation; never throw here). + * Document-side canonicalization for the CSV-backed array rows: joins with `,` then re-splits with + * {@link splitCommaSeparated}, so an element containing a literal comma or padding whitespace + * converges on what actually exists hosted after a push, instead of round-tripping into a + * different array. Non-array or non-string-element values pass through verbatim. */ export function canonicalizeCommaJoinedArray(value: unknown): unknown { if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) { From f010af1a69ab71c1732daf60ea56650500fac161 Mon Sep 17 00:00:00 2001 From: Colum Ferry <cferry09@gmail.com> Date: Thu, 10 Sep 2026 13:29:17 +0100 Subject: [PATCH 56/56] chore(repo): sweep residual provenance comments and restore knip @public tag --- AGENTS.md | 2 +- apps/cli-e2e/src/tests/config-toml.e2e.test.ts | 2 +- apps/cli/src/command-internal/connect-errors.ts | 2 +- .../db-bootstrap/restart-services.unit.test.ts | 9 ++++----- apps/cli/src/command-internal/global-flags.ts | 2 +- apps/cli/src/command-internal/migration-apply.ts | 2 +- apps/cli/src/command-internal/pflag-reconcile.ts | 2 ++ apps/cli/src/command-internal/pg-dump.scripts.ts | 2 +- apps/cli/src/command-internal/pooler-fallback.ts | 5 +---- apps/cli/src/command-internal/prompt-yes-no.unit.test.ts | 1 - .../cli/src/command-internal/sql-files-glob.unit.test.ts | 2 +- .../cli/src/commands/branches/disable/disable.handler.ts | 2 +- .../src/commands/config/pull/pull.integration.test.ts | 4 ++-- .../commands/domains/create/create.integration.test.ts | 2 +- .../cli/src/commands/domains/get/get.integration.test.ts | 4 ++-- apps/cli/src/commands/functions/delete/delete.handler.ts | 4 +--- .../src/commands/functions/list/list.integration.test.ts | 2 +- apps/cli/src/commands/functions/list/list.live.test.ts | 6 ++---- apps/cli/src/commands/inspect/report/report.command.ts | 4 +--- apps/cli/src/commands/migration/fetch/fetch.command.ts | 4 +--- apps/cli/src/commands/migration/up/up.command.ts | 4 +--- .../src/commands/orgs/create/create.integration.test.ts | 2 +- apps/cli/src/commands/orgs/list/list.integration.test.ts | 2 +- .../postgres-config/postgres-config.integration.test.ts | 5 ++--- apps/cli/src/commands/secrets/unset/unset.command.ts | 3 +-- apps/cli/src/shared/cli/run.ts | 2 +- apps/cli/src/telemetry/analytics.layer.unit.test.ts | 4 ++-- packages/config/src/lib/resolve.ts | 4 +--- packages/config/src/lib/resolve.unit.test.ts | 2 +- packages/config/src/node.ts | 5 ++--- packages/config/src/project-config/project-config.ts | 4 ++-- packages/config/src/project-config/registry-auth.ts | 2 +- packages/config/src/storage.unit.test.ts | 6 ++---- 33 files changed, 44 insertions(+), 64 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0330b79907..67454f028a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -279,7 +279,7 @@ The test name carries the intent. Comment only non-obvious fixture setup, in one ### Directive comments -`// oxlint-disable-next-line`, `// @ts-expect-error`, `/// <reference …>`, shebangs, and similar are instructions to tools, not prose. Keep them, and give every lint disable a short reason after `--`. +`// oxlint-disable-next-line`, `// @ts-expect-error`, `/// <reference …>`, shebangs, and similar are instructions to tools, not prose. Keep them, and give every lint disable a short reason after `--`. The same goes for tool-facing JSDoc tags: `@public` tells knip an export is intentionally unused, `@internal` and `@deprecated` carry meaning for consumers. Keep them even when trimming the rest of a doc comment. ### Size check diff --git a/apps/cli-e2e/src/tests/config-toml.e2e.test.ts b/apps/cli-e2e/src/tests/config-toml.e2e.test.ts index 39cb7b0208..e21e572773 100644 --- a/apps/cli-e2e/src/tests/config-toml.e2e.test.ts +++ b/apps/cli-e2e/src/tests/config-toml.e2e.test.ts @@ -4,7 +4,7 @@ import { describe, expect } from "vitest"; import { PROJECT_REF } from "./env.ts"; import { testBehaviour } from "./test-context.ts"; -// Regression guard (CLI-1489): a non-string field written as `env(VAR)` (e.g. +// Regression guard: a non-string field written as `env(VAR)` (e.g. // a port) must not crash config decoding before env resolution runs. // // A 401 is injected so the test doesn't need a real API fixture; it only diff --git a/apps/cli/src/command-internal/connect-errors.ts b/apps/cli/src/command-internal/connect-errors.ts index 3a1c345859..2c89b40380 100644 --- a/apps/cli/src/command-internal/connect-errors.ts +++ b/apps/cli/src/command-internal/connect-errors.ts @@ -222,7 +222,7 @@ const SERVER_REFUSED_SSL = "The server does not support SSL connections"; // `onConnectEnd`; Bun emits the same text for both FIN and RST). Phase-specific // by construction — only ever raised pre-secure-connection — so it maps to // pgconn's startTLS stage (`tls error (…)`). Its code is -// ECONNRESET, deliberately absent from DIAL_ERROR_CODES: a raw +// ECONNRESET, absent from DIAL_ERROR_CODES: a raw // post-handshake `read ECONNRESET` is not phase-specific and stays verbatim. const TLS_DISCONNECT_MESSAGE = "Client network socket disconnected before secure TLS connection was established"; diff --git a/apps/cli/src/command-internal/db-bootstrap/restart-services.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/restart-services.unit.test.ts index 6507701372..0ac994b4d5 100644 --- a/apps/cli/src/command-internal/db-bootstrap/restart-services.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/restart-services.unit.test.ts @@ -281,14 +281,13 @@ describe("restartServicesAndReloadKong", () => { Effect.map((error) => { expect(error).toBeInstanceOf(KongReloadError); if (!(error instanceof KongReloadError)) return; - // Byte-matches Go: `DockerExecOnceWithStream` sets a fixed `error executing command` - // for a non-zero exec exit code (`utils/docker.go:646-648`) — not the exit code itself. + // A non-zero exec exit code surfaces as the fixed `error executing command` text, not the + // exit code itself. expect(error.message).toContain("failed to reload kong: error executing command"); expect(error.message).toContain("nginx: [error] invalid config"); expect(error.suggestion).toContain(`docker restart ${KONG_ID}`); - // Pins the `--nginx-conf` flag (reset.go:269, reset_test.go:512) — a bare - // `kong reload` regenerates nginx.conf from Kong's default template and - // drops the custom `email_templates` server, reintroducing #6059. + // A bare `kong reload` regenerates nginx.conf from Kong's default template and drops the + // custom `email_templates` server, so the `--nginx-conf` flag is required. expect(mock.spawned).toContainEqual([ "exec", KONG_ID, diff --git a/apps/cli/src/command-internal/global-flags.ts b/apps/cli/src/command-internal/global-flags.ts index 5f9120ac67..1d21906625 100644 --- a/apps/cli/src/command-internal/global-flags.ts +++ b/apps/cli/src/command-internal/global-flags.ts @@ -306,7 +306,7 @@ const debugFlagExplicitlyFalse = (args: ReadonlyArray<string>): boolean => { /** * `--debug` resolved with an env fallback, and the project `.env` consulted too, for debug-gated * behavior downstream of a command that already loaded the nested project env. `loadProjectEnv` - * is deliberately pure, so callers pass the loaded map through explicitly — same shape as + * is pure, so callers pass the loaded map through explicitly — same shape as * {@link resolveYesWithProjectEnv}. Shell env *presence* suppresses the file value; an explicit * `--debug` wins over both. */ diff --git a/apps/cli/src/command-internal/migration-apply.ts b/apps/cli/src/command-internal/migration-apply.ts index 0b1cd71ace..937709cd4f 100644 --- a/apps/cli/src/command-internal/migration-apply.ts +++ b/apps/cli/src/command-internal/migration-apply.ts @@ -47,7 +47,7 @@ const BOM_CODE_POINT = 0xfeff; // // JS's `\s` also matches `\v` (vertical tab), which PostgreSQL >= 14 treats as SQL whitespace — // so a statement separated only by `\v` (e.g. `VACUUM\v(FULL)`) classifies as -// pipeline-incompatible here, a known, deliberately unfixed edge case. +// pipeline-incompatible here, a known, unfixed edge case. const CREATE_INDEX_CONCURRENTLY_PATTERN = /^CREATE\s+(?:UNIQUE\s+)?INDEX\s+CONCURRENTLY(?:\s|$)/u; const DROP_INDEX_CONCURRENTLY_PATTERN = /^DROP\s+INDEX\s+CONCURRENTLY(?:\s|$)/u; const REINDEX_CONCURRENTLY_PATTERN = /^REINDEX(?:\s|\().*\sCONCURRENTLY(?:\s|$)/u; diff --git a/apps/cli/src/command-internal/pflag-reconcile.ts b/apps/cli/src/command-internal/pflag-reconcile.ts index da632556c7..412335c4db 100644 --- a/apps/cli/src/command-internal/pflag-reconcile.ts +++ b/apps/cli/src/command-internal/pflag-reconcile.ts @@ -21,6 +21,8 @@ import { validateWorkdirIsDirectory } from "./workdir-validation.ts"; * * Flows through {@link validatePflagWorkdir}'s inferred Effect error channel; no call site * imports the class by name. + * + * @public */ export class PflagWorkdirError extends Data.TaggedError("PflagWorkdirError")<{ readonly message: string; diff --git a/apps/cli/src/command-internal/pg-dump.scripts.ts b/apps/cli/src/command-internal/pg-dump.scripts.ts index afe91addf7..6c9bc741be 100644 --- a/apps/cli/src/command-internal/pg-dump.scripts.ts +++ b/apps/cli/src/command-internal/pg-dump.scripts.ts @@ -1,4 +1,4 @@ -// Verbatim copies of the Go pg_dump scripts (`apps/cli-go/pkg/migration/scripts/`). +// pg_dump scripts, kept byte-identical to `apps/cli-go/pkg/migration/scripts/`. // These embed the dump pipelines byte-for-byte; `pg-dump.env.unit.test.ts` // asserts equality against the Go `.sh` sources. Do not hand-edit — regenerate from Go. diff --git a/apps/cli/src/command-internal/pooler-fallback.ts b/apps/cli/src/command-internal/pooler-fallback.ts index f5e741a9f7..826a2d9d02 100644 --- a/apps/cli/src/command-internal/pooler-fallback.ts +++ b/apps/cli/src/command-internal/pooler-fallback.ts @@ -18,10 +18,7 @@ export interface PoolerFallbackOptions<A, E, R, R2, RF> { readonly classifyResult?: (result: A) => boolean; } -/** - * Go's IPv6 pooler-fallback warning (`internal/utils/connect.go:283-289`), to stderr, - * `Yellow`-wrapped, byte-for-byte. Emitted just before the IPv4 pooler retry. - */ +/** The established IPv6 pooler-fallback warning, yellow on stderr, emitted before the IPv4 retry. */ export const emitPoolerFallbackWarning = (host: string): Effect.Effect<void, never, Output> => Effect.gen(function* () { const output = yield* Output; diff --git a/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts b/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts index 151379dd8c..124548ac4a 100644 --- a/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts +++ b/apps/cli/src/command-internal/prompt-yes-no.unit.test.ts @@ -2,7 +2,6 @@ import { describe, expect, it } from "vitest"; import { parseYesNo } from "./prompt-yes-no.ts"; -// Port of Go's `parseYesNo` (`apps/cli-go/internal/utils/console.go:84-93`). describe("parseYesNo", () => { it("parses affirmative answers (case-insensitive, trimmed)", () => { for (const input of ["y", "Y", "yes", "YES", " Yes ", "yEs"]) { diff --git a/apps/cli/src/command-internal/sql-files-glob.unit.test.ts b/apps/cli/src/command-internal/sql-files-glob.unit.test.ts index 695331aaac..6d6e904185 100644 --- a/apps/cli/src/command-internal/sql-files-glob.unit.test.ts +++ b/apps/cli/src/command-internal/sql-files-glob.unit.test.ts @@ -586,7 +586,7 @@ describe("sqlFilesGlob", () => { it.effect.skipIf(isRoot)( "picks the lexically-first failing subdirectory as the fatal error, matching Go's fs.WalkDir sorted-visit order (review CLI-1958)", () => { - // The fake `readDirectory` deliberately returns "schemas"'s children in reverse + // The fake `readDirectory` returns "schemas"'s children in reverse // order, to prove the walk sorts them back (`utf8Compare`) before iterating. const dir = mkdtempSync(join(tmpdir(), "sql-glob-walk-order-")); const schemasDir = join(dir, "schemas"); diff --git a/apps/cli/src/commands/branches/disable/disable.handler.ts b/apps/cli/src/commands/branches/disable/disable.handler.ts index c37be3b596..2bd1903596 100644 --- a/apps/cli/src/commands/branches/disable/disable.handler.ts +++ b/apps/cli/src/commands/branches/disable/disable.handler.ts @@ -29,7 +29,7 @@ export const branchesDisable = Effect.fn("branches.disable")(function* ( // `branches` is PARENT-scoped: after `supabase link <branch>`, // `supabase/.temp/project-ref` holds the branch's own ref, and the platform - // 403s on that ref for every branches-management endpoint (CLI-2167 follow-up). + // 403s on that ref for every branches-management endpoint. const ref = yield* resolveParentScopedProjectRef(flags.projectRef); yield* Effect.gen(function* () { diff --git a/apps/cli/src/commands/config/pull/pull.integration.test.ts b/apps/cli/src/commands/config/pull/pull.integration.test.ts index 9e8beb8d91..88fa0d93ad 100644 --- a/apps/cli/src/commands/config/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/config/pull/pull.integration.test.ts @@ -2192,7 +2192,7 @@ describe("config pull integration", () => { ...(attributes["auth"] as Record<string, unknown>), sms_provider: "twilio", sms_twilio_account_sid: "ACreal0000000000000000000000000", - // sms_twilio_message_service_sid is deliberately absent: the remote never + // sms_twilio_message_service_sid is absent: the remote never // reports it, so message_service_sid stays "" after the fixpoint absorbs // account_sid, and the projected file would fail to decode once enabled is // written. @@ -2440,7 +2440,7 @@ describe("config pull integration", () => { ...(attributes["auth"] as Record<string, unknown>), sms_provider: "twilio", sms_twilio_account_sid: "ACreal0000000000000000000000000", - // sms_twilio_message_service_sid is deliberately absent: writing + // sms_twilio_message_service_sid is absent: writing // enabled/account_sid alone into [remotes.staging.*] decodes fine raw (remotes // skip business-rule checks), but fails once this block is selected and merged // over root — the same projection a future config pull/push targeting this ref diff --git a/apps/cli/src/commands/domains/create/create.integration.test.ts b/apps/cli/src/commands/domains/create/create.integration.test.ts index 13473b62bf..22eab2b0f2 100644 --- a/apps/cli/src/commands/domains/create/create.integration.test.ts +++ b/apps/cli/src/commands/domains/create/create.integration.test.ts @@ -229,7 +229,7 @@ describe("domains create integration", () => { const { layer, out } = setup({ goOutput: "yaml" }); return Effect.gen(function* () { yield* domainsCreate(flags()); - // yaml.v3 lowercases the whole field name (CLI-1975). + // yaml.v3 lowercases the whole field name. expect(out.stdoutText).toContain(`customhostname: ${CUSTOM_HOSTNAME}`); expect(out.stderrText).toBe(""); }).pipe(Effect.provide(layer)); diff --git a/apps/cli/src/commands/domains/get/get.integration.test.ts b/apps/cli/src/commands/domains/get/get.integration.test.ts index 3fa3895b9c..e37268c98c 100644 --- a/apps/cli/src/commands/domains/get/get.integration.test.ts +++ b/apps/cli/src/commands/domains/get/get.integration.test.ts @@ -237,7 +237,7 @@ describe("domains get integration", () => { const { layer, out } = setup({ goOutput: "yaml" }); return Effect.gen(function* () { yield* domainsGet(baseFlags); - // yaml.v3 lowercases the whole field name (CLI-1975). + // yaml.v3 lowercases the whole field name. expect(out.stdoutText).toContain("customhostname: shop.acme.dev"); }).pipe(Effect.provide(layer)); }); @@ -246,7 +246,7 @@ describe("domains get integration", () => { const { layer, out } = setup({ goOutput: "toml" }); return Effect.gen(function* () { yield* domainsGet(baseFlags); - // BurntSushi emits PascalCase field names (CLI-1975). + // BurntSushi emits PascalCase field names. expect(out.stdoutText).toContain('CustomHostname = "shop.acme.dev"'); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/functions/delete/delete.handler.ts b/apps/cli/src/commands/functions/delete/delete.handler.ts index d41c795fe9..ad49a6df3a 100644 --- a/apps/cli/src/commands/functions/delete/delete.handler.ts +++ b/apps/cli/src/commands/functions/delete/delete.handler.ts @@ -28,9 +28,7 @@ export const functionsDelete = Effect.fn("functions.delete")(function* ( }), ), ), - // Go: `fmt.Printf("Deleted Function %s from project %s.\n", utils.Aqua(slug), - // utils.Aqua(projectRef))` (`internal/functions/delete/delete.go:20`) — - // stdout-bound, so the TTY gate must check stdout. + // The "Deleted Function" line is stdout-bound, so the TTY gate must check stdout. styleIdentifier: (text) => aqua(text, process.stdout), }, ).pipe( diff --git a/apps/cli/src/commands/functions/list/list.integration.test.ts b/apps/cli/src/commands/functions/list/list.integration.test.ts index 992aa401f0..b1a35a2ca0 100644 --- a/apps/cli/src/commands/functions/list/list.integration.test.ts +++ b/apps/cli/src/commands/functions/list/list.integration.test.ts @@ -185,7 +185,7 @@ describe("functions list integration", () => { const { layer, out } = setup({ goOutput: "toml" }); return Effect.gen(function* () { yield* functionsList({ projectRef: Option.none() }); - // BurntSushi indents array-of-table keys by 2 spaces (CLI-1975). + // BurntSushi indents array-of-table keys by 2 spaces. expect(out.stdoutText).toContain(`[[functions]] CreatedAt = 1687423025152 EntrypointPath = "functions/hello-world/index.ts" diff --git a/apps/cli/src/commands/functions/list/list.live.test.ts b/apps/cli/src/commands/functions/list/list.live.test.ts index c2d13d9a1b..fed0af3fb4 100644 --- a/apps/cli/src/commands/functions/list/list.live.test.ts +++ b/apps/cli/src/commands/functions/list/list.live.test.ts @@ -4,10 +4,8 @@ import { test } from "../../../../tests/helpers/live.ts"; const LIVE_TIMEOUT_MS = 120_000; -// This is the entry point for the broader edge-functions coverage tracked in -// CLI-1834 (deploy + invoke over :443 / {ref}.supabase.red), which needs the -// project's gateway reachable from the host — author those here as they become -// runnable on the full stack. +// TODO(CLI-1834): add deploy + invoke coverage over :443 / {ref}.supabase.red once the project's +// gateway is reachable from the host. describe("supabase functions list (live)", () => { test( "lists edge functions for the project", diff --git a/apps/cli/src/commands/inspect/report/report.command.ts b/apps/cli/src/commands/inspect/report/report.command.ts index 8fc372609d..5d97d7a106 100644 --- a/apps/cli/src/commands/inspect/report/report.command.ts +++ b/apps/cli/src/commands/inspect/report/report.command.ts @@ -47,9 +47,7 @@ export const inspectReportCommand = Command.make("report", config).pipe( "project-ref": flags.projectRef, "output-dir": flags.outputDir, }, - // TS-only flag with no Go telemetry-safety baseline; Go's nearest - // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most - // others) are unmarked, so it stays redacted. + // `--project-ref` has no telemetry-safety baseline, so it stays redacted. }), withJsonErrorHandling, ), diff --git a/apps/cli/src/commands/migration/fetch/fetch.command.ts b/apps/cli/src/commands/migration/fetch/fetch.command.ts index 682622173a..eb5212e06f 100644 --- a/apps/cli/src/commands/migration/fetch/fetch.command.ts +++ b/apps/cli/src/commands/migration/fetch/fetch.command.ts @@ -42,9 +42,7 @@ export const migrationFetchCommand = Command.make("fetch", config).pipe( local: flags.local, "project-ref": flags.projectRef, }, - // TS-only flag with no Go telemetry-safety baseline; Go's nearest - // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most - // others) are unmarked, so it stays redacted. + // `--project-ref` has no telemetry-safety baseline, so it stays redacted. }), withJsonErrorHandling, ), diff --git a/apps/cli/src/commands/migration/up/up.command.ts b/apps/cli/src/commands/migration/up/up.command.ts index 26d9a113ca..380a33841a 100644 --- a/apps/cli/src/commands/migration/up/up.command.ts +++ b/apps/cli/src/commands/migration/up/up.command.ts @@ -47,9 +47,7 @@ export const migrationUpCommand = Command.make("up", config).pipe( local: flags.local, "project-ref": flags.projectRef, }, - // TS-only flag with no Go telemetry-safety baseline; Go's nearest - // --project-ref registrations (cmd/pgdelta_catalog.go:44 and most - // others) are unmarked, so it stays redacted. + // `--project-ref` has no telemetry-safety baseline, so it stays redacted. }), withJsonErrorHandling, ), diff --git a/apps/cli/src/commands/orgs/create/create.integration.test.ts b/apps/cli/src/commands/orgs/create/create.integration.test.ts index 51c39b72d7..09b833344b 100644 --- a/apps/cli/src/commands/orgs/create/create.integration.test.ts +++ b/apps/cli/src/commands/orgs/create/create.integration.test.ts @@ -109,7 +109,7 @@ describe("orgs create integration", () => { return Effect.gen(function* () { yield* orgsCreate({ name: "Acme" }); expect(out.stdoutText).toContain("Created organization: combined-fuchsia-lion\n"); - // PascalCase field names at the top level — no table header (CLI-1975). + // PascalCase field names at the top level — no table header. expect(out.stdoutText).toContain('Name = "Acme"'); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/orgs/list/list.integration.test.ts b/apps/cli/src/commands/orgs/list/list.integration.test.ts index 940849b557..e2af7c9f02 100644 --- a/apps/cli/src/commands/orgs/list/list.integration.test.ts +++ b/apps/cli/src/commands/orgs/list/list.integration.test.ts @@ -139,7 +139,7 @@ describe("orgs list integration", () => { return Effect.gen(function* () { yield* orgsList({}); expect(out.stdoutText).toContain("[[organizations]]"); - // PascalCase field names with BurntSushi's 2-space indent (CLI-1975). + // PascalCase field names with BurntSushi's 2-space indent. expect(out.stdoutText).toContain(' Name = "Test Org"'); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts b/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts index 6f23c9b3fb..be155ec410 100644 --- a/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts +++ b/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts @@ -61,9 +61,8 @@ describe("postgres-config get", () => { }); it.live("renders a large integral config value with Go's float64 %g in the pretty table", () => { - // Go decodes the API response with `json.Unmarshal` into `map[string]any`, - // so every JSON number is a `float64`; `get.go:32-35`'s `%+v` then prints - // it with shortest `%g` — 1000000 renders as `1e+06`, never `1000000`. + // The established table output renders every JSON number as a float64 with shortest `%g` + // formatting, so 1000000 renders as `1e+06`, never `1000000`. const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ response: { status: 200, body: { max_connections: 1000000 } }, diff --git a/apps/cli/src/commands/secrets/unset/unset.command.ts b/apps/cli/src/commands/secrets/unset/unset.command.ts index 41b0076a08..bafcb73196 100644 --- a/apps/cli/src/commands/secrets/unset/unset.command.ts +++ b/apps/cli/src/commands/secrets/unset/unset.command.ts @@ -37,7 +37,6 @@ export const secretsUnsetCommand = Command.make("unset", config).pipe( Command.withHandler((flags) => secretsUnset(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), - // `stdinLayer`: the confirmation prompt reads piped stdin via `promptYesNo` - // (`Console.ReadLine`, `console.go:38-61`) on a non-TTY stdin. + // `stdinLayer`: the confirmation prompt reads piped stdin via `promptYesNo` on a non-TTY stdin. Command.provide(Layer.mergeAll(managementApiRuntimeLayer(["secrets", "unset"]), stdinLayer)), ); diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 86733ecf61..b55314207e 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -110,7 +110,7 @@ const globalFlagsWithValues: ReadonlySet<string> = GLOBAL_VALUE_FLAG_TOKENS; // wrapped by the global signal-interrupt handler, which would otherwise race their graceful // shutdown. Matched by leading command-path segments. // -// `start` and `db start` are deliberately NOT listed here even though they sound similar: their +// `start` and `db start` are not listed here even though they sound similar: their // native implementations install no signal handling of their own, and instead rely on the global // handler's interruption to trigger their own rollback-on-error cleanup. Listing them would let a // raw Ctrl-C skip that cleanup entirely. diff --git a/apps/cli/src/telemetry/analytics.layer.unit.test.ts b/apps/cli/src/telemetry/analytics.layer.unit.test.ts index ac38f56802..991c007118 100644 --- a/apps/cli/src/telemetry/analytics.layer.unit.test.ts +++ b/apps/cli/src/telemetry/analytics.layer.unit.test.ts @@ -106,8 +106,8 @@ describe("resolveGroups", () => { it("keys the organization group by organization_id (not slug) to match Go", () => { const groups = resolveGroups({}, Option.some(linkedCacheValue())); - // Must be the org ID so the event group matches what groupIdentify published - // (apps/cli-go/internal/telemetry/project.go:99-103). The slug is never a key. + // Must be the org ID so the event group matches what groupIdentify published; the slug is + // never a key. expect(groups).toEqual({ [GroupOrganization]: "org-id-123", [GroupProject]: "proj-ref", diff --git a/packages/config/src/lib/resolve.ts b/packages/config/src/lib/resolve.ts index ca640f217a..ef4d38d85d 100644 --- a/packages/config/src/lib/resolve.ts +++ b/packages/config/src/lib/resolve.ts @@ -41,9 +41,7 @@ function interpolateLeafValue( const resolved = env[envName]; // Preserve the literal `env(VAR)` verbatim when VAR is unset OR present but - // empty (e.g. a dotenv `KEY=` line). Matches Go's `LoadEnvHook` - // (`apps/cli-go/pkg/config/decode_hooks.go:19-24`: `len(env) > 0`), which - // only substitutes a non-empty value — same gate as `substituteEnvLeaf` in + // empty (e.g. a dotenv `KEY=` line), the same gate as `substituteEnvLeaf` in // `./env.ts`. Without this, a present-but-empty `env(...)` secret (e.g. // `edge_runtime.secrets.FOO = "env(EMPTY)"`) resolves to `""` here, gets // redacted by `redactValue` as a real value instead of skipped as an diff --git a/packages/config/src/lib/resolve.unit.test.ts b/packages/config/src/lib/resolve.unit.test.ts index b2765ca3aa..5a9eee48d1 100644 --- a/packages/config/src/lib/resolve.unit.test.ts +++ b/packages/config/src/lib/resolve.unit.test.ts @@ -4,7 +4,7 @@ import { resolveCliConfigValue, resolveCliConfigSubtree } from "../index.ts"; // Behavioral coverage of the two public sync resolvers, imported from the // public `.` entrypoint (not `./resolve.ts` directly) — this is the exact -// surface an external consumer sees, options param removed (CLI-2234). +// surface an external consumer sees. describe("resolveCliConfigValue", () => { test("a plain leaf passes through unchanged", () => { diff --git a/packages/config/src/node.ts b/packages/config/src/node.ts index c1cfe4e695..a9981b199c 100644 --- a/packages/config/src/node.ts +++ b/packages/config/src/node.ts @@ -17,7 +17,6 @@ export type { CliConfigIo } from "./promise-facade.ts"; // Re-exports every pure symbol from `.` (types, schema, errors, etc.) so // `./io` consumers can name `LoadedCliConfig`/`CliProjectPaths`/etc. without // a second import from `@supabase/config` — `index.ts`'s own graph is pure, -// so this doesn't drag anything platform-specific into it. No name -// collisions with the seven facade functions above (verified against -// `index.ts`'s export surface). +// so this doesn't drag anything platform-specific into it, and none of its names collide with +// the facade functions above. export * from "./index.ts"; diff --git a/packages/config/src/project-config/project-config.ts b/packages/config/src/project-config/project-config.ts index 7783aa2323..874e5ce88a 100644 --- a/packages/config/src/project-config/project-config.ts +++ b/packages/config/src/project-config/project-config.ts @@ -99,9 +99,9 @@ function copyHostedValueForDocument(value: unknown, path: ReadonlyArray<string>) * every config-side `realtime.*` field, and local-only `experimental.*` engine/backend selection. * * `db.major_version` and `db.pooler`'s other three fields (`pool_mode`, `default_pool_size`, - * `max_client_conn`) are real hosted facts and deliberately excluded from this list, so `config + * `max_client_conn`) are real hosted facts and excluded from this list, so `config * diff`/`config pull` keep them comparable and can sync them from the platform. `auth.enabled`/ - * `storage.enabled` and `db.network_restrictions.enabled` are also deliberately excluded: each is a + * `storage.enabled` and `db.network_restrictions.enabled` are also excluded: each is a * genuine management opt-out a document can still declare, not a value to hide. * * Exact-match only; every path below names a static struct field. diff --git a/packages/config/src/project-config/registry-auth.ts b/packages/config/src/project-config/registry-auth.ts index 1e0e0c4db6..f456fc1bdd 100644 --- a/packages/config/src/project-config/registry-auth.ts +++ b/packages/config/src/project-config/registry-auth.ts @@ -323,7 +323,6 @@ function parseUint16(s: string): number | undefined { return n > 65535 ? undefined : n; } -/** Splits on `,` then each entry on the first `=`; entries without `=` are dropped. */ /** * Document-side canonicalization for `sms.test_otp`: serializes then re-parses through * {@link envToMap} so a key or value holding a literal comma converges on the value that @@ -343,6 +342,7 @@ function canonicalizeTestOtpMap(value: unknown): unknown { return Object.keys(canonical).length > 0 ? canonical : undefined; } +/** Splits on `,` then each entry on the first `=`; entries without `=` are dropped. */ function envToMap(input: string): Record<string, string> { const entries = input.length === 0 ? [] : input.split(","); const result: Record<string, string> = {}; diff --git a/packages/config/src/storage.unit.test.ts b/packages/config/src/storage.unit.test.ts index 97f46f2d6d..8bb7f6171b 100644 --- a/packages/config/src/storage.unit.test.ts +++ b/packages/config/src/storage.unit.test.ts @@ -6,7 +6,6 @@ describe("storage schema", () => { const decodeStorage = Schema.decodeUnknownSync(storage); describe("file_size_limit accepts numeric and string forms (Go sizeInBytes parity)", () => { - // Mirrors apps/cli-go/pkg/config/config_test.go:TestFileSizeLimitConfigParsing. test("accepts a bare byte count and normalizes it to a string", () => { expect(decodeStorage({ file_size_limit: 5000000 }).file_size_limit).toBe("5000000"); }); @@ -31,9 +30,8 @@ describe("storage schema", () => { describe("default-enabled values match Go's merged template", () => { test("vector.enabled defaults to true when omitted", () => { - // Go merges templates/config.toml (enabled = true) as the base layer, so an - // omitted key resolves to true (config_test.go:40). Common partial configs - // declare [storage.vector.buckets.*] without [storage.vector].enabled. + // The template config.toml (enabled = true) is the base layer, so an omitted key resolves + // to true; partial configs commonly declare [storage.vector.buckets.*] without it. expect(decodeStorage({}).vector.enabled).toBe(true); expect(decodeStorage({ vector: { buckets: { "docs-openai": {} } } }).vector.enabled).toBe( true,