diff --git a/AGENTS.md b/AGENTS.md index eb5e0c2e11..dd495e39ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -111,7 +111,7 @@ page, or upstream issue. If the rationale needs more than three lines, move it t ### Never write - Code narration, provenance/history, evidence trails, ticket IDs as provenance, or meta-commentary on code shape. A ticket ID belongs only in a `TODO(CLI-1234):` or when no ADR exists and the ticket is the only home for a decision. -- Restatements of docs, section banners, or Go-parity framing. Link to maintained docs instead. +- Restatements of docs, or section banners. Link to maintained docs instead. - ALL-CAPS emphasis or words such as “deliberately”, “crucially”, and “exactly”. Tests should carry intent in their names; comments only explain non-obvious fixture setup. Keep diff --git a/apps/cli-e2e/src/server/pg-mock.ts b/apps/cli-e2e/src/server/pg-mock.ts index b419eed1b1..f187f3cfed 100644 --- a/apps/cli-e2e/src/server/pg-mock.ts +++ b/apps/cli-e2e/src/server/pg-mock.ts @@ -367,7 +367,7 @@ function processMessages(socket: Bun.Socket, getState: () => PgMockS socket.data.buf = buf.slice(totalLen); if (msgType === 0x51) { - // 'Q' — simple query (used by pgconn.CopyTo and simple-protocol callers) + // 'Q' — simple query (used by COPY and simple-protocol callers) const query = payload.slice(0, payload.length - 1).toString("utf8"); const isProbe = /^\s*select\s+1\s*;?\s*$/i.test(query); const isCopy = /^\s*COPY\s+/i.test(query); diff --git a/apps/cli-e2e/src/server/request-matcher.ts b/apps/cli-e2e/src/server/request-matcher.ts index b77e02e0fd..7f75ca9fd5 100644 --- a/apps/cli-e2e/src/server/request-matcher.ts +++ b/apps/cli-e2e/src/server/request-matcher.ts @@ -86,7 +86,7 @@ export function resetCounters(counters: SequenceCounters): void { /** 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 + * proxied command building a request body from a 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 { diff --git a/apps/cli-e2e/src/tests/auth.e2e.test.ts b/apps/cli-e2e/src/tests/auth.e2e.test.ts index d2ea7ae761..6172a265e4 100644 --- a/apps/cli-e2e/src/tests/auth.e2e.test.ts +++ b/apps/cli-e2e/src/tests/auth.e2e.test.ts @@ -5,5 +5,5 @@ import { describe, it } from "vitest"; // 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)"); + it.todo("login and logout tests blocked on keyring opt-out (see file comment)"); }); diff --git a/apps/cli/docs/binary-distribution.md b/apps/cli/docs/binary-distribution.md index cf111b3bb5..1c9705a51d 100644 --- a/apps/cli/docs/binary-distribution.md +++ b/apps/cli/docs/binary-distribution.md @@ -59,17 +59,7 @@ This: 3. Bundles the binary into the platform archives (`.tar.gz` / `.zip`) 4. Includes the binary in the Linux package manager packages (deb/rpm/apk) -## History - -The CLI was originally a two-binary distribution during its gradual port from a Go CLI to -TypeScript: a `supabase` Bun single-file executable alongside a `supabase-go` binary that the TS -CLI proxied to for not-yet-ported commands. The port completed (CLI-1970), the last residual -delegation surface was removed (CLI-2432), and the Go source tree (`apps/cli-go/`) was deleted in -the same effort — the CLI now ships as a single compiled binary per platform. See -[ADR 0030](../../../docs/adr/0030-go-cli-removal.md) for the full removal record. - ## See Also - [ADR 0011](../../../docs/adr/0011-cli-release-and-distribution-strategy.md) — the release & distribution strategy decision (binary packaging choice, per-channel publish mechanisms, CI pipeline design, open blockers). -- [ADR 0030](../../../docs/adr/0030-go-cli-removal.md) — the Go CLI removal decision record. - [`release-process.md`](./release-process.md) — operational playbook for local, PoC, and production releases. diff --git a/apps/cli/docs/profile.md b/apps/cli/docs/profile.md index 59949999a6..b5b6065a67 100644 --- a/apps/cli/docs/profile.md +++ b/apps/cli/docs/profile.md @@ -4,8 +4,8 @@ This document captures the intended design for advanced non-default backend rout CLI. The file is intentionally named `profile.md` to preserve continuity with the earlier discussion -and the old Go CLI concept. The design in this document does **not** recommend carrying the old -`profile` abstraction forward. The preferred term in the new CLI is **platform**. +and the previous `profile` concept. The design in this document does **not** recommend carrying the +previous `profile` abstraction forward. The preferred term in the new CLI is **platform**. ## Overview @@ -21,9 +21,9 @@ A much smaller group of users needs to point the CLI somewhere else: The goal is to support those cases cleanly without turning non-default backend routing into a first-class everyday feature. -## Why Not Go-Style Profiles +## Why Not Profiles -The old Go CLI `profile` abstraction became too broad. +The previous `profile` abstraction became too broad. It started as a way to switch API endpoints, but it ended up bundling multiple unrelated concerns: @@ -195,7 +195,7 @@ This design does not propose: - a built-in first-party staging alias in the public UX - customer-specific platform definitions baked into the CLI - plugin or product behavior encoded directly inside platform config -- backward compatibility with Go CLI `profile` names or storage behavior +- backward compatibility with previous `profile` names or storage behavior ## Open Follow-up Work diff --git a/apps/cli/docs/supabase/login.md b/apps/cli/docs/supabase/login.md index 0387dbd3f6..5dbc7df448 100644 --- a/apps/cli/docs/supabase/login.md +++ b/apps/cli/docs/supabase/login.md @@ -2,7 +2,7 @@ Connect the Supabase CLI to your Supabase account by logging in with your [personal access token](https://supabase.com/dashboard/account/tokens). -Your access token is stored securely in [native credentials storage](https://github.com/zalando/go-keyring#dependencies). If native credentials storage is unavailable, it will be written to a plain text file at `/access-token`. +Your access token is stored securely in native credentials storage (the operating system keyring). If native credentials storage is unavailable, it will be written to a plain text file at `/access-token`. > If this behavior is not desired, such as in a CI environment, you may skip login by specifying the `SUPABASE_ACCESS_TOKEN` environment variable in other commands. diff --git a/apps/cli/src/SIDE_EFFECTS_TEMPLATE.md b/apps/cli/src/SIDE_EFFECTS_TEMPLATE.md index cfb21c9fa4..b2b4bb1a51 100644 --- a/apps/cli/src/SIDE_EFFECTS_TEMPLATE.md +++ b/apps/cli/src/SIDE_EFFECTS_TEMPLATE.md @@ -120,5 +120,5 @@ diff --git a/apps/cli/src/auth/command-credentials.layer.ts b/apps/cli/src/auth/command-credentials.layer.ts index 8701566a7c..7edba3e7b7 100644 --- a/apps/cli/src/auth/command-credentials.layer.ts +++ b/apps/cli/src/auth/command-credentials.layer.ts @@ -52,9 +52,9 @@ const tryKeyringRead = ( if (value && value.length > 0) return Option.some(normalizeKeyringToken(value)); if (platform === "win32") { - const goWindowsValue = readGoWindowsTarget(module, account); - if (goWindowsValue && goWindowsValue.length > 0) { - return Option.some(normalizeKeyringToken(goWindowsValue)); + const windowsValue = readWindowsTarget(module, account); + if (windowsValue && windowsValue.length > 0) { + return Option.some(normalizeKeyringToken(windowsValue)); } } @@ -72,7 +72,7 @@ const tryKeyringWrite = ( Effect.try({ try: () => { if (platform === "win32") { - return writeGoWindowsTarget(module, account, token); + return writeWindowsTarget(module, account, token); } const entry = new module.Entry(KEYRING_SERVICE, account); @@ -99,7 +99,7 @@ const tryKeyringDelete = ( } if (platform === "win32" && probeWindowsTarget(module, account) !== "absent") { - deleted = deleteGoWindowsTarget(module, account) || deleted; + deleted = deleteWindowsTarget(module, account) || deleted; } return deleted; @@ -115,15 +115,15 @@ function readEntryPassword(entry: KeyringEntry): string | null { } } -function goWindowsCredentialTarget(account: string): string { +function windowsCredentialTarget(account: string): string { return `${KEYRING_SERVICE}:${account}`; } -function readGoWindowsTarget(module: KeyringModule, account: string): string | null { +function readWindowsTarget(module: KeyringModule, account: string): string | null { try { - const credentials = module.findCredentials(KEYRING_SERVICE, goWindowsCredentialTarget(account)); + const credentials = module.findCredentials(KEYRING_SERVICE, windowsCredentialTarget(account)); const credential = credentials.find((item) => item.account === account); - return credential ? normalizeGoWindowsPassword(credential.password) : null; + return credential ? normalizeWindowsPassword(credential.password) : null; } catch { return null; } @@ -135,7 +135,7 @@ type WindowsTargetProbe = "present" | "absent" | "unknown"; function probeWindowsTarget(module: KeyringModule, account: string): WindowsTargetProbe { try { - const credentials = module.findCredentials(KEYRING_SERVICE, goWindowsCredentialTarget(account)); + const credentials = module.findCredentials(KEYRING_SERVICE, windowsCredentialTarget(account)); // An empty password is an orphaned placeholder, not a real credential. const credential = credentials.find( (item) => item.account === account && item.password.length > 0, @@ -157,7 +157,7 @@ const deleteProbedWindowsTarget = ( Effect.gen(function* () { const result = yield* Effect.try(() => module.Entry.withTarget( - goWindowsCredentialTarget(account), + windowsCredentialTarget(account), KEYRING_SERVICE, account, ).deleteCredential(), @@ -167,11 +167,11 @@ const deleteProbedWindowsTarget = ( return yield* Effect.fail(onFailure(cause)); }); -function normalizeGoWindowsPassword(value: string): string { +function normalizeWindowsPassword(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, which the keyring search API + // Legacy Windows CredentialBlob values are 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) { @@ -183,10 +183,10 @@ function normalizeGoWindowsPassword(value: string): string { return Buffer.from(bytes).toString("utf8"); } -function writeGoWindowsTarget(module: KeyringModule, account: string, token: string): boolean { +function writeWindowsTarget(module: KeyringModule, account: string, token: string): boolean { try { const entry = module.Entry.withTarget( - goWindowsCredentialTarget(account), + windowsCredentialTarget(account), KEYRING_SERVICE, account, ); @@ -197,10 +197,10 @@ function writeGoWindowsTarget(module: KeyringModule, account: string, token: str } } -function deleteGoWindowsTarget(module: KeyringModule, account: string): boolean { +function deleteWindowsTarget(module: KeyringModule, account: string): boolean { try { const entry = module.Entry.withTarget( - goWindowsCredentialTarget(account), + windowsCredentialTarget(account), KEYRING_SERVICE, account, ); @@ -298,12 +298,9 @@ const deleteAllKeyringEntries = ( Effect.sync(() => { if (platform === "win32") { try { - const entries = module.findCredentials( - KEYRING_SERVICE, - `${goWindowsCredentialTarget("")}*`, - ); + const entries = module.findCredentials(KEYRING_SERVICE, `${windowsCredentialTarget("")}*`); for (const { account } of entries) { - deleteGoWindowsTarget(module, account); + deleteWindowsTarget(module, account); } } catch { // best-effort @@ -379,7 +376,7 @@ const readFallbackFile = ( /** * 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 + * profile resolved from raw argv after `CommandCredentials` already captured a different one at * construction. Fails with the same validation error as `resolveAccessToken` and propagates * credential storage failures. */ 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 e05c9c7f57..4b45208fc4 100644 --- a/apps/cli/src/auth/command-credentials.layer.unit.test.ts +++ b/apps/cli/src/auth/command-credentials.layer.unit.test.ts @@ -156,10 +156,10 @@ afterEach(() => { const VALID_TOKEN = "sbp_" + "a".repeat(40); const VALID_OAUTH_TOKEN = "sbp_oauth_" + "b".repeat(40); const VALID_V0_TOKEN = "sbp_v0_" + "c".repeat(40); -const encodeGoKeyringBase64 = (token: string) => +const encodeKeyringBase64 = (token: string) => `go-keyring-base64:${Buffer.from(token).toString("base64")}`; -const goWindowsKey = (account: string) => `Supabase CLI:${account}/Supabase CLI/${account}`; -const encodeGoWindowsPassword = (token: string) => { +const windowsKey = (account: string) => `Supabase CLI:${account}/Supabase CLI/${account}`; +const encodeWindowsPassword = (token: string) => { const bytes = Buffer.from(token, "utf8"); let encoded = ""; for (let index = 0; index < bytes.length; index += 2) { @@ -252,8 +252,8 @@ describe("commandCredentialsLayer.getAccessToken", () => { ); }); - it.effect("decodes Go keyring base64 values from the keyring profile account", () => { - passwords.set("Supabase CLI/supabase", encodeGoKeyringBase64(VALID_TOKEN)); + it.effect("decodes legacy keyring base64 values from the keyring profile account", () => { + passwords.set("Supabase CLI/supabase", encodeKeyringBase64(VALID_TOKEN)); return Effect.gen(function* () { const { getAccessToken } = yield* CommandCredentials; const token = yield* getAccessToken; @@ -261,8 +261,8 @@ describe("commandCredentialsLayer.getAccessToken", () => { }).pipe(Effect.provide(makeLayer())); }); - it.effect("reads Windows credentials created by Go keyring", () => { - passwords.set(goWindowsKey("supabase"), encodeGoWindowsPassword(VALID_TOKEN)); + it.effect("reads Windows credentials stored in the legacy target format", () => { + passwords.set(windowsKey("supabase"), encodeWindowsPassword(VALID_TOKEN)); return Effect.gen(function* () { const { getAccessToken } = yield* CommandCredentials; const token = yield* getAccessToken; @@ -271,8 +271,8 @@ describe("commandCredentialsLayer.getAccessToken", () => { }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); - it.effect("does not search Go Windows targets on other platforms", () => { - passwords.set(goWindowsKey("supabase"), VALID_TOKEN); + it.effect("does not search legacy Windows targets on other platforms", () => { + passwords.set(windowsKey("supabase"), VALID_TOKEN); return Effect.gen(function* () { const { getAccessToken } = yield* CommandCredentials; const token = yield* getAccessToken; @@ -436,11 +436,11 @@ describe("commandCredentialsLayer.saveAccessToken", () => { }).pipe(Effect.provide(makeLayer())), ); - it.effect("writes Windows credentials where Go keyring reads them", () => + it.effect("writes Windows credentials under the legacy target name", () => Effect.gen(function* () { const { saveAccessToken } = yield* CommandCredentials; yield* saveAccessToken(VALID_TOKEN); - expect(passwords.get(goWindowsKey("supabase"))).toBe(VALID_TOKEN); + expect(passwords.get(windowsKey("supabase"))).toBe(VALID_TOKEN); expect(passwords.has("Supabase CLI/supabase")).toBe(false); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))), ); @@ -450,7 +450,7 @@ describe("commandCredentialsLayer.saveAccessToken", () => { return Effect.gen(function* () { const { saveAccessToken } = yield* CommandCredentials; yield* saveAccessToken(VALID_TOKEN); - expect(passwords.get(goWindowsKey("supabase")) ?? "").toBe(""); + expect(passwords.get(windowsKey("supabase")) ?? "").toBe(""); expect(passwords.has("Supabase CLI/supabase")).toBe(false); const content = readFileSync(join(tempHome, ".supabase", "access-token"), "utf-8"); expect(content).toBe(VALID_TOKEN); @@ -540,7 +540,7 @@ describe("commandCredentialsLayer.deleteAccessToken", () => { }, ); - it.effect("win32: no Go target credential → NotLoggedInError, nothing fabricated", () => { + it.effect("win32: no legacy target credential → NotLoggedInError, nothing fabricated", () => { return Effect.gen(function* () { const { deleteAccessToken } = yield* CommandCredentials; const exit = yield* Effect.exit(deleteAccessToken); @@ -548,13 +548,13 @@ describe("commandCredentialsLayer.deleteAccessToken", () => { if (exit._tag === "Failure") { expect(JSON.stringify(exit.cause)).toContain("NotLoggedInError"); } - expect(passwords.has(goWindowsKey("supabase"))).toBe(false); + expect(passwords.has(windowsKey("supabase"))).toBe(false); expect(withTargetCalls).toEqual([]); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); - it.effect("win32: an empty placeholder Go target → NotLoggedInError", () => { - passwords.set(goWindowsKey("supabase"), ""); + it.effect("win32: an empty placeholder legacy target → NotLoggedInError", () => { + passwords.set(windowsKey("supabase"), ""); return Effect.gen(function* () { const { deleteAccessToken } = yield* CommandCredentials; const exit = yield* Effect.exit(deleteAccessToken); @@ -648,43 +648,46 @@ describe("commandCredentialsLayer.deleteAccessToken", () => { }).pipe(Effect.provide(makeLayer())); }); - it.effect("win32: deletes both the plain and the Go Windows target entries", () => { + it.effect("win32: deletes both the plain and the legacy Windows target entries", () => { passwords.set("Supabase CLI/supabase", VALID_TOKEN); - passwords.set(goWindowsKey("supabase"), VALID_TOKEN); + passwords.set(windowsKey("supabase"), VALID_TOKEN); return Effect.gen(function* () { const { deleteAccessToken } = yield* CommandCredentials; yield* deleteAccessToken; expect(passwords.has("Supabase CLI/supabase")).toBe(false); - expect(passwords.has(goWindowsKey("supabase"))).toBe(false); + expect(passwords.has(windowsKey("supabase"))).toBe(false); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); it.effect( - "win32: deletes a Go target whose blob getPassword/findCredentials can't decode", + "win32: deletes a legacy target whose blob getPassword/findCredentials can't decode", () => { - passwords.set(goWindowsKey("supabase"), VALID_TOKEN); - opaqueAccounts.add(goWindowsKey("supabase")); + passwords.set(windowsKey("supabase"), VALID_TOKEN); + opaqueAccounts.add(windowsKey("supabase")); return Effect.gen(function* () { const { deleteAccessToken } = yield* CommandCredentials; yield* deleteAccessToken; - expect(passwords.has(goWindowsKey("supabase"))).toBe(false); + expect(passwords.has(windowsKey("supabase"))).toBe(false); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }, ); - it.effect("win32: a confirmed Go target whose delete returns false → DeleteTokenError", () => { - passwords.set(goWindowsKey("supabase"), VALID_TOKEN); - failDeleteAccounts.add(goWindowsKey("supabase")); - return Effect.gen(function* () { - const { deleteAccessToken } = yield* CommandCredentials; - const exit = yield* Effect.exit(deleteAccessToken); - expect(exit._tag).toBe("Failure"); - if (exit._tag === "Failure") { - expect(JSON.stringify(exit.cause)).toContain("DeleteTokenError"); - } - expect(passwords.has(goWindowsKey("supabase"))).toBe(true); - }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); - }); + it.effect( + "win32: a confirmed legacy target whose delete returns false → DeleteTokenError", + () => { + passwords.set(windowsKey("supabase"), VALID_TOKEN); + failDeleteAccounts.add(windowsKey("supabase")); + return Effect.gen(function* () { + const { deleteAccessToken } = yield* CommandCredentials; + const exit = yield* Effect.exit(deleteAccessToken); + expect(exit._tag).toBe("Failure"); + if (exit._tag === "Failure") { + expect(JSON.stringify(exit.cause)).toContain("DeleteTokenError"); + } + expect(passwords.has(windowsKey("supabase"))).toBe(true); + }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); + }, + ); it.effect("win32: an enumeration hiccup alone does not block logout", () => { throwOnFindCredentials = true; @@ -699,7 +702,7 @@ describe("commandCredentialsLayer.deleteAccessToken", () => { "win32: an enumeration failure whose target also fails to delete → DeleteTokenError", () => { throwOnFindCredentials = true; - failDeleteAccounts.add(goWindowsKey("supabase")); + failDeleteAccounts.add(windowsKey("supabase")); return Effect.gen(function* () { const { deleteAccessToken } = yield* CommandCredentials; const exit = yield* Effect.exit(deleteAccessToken); @@ -754,50 +757,50 @@ describe("commandCredentialsLayer.deleteAllProjectCredentials", () => { }).pipe(Effect.provide(makeLayer())); }); - it.effect("win32: deletes Go target-shaped project credentials", () => { - passwords.set(goWindowsKey("abcdefghijklmnopqrs1"), "secret-1"); - passwords.set(goWindowsKey("abcdefghijklmnopqrs2"), "secret-2"); + it.effect("win32: deletes legacy target-shaped project credentials", () => { + passwords.set(windowsKey("abcdefghijklmnopqrs1"), "secret-1"); + passwords.set(windowsKey("abcdefghijklmnopqrs2"), "secret-2"); return Effect.gen(function* () { const { deleteAllProjectCredentials } = yield* CommandCredentials; yield* deleteAllProjectCredentials; - expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs1"))).toBe(false); - expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs2"))).toBe(false); + expect(passwords.has(windowsKey("abcdefghijklmnopqrs1"))).toBe(false); + expect(passwords.has(windowsKey("abcdefghijklmnopqrs2"))).toBe(false); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); it.effect( - "win32: a single undecodable entry aborts the Go target sweep without failing logout", + "win32: a single undecodable entry aborts the legacy target sweep without failing logout", () => { - passwords.set(goWindowsKey("abcdefghijklmnopqrs1"), "secret-1"); - passwords.set(goWindowsKey("abcdefghijklmnopqrs2"), "secret-2"); - opaqueAccounts.add(goWindowsKey("abcdefghijklmnopqrs1")); + passwords.set(windowsKey("abcdefghijklmnopqrs1"), "secret-1"); + passwords.set(windowsKey("abcdefghijklmnopqrs2"), "secret-2"); + opaqueAccounts.add(windowsKey("abcdefghijklmnopqrs1")); return Effect.gen(function* () { const { deleteAllProjectCredentials } = yield* CommandCredentials; const exit = yield* Effect.exit(deleteAllProjectCredentials); expect(exit._tag).toBe("Success"); - expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs1"))).toBe(true); - expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs2"))).toBe(true); + expect(passwords.has(windowsKey("abcdefghijklmnopqrs1"))).toBe(true); + expect(passwords.has(windowsKey("abcdefghijklmnopqrs2"))).toBe(true); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }, ); }); describe("commandCredentialsLayer.deleteProjectCredential", () => { - it.effect("win32: deletes a Go target-shaped project credential", () => { - passwords.set(goWindowsKey("abcdefghijklmnopqrs1"), "db-password"); + it.effect("win32: deletes a legacy target-shaped project credential", () => { + passwords.set(windowsKey("abcdefghijklmnopqrs1"), "db-password"); return Effect.gen(function* () { const { deleteProjectCredential } = yield* CommandCredentials; const deleted = yield* deleteProjectCredential("abcdefghijklmnopqrs1"); expect(deleted).toBe(true); - expect(passwords.has(goWindowsKey("abcdefghijklmnopqrs1"))).toBe(false); + expect(passwords.has(windowsKey("abcdefghijklmnopqrs1"))).toBe(false); }).pipe(Effect.provide(makeLayer({ platform: "win32" }))); }); it.effect( "win32: a confirmed project credential whose delete fails → CredentialDeleteError", () => { - passwords.set(goWindowsKey("abcdefghijklmnopqrs1"), "db-password"); - throwOnDeleteAccounts.add(goWindowsKey("abcdefghijklmnopqrs1")); + passwords.set(windowsKey("abcdefghijklmnopqrs1"), "db-password"); + throwOnDeleteAccounts.add(windowsKey("abcdefghijklmnopqrs1")); return Effect.gen(function* () { const { deleteProjectCredential } = yield* CommandCredentials; const exit = yield* Effect.exit(deleteProjectCredential("abcdefghijklmnopqrs1")); 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 84dc920389..33bb385f3f 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 @@ -278,35 +278,32 @@ describe("commandPlatformApiLayer", () => { }); }); - it.effect( - "fails with the invalid-token error when the env token is malformed (Go parity)", - () => { - const http = captureRequests(); - const layer = commandPlatformApiLayer.pipe( - Layer.provide(mockCliSettings({ accessToken: "sbp_not_a_valid_token" })), - Layer.provide(mockCredentials(Option.none())), - Layer.provide(http.layer), - withBaseDeps(), + it.effect("fails with the invalid-token error when the env token is malformed", () => { + const http = captureRequests(); + const layer = commandPlatformApiLayer.pipe( + Layer.provide(mockCliSettings({ accessToken: "sbp_not_a_valid_token" })), + Layer.provide(mockCredentials(Option.none())), + Layer.provide(http.layer), + withBaseDeps(), + ); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + Effect.gen(function* () { + const api = yield* CommandPlatformApi; + return yield* api.v1.listAllProjects(); + }).pipe(Effect.provide(layer)), ); - return Effect.gen(function* () { - const exit = yield* Effect.exit( - Effect.gen(function* () { - const api = yield* CommandPlatformApi; - return yield* api.v1.listAllProjects(); - }).pipe(Effect.provide(layer)), - ); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const errorJson = JSON.stringify(exit.cause); - expect(errorJson).toContain("InvalidAccessTokenError"); - expect(errorJson).toContain("Invalid access token format"); - } - expect(http.requests).toHaveLength(0); - }); - }, - ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const errorJson = JSON.stringify(exit.cause); + expect(errorJson).toContain("InvalidAccessTokenError"); + expect(errorJson).toContain("Invalid access token format"); + } + expect(http.requests).toHaveLength(0); + }); + }); - it.effect("sends Go-style User-Agent and no X-Supabase-Command headers", () => { + it.effect("sends the CLI User-Agent and no X-Supabase-Command headers", () => { const http = captureRequests(); const layer = commandPlatformApiLayer.pipe( Layer.provide( diff --git a/apps/cli/src/cli/complete.ts b/apps/cli/src/cli/complete.ts index c305a00051..ff4eaa4da0 100644 --- a/apps/cli/src/cli/complete.ts +++ b/apps/cli/src/cli/complete.ts @@ -7,7 +7,7 @@ import process from "node:process"; import { QUERY_OUTPUT_FORMATS, RESOURCE_OUTPUT_FORMATS, -} from "../command-internal/go-output-flag.ts"; +} from "../command-internal/output-formats.ts"; import { unwrapParam } from "../command-internal/param-introspection.ts"; import { isValidBase0Int64, parseUintBase0 } from "../command-internal/parse-uint.ts"; import { parseStringSliceFlag } from "../command-internal/string-slice-flag.ts"; @@ -28,7 +28,7 @@ import { analyticsLayer } from "../telemetry/analytics.layer.ts"; import { formatCliError, normalizeCliError } from "../shared/output/normalize-error.ts"; /** - * Implements the shell completion protocol that cobra-generated scripts (`supabase + * Implements the shell completion protocol that the 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 @@ -472,7 +472,7 @@ const COMPLETION_UINT_FLAGS: ReadonlySet = new Set([ ]); /** - * Flags validated against Go duration syntax (see `isValidGoDuration`) rather than this + * Flags validated against duration syntax (see `isValidDuration`) rather than this * tree's plain `Flag.string` declarations. */ const COMPLETION_DURATION_FLAGS: ReadonlySet = new Set([ @@ -483,8 +483,8 @@ const COMPLETION_DURATION_FLAGS: ReadonlySet = new Set([ /** `--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 the Go duration grammar accepts. */ -const GO_DURATION_UNIT_NANOS: ReadonlyMap = new Map([ +/** Nanosecond scale for each unit the duration grammar accepts. */ +const DURATION_UNIT_NANOS: ReadonlyMap = new Map([ ["ns", 1n], ["us", 1_000n], ["µs", 1_000n], // U+00B5 micro sign @@ -495,23 +495,23 @@ const GO_DURATION_UNIT_NANOS: ReadonlyMap = new Map([ ["h", 3_600_000_000_000n], ]); -// Go's duration grammar accumulates into a `uint64` (range-checked against `1<<63` +// The 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; +const DURATION_UINT64_OVERFLOW_BOUND = 1n << 63n; +const DURATION_MAX_INT64 = (1n << 63n) - 1n; function isAsciiDigit(char: string | undefined): boolean { return char !== undefined && char >= "0" && char <= "9"; } /** - * Validates the Go duration syntax (`1h30m`, `1.5h`, `.5s`) these flags accept, returning a + * Validates the 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 + * accumulators to get `uint64` overflow semantics, and a plain `Number` for + * the one step performed 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 { +function isValidDuration(value: string): boolean { let rest = value; let negative = false; if (rest.length > 0 && (rest[0] === "-" || rest[0] === "+")) { @@ -529,9 +529,9 @@ function isValidGoDuration(value: string): boolean { let i = 0; let intPart = 0n; while (isAsciiDigit(rest[i])) { - if (intPart > GO_DURATION_UINT64_OVERFLOW_BOUND / 10n) return false; + if (intPart > DURATION_UINT64_OVERFLOW_BOUND / 10n) return false; intPart = intPart * 10n + BigInt(rest[i] as string); - if (intPart > GO_DURATION_UINT64_OVERFLOW_BOUND) return false; + if (intPart > DURATION_UINT64_OVERFLOW_BOUND) return false; i++; } const hasIntDigits = i > 0; @@ -547,11 +547,11 @@ function isValidGoDuration(value: string): boolean { let fracOverflowed = false; while (isAsciiDigit(rest[j])) { if (!fracOverflowed) { - if (fracPart > GO_DURATION_MAX_INT64 / 10n) { + if (fracPart > DURATION_MAX_INT64 / 10n) { fracOverflowed = true; } else { const next = fracPart * 10n + BigInt(rest[j] as string); - if (next > GO_DURATION_UINT64_OVERFLOW_BOUND) { + if (next > DURATION_UINT64_OVERFLOW_BOUND) { fracOverflowed = true; } else { fracPart = next; @@ -570,24 +570,24 @@ function isValidGoDuration(value: string): boolean { let k = 0; while (k < rest.length && !(rest[k] === "." || isAsciiDigit(rest[k]))) k++; if (k === 0) return false; // missing unit - const unitNanos = GO_DURATION_UNIT_NANOS.get(rest.slice(0, k)); + const unitNanos = DURATION_UNIT_NANOS.get(rest.slice(0, k)); rest = rest.slice(k); if (unitNanos === undefined) return false; // unknown unit - if (intPart > GO_DURATION_UINT64_OVERFLOW_BOUND / unitNanos) return false; + if (intPart > DURATION_UINT64_OVERFLOW_BOUND / unitNanos) return false; let termNanos = intPart * unitNanos; if (fracPart > 0n) { const fractional = Number(fracPart) * (Number(unitNanos) / Number(scale)); termNanos += BigInt(Math.trunc(fractional)); - if (termNanos > GO_DURATION_UINT64_OVERFLOW_BOUND) return false; + if (termNanos > DURATION_UINT64_OVERFLOW_BOUND) return false; } total += termNanos; - if (total > GO_DURATION_UINT64_OVERFLOW_BOUND) return false; + if (total > DURATION_UINT64_OVERFLOW_BOUND) return false; } // 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; + return negative || total <= DURATION_MAX_INT64; } /** @@ -597,11 +597,11 @@ function isValidGoDuration(value: string): boolean { * through `Date#setUTCFullYear`, which — unlike the `Date` constructor — doesn't special-case * a 0-99 year into 1900+year. */ -const GO_RFC3339_PATTERN = +const RFC3339_PATTERN = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:[.,]\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/; -function isValidGoRfc3339(value: string): boolean { - const match = GO_RFC3339_PATTERN.exec(value); +function isValidRfc3339(value: string): boolean { + const match = RFC3339_PATTERN.exec(value); if (match === null) return false; const [, year, month, day, hour, minute, second, offsetHour, offsetMinute] = match; const y = Number(year); @@ -670,10 +670,10 @@ function isValidFlagValue( return "value" in parseUintBase0(value); } if (COMPLETION_DURATION_FLAGS.has(key)) { - return isValidGoDuration(value); + return isValidDuration(value); } if (COMPLETION_RFC3339_FLAGS.has(key)) { - return isValidGoRfc3339(value); + return isValidRfc3339(value); } if ( flag.isVariadic && @@ -684,7 +684,7 @@ function isValidFlagValue( } switch (flag.primitiveTag) { case "Boolean": - return parseGoBool(value) !== undefined; + return parseBoolLiteral(value) !== undefined; case "Choice": if (flag.name === "output") { return outputFlagChoiceKeys(matchedPath).includes(value); @@ -1019,15 +1019,8 @@ export function respondToComplete( }); } -const GO_TRUE_BOOL_SPELLINGS: ReadonlySet = new Set([ - "1", - "t", - "T", - "TRUE", - "true", - "True", -]); -const GO_FALSE_BOOL_SPELLINGS: ReadonlySet = new Set([ +const TRUE_BOOL_SPELLINGS: ReadonlySet = new Set(["1", "t", "T", "TRUE", "true", "True"]); +const FALSE_BOOL_SPELLINGS: ReadonlySet = new Set([ "0", "f", "F", @@ -1036,16 +1029,15 @@ const GO_FALSE_BOOL_SPELLINGS: ReadonlySet = new Set([ "False", ]); -function parseGoBool(value: string): boolean | undefined { - if (GO_TRUE_BOOL_SPELLINGS.has(value)) return true; - if (GO_FALSE_BOOL_SPELLINGS.has(value)) return false; +function parseBoolLiteral(value: string): boolean | undefined { + if (TRUE_BOOL_SPELLINGS.has(value)) return true; + if (FALSE_BOOL_SPELLINGS.has(value)) return false; return undefined; } /** * `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. + * is consulted. An unparseable value is ignored, leaving the `argv[0]`-derived default in place. */ export function resolveIncludeDescriptions( argv0: string | undefined, @@ -1053,8 +1045,8 @@ export function resolveIncludeDescriptions( ): boolean { let includeDescriptions = argv0 !== "__completeNoDesc"; if (includeDescriptions) { - const raw = env.SUPABASE_COMPLETION_DESCRIPTIONS || env.COBRA_COMPLETION_DESCRIPTIONS || ""; - const parsed = parseGoBool(raw); + const raw = env.SUPABASE_COMPLETION_DESCRIPTIONS ?? ""; + const parsed = parseBoolLiteral(raw); if (parsed !== undefined) includeDescriptions = parsed; } return includeDescriptions; diff --git a/apps/cli/src/cli/complete.unit.test.ts b/apps/cli/src/cli/complete.unit.test.ts index 9e38f872b2..e4219d6b14 100644 --- a/apps/cli/src/cli/complete.unit.test.ts +++ b/apps/cli/src/cli/complete.unit.test.ts @@ -228,7 +228,7 @@ describe("respondToComplete", () => { ); }); - it("does not treat a surviving bare `-` leftover as an unmatched command (pflag's stripFlags drops it)", () => { + it("does not treat a surviving bare `-` leftover as an unmatched command (it is dropped)", () => { const result = respondToComplete(rootCommand, ["__complete", "-", "--d"]); expect(result?.candidates.map((c) => c.name)).toEqual( expect.arrayContaining(["--debug", "--dns-resolver"]), @@ -257,7 +257,7 @@ describe("respondToComplete", () => { }); }); - it("short-circuits on a flag Go marks required even though this port made it optional at parse time", () => { + it("short-circuits on a flag marked required even though it is optional at parse time", () => { // Required-ness comes from the explicit `COMPLETION_REQUIRED_FLAGS` table, not from // inferring it off `Flag.optional`. const result = respondToComplete(rootCommand, [ @@ -378,7 +378,7 @@ describe("respondToComplete", () => { }); }); - describe("attached shorthand values resolve via pflag's real strict parser (CLI-1965 review)", () => { + describe("attached shorthand values resolve via the strict flag parser (CLI-1965 review)", () => { it("parses a non-boolean shorthand's attached value instead of treating the token as unknown", () => { const result = respondToComplete(rootCommand, [ "__complete", @@ -504,7 +504,7 @@ describe("respondToComplete", () => { }); }); - describe("uint-backed flags reject a leading sign like real pflag's ParseUint (CLI-1965 review)", () => { + describe("uint-backed flags reject a leading sign (CLI-1965 review)", () => { it.each([ { path: ["functions", "deploy"], flag: "jobs" }, { path: ["migration", "down"], flag: "last" }, @@ -568,14 +568,14 @@ describe("respondToComplete", () => { expect(result?.candidates.map((c) => c.name)).toContain("branches"); }); - it("still accepts json everywhere — the one value both Go enums share", () => { + it("still accepts json everywhere — the one value every output format enum shares", () => { const result = respondToComplete(rootCommand, ["__complete", "--output", "json", ""]); expect(result?.directive).toBe(CompletionDirective.NoFileComp); expect(result?.candidates.map((c) => c.name)).toContain("branches"); }); }); - describe("flag values are validated the way real pflag parses them (CLI-1965 review)", () => { + describe("flag values are validated with the strict flag parser (CLI-1965 review)", () => { it("accepts a base-0 hex value for a plain (non-uint) integer flag", () => { const result = respondToComplete(rootCommand, [ "__complete", @@ -625,7 +625,7 @@ describe("respondToComplete", () => { expect(min?.candidates.map((c) => c.name)).toContain("--profile"); }); - it("rejects a malformed value for Go's DurationVar flags (gen types --query-timeout, gen bearer-jwt --valid-for)", () => { + it("rejects a malformed value for duration flags (gen types --query-timeout, gen bearer-jwt --valid-for)", () => { const queryTimeout = respondToComplete(rootCommand, [ "__complete", "gen", @@ -674,7 +674,7 @@ describe("respondToComplete", () => { expect(validForValid?.candidates.map((c) => c.name)).toContain("--profile"); }); - it("rejects a duration one unit past Go's int64 nanosecond range for Go's DurationVar flags", () => { + it("rejects a duration one unit past the int64 nanosecond range for duration flags", () => { const overflow = respondToComplete(rootCommand, [ "__complete", "gen", @@ -696,7 +696,7 @@ describe("respondToComplete", () => { expect(max?.candidates.map((c) => c.name)).toContain("--local"); }); - it("rejects a malformed value for Go's TimeVar flag (gen bearer-jwt --exp, RFC3339 only)", () => { + it("rejects a malformed value for the time flag (gen bearer-jwt --exp, RFC3339 only)", () => { const invalid = respondToComplete(rootCommand, [ "__complete", "gen", @@ -950,7 +950,7 @@ describe("respondToComplete", () => { }); }); - it("returns undefined for zero completion args (mirrors cobra's MinimumNArgs(1) failure)", () => { + it("returns undefined for zero completion args", () => { expect(respondToComplete(rootCommand, ["__complete"])).toBeUndefined(); }); @@ -1059,7 +1059,7 @@ describe("collectInScopeFlags", () => { ); }); - it("orders flags like cobra's InheritedFlags().VisitAll then NonInheritedFlags().VisitAll — alphabetical within each block, not declaration order (CLI-1965 review)", () => { + it("orders inherited flags before the command's own flags — alphabetical within each block, not declaration order (CLI-1965 review)", () => { const { commandChain } = resolveCommandPath(rootCommand, ["db", "dump"]); const names = collectInScopeFlags(rootCommand, commandChain).map((flag) => flag.name); @@ -1076,7 +1076,7 @@ describe("collectInScopeFlags", () => { expect(ownBlock).toContain("help"); }); - it("orders root's own flags alphabetically end-to-end (InheritedFlags() is empty at root)", () => { + it("orders root's own flags alphabetically end-to-end (no inherited flags at root)", () => { const atRoot = collectInScopeFlags( rootCommand, resolveCommandPath(rootCommand, []).commandChain, @@ -1119,7 +1119,6 @@ describe("resolveIncludeDescriptions", () => { expect( resolveIncludeDescriptions("__completeNoDesc", { SUPABASE_COMPLETION_DESCRIPTIONS: "true", - COBRA_COMPLETION_DESCRIPTIONS: "true", }), ).toBe(false); }); @@ -1130,9 +1129,9 @@ describe("resolveIncludeDescriptions", () => { ).toBe(false); }); - it("falls back to the generic COBRA_COMPLETION_DESCRIPTIONS when the program-specific var is unset", () => { + it("ignores COBRA_COMPLETION_DESCRIPTIONS", () => { expect(resolveIncludeDescriptions("__complete", { COBRA_COMPLETION_DESCRIPTIONS: "0" })).toBe( - false, + true, ); }); @@ -1143,15 +1142,6 @@ describe("resolveIncludeDescriptions", () => { }), ).toBe(true); }); - - it("prioritizes the program-specific var over the generic one when both are set and conflict", () => { - expect( - resolveIncludeDescriptions("__complete", { - SUPABASE_COMPLETION_DESCRIPTIONS: "true", - COBRA_COMPLETION_DESCRIPTIONS: "false", - }), - ).toBe(true); - }); }); describe("formatCompletionResponse", () => { diff --git a/apps/cli/src/cli/root.ts b/apps/cli/src/cli/root.ts index 7604fde4ff..f45c995b96 100644 --- a/apps/cli/src/cli/root.ts +++ b/apps/cli/src/cli/root.ts @@ -209,7 +209,7 @@ export const rootCommandForFeatures = (options: RootCommandFeatures = {}) => const outputFormat = resolveAgentOutputFormat({ explicitOutputFormat, agentDefaultOutputFormat: agentDefaultOutputFormatFor(options, cliArgs.args), - goOutputFormat: resourceOutput, + outputFlagFormat: resourceOutput, agentOverride: agent, detectedAgentName: aiTool.name, isBuiltInTextRequest: isBuiltInTextRequest(cliArgs.args), diff --git a/apps/cli/src/command-internal/api-url.ts b/apps/cli/src/command-internal/api-url.ts index 8a91bbf436..fad07ecae0 100644 --- a/apps/cli/src/command-internal/api-url.ts +++ b/apps/cli/src/command-internal/api-url.ts @@ -1,4 +1,4 @@ -import { goUrlParse } from "./storage-url.ts"; +import { parseUrl } from "./storage-url.ts"; /** * Derives the local API URL: an explicit `api.external_url` wins, otherwise @@ -34,6 +34,6 @@ export function resolveStudioApiUrl( hostname: string, apiExternalUrl: string, ): string { - const { host } = goUrlParse(rawApiUrl); + const { host } = parseUrl(rawApiUrl); return host === "" || host === hostname ? apiExternalUrl : rawApiUrl; } diff --git a/apps/cli/src/command-internal/pflag-reconcile.ts b/apps/cli/src/command-internal/argv-flag-reconcile.ts similarity index 79% rename from apps/cli/src/command-internal/pflag-reconcile.ts rename to apps/cli/src/command-internal/argv-flag-reconcile.ts index 412335c4db..ff1e6bcd39 100644 --- a/apps/cli/src/command-internal/pflag-reconcile.ts +++ b/apps/cli/src/command-internal/argv-flag-reconcile.ts @@ -1,7 +1,7 @@ import { Data, Effect, FileSystem, Option, Path, Result } from "effect"; import { CliArgs } from "../shared/cli/cli-args.service.ts"; -import { lastExplicitLongFlagValue, type PflagArgvScan } from "../shared/cli/cobra-flag-groups.ts"; +import { lastExplicitLongFlagValue, type ArgvFlagScan } from "../shared/cli/flag-groups.ts"; import { ProfileFlag, WorkdirFlag } from "./global-flags.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { @@ -19,12 +19,12 @@ import { validateWorkdirIsDirectory } from "./workdir-validation.ts"; * 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. + * Flows through {@link validateArgvWorkdir}'s inferred Effect error channel; no call site + * imports the class by name. The tag is the telemetry fingerprint and must stay stable. * * @public */ -export class PflagWorkdirError extends Data.TaggedError("PflagWorkdirError")<{ +export class ArgvWorkdirError extends Data.TaggedError("PflagWorkdirError")<{ readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -33,17 +33,16 @@ export class PflagWorkdirError extends Data.TaggedError("PflagWorkdirError")<{ } /** - * 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`). + * Reconciles an Effect-parsed option flag with the raw-argv scan: the flag is only set when + * the scan shows it set, using the scan's own value (last occurrence wins). * - * 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 + * The vendored Effect parser refuses to consume a flag-shaped token as a value, while the raw + * scan consumes it unconditionally — in `--project-ref --metadata-file x.xml --metadata-url u`, + * the scan 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( +export function argvStringValue( occurrences: ReadonlyMap>, flagName: string, ): Option.Option { @@ -52,7 +51,7 @@ export function pflagStringValue( } /** - * Like `pflagStringValue`, but for CSV-accumulating slice flags: every occurrence is + * Like `argvStringValue`, 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). * @@ -60,7 +59,7 @@ export function pflagStringValue( * 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( +export function argvSliceValue( occurrences: ReadonlyMap>, flagName: string, parsedFallback: ReadonlyArray, @@ -83,7 +82,7 @@ export function pflagSliceValue( * default applies, which cannot fail. * * Resolution order: - * - the scan's last `--workdir` occurrence wins — pflag consumes flag-shaped tokens the + * - the scan's last `--workdir` occurrence wins — the scan 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 @@ -92,12 +91,12 @@ export function pflagSliceValue( * - 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, +export function argvWorkdirValue( + scan: Pick, parsedWorkdir: Option.Option, envWorkdir: string | undefined, ): Option.Option { - const scanned = pflagStringValue(scan.occurrences, "workdir"); + const scanned = argvStringValue(scan.occurrences, "workdir"); // 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"); @@ -121,7 +120,7 @@ export function pflagWorkdirValue( } /** - * Validates the workdir {@link pflagWorkdirValue} resolves, aborting before any API call when + * Validates the workdir {@link argvWorkdirValue} 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. @@ -130,19 +129,19 @@ export function pflagWorkdirValue( * may have resolved the workdir from different sources, but they then issue the identical * request regardless. */ -export const validatePflagWorkdir = Effect.fnUntraced(function* ( - scan: Pick, +export const validateArgvWorkdir = Effect.fnUntraced(function* ( + scan: Pick, ) { // `serviceOption`: absent outside the real CLI tree (handler-level tests // provide argv via `Stdio.layerTest`, not the global flag settings). const parsedWorkdir = Option.flatten(yield* Effect.serviceOption(WorkdirFlag)); - const workdir = pflagWorkdirValue(scan, parsedWorkdir, process.env["SUPABASE_WORKDIR"]); + const workdir = argvWorkdirValue(scan, parsedWorkdir, process.env["SUPABASE_WORKDIR"]); if (Option.isNone(workdir)) { return; } const fs = yield* FileSystem.FileSystem; yield* validateWorkdirIsDirectory(workdir.value, fs).pipe( - Effect.mapError((cause) => new PflagWorkdirError({ message: cause.message })), + Effect.mapError((cause) => new ArgvWorkdirError({ message: cause.message })), ); }); @@ -151,8 +150,8 @@ export const validatePflagWorkdir = Effect.fnUntraced(function* ( * it falls through to the persisted `~/.supabase/profile` file and then the `supabase` * default. * - * Resolution order mirrors {@link pflagWorkdirValue}: - * - the scan's last `--profile` occurrence wins — pflag consumes flag-shaped tokens the + * Resolution order mirrors {@link argvWorkdirValue}: + * - the scan's last `--profile` occurrence wins — the scan 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; @@ -164,12 +163,12 @@ export const validatePflagWorkdir = Effect.fnUntraced(function* ( * distinguish an explicit `--profile supabase` from the flag's default, so that value is * treated as unset. */ -export function pflagProfileValue( - scan: Pick, +export function argvProfileValue( + scan: Pick, parsedProfile: Option.Option, envProfile: string | undefined, ): Option.Option { - const scanned = pflagStringValue(scan.occurrences, "profile"); + const scanned = argvStringValue(scan.occurrences, "profile"); // 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. @@ -194,7 +193,7 @@ export function pflagProfileValue( } /** - * Reconciles the profile the Effect config layer resolved with the {@link pflagProfileValue} + * Reconciles the profile the Effect config layer resolved with the {@link argvProfileValue} * 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 @@ -209,15 +208,15 @@ export function pflagProfileValue( * `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, +export const resolveArgvProfile = Effect.fnUntraced(function* ( + scan: Pick, ) { const parsedRaw = yield* Effect.serviceOption(ProfileFlag); const parsedProfile = Option.filter(parsedRaw, (value) => value !== "supabase"); const env = process.env["SUPABASE_PROFILE"]; const envProfile = env !== undefined && env.length > 0 ? env : undefined; - // The explicit token pflag-equivalent semantics resolve, vs. the one the config layer's own + // The explicit token the raw-argv scan resolves, 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. @@ -225,7 +224,7 @@ export const resolvePflagProfile = Effect.fnUntraced(function* ( onNone: () => undefined, onSome: ({ args }) => lastExplicitLongFlagValue(args, [], "profile"), }); - const goExplicit = pflagProfileValue(scan, parsedProfile, envProfile); + const argvExplicit = argvProfileValue(scan, parsedProfile, envProfile); const layerExplicit = scanExplicit !== undefined ? Option.some(scanExplicit) @@ -235,10 +234,10 @@ export const resolvePflagProfile = Effect.fnUntraced(function* ( ? Option.some(envProfile) : Option.none(); if ( - Option.isSome(goExplicit) && + Option.isSome(argvExplicit) && Option.isSome(layerExplicit) && - goExplicit.value === layerExplicit.value && - goExplicit.value !== "" + argvExplicit.value === layerExplicit.value && + argvExplicit.value !== "" ) { return Option.none(); } @@ -257,8 +256,8 @@ export const resolvePflagProfile = Effect.fnUntraced(function* ( .readFileString(profileFilePath(path.value, runtimeInfo.value.homeDir)) .pipe(Effect.option); - const goToken = Option.isSome(goExplicit) - ? goExplicit.value + const argvToken = Option.isSome(argvExplicit) + ? argvExplicit.value : Option.isSome(fileRaw) ? fileRaw.value : "supabase"; @@ -272,14 +271,14 @@ export const resolvePflagProfile = Effect.fnUntraced(function* ( }, }); - if (goToken === layerToken && goToken !== "") { + if (argvToken === layerToken && argvToken !== "") { return Option.none(); } - return Option.some(yield* loadProfile(goToken, fs.value)); + return Option.some(yield* loadProfile(argvToken, fs.value)); }); -/** Accepted literal spellings for a pflag boolean flag value (`strconv.ParseBool`). */ -const GO_PARSE_BOOL: ReadonlyMap = new Map([ +/** Accepted literal spellings for a boolean flag value: `1`, `t`, `T`, `TRUE`, `true`, `True`, `0`, `f`, `F`, `FALSE`, `false`, `False`. */ +const BOOL_LITERALS: ReadonlyMap = new Map([ ["1", true], ["t", true], ["T", true], @@ -295,17 +294,17 @@ const GO_PARSE_BOOL: ReadonlyMap = new Map([ ]); /** - * Like `pflagStringValue`, but for boolean flags: every occurrence is applied in argv order + * Like `argvStringValue`, 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. * * 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 + * flags first-wins (the scan 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( +export function argvBoolValue( occurrences: ReadonlyMap>, flagName: string, ): Result.Result { @@ -315,10 +314,10 @@ export function pflagBoolValue( } let effective = false; for (const raw of values) { - const parsed = GO_PARSE_BOOL.get(raw); + const parsed = BOOL_LITERALS.get(raw); if (parsed === undefined) { return Result.fail( - `invalid argument ${JSON.stringify(raw)} for "--${flagName}" flag: strconv.ParseBool: parsing ${JSON.stringify(raw)}: invalid syntax`, + `invalid argument ${JSON.stringify(raw)} for "--${flagName}" flag: expected a boolean`, ); } effective = parsed; @@ -327,7 +326,7 @@ export function pflagBoolValue( } /** - * Like `pflagStringValue`, but for enum-valued flags: every occurrence must be in `allowed`, + * Like `argvStringValue`, 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 @@ -336,7 +335,7 @@ export function pflagBoolValue( * `flagLabel` names the flag in the error message: `--name` without a shorthand, `-s, --name` * with one. */ -export function pflagEnumValue( +export function argvEnumValue( occurrences: ReadonlyMap>, flagName: string, allowed: ReadonlyArray, diff --git a/apps/cli/src/command-internal/pflag-reconcile.unit.test.ts b/apps/cli/src/command-internal/argv-flag-reconcile.unit.test.ts similarity index 62% rename from apps/cli/src/command-internal/pflag-reconcile.unit.test.ts rename to apps/cli/src/command-internal/argv-flag-reconcile.unit.test.ts index 8b3e8f7d88..2d1d98c584 100644 --- a/apps/cli/src/command-internal/pflag-reconcile.unit.test.ts +++ b/apps/cli/src/command-internal/argv-flag-reconcile.unit.test.ts @@ -10,12 +10,12 @@ import { CliArgs } from "../shared/cli/cli-args.service.ts"; import { ProfileFlag } from "./global-flags.ts"; import { mockRuntimeInfo } from "../../tests/helpers/mocks.ts"; import { - pflagBoolValue, - pflagEnumValue, - pflagProfileValue, - pflagWorkdirValue, - resolvePflagProfile, -} from "./pflag-reconcile.ts"; + argvBoolValue, + argvEnumValue, + argvProfileValue, + argvWorkdirValue, + resolveArgvProfile, +} from "./argv-flag-reconcile.ts"; // Sample enum values for the generic enum-reconciliation helper under test. const NAME_ID_FORMATS = [ @@ -28,76 +28,70 @@ const NAME_ID_FORMATS = [ const occ = (entries: ReadonlyArray]>) => new Map(entries.map(([name, values]) => [name, [...values]])); -describe("pflagBoolValue", () => { - it("is false when the flag never occurs (Go default)", () => { - expect(pflagBoolValue(occ([]), "skip-url-validation")).toEqual(Result.succeed(false)); +describe("argvBoolValue", () => { + it("is false when the flag never occurs", () => { + expect(argvBoolValue(occ([]), "skip-url-validation")).toEqual(Result.succeed(false)); }); - it('treats a bare occurrence (recorded as pflag\'s NoOptDefVal "true") as true', () => { - expect(pflagBoolValue(occ([["skip-url-validation", ["true"]]]), "skip-url-validation")).toEqual( + it('treats a bare occurrence (recorded as "true") as true', () => { + expect(argvBoolValue(occ([["skip-url-validation", ["true"]]]), "skip-url-validation")).toEqual( Result.succeed(true), ); }); - it("resolves repeats last-wins, not first-wins (pflag Sets every occurrence)", () => { + it("resolves repeats last-wins, not first-wins", () => { // --skip-url-validation=false --skip-url-validation expect( - pflagBoolValue(occ([["skip-url-validation", ["false", "true"]]]), "skip-url-validation"), + argvBoolValue(occ([["skip-url-validation", ["false", "true"]]]), "skip-url-validation"), ).toEqual(Result.succeed(true)); // --skip-url-validation --skip-url-validation=false expect( - pflagBoolValue(occ([["skip-url-validation", ["true", "false"]]]), "skip-url-validation"), + argvBoolValue(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", () => { + it("fails on an inline-empty occurrence", () => { // --skip-url-validation=false --skip-url-validation= expect( - pflagBoolValue(occ([["skip-url-validation", ["false", ""]]]), "skip-url-validation"), + argvBoolValue(occ([["skip-url-validation", ["false", ""]]]), "skip-url-validation"), ).toEqual( - Result.fail( - `invalid argument "" for "--skip-url-validation" flag: strconv.ParseBool: parsing "": invalid syntax`, - ), + Result.fail(`invalid argument "" for "--skip-url-validation" flag: expected a boolean`), ); }); - it("accepts exactly Go's strconv.ParseBool literal set", () => { + it("accepts exactly the boolean literal set", () => { for (const raw of ["1", "t", "T", "TRUE", "true", "True"]) { - expect(pflagBoolValue(occ([["f", [raw]]]), "f")).toEqual(Result.succeed(true)); + expect(argvBoolValue(occ([["f", [raw]]]), "f")).toEqual(Result.succeed(true)); } for (const raw of ["0", "f", "F", "FALSE", "false", "False"]) { - expect(pflagBoolValue(occ([["f", [raw]]]), "f")).toEqual(Result.succeed(false)); + expect(argvBoolValue(occ([["f", [raw]]]), "f")).toEqual(Result.succeed(false)); } }); - it("fails with pflag's byte-exact invalid-argument message on the first bad occurrence", () => { - 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`, - ), + it("fails with the invalid-argument message on the first bad occurrence", () => { + expect(argvBoolValue(occ([["skip-url-validation", ["yes"]]]), "skip-url-validation")).toEqual( + Result.fail(`invalid argument "yes" for "--skip-url-validation" flag: expected a boolean`), ); expect( - pflagBoolValue(occ([["skip-url-validation", ["true", "no"]]]), "skip-url-validation"), + argvBoolValue(occ([["skip-url-validation", ["true", "no"]]]), "skip-url-validation"), ).toEqual( - Result.fail( - `invalid argument "no" for "--skip-url-validation" flag: strconv.ParseBool: parsing "no": invalid syntax`, - ), + Result.fail(`invalid argument "no" for "--skip-url-validation" flag: expected a boolean`), ); }); }); -describe("pflagEnumValue", () => { +describe("argvEnumValue", () => { it("is none when the flag never occurs", () => { - expect(pflagEnumValue(occ([]), "name-id-format", NAME_ID_FORMATS)).toEqual( + expect(argvEnumValue(occ([]), "name-id-format", NAME_ID_FORMATS)).toEqual( Result.succeed(Option.none()), ); }); - it("resolves repeats last-wins, matching pflag", () => { + it("resolves repeats last-wins", () => { const persistent = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"; const transient = "urn:oasis:names:tc:SAML:2.0:nameid-format:transient"; expect( - pflagEnumValue( + argvEnumValue( occ([["name-id-format", [transient, persistent]]]), "name-id-format", NAME_ID_FORMATS, @@ -105,10 +99,10 @@ describe("pflagEnumValue", () => { ).toEqual(Result.succeed(Option.some(persistent))); }); - it("fails with the Go enum Set message when any occurrence is invalid", () => { + it("fails with the enum message when any occurrence is invalid", () => { const persistent = "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent"; expect( - pflagEnumValue( + argvEnumValue( occ([["name-id-format", [persistent, "bogus"]]]), "name-id-format", NAME_ID_FORMATS, @@ -120,14 +114,14 @@ describe("pflagEnumValue", () => { ); }); - it("names the flag with its shorthand when a label is given (pflag errors.go:39-41)", () => { - expect(pflagEnumValue(occ([["type", ["bogus"]]]), "type", ["saml"], "-t, --type")).toEqual( + it("names the flag with its shorthand when a label is given", () => { + expect(argvEnumValue(occ([["type", ["bogus"]]]), "type", ["saml"], "-t, --type")).toEqual( Result.fail(`invalid argument "bogus" for "-t, --type" flag: must be one of [ saml ]`), ); }); }); -describe("pflagWorkdirValue", () => { +describe("argvWorkdirValue", () => { const scan = ( entries: ReadonlyArray]>, consumed: ReadonlyArray = [], @@ -138,10 +132,10 @@ describe("pflagWorkdirValue", () => { prePathOccurrences: occ(prePath), }); - it("resolves pre-path repeats last-wins, like pflag (the parser is first-wins)", () => { + it("resolves pre-path repeats last-wins (the parser is first-wins)", () => { // --workdir /existing --workdir /missing sso add … expect( - pflagWorkdirValue( + argvWorkdirValue( scan([], [], [["workdir", ["/existing", "/missing"]]]), Option.some("/existing"), undefined, @@ -151,17 +145,13 @@ describe("pflagWorkdirValue", () => { it("keeps a pre-path occurrence when the only post-path workdir token was consumed", () => { expect( - pflagWorkdirValue( - scan([], ["workdir"], [["workdir", ["/pre"]]]), - Option.some("/pre"), - "/env", - ), + argvWorkdirValue(scan([], ["workdir"], [["workdir", ["/pre"]]]), Option.some("/pre"), "/env"), ).toEqual(Option.some("/pre")); }); it("post-path occurrences still win over pre-path ones (argv-order last-wins)", () => { expect( - pflagWorkdirValue( + argvWorkdirValue( scan([["workdir", ["/post"]]], [], [["workdir", ["/pre"]]]), Option.some("/pre"), undefined, @@ -169,57 +159,57 @@ describe("pflagWorkdirValue", () => { ).toEqual(Option.some("/post")); }); - it("resolves nothing when no flag, parsed value, or env var is present (Go walks up)", () => { - expect(pflagWorkdirValue(scan([]), Option.none(), undefined)).toEqual(Option.none()); + it("resolves nothing when no flag, parsed value, or env var is present", () => { + expect(argvWorkdirValue(scan([]), Option.none(), undefined)).toEqual(Option.none()); }); it("prefers the scan's occurrence over the parsed flag and the env var", () => { // --workdir --metadata-file … expect( - pflagWorkdirValue(scan([["workdir", ["--metadata-file"]]]), Option.none(), "/env"), + argvWorkdirValue(scan([["workdir", ["--metadata-file"]]]), Option.none(), "/env"), ).toEqual(Option.some("--metadata-file")); }); - it("resolves repeats last-wins, matching pflag StringVar", () => { + it("resolves repeats last-wins", () => { expect( - pflagWorkdirValue(scan([["workdir", ["/a", "/b"]]]), Option.some("/a"), undefined), + argvWorkdirValue(scan([["workdir", ["/a", "/b"]]]), Option.some("/a"), undefined), ).toEqual(Option.some("/b")); }); it("falls back to the parsed flag when the anchored scan saw no occurrence (pre-path --workdir)", () => { - expect(pflagWorkdirValue(scan([]), Option.some("/pre-path"), "/env")).toEqual( + expect(argvWorkdirValue(scan([]), Option.some("/pre-path"), "/env")).toEqual( Option.some("/pre-path"), ); }); it("ignores the parsed flag when the --workdir token was consumed by another flag, falling to the env var", () => { // --domains --workdir /x - expect(pflagWorkdirValue(scan([], ["workdir"]), Option.some("/x"), "/env")).toEqual( + expect(argvWorkdirValue(scan([], ["workdir"]), Option.some("/x"), "/env")).toEqual( Option.some("/env"), ); - expect(pflagWorkdirValue(scan([], ["workdir"]), Option.some("/x"), undefined)).toEqual( + expect(argvWorkdirValue(scan([], ["workdir"]), Option.some("/x"), undefined)).toEqual( Option.none(), ); }); it("uses the env var when neither the scan nor the parser saw the flag", () => { - expect(pflagWorkdirValue(scan([]), Option.none(), "/env")).toEqual(Option.some("/env")); + expect(argvWorkdirValue(scan([]), Option.none(), "/env")).toEqual(Option.some("/env")); }); - it("treats a changed-but-empty flag as the walk-up default, shadowing the env var (viper precedence)", () => { + it("treats a changed-but-empty flag as the walk-up default, shadowing the env var", () => { // --workdir= - expect(pflagWorkdirValue(scan([["workdir", [""]]]), Option.none(), "/env")).toEqual( + expect(argvWorkdirValue(scan([["workdir", [""]]]), Option.none(), "/env")).toEqual( Option.none(), ); - expect(pflagWorkdirValue(scan([]), Option.some(""), "/env")).toEqual(Option.none()); + expect(argvWorkdirValue(scan([]), Option.some(""), "/env")).toEqual(Option.none()); }); it("treats an empty env var as unset", () => { - expect(pflagWorkdirValue(scan([]), Option.none(), "")).toEqual(Option.none()); + expect(argvWorkdirValue(scan([]), Option.none(), "")).toEqual(Option.none()); }); }); -describe("pflagProfileValue", () => { +describe("argvProfileValue", () => { const scan = ( entries: ReadonlyArray]>, consumed: ReadonlyArray = [], @@ -233,7 +223,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 expect( - pflagProfileValue( + argvProfileValue( scan([], ["profile"], [["profile", ["a.yml"]]]), Option.some("a.yml"), "env.yml", @@ -241,9 +231,9 @@ describe("pflagProfileValue", () => { ).toEqual(Option.some("a.yml")); }); - it("resolves pre-path repeats last-wins, like pflag (the parser is first-wins)", () => { + it("resolves pre-path repeats last-wins (the parser is first-wins)", () => { expect( - pflagProfileValue( + argvProfileValue( scan([], [], [["profile", ["a.yml", "b.yml"]]]), Option.some("a.yml"), undefined, @@ -253,7 +243,7 @@ describe("pflagProfileValue", () => { it("post-path occurrences still win over pre-path ones (argv-order last-wins)", () => { expect( - pflagProfileValue( + argvProfileValue( scan([["profile", ["post.yml"]]], [], [["profile", ["pre.yml"]]]), Option.some("pre.yml"), undefined, @@ -261,37 +251,37 @@ describe("pflagProfileValue", () => { ).toEqual(Option.some("post.yml")); }); - it("resolves nothing when no flag, parsed value, or env var is present (Go falls to the file/default)", () => { - expect(pflagProfileValue(scan([]), Option.none(), undefined)).toEqual(Option.none()); + it("resolves nothing when no flag, parsed value, or env var is present (falls to the file/default)", () => { + expect(argvProfileValue(scan([]), Option.none(), undefined)).toEqual(Option.none()); }); it("prefers the scan's occurrence over the parsed flag and the env var", () => { // --profile --metadata-url … expect( - pflagProfileValue(scan([["profile", ["--metadata-url"]]]), Option.none(), "env.yml"), + argvProfileValue(scan([["profile", ["--metadata-url"]]]), Option.none(), "env.yml"), ).toEqual(Option.some("--metadata-url")); }); - it("resolves repeats last-wins, matching pflag StringVar (the parser is first-wins)", () => { + it("resolves repeats last-wins (the parser is first-wins)", () => { expect( - pflagProfileValue(scan([["profile", ["a.yml", "b.yml"]]]), Option.some("a.yml"), undefined), + argvProfileValue(scan([["profile", ["a.yml", "b.yml"]]]), Option.some("a.yml"), undefined), ).toEqual(Option.some("b.yml")); }); - it("keeps an explicit scanned `supabase` — pflag marks it changed, shadowing the env var", () => { - expect(pflagProfileValue(scan([["profile", ["supabase"]]]), Option.none(), "env.yml")).toEqual( + it("keeps an explicit scanned `supabase` — it counts as set, shadowing the env var", () => { + expect(argvProfileValue(scan([["profile", ["supabase"]]]), Option.none(), "env.yml")).toEqual( Option.some("supabase"), ); }); - it("keeps a changed-but-empty occurrence — Go fails LoadProfile on it, never falling to the env", () => { - expect(pflagProfileValue(scan([["profile", [""]]]), Option.none(), "env.yml")).toEqual( + it("keeps a changed-but-empty occurrence — profile loading fails on it, never falling to the env", () => { + expect(argvProfileValue(scan([["profile", [""]]]), Option.none(), "env.yml")).toEqual( Option.some(""), ); }); it("falls back to the parsed flag when the anchored scan saw no occurrence (pre-path --profile)", () => { - expect(pflagProfileValue(scan([]), Option.some("pre.yml"), "env.yml")).toEqual( + expect(argvProfileValue(scan([]), Option.some("pre.yml"), "env.yml")).toEqual( Option.some("pre.yml"), ); }); @@ -299,23 +289,23 @@ 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 expect( - pflagProfileValue(scan([], ["profile"]), Option.some("alternate.yml"), "env.yml"), + argvProfileValue(scan([], ["profile"]), Option.some("alternate.yml"), "env.yml"), ).toEqual(Option.some("env.yml")); expect( - pflagProfileValue(scan([], ["profile"]), Option.some("alternate.yml"), undefined), + argvProfileValue(scan([], ["profile"]), Option.some("alternate.yml"), undefined), ).toEqual(Option.none()); }); it("uses the env var when neither the scan nor the parser saw the flag", () => { - expect(pflagProfileValue(scan([]), Option.none(), "env.yml")).toEqual(Option.some("env.yml")); + expect(argvProfileValue(scan([]), Option.none(), "env.yml")).toEqual(Option.some("env.yml")); }); it("treats an empty env var as unset", () => { - expect(pflagProfileValue(scan([]), Option.none(), "")).toEqual(Option.none()); + expect(argvProfileValue(scan([]), Option.none(), "")).toEqual(Option.none()); }); }); -describe("resolvePflagProfile", () => { +describe("resolveArgvProfile", () => { const withEnvProfile = (value: string, effect: Effect.Effect) => { const prev = process.env["SUPABASE_PROFILE"]; process.env["SUPABASE_PROFILE"] = value; @@ -343,7 +333,7 @@ describe("resolvePflagProfile", () => { it.effect( "re-loads the env profile when the layer's scan wrongly shadowed a consumed token", () => { - const dir = mkdtempSync(join(tmpdir(), "supabase-pflag-reconcile-")); + const dir = mkdtempSync(join(tmpdir(), "supabase-argv-flag-reconcile-")); const profilePath = join(dir, "env.yml"); writeFileSync( profilePath, @@ -357,7 +347,7 @@ describe("resolvePflagProfile", () => { return withEnvProfile( profilePath, Effect.gen(function* () { - const resolved = yield* resolvePflagProfile({ + const resolved = yield* resolveArgvProfile({ occurrences: new Map(), consumedFlagNames: new Set(["profile"]), prePathOccurrences: new Map(), @@ -378,11 +368,11 @@ describe("resolvePflagProfile", () => { ); it.effect("returns none when the scan and the layer agree on an explicit supabase", () => { - const dir = mkdtempSync(join(tmpdir(), "supabase-pflag-reconcile-")); + const dir = mkdtempSync(join(tmpdir(), "supabase-argv-flag-reconcile-")); return withEnvProfile( "rogue-profile", Effect.gen(function* () { - const resolved = yield* resolvePflagProfile({ + const resolved = yield* resolveArgvProfile({ occurrences: new Map([["profile", ["supabase"]]]), consumedFlagNames: new Set(), prePathOccurrences: new Map(), diff --git a/apps/cli/src/command-internal/go-quote.ts b/apps/cli/src/command-internal/byte-quote.ts similarity index 54% rename from apps/cli/src/command-internal/go-quote.ts rename to apps/cli/src/command-internal/byte-quote.ts index dd72c5646b..e444908f14 100644 --- a/apps/cli/src/command-internal/go-quote.ts +++ b/apps/cli/src/command-internal/byte-quote.ts @@ -1,19 +1,16 @@ /** - * `strconv.Quote` (the `%q` verb) over raw UTF-8 bytes, shared by every error message that must - * reproduce a `%q` interpolation byte-for-byte. + * Double-quotes a string over raw UTF-8 bytes for error messages. * - * 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. + * Operates on bytes, not JS strings: a byte slice can split a multibyte code point (rendered as + * `\xNN` per orphan byte). Callers with a whole JS string encode it first; invalid UTF-8 in + * `process.argv` has already been replaced with U+FFFD by then. */ /** - * `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`. + * Decodes the UTF-8 code point at `i`, returning its value and byte length, or `cp: -1` with + * `size: 1` for an invalid or truncated sequence. */ -function decodeUtf8Rune( +function decodeUtf8CodePoint( bytes: Uint8Array, i: number, ): { readonly cp: number; readonly size: number } { @@ -47,12 +44,12 @@ function decodeUtf8Rune( return { cp, size: extra + 1 }; } -// 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; +// Printable code points ≥ 0x80: letters, marks, numbers, punctuation, symbols (ASCII is handled +// explicitly in quoteBytes). Unicode-table drift between engines only affects which escape a garbage +// code point gets in one error message. +const PRINTABLE_RE = /[\p{L}\p{M}\p{N}\p{P}\p{S}]/u; -const GO_ESCAPES: Readonly> = { +const ESCAPE_SHORTHANDS: Readonly> = { 0x07: "\\a", 0x08: "\\b", 0x0c: "\\f", @@ -63,26 +60,27 @@ const GO_ESCAPES: Readonly> = { }; /** - * `%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`. + * Wraps the bytes in double quotes. Valid printable code points print literally, control + * characters use the `\a \b \f \n \r \t \v` shorthands, each invalid byte becomes `\xNN`, and + * other non-printable code points become `\uNNNN` or `\UNNNNNNNN`. */ -export function goQuote(bytes: Uint8Array): string { +export function quoteBytes(bytes: Uint8Array): string { let out = '"'; for (let i = 0; i < bytes.length;) { - const { cp, size } = decodeUtf8Rune(bytes, i); + const { cp, size } = decodeUtf8CodePoint(bytes, i); if (cp === -1) { out += `\\x${(bytes[i] ?? 0).toString(16).padStart(2, "0")}`; i += 1; continue; } i += size; - const escape = GO_ESCAPES[cp]; + const escape = ESCAPE_SHORTHANDS[cp]; const ch = String.fromCodePoint(cp); if (ch === '"' || ch === "\\") out += `\\${ch}`; else if (escape !== undefined) out += escape; else if (cp >= 0x20 && cp < 0x7f) out += ch; else if (cp < 0x80) out += `\\x${cp.toString(16).padStart(2, "0")}`; - else if (GO_PRINTABLE_RE.test(ch)) out += ch; + else if (PRINTABLE_RE.test(ch)) out += ch; else if (cp < 0x10000) out += `\\u${cp.toString(16).padStart(4, "0")}`; else out += `\\U${cp.toString(16).padStart(8, "0")}`; } diff --git a/apps/cli/src/command-internal/cli-config-load.ts b/apps/cli/src/command-internal/cli-config-load.ts new file mode 100644 index 0000000000..55dd5d2c85 --- /dev/null +++ b/apps/cli/src/command-internal/cli-config-load.ts @@ -0,0 +1,37 @@ +import type { CliProjectEnvironment } from "@supabase/config"; +import { + decodeCliConfigDocumentForValidationEffect as decodeDocumentWithCompat, + loadCliConfig as loadConfigWithCompat, + resolveCliConfigSubtree as resolveSubtreeWithCompat, + resolveCliConfigValue as resolveValueWithCompat, + type DecodeCliConfigDocumentForValidationEffectOptions, + type InternalLoadCliConfigOptions, +} from "@supabase/config/internal"; + +export type CliConfigLoadOptions = Omit; +export type CliConfigValidationOptions = Omit< + DecodeCliConfigDocumentForValidationEffectOptions, + "cliCompat" +>; + +/** The single `apps/cli` entrypoint that loads config with the CLI's own loader semantics. */ +export const loadCliConfig = (cwd: string, options?: CliConfigLoadOptions) => + loadConfigWithCompat(cwd, { ...options, cliCompat: true }); + +/** Decodes a pending document with the same semantics as {@link loadCliConfig}. */ +export const decodeCliConfigDocumentForValidation = ( + document: Record, + options: CliConfigValidationOptions, +) => decodeDocumentWithCompat(document, { ...options, cliCompat: true }); + +export const resolveCliConfigValue = ( + value: T, + cliProjectEnv: Pick, + configPath: string, +) => resolveValueWithCompat(value, cliProjectEnv, configPath, { cliCompat: true }); + +export const resolveCliConfigSubtree = ( + value: T, + cliProjectEnv: Pick, + pathPrefix: string, +) => resolveSubtreeWithCompat(value, cliProjectEnv, pathPrefix, { cliCompat: true }); diff --git a/apps/cli/src/command-internal/config-pull-run.ts b/apps/cli/src/command-internal/config-pull-run.ts index 8327855dba..167d3f415b 100644 --- a/apps/cli/src/command-internal/config-pull-run.ts +++ b/apps/cli/src/command-internal/config-pull-run.ts @@ -9,11 +9,9 @@ import { } from "@supabase/config/effect"; import { applyConfigEdits, - decodeCliConfigDocumentForValidationEffect, writeCliConfigDocumentText, type ConfigEdit, type ConfigEditRefusalReason, - type DecodeCliConfigDocumentForValidationEffectOptions, } from "@supabase/config/internal"; import type { ConfigChange } from "@supabase/config"; import { operationDefinitions } from "@supabase/api/effect"; @@ -22,6 +20,10 @@ import { Effect, FileSystem, Result, Schema, SchemaIssue } from "effect"; import { CommandPlatformApi } from "../auth/command-platform-api.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; import { Output } from "../shared/output/output.service.ts"; +import { + decodeCliConfigDocumentForValidation, + type CliConfigValidationOptions, +} from "./cli-config-load.ts"; import { sanitizeErrorBody, sanitizeInlineName } from "./http-errors.ts"; import type { ConfigTarget } from "./project-target.ts"; import { BRANCH_UUID_PATTERN } from "./ref-patterns.ts"; @@ -298,7 +300,7 @@ function configPullChangeRelativeValue( } /** - * Converts a failed {@link decodeCliConfigDocumentForValidationEffect} attempt's + * Converts a failed {@link decodeCliConfigDocumentForValidation} 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 @@ -372,16 +374,16 @@ function configPullFamiliesForChangePaths( } /** - * Runs {@link decodeCliConfigDocumentForValidationEffect}, capturing only its own + * Runs {@link decodeCliConfigDocumentForValidation}, 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, - options: DecodeCliConfigDocumentForValidationEffectOptions, + options: CliConfigValidationOptions, ) { - return decodeCliConfigDocumentForValidationEffect(document, options).pipe( + return decodeCliConfigDocumentForValidation(document, options).pipe( Effect.map(Result.succeed), Effect.catchTag("CliConfigParseError", (cause) => Effect.succeed(Result.fail(cause))), ); @@ -389,7 +391,7 @@ function decodeConfigPullValidation( /** * The change-path keys ({@link configPathKey}) that already fail - * {@link decodeCliConfigDocumentForValidationEffect} in `rawDocument` as it sits on disk + * {@link decodeCliConfigDocumentForValidation} 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 @@ -406,7 +408,6 @@ const configPullPreExistingFailingChangePathKeys = Effect.fnUntraced(function* ( const rawDecoded = yield* decodeConfigPullValidation(input.rawDocument, { path: input.configPath, format: input.format, - goViperCompat: true, }); if (Result.isFailure(rawDecoded)) { for (const path of configPullSchemaIssueChangePaths( @@ -420,7 +421,6 @@ const configPullPreExistingFailingChangePathKeys = Effect.fnUntraced(function* ( const mergedDecoded = yield* decodeConfigPullValidation(input.rawDocument, { path: input.configPath, format: input.format, - goViperCompat: true, remoteName: input.destination.label, }); if (Result.isFailure(mergedDecoded)) { @@ -471,14 +471,12 @@ const validateConfigPullPlan = Effect.fnUntraced(function* (input: { input.projectRef, ); const rawDecoded = yield* decodeConfigPullValidation(document, { - goViperCompat: true, path: input.configPath, format: input.format, }); const mergedDecoded = destination.kind === "remote" ? yield* decodeConfigPullValidation(document, { - goViperCompat: true, path: input.configPath, format: input.format, remoteName: destination.label, diff --git a/apps/cli/src/command-internal/config-validate.ts b/apps/cli/src/command-internal/config-validate.ts index 9549248426..d02d21efa3 100644 --- a/apps/cli/src/command-internal/config-validate.ts +++ b/apps/cli/src/command-internal/config-validate.ts @@ -9,7 +9,7 @@ import { } from "../shared/telemetry/error-actionability.ts"; import { canonicalPathForContainment, isPathContainedInRoot } from "./path-containment.ts"; import { BRANCH_PROJECT_REF_PATTERN } from "./ref-patterns.ts"; -import { goUrlParse } from "./storage-url.ts"; +import { parseUrl } from "./storage-url.ts"; /** * Single home for config validation, shared by the two config readers: @@ -45,19 +45,20 @@ export const HOOK_SECRET_PATTERN = /^v1,whsec_[A-Za-z0-9+/=]{32,88}$/u; export const CLERK_DOMAIN_PATTERN = /^(clerk([.][a-z0-9-]+){2,}|([a-z0-9-]+[.])+clerk[.]accounts[.]dev)$/u; -// Accepted boolean string forms, matching Go's `strconv.ParseBool`; any other value is a -// parse error. -const GO_BOOL_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); -const GO_BOOL_FALSE = new Set(["0", "f", "F", "FALSE", "false", "False", ""]); +// Accepted boolean string forms (`1`/`t`/`T`/`TRUE`/`true`/`True`, `0`/`f`/`F`/`FALSE`/`false`/`False`, +// and the empty string as false); any other value is a parse error. +const BOOL_TRUE_LITERALS = new Set(["1", "t", "T", "TRUE", "true", "True"]); +const BOOL_FALSE_LITERALS = new Set(["0", "f", "F", "FALSE", "false", "False", ""]); /** - * Parses a config bool value: accepts the same string forms as `strconv.ParseBool`, returns + * Parses a config bool value: accepts `1`/`t`/`T`/`TRUE`/`true`/`True` and + * `0`/`f`/`F`/`FALSE`/`false`/`False` (and the empty string as false), returns * `undefined` for anything else (surfaced by callers as a `failed to parse config` error). * Used by both D and L for `SUPABASE_*` bool-flavored env overrides and TOML bool decoding. */ -export function parseGoBool(value: string): boolean | undefined { - if (GO_BOOL_TRUE.has(value)) return true; - if (GO_BOOL_FALSE.has(value)) return false; +export function parseBoolLiteral(value: string): boolean | undefined { + if (BOOL_TRUE_LITERALS.has(value)) return true; + if (BOOL_FALSE_LITERALS.has(value)) return false; return undefined; } @@ -312,7 +313,7 @@ export function validateResolvedConfig(input: ConfigValidationInput): void { throw new ConfigValidateError("Missing required field in config: studio.port"); } try { - goUrlParse(input.studio.apiUrl); + parseUrl(input.studio.apiUrl); } catch (cause) { throw new ConfigValidateError(`Invalid config for studio.api_url: ${messageOf(cause)}`); } @@ -340,7 +341,7 @@ export function validateResolvedConfig(input: ConfigValidationInput): void { provider !== "turnstile" ) { throw new ConfigValidateError( - "failed to parse config: decoding failed due to the following error(s):\n\n'auth.captcha.provider' must be one of [hcaptcha turnstile]", + "failed to parse config:\nauth.captcha.provider: must be one of hcaptcha, turnstile", ); } if (auth.captcha.enabled) { @@ -377,12 +378,12 @@ export function validateResolvedConfig(input: ConfigValidationInput): void { `Missing required field in config: auth.hook.${hook.type}.uri`, ); } - // Uses `goUrlParse`'s stricter semantics (the same port used for `studio.api_url` + // Uses `parseUrl`'s stricter semantics (the same port used for `studio.api_url` // above) so a malformed URI like an unterminated IPv6 host (`http://[::1`) fails the // whole load instead of passing a bare scheme-prefix regex. let scheme: string; try { - scheme = goUrlParse(hook.uri).scheme; + scheme = parseUrl(hook.uri).scheme; } catch (cause) { throw new ConfigValidateError(`failed to parse template url: ${messageOf(cause)}`); } @@ -537,7 +538,7 @@ export function validateResolvedConfig(input: ConfigValidationInput): void { backend !== "bigquery" ) { throw new ConfigValidateError( - "failed to parse config: decoding failed due to the following error(s):\n\n'analytics.backend' must be one of [postgres bigquery]", + "failed to parse config:\nanalytics.backend: must be one of postgres, bigquery", ); } if (input.analytics.enabled && backend === "bigquery") { diff --git a/apps/cli/src/command-internal/config-validate.unit.test.ts b/apps/cli/src/command-internal/config-validate.unit.test.ts index 6140bcd76f..9ad09db7eb 100644 --- a/apps/cli/src/command-internal/config-validate.unit.test.ts +++ b/apps/cli/src/command-internal/config-validate.unit.test.ts @@ -21,27 +21,27 @@ import { ConfigValidateError, type AuthInput, type ConfigValidationInput, - parseGoBool, + parseBoolLiteral, resolveEmailTemplateContentPath, validateResolvedConfig, } from "./config-validate.ts"; -describe("parseGoBool", () => { - it("accepts Go's strconv.ParseBool true forms", () => { +describe("parseBoolLiteral", () => { + it("accepts the true forms", () => { for (const value of ["1", "t", "T", "TRUE", "true", "True"]) { - expect(parseGoBool(value)).toBe(true); + expect(parseBoolLiteral(value)).toBe(true); } }); - it("accepts Go's strconv.ParseBool false forms, including the empty string", () => { + it("accepts the false forms, including the empty string", () => { for (const value of ["0", "f", "F", "FALSE", "false", "False", ""]) { - expect(parseGoBool(value)).toBe(false); + expect(parseBoolLiteral(value)).toBe(false); } }); - it("returns undefined for a value outside Go's strconv.ParseBool acceptance set", () => { - expect(parseGoBool("yes")).toBeUndefined(); - expect(parseGoBool("2")).toBeUndefined(); + it("returns undefined for a value outside the accepted set", () => { + expect(parseBoolLiteral("yes")).toBeUndefined(); + expect(parseBoolLiteral("2")).toBeUndefined(); }); }); @@ -57,11 +57,11 @@ describe("PROJECT_REF_PATTERN", () => { }); describe("BUCKET_NAME_PATTERN", () => { - it("matches Go-legal bucket name characters", () => { + it("matches legal bucket name characters", () => { expect(BUCKET_NAME_PATTERN.test("my-bucket.1")).toBe(true); }); - it("rejects characters outside Go's bucketNamePattern", () => { + it("rejects characters outside the bucket name pattern", () => { expect(BUCKET_NAME_PATTERN.test("bad#name")).toBe(false); expect(BUCKET_NAME_PATTERN.test("bad/name")).toBe(false); }); @@ -84,7 +84,7 @@ describe("HOOK_SECRET_PATTERN", () => { expect(HOOK_SECRET_PATTERN.test(`v1,whsec_${"a".repeat(32)}`)).toBe(true); }); - it("rejects a secret that doesn't match Go's hookSecretPattern", () => { + it("rejects a secret that doesn't match the hook secret pattern", () => { expect(HOOK_SECRET_PATTERN.test("not-a-valid-secret")).toBe(false); }); }); @@ -98,7 +98,7 @@ describe("CLERK_DOMAIN_PATTERN", () => { expect(CLERK_DOMAIN_PATTERN.test("example.clerk.accounts.dev")).toBe(true); }); - it("rejects a domain that doesn't match Go's clerkDomainPattern", () => { + it("rejects a domain that doesn't match the clerk domain pattern", () => { expect(CLERK_DOMAIN_PATTERN.test("not-a-clerk-domain")).toBe(false); }); }); @@ -444,7 +444,7 @@ describe("validateResolvedConfig", () => { ).toThrow("Missing required field in config: db.major_version"); }); - it("rejects the unsupported Postgres 12.x major_version with Go's dedicated message", () => { + it("rejects the unsupported Postgres 12.x major_version with its dedicated message", () => { expect(() => validateResolvedConfig(minimalInput({ db: { port: 5432, majorVersion: 12 } })), ).toThrow("Postgres version 12.x is unsupported."); @@ -464,7 +464,7 @@ describe("validateResolvedConfig", () => { }); describe("storage.buckets", () => { - it("rejects a bucket name Go's ValidateBucketName refuses", () => { + it("rejects an invalid bucket name", () => { expect(() => validateResolvedConfig(minimalInput({ storageBucketNames: ["bad/name"] })), ).toThrow("Invalid Bucket name: bad/name."); @@ -948,7 +948,7 @@ describe("validateResolvedConfig", () => { ).toThrow("Missing required field in config: auth.hook.custom_access_token.secrets"); }); - it("rejects an http(s) hook secret that doesn't match Go's hookSecretPattern", () => { + it("rejects an http(s) hook secret that doesn't match the hook secret pattern", () => { expect(() => validateResolvedConfig( minimalInput({ @@ -1034,7 +1034,7 @@ describe("validateResolvedConfig", () => { ).toThrow("auth.hook.custom_access_token.uri should be a HTTP, HTTPS, or pg-functions URI"); }); - it("rejects a hook uri that fails Go's url.Parse (malformed IPv6 host)", () => { + it("rejects a hook uri that fails URL parsing (malformed IPv6 host)", () => { expect(() => validateResolvedConfig( minimalInput({ @@ -1150,7 +1150,7 @@ describe("validateResolvedConfig", () => { ).toThrow("Invalid config: auth.third_party.clerk is enabled but without a domain."); }); - it("rejects clerk enabled with a domain that doesn't match Go's clerkDomainPattern", () => { + it("rejects clerk enabled with a domain that doesn't match the clerk domain pattern", () => { expect(() => validateResolvedConfig( minimalInput({ @@ -1211,7 +1211,7 @@ describe("validateResolvedConfig", () => { }); describe("functions.*", () => { - it("rejects a function slug Go's ValidateFunctionSlug refuses", () => { + it("rejects an invalid function slug", () => { expect(() => validateResolvedConfig(minimalInput({ functionSlugs: ["1bad"] }))).toThrow( "Invalid Function name: 1bad.", ); @@ -1269,13 +1269,13 @@ describe("validateResolvedConfig", () => { // Coverage for behavior only meaningfully testable at this shared layer (e.g. the captcha // enum), not moved from either caller's own suite. describe("Config.Validate divergence regression coverage", () => { - it("throws the Go-parity missing-required message for db.major_version = 0 (regression for the D fix in 0c62a914)", () => { + it("throws the missing-required message for db.major_version = 0 (regression for the D fix in 0c62a914)", () => { expect(() => validateResolvedConfig({ ...minimalInput(), db: { port: 5432, majorVersion: 0 } }), ).toThrow("Missing required field in config: db.major_version"); }); - it("throws Go's decode-time enum message for an invalid auth.captcha.provider, regardless of enabled", () => { + it("throws the decode-time enum message for an invalid auth.captcha.provider, regardless of enabled", () => { // Unreachable through L's real flow: `@supabase/config`'s schema already narrows // `provider` to "hcaptcha" | "turnstile" | undefined, so an invalid value fails schema // decoding first. D's real TOML flow can reach this branch; its own suite covers that @@ -1289,7 +1289,7 @@ describe("validateResolvedConfig", () => { }), ), ).toThrow( - "failed to parse config: decoding failed due to the following error(s):\n\n'auth.captcha.provider' must be one of [hcaptcha turnstile]", + "failed to parse config:\nauth.captcha.provider: must be one of hcaptcha, turnstile", ); }); }); diff --git a/apps/cli/src/command-internal/connect-errors.ts b/apps/cli/src/command-internal/connect-errors.ts index d9eb65d081..fa0c8c05e3 100644 --- a/apps/cli/src/command-internal/connect-errors.ts +++ b/apps/cli/src/command-internal/connect-errors.ts @@ -1,6 +1,5 @@ /** - * Connection-error classification and rendering ported from the established - * connect helpers and pgconn's `connectError`. + * Connection-error classification and rendering. * Used by the container-level pooler fallback (`db dump --linked`) to decide * whether a failed pg_dump/pg container was an IPv6 connectivity failure that * warrants retrying through the IPv4 transaction pooler, and by the connection @@ -29,7 +28,7 @@ export function ipv6Suggestion(): string { const IPV6_LITERAL_PATTERN = /(?:\[[0-9a-fA-F:]+\]|\([0-9a-fA-F:]+\))/; // Node's dial-failure shape (`connect ENETUNREACH 2600:…:5432`). The port may be // followed by whitespace, end-of-string, or a closing paren — the connect-failure -// message renders the driver cause parenthesized (pgconn `dial error (…)` form). +// message renders the driver cause parenthesized (`dial error (…)` form). const NODE_ENETUNREACH_PATTERN = /\benetunreach\s+([0-9a-fA-F:]+):\d+(?:[\s)]|$)/i; /** @@ -86,8 +85,8 @@ export interface ConnectSuggestionContext { * and `code`. The `@effect/sql` `SqlError` wraps the node-postgres/node `net` driver error on * its `cause`; a multi-address dial wraps an `AggregateError` whose `errors[]` carry the * per-IP `ECONNREFUSED`/`ENETUNREACH` system errors — an aggregate node contributes nothing - * itself and only its last child is visited, since pgconn's own fallback loop overwrites its - * error on every attempt so only the last one survives. The parent's own fields are skipped: + * itself and only its last child is visited, since each attempt overwrites the previous error + * so only the last one survives. The parent's own fields are skipped: * node's `aggregateErrors` copies `errors[0].code` onto the aggregate itself (`lib/internal/ * errors.js`, Bun matches), so reading them would blame an abandoned first attempt. */ @@ -126,7 +125,7 @@ export interface ConnectFailureTarget { * Walks to the deepest underlying driver error: unwraps `cause` chains (the `@effect/sql` * `SqlError` exposes its `ConnectionError` reason as `cause`, and the reason exposes the * node-postgres error the same way) and descends into the last entry of an `AggregateError`'s - * `errors[]` — pgconn's multi-address fallback loop likewise surfaces the last attempt's error. + * `errors[]` — the last attempt's error is the one surfaced. */ function deepestConnectCause(error: unknown): unknown { let current: unknown = error; @@ -146,8 +145,8 @@ function deepestConnectCause(error: unknown): unknown { return current; } -// Node/Bun errno codes raised while dialing the server — pgconn wraps the -// equivalent net.Dial failures as `dial error (…)`. +// Node/Bun errno codes raised while dialing the server, rendered as +// `dial error (…)`. const DIAL_ERROR_CODES = new Set([ "ECONNREFUSED", "ETIMEDOUT", @@ -179,8 +178,7 @@ export function isDialFailure(error: unknown): boolean { // The complete documented Node/OpenSSL X509 certificate-verification code // family (Node tls docs "X509 certificate error codes", OpenSSL's // `X509_verify_cert_error` set), complemented by node's ERR_TLS_*/ERR_SSL_* -// prefixes at the use site. pgconn stages by connection phase — any `startTLS` -// failure becomes `tls error (…)` — but node exposes no +// prefixes at the use site. Any TLS-phase failure renders as `tls error (…)`, but node exposes no // phase marker, so the full code family is the proxy. These strings are unique // to TLS-layer verification: server SQLSTATEs and dial/DNS `E…` errnos are // classified by earlier branches. @@ -215,13 +213,13 @@ const TLS_ERROR_CODES = new Set([ "HOSTNAME_MISMATCH", ]); // node-postgres' own message when the server answers `N` to SSLRequest -// (`pg/lib/connection.js`); pgconn: `tls error (server refused TLS connection)`. +// (`pg/lib/connection.js`); rendered as `tls error (server refused TLS connection)`. const SERVER_REFUSED_SSL = "The server does not support SSL connections"; // Node/Bun's TLS-socket message when the server accepts SSLRequest but closes // the socket before the handshake completes (node `lib/_tls_wrap.js` // `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 +// the TLS stage (`tls error (…)`). Its code is // ECONNRESET, absent from DIAL_ERROR_CODES: a raw // post-handshake `read ECONNRESET` is not phase-specific and stays verbatim. const TLS_DISCONNECT_MESSAGE = @@ -240,24 +238,17 @@ const SQLSTATE_PATTERN = /^[0-9A-Z]{5}$/; export const isSqlState = (code: string): boolean => SQLSTATE_PATTERN.test(code); /** - * Render the underlying driver failure the way pgconn stages its - * `connectError.msg` (`server error` / `hostname resolving error` / + * Render the underlying driver failure by stage (`server error` / `hostname resolving error` / * `dial error` / `tls error`, each with the cause parenthesized). - * A server ErrorResponse reproduces pgconn's `PgError` - * rendering byte-for-byte (`Severity: Message (SQLSTATE Code)`); - * for the other stages the parenthesized text is the node driver's own message, - * which cannot byte-match libpq/pgconn wording (e.g. node's - * `connect ECONNREFUSED 1.2.3.4:5432` vs - * `dial tcp 1.2.3.4:5432: connect: connection refused`). An unrecognized cause - * (e.g. node-postgres' `Connection terminated unexpectedly`, where pgconn would - * say `failed to receive message (unexpected EOF)`) is rendered verbatim rather + * A server ErrorResponse renders as `Severity: Message (SQLSTATE Code)`; + * for the other stages the parenthesized text is the node driver's own message + * (e.g. `connect ECONNREFUSED 1.2.3.4:5432`). An unrecognized cause + * (e.g. node-postgres' `Connection terminated unexpectedly`) is rendered verbatim rather * than guessing a stage. * - * Known stage-label caveat: pgconn labels by auth phase, which node-postgres - * does not expose — a wrong password over SCRAM arrives mid-SASL, so pgconn renders - * `failed SASL auth (FATAL: password authentication failed … (SQLSTATE 28P01))` - * where this renders `server error (…)` with the identical - * inner `PgError` bytes. The suggestion classifier keys off the inner text, so + * Known stage-label caveat: node-postgres does not expose the auth phase, so a wrong password + * over SCRAM arrives mid-SASL and renders as `server error (…)` with the same inner server-error + * text a SASL-stage label would carry. The suggestion classifier keys off the inner text, so * the `SUPABASE_DB_PASSWORD` hint fires identically either way. */ function connectCauseDetail(cause: unknown): string { @@ -293,10 +284,9 @@ function connectCauseDetail(cause: unknown): string { } /** - * Port of pgconn's `connectError.Error()`, the inner text of - * `failed to connect to postgres: %w` wrap: + * The inner text of the `failed to connect to postgres: ` wrap: * `` failed to connect to `host=… user=… database=…`: ``. - * Callers pass the connection config (pgconn embeds the config-level identity) + * Callers pass the connection config (its identity is embedded in the message) * and the raw failure — either the `@effect/sql` `SqlError` * from the pooled connect probe or the bare node-postgres error from the raw * client, both unwrapped by {@link deepestConnectCause}. @@ -344,7 +334,7 @@ function hasIPv6DialCause(error: unknown, depth = 0): boolean { * `SqlError` cause/aggregate chain. Returns `undefined` when no specific suggestion applies. * * The rendered message ({@link connectFailureMessage}) and this classifier inspect the same - * surfaced attempt: pgconn's fallback loop keeps only the last error on every attempt, so the + * surfaced attempt: only the last attempt's error survives, so the * collectors above descend into only the last aggregate child too — the displayed cause and * the suggestion can never disagree. */ diff --git a/apps/cli/src/command-internal/connect-errors.unit.test.ts b/apps/cli/src/command-internal/connect-errors.unit.test.ts index 5420f47ef3..7900811923 100644 --- a/apps/cli/src/command-internal/connect-errors.unit.test.ts +++ b/apps/cli/src/command-internal/connect-errors.unit.test.ts @@ -101,7 +101,7 @@ describe("connectFailureMessage", () => { ); }); - it("surfaces the last dial attempt of a dual-stack AggregateError (pgconn last-fallback parity)", () => { + it("surfaces the last dial attempt of a dual-stack AggregateError (last attempt wins)", () => { const aggregate = Object.assign(new AggregateError([], ""), { code: "ECONNREFUSED", errors: [ @@ -114,7 +114,7 @@ describe("connectFailureMessage", () => { ); }); - it("reproduces pgconn's server-error rendering byte-for-byte for a server ErrorResponse", () => { + it("renders a server ErrorResponse byte-for-byte", () => { expect(connectFailureMessage(target, realSqlConnectError(authFailedError()))).toBe( `${prefix} server error (FATAL: password authentication failed for user "postgres" (SQLSTATE 28P01))`, ); @@ -336,7 +336,7 @@ describe("connectSuggestion", () => { expect(connectSuggestion(realSqlConnectError(aggregate), ctx)).toBe(ipv6Suggestion()); }); - it("classifies only the LAST attempt of a mixed-family aggregate (pgconn last-fallback parity)", () => { + it("classifies only the LAST attempt of a mixed-family aggregate (last attempt wins)", () => { const aggregate = Object.assign(new AggregateError([], ""), { code: "EHOSTUNREACH", errors: [ @@ -382,7 +382,7 @@ describe("connectSuggestion", () => { ); }); - it("sets no suggestion for a mid-handshake TLS disconnect, like Go", () => { + it("sets no suggestion for a mid-handshake TLS disconnect", () => { const midHandshake = Object.assign( new Error("Client network socket disconnected before secure TLS connection was established"), { code: "ECONNRESET" }, @@ -390,7 +390,7 @@ describe("connectSuggestion", () => { expect(connectSuggestion(realSqlConnectError(midHandshake), ctx)).toBeUndefined(); }); - it("keeps an IPv4 EADDRNOTAVAIL unclassified, like Go without an IPv6 literal", () => { + it("keeps an IPv4 EADDRNOTAVAIL unclassified without an IPv6 literal", () => { const err = realSqlConnectError(dialError("EADDRNOTAVAIL", "10.1.2.3", 5432)); expect(connectSuggestion(err, ctx)).toBeUndefined(); }); 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 55b5edb922..db37a3dd56 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 @@ -149,7 +149,7 @@ describe("awaitStorageReady", () => { ); it.effect( - "is still retrying after 29 seconds, but fails once the 30th second is exhausted — pins Go's hardcoded 30s constant", + "is still retrying after 29 seconds, but fails once the 30th second is exhausted — pins the hardcoded 30s timeout", () => Effect.gen(function* () { const mock = mockSpawner((args) => { 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 577eb95193..eb2da95569 100644 --- a/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts +++ b/apps/cli/src/command-internal/db-bootstrap/bootstrap-config.ts @@ -14,7 +14,7 @@ import { Effect, type FileSystem, type Path } from "effect"; import type { LocalServiceVersionOverrides } from "../../shared/services/services.shared.ts"; import { makeRemoteWins } from "../db-config.toml-read.ts"; import { resolveDbImage } from "../db-image.ts"; -import { resolveHealthTimeoutSeconds } from "../go-duration.ts"; +import { resolveHealthTimeoutSeconds } from "../duration.ts"; import { envOverride, envOverrideBool, 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 5c250bf23b..d8d15c1280 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 @@ -720,17 +720,14 @@ describe("volumeExists", () => { ); }); - it.live( - "resolves true (protected, not fresh) on an ambiguous inspect failure, matching Go's IsNotFound gate", - () => { - const mock = mockSpawner(() => ({ exitCode: 1, stderr: "permission denied\n" })); - return volumeExists(mock.spawner, "supabase_db_proj").pipe( - Effect.map((exists) => { - expect(exists).toBe(true); - }), - ); - }, - ); + it.live("resolves true (protected, not fresh) on an ambiguous inspect failure", () => { + const mock = mockSpawner(() => ({ exitCode: 1, stderr: "permission denied\n" })); + return volumeExists(mock.spawner, "supabase_db_proj").pipe( + Effect.map((exists) => { + expect(exists).toBe(true); + }), + ); + }); it.live("fails with VolumeInspectError when no runtime can be spawned", () => { const spawner = ChildProcessSpawner.make(() => 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 97ee24487e..4d850c9793 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 @@ -366,32 +366,29 @@ describe("startSetupLocalDatabase", () => { }, ); - it.effect( - "labels every one-shot job with the project's Docker labels, matching Go's DockerStart (review: Codex, PR #6022)", - () => { - const workdir = makeWorkdir(); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - // Default config: realtime, storage, and auth are all enabled — 3 jobs. - return run( - baseInput(workdir, session, { majorVersion: 15, projectId: "labeled-proj" }), - out, - docker, - ).pipe( - Effect.map(() => { - expect(docker.runs.length).toBe(3); - for (const job of docker.runs) { - expect(job.labels).toEqual({ - "com.supabase.cli.project": "labeled-proj", - "com.docker.compose.project": "labeled-proj", - }); - } - rmSync(workdir, { recursive: true, force: true }); - }), - ); - }, - ); + it.effect("labels every one-shot job with the project's Docker labels", () => { + const workdir = makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + // Default config: realtime, storage, and auth are all enabled — 3 jobs. + return run( + baseInput(workdir, session, { majorVersion: 15, projectId: "labeled-proj" }), + out, + docker, + ).pipe( + Effect.map(() => { + expect(docker.runs.length).toBe(3); + for (const job of docker.runs) { + expect(job.labels).toEqual({ + "com.supabase.cli.project": "labeled-proj", + "com.docker.compose.project": "labeled-proj", + }); + } + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); it.effect( "the realtime job's env matches `buildRealtimeEnv` on the internal db address + jwks", @@ -489,31 +486,28 @@ describe("startSetupLocalDatabase", () => { }, ); - it.effect( - "--debug tees every one-shot job's stderr, matching Go's utils.GetDebugLogger()", - () => { - const workdir = makeWorkdir(); - const { session } = fakeSession(); - const out = mockOutput(); - const docker = mockDockerRun(); - const config = decodeConfig({ - realtime: { enabled: true }, - storage: { enabled: false }, - auth: { enabled: true }, - }); - return run( - baseInput(workdir, session, { majorVersion: 15, config, debug: true }), - out, - docker, - ).pipe( - Effect.map(() => { - expect(docker.runs.length).toBe(2); - expect(docker.captureOptsCalls).toEqual([{ teeStderr: true }, { teeStderr: true }]); - rmSync(workdir, { recursive: true, force: true }); - }), - ); - }, - ); + it.effect("--debug tees every one-shot job's stderr", () => { + const workdir = makeWorkdir(); + const { session } = fakeSession(); + const out = mockOutput(); + const docker = mockDockerRun(); + const config = decodeConfig({ + realtime: { enabled: true }, + storage: { enabled: false }, + auth: { enabled: true }, + }); + return run( + baseInput(workdir, session, { majorVersion: 15, config, debug: true }), + out, + docker, + ).pipe( + Effect.map(() => { + expect(docker.runs.length).toBe(2); + expect(docker.captureOptsCalls).toEqual([{ teeStderr: true }, { teeStderr: true }]); + rmSync(workdir, { recursive: true, force: true }); + }), + ); + }); it.effect("without --debug, one-shot jobs run with teeStderr off", () => { const workdir = makeWorkdir(); @@ -679,46 +673,40 @@ describe("resolveDbSetupPrelude", () => { ); }); - it.effect( - 'prints the banner BEFORE a JWKS resolution failure — matching Go\'s "initSchema" printing the banner before ever calling "initSchema15" -> "ResolveJWKS" (review: PRRT_kwDOErm0O86W6R-O)', - () => { - const out = mockOutput(); - return run( - { - majorVersion: 15, - realtimeEnabledForSetup: true, - jwks: Effect.fail(new Error("jwks discovery failed")), - }, - out, - ).pipe( - Effect.flip, - Effect.map((error) => { - expect(error.message).toBe("jwks discovery failed"); - const banner = out.rawChunks.filter((c) => c.text === "Initialising schema...\n"); - expect(banner.length).toBe(1); - expect(banner[0]?.stream).toBe("stderr"); - }), - ); - }, - ); + it.effect("prints the banner BEFORE a JWKS resolution failure", () => { + const out = mockOutput(); + return run( + { + majorVersion: 15, + realtimeEnabledForSetup: true, + jwks: Effect.fail(new Error("jwks discovery failed")), + }, + out, + ).pipe( + Effect.flip, + Effect.map((error) => { + expect(error.message).toBe("jwks discovery failed"); + const banner = out.rawChunks.filter((c) => c.text === "Initialising schema...\n"); + expect(banner.length).toBe(1); + expect(banner[0]?.stream).toBe("stderr"); + }), + ); + }); - it.effect( - "does not resolve JWKS on PG <= 14 even with realtime enabled — Go's initSchema never reaches initSchema15 there", - () => { - const out = mockOutput(); - let jwksCalled = false; - const jwks = Effect.sync(() => { - jwksCalled = true; - return "unused"; - }); - return run({ majorVersion: 14, realtimeEnabledForSetup: true, jwks }, out).pipe( - Effect.map((resolved) => { - expect(jwksCalled).toBe(false); - expect(resolved.jwks).toBe(""); - }), - ); - }, - ); + it.effect("does not resolve JWKS on PG <= 14 even with realtime enabled", () => { + const out = mockOutput(); + let jwksCalled = false; + const jwks = Effect.sync(() => { + jwksCalled = true; + return "unused"; + }); + return run({ majorVersion: 14, realtimeEnabledForSetup: true, jwks }, out).pipe( + Effect.map((resolved) => { + expect(jwksCalled).toBe(false); + expect(resolved.jwks).toBe(""); + }), + ); + }); }); /** 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 21f248b19a..39aa659629 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 @@ -226,7 +226,7 @@ describe("buildStartContainerCreateArgs", () => { ); }); - test("emits cmd tokens after the image even when entrypoint is absent (Pooler: start.go:1234-1237)", () => { + test("emits cmd tokens after the image even when entrypoint is absent", () => { const spec: StartContainerSpec = { image: "supabase/supavisor:2.0.0", containerName: "supabase_pooler_proj", @@ -247,7 +247,7 @@ describe("buildStartContainerCreateArgs", () => { ]); }); - test("omits health flags entirely when healthcheck is absent (Kong/PostgREST: start.go:975)", () => { + test("omits health flags entirely when healthcheck is absent", () => { const spec: StartContainerSpec = { image: "kong:3", containerName: "supabase_kong_proj", 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 ce589339a3..e0a320cfe9 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 @@ -20,7 +20,7 @@ import { DbConfigLoadError } from "../db-config.errors.ts"; import { InvalidServiceVersionTagError } from "../service-version-overrides.ts"; import { localDbContainerId } from "../docker-ids.ts"; import { resolveDockerNetworkMode } from "../../shared/functions/functions-docker.ts"; -import { viperEnvStringWithProjectFallback } from "../viper-env.ts"; +import { supabaseEnvStringWithProjectFallback } from "../supabase-env.ts"; import { isBitbucketPipeline } from "../bitbucket-pipeline.ts"; import { resolveAuthExternalUrl, @@ -136,7 +136,7 @@ export const buildLocalDbContainerInputs = ( // See {@link resolveDockerNetworkMode} for the full flag/env/fallback precedence. const networkId = resolveDockerNetworkMode({ explicit: Option.getOrUndefined(networkIdFlag), - envOverride: viperEnvStringWithProjectFallback("SUPABASE_NETWORK_ID", projectEnvValues), + envOverride: supabaseEnvStringWithProjectFallback("SUPABASE_NETWORK_ID", projectEnvValues), projectId, }); // Only needed on Linux; Docker Desktop already resolves `host.docker.internal` elsewhere. 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 be97398051..18b0b3b84d 100644 --- a/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts +++ b/apps/cli/src/command-internal/db-bootstrap/postgres.service.ts @@ -11,7 +11,7 @@ import type { CliConfig } from "@supabase/config"; import { localDbContainerId } from "../docker-ids.ts"; import { toDockerMountPath } from "../docker-path.ts"; -import { encodeToml } from "../go-output.encoders.ts"; +import { encodeToml } from "../output.encoders.ts"; import { POSTGRES_DEFAULT_ROOT_KEY } from "../local-config-values.ts"; import type { StartContainerSpec } from "./docker-create-args.ts"; import { START_DB_RESTORE_SH } from "./templates/db-restore.sh.ts"; diff --git a/apps/cli/src/command-internal/db-bootstrap/postgres.service.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/postgres.service.unit.test.ts index f431f2181c..47f7bf755c 100644 --- a/apps/cli/src/command-internal/db-bootstrap/postgres.service.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/postgres.service.unit.test.ts @@ -114,7 +114,7 @@ describe("buildPostgresStartContainerSpec", () => { expect(spec.secretFiles).toBeUndefined(); }); - test("uses a rootKey override instead of the Go default when provided", () => { + test("uses a rootKey override instead of the default when provided", () => { const spec = buildPostgresStartContainerSpec( baseInput({ db: baseDb({ major_version: 17 }), rootKey: "custom-root-key" }), ); @@ -163,7 +163,7 @@ describe("buildPostgresStartContainerSpec", () => { }); }); - test("OrioleDB branch defaults unset S3 fields to empty strings, matching Go's zero-value string fields", () => { + test("OrioleDB branch defaults unset S3 fields to empty strings", () => { const spec = buildPostgresStartContainerSpec( baseInput({ db: baseDb({ orioledb_version: "17.4.1.030" }) }), ); @@ -223,7 +223,7 @@ describe("buildPostgresStartContainerSpec", () => { expect(spec.env.POSTGRES_INITDB_ARGS).toBeUndefined(); }); - test("healthcheck matches Go's pg_isready probe", () => { + test("healthcheck uses the pg_isready probe", () => { const spec = buildPostgresStartContainerSpec(baseInput()); expect(spec.healthcheck).toEqual({ test: ["CMD", "pg_isready", "-U", "postgres", "-h", "127.0.0.1", "-p", "5432"], @@ -380,7 +380,7 @@ describe("postgresImageVersionTag", () => { expect(postgresImageVersionTag("supabase/postgres:17.4.1.030")).toBe("17.4.1.030"); }); - test("degrades to the whole string when there is no colon at all, matching Go's Image[i+1:] with i=-1", () => { + test("degrades to the whole string when there is no colon at all", () => { expect(postgresImageVersionTag("supabase/postgres")).toBe("supabase/postgres"); }); }); @@ -451,7 +451,7 @@ describe("buildShadowPostgresContainerSpec", () => { expect(spec.labels).toEqual({}); }); - test("initializes POSTGRES_PASSWORD from the resolved [db] password, not a hardcoded literal — the deliberate TS extension the input's own doc describes (Go rejects the toml key at config load and always uses 'postgres')", () => { + test("initializes POSTGRES_PASSWORD from the resolved [db] password, not a hardcoded literal", () => { const spec = buildShadowPostgresContainerSpec(baseShadowInput({ password: "hunter2" })); expect(spec.env?.["POSTGRES_PASSWORD"]).toBe("hunter2"); }); diff --git a/apps/cli/src/command-internal/db-bootstrap/realtime-env.unit.test.ts b/apps/cli/src/command-internal/db-bootstrap/realtime-env.unit.test.ts index 18ed9b0dc9..a4f456cd5f 100644 --- a/apps/cli/src/command-internal/db-bootstrap/realtime-env.unit.test.ts +++ b/apps/cli/src/command-internal/db-bootstrap/realtime-env.unit.test.ts @@ -24,7 +24,7 @@ describe("buildRealtimeEnv", () => { expect(env["API_JWT_JWKS"]).toBe(base.jwks); }); - test("matches Go's remaining static env values", () => { + test("sets the remaining static env values", () => { const env = buildRealtimeEnv(base); expect(env).toMatchObject({ PORT: "4000", 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 bc1313b5eb..c05c77d8b5 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 @@ -63,7 +63,7 @@ describe("resetDisconnectClients", () => { ); it.effect( - "is still retrying after 9 one-second backoffs, but fails once the 10th is exhausted — pins Go's `NewBackoffPolicy(ctx, 10*time.Second)` constant", + "is still retrying after 9 one-second backoffs, but fails once the 10th is exhausted — pins the 10-second backoff limit", () => Effect.gen(function* () { // Never drains — pegs the retry schedule to its hard 10-retry ceiling. 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 1ffe855cc4..3a64dfddfa 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-cache.ts @@ -20,7 +20,7 @@ import { } from "effect"; import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"; -import { viperEnvBoolWithProjectFallback } from "../viper-env.ts"; +import { supabaseEnvBoolWithProjectFallback } from "../supabase-env.ts"; import { Output } from "../../shared/output/output.service.ts"; import { containerCliExitCode, describeContainerCliFailure } from "../container-cli.ts"; import { DbConnection } from "../db-connection.service.ts"; @@ -60,7 +60,7 @@ import { type Spawner = ChildProcessSpawner["Service"]; -/** `SUPABASE_SHADOW_CACHE` — opt-out gate (viper bool when set; unset is ON). */ +/** `SUPABASE_SHADOW_CACHE` — opt-out gate (parsed as a boolean when set; unset is ON). */ export const SHADOW_CACHE_ENV = "SUPABASE_SHADOW_CACHE"; /** @@ -688,7 +688,7 @@ export const peekShadowBaseline = ( Effect.gen(function* () { if ( opts.bypassCache === true || - !viperEnvBoolWithProjectFallback(SHADOW_CACHE_ENV, input.setup.projectEnvValues ?? {}) + !supabaseEnvBoolWithProjectFallback(SHADOW_CACHE_ENV, input.setup.projectEnvValues ?? {}) ) { return { state: "uncachable" } as const; } @@ -868,7 +868,7 @@ export const acquireShadowDatabase = ( Effect.gen(function* () { if ( opts.bypassCache === true || - !viperEnvBoolWithProjectFallback(SHADOW_CACHE_ENV, input.setup.projectEnvValues ?? {}, { + !supabaseEnvBoolWithProjectFallback(SHADOW_CACHE_ENV, input.setup.projectEnvValues ?? {}, { whenUnset: 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 9451314dc1..b4317f66e1 100644 --- a/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts +++ b/apps/cli/src/command-internal/db-bootstrap/shadow-database.ts @@ -751,8 +751,8 @@ const migrateShadowDatabaseWith = ( ); /** - * Migrates a shadow for migra and the legacy pg-delta engine. Those Go-backed - * workflows historically include `pg_net` in the platform baseline regardless of + * Migrates a shadow for migra and the legacy pg-delta engine. Those + * workflows include `pg_net` in the platform baseline regardless of * project config, so preserve that baseline while sharing the native TS setup path. */ export const migrateShadowDatabase = ( 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 db9961f353..72c96f3778 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 @@ -45,9 +45,9 @@ import { resolveLocalConfigValues, resolveThirdPartyProviders, } from "../local-config-values.ts"; -import { parseGoDuration, resolveHealthTimeoutSeconds } from "../go-duration.ts"; +import { parseDuration, resolveHealthTimeoutSeconds } from "../duration.ts"; import { ramInBytes } from "../size-units.ts"; -import { goUrlParse } from "../storage-url.ts"; +import { parseUrl } from "../storage-url.ts"; import { loadLocalProjectContext } from "../local-project-context.ts"; import { cliProjectFilterValue } from "../docker-ids.ts"; import { buildLocalDbContainerInputs } from "./local-container-inputs.ts"; @@ -137,7 +137,7 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu resolveAuthEmail(config.auth.email, authDocForValidation, projectEnvValues), ); yield* wrapDbConfigOverride("auth.email.max_frequency", () => - parseGoDuration(resolvedEmailForValidation.max_frequency), + parseDuration(resolvedEmailForValidation.max_frequency), ); yield* wrapDbConfigOverride("auth.email.smtp", () => resolveAuthEmailSmtp(authDocForValidation, projectEnvValues), @@ -146,7 +146,7 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu resolveAuthSms(authDocForValidation, config.auth.sms, projectEnvValues), ); yield* wrapDbConfigOverride("auth.sms.max_frequency", () => - parseGoDuration(smsForValidation.max_frequency), + parseDuration(smsForValidation.max_frequency), ); const authEnabledForValidation = envOverrideBool( "SUPABASE_AUTH_ENABLED", @@ -173,16 +173,16 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu const gotrueSessionsForValidation = resolveGotrueSessions(config.auth.sessions, projectEnvValues); if (gotrueSessionsForValidation?.timebox !== undefined) { yield* wrapDbConfigOverride("auth.sessions.timebox", () => - parseGoDuration(gotrueSessionsForValidation.timebox!), + parseDuration(gotrueSessionsForValidation.timebox!), ); } if (gotrueSessionsForValidation?.inactivity_timeout !== undefined) { yield* wrapDbConfigOverride("auth.sessions.inactivity_timeout", () => - parseGoDuration(gotrueSessionsForValidation.inactivity_timeout!), + parseDuration(gotrueSessionsForValidation.inactivity_timeout!), ); } yield* wrapDbConfigOverride("auth.mfa.phone.max_frequency", () => - parseGoDuration(resolveAuthMfa(config.auth.mfa, projectEnvValues).phone.max_frequency), + parseDuration(resolveAuthMfa(config.auth.mfa, projectEnvValues).phone.max_frequency), ); yield* wrapDbConfigOverride("auth.rate_limit", () => resolveGotrueRateLimit(config.auth.rate_limit, projectEnvValues), @@ -532,7 +532,7 @@ export const startLocalDatabase = Effect.fn("DbBootstrap.startLocalDatabase")(fu config.studio.api_url; if (studioEnabledForValidation) { yield* Effect.try({ - try: () => goUrlParse(studioApiUrlForValidation), + try: () => parseUrl(studioApiUrlForValidation), catch: (cause) => new DbConfigLoadError({ message: `Invalid config for studio.api_url: ${cause instanceof Error ? cause.message : String(cause)}`, 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 a556e887e8..22a11e75aa 100644 --- a/apps/cli/src/command-internal/db-config.integration.test.ts +++ b/apps/cli/src/command-internal/db-config.integration.test.ts @@ -608,46 +608,43 @@ describe("dbConfigResolver (db-url under the stack backend)", () => { }); describe("dbConfigResolver (linked config ordering)", () => { - it.effect( - "validates the ref-merged config before any network work (Go ParseDatabaseConfig order)", - () => { - // 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( - [ - `project_id = "${ref}"`, - "[db]", - "major_version = 15", - `[remotes.${ref.slice(0, 4)}]`, - `project_id = "${ref}"`, - `[remotes.${ref.slice(0, 4)}.db]`, - "major_version = 99", - "", - ].join("\n"), - ); - // The linked ref is sourced via the project-ref resolver's env fallback. - return resolve(dir, linkedFlags, { configEnv: { SUPABASE_PROJECT_ID: ref } }).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - expect(JSON.stringify(exit.cause)).toContain( - "Failed reading config: Invalid db.major_version: 99.", - ); - } - }), - ), - Effect.ensuring( - Effect.sync(() => { - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); + it.effect("validates the ref-merged config before any network work", () => { + // 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( + [ + `project_id = "${ref}"`, + "[db]", + "major_version = 15", + `[remotes.${ref.slice(0, 4)}]`, + `project_id = "${ref}"`, + `[remotes.${ref.slice(0, 4)}.db]`, + "major_version = 99", + "", + ].join("\n"), + ); + // The linked ref is sourced via the project-ref resolver's env fallback. + return resolve(dir, linkedFlags, { configEnv: { SUPABASE_PROJECT_ID: ref } }).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain( + "Failed reading config: Invalid db.major_version: 99.", + ); + } + }), + ), + Effect.ensuring( + Effect.sync(() => { + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); it.effect("surfaces a project-ref read failure instead of reporting not-linked", () => { // The ref file is seeded as a directory (not a file), with no project_id or env fallback, diff --git a/apps/cli/src/command-internal/db-config.parse.ts b/apps/cli/src/command-internal/db-config.parse.ts index 75ca603477..38031e3c3a 100644 --- a/apps/cli/src/command-internal/db-config.parse.ts +++ b/apps/cli/src/command-internal/db-config.parse.ts @@ -157,7 +157,7 @@ function unbracketIpv6(host: string): string { return host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host; } -/** Sentinel for a present-but-non-numeric `connect_timeout` (pgconn parse error). */ +/** Sentinel for a present-but-non-numeric `connect_timeout` (parse error). */ const CONNECT_TIMEOUT_INVALID = Symbol("connect-timeout-invalid"); /** 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 fdefee3402..c49f408dc3 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 @@ -40,7 +40,7 @@ describe("parseConnectionString (URL form)", () => { }); }); - it("defaults the user to the OS account when userinfo is omitted (libpq/pgconn parity)", () => { + it("defaults the user to the OS account when userinfo is omitted (libpq parity)", () => { expect(parseConnectionString("postgresql://localhost/mydb")).toEqual({ host: "localhost", port: 5432, @@ -92,7 +92,7 @@ describe("parseConnectionString (URL form)", () => { } }); - it("honors libpq query params (host/dbname) over the structural URL (pgconn parity)", () => { + it("honors libpq query params (host/dbname) over the structural URL", () => { expect(parseConnectionString("postgresql:///postgres?host=/var/run/postgresql")).toEqual({ host: "/var/run/postgresql", port: 5432, @@ -111,7 +111,7 @@ describe("parseConnectionString (URL form)", () => { }); }); - it("strips the brackets from an IPv6 literal host (Go url.Hostname parity)", () => { + it("strips the brackets from an IPv6 literal host", () => { expect(parseConnectionString("postgresql://postgres:pw@[::1]:5432/postgres")).toEqual({ host: "::1", port: 5432, @@ -135,7 +135,7 @@ describe("parseConnectionString (URL form)", () => { expect(parsed).not.toHaveProperty("options"); }); - it("collects non-structural query settings as runtimeParams (pgconn parity)", () => { + it("collects non-structural query settings as runtimeParams", () => { const parsed = parseConnectionString( "postgres://u:pw@h/db?search_path=tenant&statement_timeout=5000&sslmode=require&options=reference%3Dabc", ); @@ -149,13 +149,13 @@ describe("parseConnectionString (URL form)", () => { expect(parsed).not.toHaveProperty("runtimeParams"); }); - it("merges PGAPPNAME into runtimeParams as application_name (pgconn env merge)", () => { + it("merges PGAPPNAME into runtimeParams as application_name (env merge)", () => { const env = (name: string): string | undefined => (name === "PGAPPNAME" ? "myapp" : undefined); const parsed = parseConnectionString("postgres://u:pw@h/db", env); expect(parsed?.runtimeParams).toEqual({ application_name: "myapp" }); }); - it("lets a connection-string application_name override PGAPPNAME (pgconn precedence)", () => { + it("lets a connection-string application_name override PGAPPNAME (precedence)", () => { const env = (name: string): string | undefined => name === "PGAPPNAME" ? "from-env" : undefined; const parsed = parseConnectionString("postgres://u:pw@h/db?application_name=from-url", env); @@ -185,7 +185,7 @@ describe("parseConnectionString (URL form)", () => { expect(parsed).not.toHaveProperty("runtimeParams"); }); - it("resolves client certs from PGSSLCERT/PGSSLKEY env (pgconn precedence)", () => { + it("resolves client certs from PGSSLCERT/PGSSLKEY env (precedence)", () => { const env = (name: string): string | undefined => name === "PGSSLCERT" ? "/e/c.crt" : name === "PGSSLKEY" ? "/e/c.key" : undefined; const parsed = parseConnectionString("postgres://u:pw@h/db", env); @@ -193,7 +193,7 @@ describe("parseConnectionString (URL form)", () => { expect(parsed?.sslkey).toBe("/e/c.key"); }); - it("rejects a client cert with sslcert but no sslkey (pgconn both-or-neither)", () => { + it("rejects a client cert with sslcert but no sslkey (both or neither)", () => { expect(parseConnectionString("postgres://u:pw@h/db?sslcert=/c/client.crt")).toBeUndefined(); expect(parseConnectionString("host=h user=u sslkey=/c/client.key")).toBeUndefined(); }); @@ -206,7 +206,7 @@ describe("parseConnectionString (URL form)", () => { expect(parseConnectionString("postgres://user:p%zz@example.com/db")).toBeUndefined(); }); - it("rejects a non-numeric or empty ?port= query override (pgconn parsePort error)", () => { + it("rejects a non-numeric or empty ?port= query override (invalid port)", () => { expect(parseConnectionString("postgresql://host/db?port=abc")).toBeUndefined(); expect(parseConnectionString("postgresql://db.example.com/app?port=")).toBeUndefined(); }); @@ -223,7 +223,7 @@ describe("parseConnectionString (URL form)", () => { } }); - it("rejects an invalid sslmode value (pgconn 'sslmode is invalid')", () => { + it("rejects an invalid sslmode value ('sslmode is invalid')", () => { expect(parseConnectionString("postgres://h/db?sslmode=verifyfull")).toBeUndefined(); expect(parseConnectionString("host=h sslmode=bogus")).toBeUndefined(); }); @@ -237,7 +237,7 @@ describe("parseConnectionString (URL form)", () => { }); }); - it("fills sslmode from PGSSLMODE when the URL omits it (pgconn env default)", () => { + it("fills sslmode from PGSSLMODE when the URL omits it (env default)", () => { const prev = process.env["PGSSLMODE"]; process.env["PGSSLMODE"] = "verify-full"; try { @@ -292,7 +292,7 @@ describe("parseConnectionString (libpq keyword/value DSN)", () => { }); }); - it("prefers PGUSER over the OS account for the default user (pgconn env precedence)", () => { + it("prefers PGUSER over the OS account for the default user (env precedence)", () => { const prev = process.env["PGUSER"]; process.env["PGUSER"] = "pg_role"; try { @@ -311,7 +311,7 @@ describe("parseConnectionString (libpq keyword/value DSN)", () => { } }); - it("fills omitted DSN fields from PG* env vars (pgconn env defaults)", () => { + it("fills omitted DSN fields from PG* env vars (env defaults)", () => { const prev = { PGHOST: process.env["PGHOST"], PGPORT: process.env["PGPORT"], @@ -365,7 +365,7 @@ describe("parseConnectionString (libpq keyword/value DSN)", () => { }); }); -describe("empty-password precedence (pgconn parity)", () => { +describe("empty-password precedence", () => { // Points PGPASSFILE at a temp file we control and sets PGPASSWORD, to prove which one wins. let tmp: string; let pgpassPath: string; @@ -425,7 +425,7 @@ describe("empty-password precedence (pgconn parity)", () => { }); }); -describe("multi-host failover (pgconn parity)", () => { +describe("multi-host failover", () => { it("parses a comma-separated multi-host URL into primary + fallbacks", () => { expect(parseConnectionString("postgres://u:pw@h1:5432,h2:5433/db")).toEqual({ host: "h1", @@ -448,7 +448,7 @@ describe("multi-host failover (pgconn parity)", () => { }); }); - it("zips hosts to the (compacted) port list exactly as pgconn does", () => { + it("zips hosts to the (compacted) port list positionally", () => { // 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]`: @@ -477,7 +477,7 @@ describe("multi-host failover (pgconn parity)", () => { ).toMatchObject({ host: "h1", sslmode: "require", fallbacks: [{ host: "h2", port: 5433 }] }); }); - it("rejects a multi-host URL with a non-numeric port (pgconn parsePort error)", () => { + it("rejects a multi-host URL with a non-numeric port (invalid port)", () => { expect(parseConnectionString("postgres://u:pw@h1:5432,h2:bad/db")).toBeUndefined(); }); @@ -494,7 +494,7 @@ describe("multi-host failover (pgconn parity)", () => { }); }); -describe("passfile= DSN setting (pgconn parity)", () => { +describe("passfile= DSN setting", () => { // Points PGPASSFILE at one file and `passfile=` at a different one, to prove the // connection-string setting wins. let tmp: string; @@ -591,7 +591,7 @@ describe("injected env lookup (project dotenv parity)", () => { }); }); -describe("pgservice resolution (pgconn parity)", () => { +describe("pgservice resolution", () => { // 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. @@ -669,7 +669,7 @@ describe("pgservice resolution (pgconn parity)", () => { }); }); -describe("keyword/value DSN backslash handling (pgconn parity)", () => { +describe("keyword/value DSN backslash handling", () => { it("preserves backslashes before ordinary chars (Windows cert paths)", () => { expect( parseConnectionString("host=h dbname=d user=u sslrootcert=C:\\certs\\root.pem")?.sslrootcert, @@ -693,7 +693,7 @@ describe("keyword/value DSN backslash handling (pgconn parity)", () => { ).toBe("C:\\certs\\root.pem"); }); - it("rejects an unquoted value ending in a lone backslash (pgconn 'invalid backslash')", () => { + it("rejects an unquoted value ending in a lone backslash ('invalid backslash')", () => { expect(parseConnectionString("host=h user=u password=secret\\")).toBeUndefined(); // A complete trailing `\\` escape pair is still accepted (→ single backslash). expect(parseConnectionString("host=h user=u dbname=d sslrootcert=C:\\\\")?.sslrootcert).toBe( @@ -702,7 +702,7 @@ describe("keyword/value DSN backslash handling (pgconn parity)", () => { }); }); -describe("connect_timeout (pgconn parity)", () => { +describe("connect_timeout", () => { it("parses connect_timeout from a URL query into connectTimeoutSeconds", () => { expect(parseConnectionString("postgres://u:p@h/db?connect_timeout=15")).toMatchObject({ connectTimeoutSeconds: 15, @@ -723,7 +723,7 @@ describe("connect_timeout (pgconn parity)", () => { }); }); - it("omits connectTimeoutSeconds when unset or zero (driver applies Go's default)", () => { + it("omits connectTimeoutSeconds when unset or zero (driver applies its default)", () => { expect(parseConnectionString("postgres://u:p@h/db")).not.toHaveProperty( "connectTimeoutSeconds", ); @@ -738,7 +738,7 @@ describe("connect_timeout (pgconn parity)", () => { }); }); -describe("empty URL query overrides (pgconn parity)", () => { +describe("empty URL query overrides", () => { it("an empty ?dbname= overrides the path with an empty database", () => { expect(parseConnectionString("postgres://u:p@host/production?dbname=")).toMatchObject({ database: "", @@ -757,7 +757,7 @@ describe("empty URL query overrides (pgconn parity)", () => { }); }); -describe("pgconn parse refinements", () => { +describe("DSN parse refinements", () => { it("accepts a comma-separated ?port= list for a multi-host URL", () => { expect(parseConnectionString("postgres://h1,h2/db?port=5432,5433")).toMatchObject({ host: "h1", @@ -799,7 +799,7 @@ describe("pgconn parse refinements", () => { }); }); -describe("database= alias and empty service values (pgconn parity)", () => { +describe("database= alias and empty service values", () => { let tmp: string; beforeEach(() => { @@ -821,7 +821,7 @@ describe("database= alias and empty service values (pgconn parity)", () => { }); }); - it("uses last-wins for dbname/database aliases in a DSN (pgconn remaps at parse time)", () => { + it("uses last-wins for dbname/database aliases in a DSN (remapped at parse time)", () => { expect(parseConnectionString("host=h user=u dbname=template1 database=appdb")).toMatchObject({ database: "appdb", }); @@ -862,7 +862,7 @@ describe("database= alias and empty service values (pgconn parity)", () => { }); }); -describe("more pgconn parse refinements", () => { +describe("more DSN parse refinements", () => { it("honors a present-but-empty ?host= as a literal empty host (overrides structural)", () => { expect(parseConnectionString("postgres://remote.example.com/postgres?host=")).toMatchObject({ host: "", @@ -879,7 +879,7 @@ describe("more pgconn parse refinements", () => { }); }); - it("does not dial hostaddr as the host (pgconn ignores hostaddr)", () => { + it("does not dial hostaddr as the host (hostaddr is ignored)", () => { const env = (name: string): string | undefined => (name === "PGHOST" ? "envhost" : undefined); const parsed = parseConnectionString("hostaddr=10.0.0.5 user=u", env); expect(parsed?.host).toBe("envhost"); 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 60bcd879d9..d1532c6adb 100644 --- a/apps/cli/src/command-internal/db-config.toml-read.ts +++ b/apps/cli/src/command-internal/db-config.toml-read.ts @@ -12,7 +12,7 @@ import { type ExperimentalInput, type HookInput, type MfaFactorInput, - parseGoBool, + parseBoolLiteral, type PasskeyInput, resolveEmailTemplateContentPath, resolveSigningKeysPath, @@ -642,18 +642,18 @@ function expandEnv(value: string, lookup: (name: string) => string | undefined): return envRefValue(value, lookup(name)); } -/** `[db]` ports decode into `uint16`. */ +/** `[db]` ports are integers in 0-65535. */ const MAX_PORT = 65535; /** - * Resolve a `[db]` port field: the TOML value decodes into a `uint16`, and a + * Resolve a `[db]` port field: the TOML value must be an integer in 0-65535, and a * quoted `env(VAR)` reference is expanded first, then parsed as the port. * Resolution rules: * * - **Omitted** (`undefined`) → the schema default. - * - **Present and resolves to a `uint16`** (a plain integer in range, or an + * - **Present and resolves to a valid port** (a plain integer in range, or an * `env(VAR)` string that expands to one) → that value. - * - **Present but cannot unmarshal** (non-numeric, negative, out of range, or an + * - **Present but invalid** (non-numeric, negative, out of range, or an * unresolved `env(VAR)`) → `undefined`, signalling the caller to abort with * `DbConfigLoadError` rather than silently defaulting and running * against the default local database while hiding a broken config. @@ -733,7 +733,7 @@ function joinSupabaseSeedPath(pattern: string): string { * 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 => { +const isAbsolutePattern = (pathSvc: Path.Path, pattern: string): boolean => { if (process.platform !== "win32") { return pathSvc.isAbsolute(pattern); } @@ -756,7 +756,9 @@ const goIsAbs = (pathSvc: Path.Path, pattern: string): boolean => { * override — all three feed the glob the same resolved paths. */ export const resolveSeedSqlPath = (pathSvc: Path.Path, pattern: string): string => - pattern.length === 0 || goIsAbs(pathSvc, pattern) ? pattern : joinSupabaseSeedPath(pattern); + pattern.length === 0 || isAbsolutePattern(pathSvc, pattern) + ? pattern + : joinSupabaseSeedPath(pattern); /** `[db]` ports default through the development env unless `SUPABASE_ENV` overrides. */ const DEFAULT_SUPABASE_ENV = "development"; @@ -831,14 +833,14 @@ function nonEmptyString(value: unknown): Option.Option { /** * Resolve a `[section] enabled` style bool: a native TOML bool, or a string - * (including an `env(VAR)` reference) accepted by {@link parseGoBool}. + * (including an `env(VAR)` reference) accepted by {@link parseBoolLiteral}. * 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; if (typeof value === "string") { - const parsed = parseGoBool(expandEnv(value, lookup)); + const parsed = parseBoolLiteral(expandEnv(value, lookup)); return parsed ?? "invalid"; } // A numeric value decodes as a bool (`value != 0`), so `enabled = 0` is an explicit @@ -863,7 +865,7 @@ const resolveBoolOrFail = Effect.fnUntraced(function* ( envValue?: string, ) { if (envValue !== undefined) { - const parsed = parseGoBool(expandEnv(envValue, lookup)); + const parsed = parseBoolLiteral(expandEnv(envValue, lookup)); if (parsed === undefined) { return yield* Effect.fail( new DbConfigLoadError({ message: `failed to parse config: invalid ${field}.` }), @@ -883,7 +885,7 @@ const resolveBoolOrFail = Effect.fnUntraced(function* ( /** * 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}, + * otherwise a present TOML bool/string is decoded with {@link parseBoolLiteral}, * and a malformed value aborts the load. */ const resolveOptionalBoolOrFail = Effect.fnUntraced(function* ( @@ -893,7 +895,7 @@ const resolveOptionalBoolOrFail = Effect.fnUntraced(function* ( lookup: EnvLookup, ) { if (envValue !== undefined) { - const parsed = parseGoBool(expandEnv(envValue, lookup)); + const parsed = parseBoolLiteral(expandEnv(envValue, lookup)); if (parsed === undefined) { return yield* Effect.fail( new DbConfigLoadError({ message: `failed to parse config: invalid ${field}.` }), @@ -905,7 +907,7 @@ const resolveOptionalBoolOrFail = Effect.fnUntraced(function* ( // 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)); + const parsed = parseBoolLiteral(expandEnv(value, lookup)); if (parsed === undefined) { return yield* Effect.fail( new DbConfigLoadError({ message: `failed to parse config: invalid ${field}.` }), @@ -1218,7 +1220,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( ); } - // A present-but-unmarshalable port aborts rather than defaulting, so a broken `[db]` + // A present-but-invalid 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"], @@ -1239,7 +1241,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( ); } // 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 + // `resolvePort` accepts 0 as a valid port, so the zero check lives here. No // equivalent check for `shadow_port`. if (port === 0) { return yield* Effect.fail( @@ -1344,7 +1346,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( let webhooksEnabled: boolean; if (webhooksEnabledEnv !== undefined) { const expandedWebhooksEnabledEnv = expandEnv(webhooksEnabledEnv, lookup); - const parsed = parseGoBool(expandedWebhooksEnabledEnv); + const parsed = parseBoolLiteral(expandedWebhooksEnabledEnv); if (parsed === undefined) { return yield* Effect.fail( new DbConfigLoadError({ @@ -1359,7 +1361,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // 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)); + const parsed = parseBoolLiteral(expandEnv(webhooksEnabledRaw, lookup)); if (parsed === undefined) { return yield* Effect.fail( new DbConfigLoadError({ @@ -1386,7 +1388,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( if (enabledEnv !== undefined) { // An `env(VAR)` indirection in the override is expanded before the bool parse. const expandedEnabledEnv = expandEnv(enabledEnv, lookup); - const parsed = parseGoBool(expandedEnabledEnv); + const parsed = parseBoolLiteral(expandedEnabledEnv); if (parsed === undefined) { return yield* Effect.fail( new DbConfigLoadError({ @@ -1401,7 +1403,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // 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)); + const parsed = parseBoolLiteral(expandEnv(enabledRaw, lookup)); if (parsed === undefined) { return yield* Effect.fail( new DbConfigLoadError({ @@ -1498,7 +1500,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( if (typeof value === "boolean") return value; if (typeof value === "number") return value !== 0; if (typeof value !== "string") return false; - const parsed = parseGoBool(expandEnv(value, lookup)); + const parsed = parseBoolLiteral(expandEnv(value, lookup)); if (parsed === undefined) return yield* fail(`failed to parse config: invalid ${field}.`); return parsed; }); @@ -1922,7 +1924,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( // 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 splitSeedPaths = (value: string): ReadonlyArray => { const expanded = expandEnv(value, lookup); return expanded.length === 0 ? [] : expanded.split(","); }; @@ -1932,7 +1934,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( * 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 => { + const formatWeakFloat = (value: number): string => { if (Number.isNaN(value)) return "NaN"; if (value === Number.POSITIVE_INFINITY) return "+Inf"; if (value === Number.NEGATIVE_INFINITY) return "-Inf"; @@ -1953,28 +1955,28 @@ const readDbTomlCore = Effect.fnUntraced(function* ( 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); + if (typeof value === "number") return formatWeakFloat(value); return undefined; }; // 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 + // an "expected a string" 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"; - return value.isLocal() ? "toml.LocalDateTime" : "time.Time"; + const tomlDateTypeName = (value: SmolToml.TomlDate): string => { + if (value.isDate()) return "local date"; + if (value.isTime()) return "local time"; + return value.isLocal() ? "local date-time" : "offset date-time"; }; - const goUnconvertibleType = (value: unknown): string | undefined => + const unsupportedTypeName = (value: unknown): string | undefined => value instanceof SmolToml.TomlDate - ? goTomlDateType(value) + ? tomlDateTypeName(value) : Array.isArray(value) - ? "[]interface {}" + ? "array" : typeof value === "object" && value !== null - ? "map[string]interface {}" + ? "table" : undefined; - // Returns the "unconvertible type" issue for each bad array element, without + // Returns the "expected a string" 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 = ( @@ -1982,26 +1984,22 @@ const readDbTomlCore = Effect.fnUntraced(function* ( values: ReadonlyArray, ): ReadonlyArray => values.flatMap((value, index) => { - const goType = goUnconvertibleType(value); - return goType === undefined + const typeName = unsupportedTypeName(value); + return typeName === undefined ? [] - : [`'${keyPath}[${index}]' expected type 'string', got unconvertible type '${goType}'`]; + : [`${keyPath}[${index}]: expected a string, got ${typeName}`]; }); // Fails once with every issue collected across both `Glob` fields, instead of // failing on the first field checked. const failOnGlobIssues = ( issues: ReadonlyArray, ): Effect.Effect => - issues.length === 0 - ? Effect.void - : fail( - `failed to parse config: decoding failed due to the following error(s):\n\n${issues.join("\n")}`, - ); + issues.length === 0 ? Effect.void : fail(`failed to parse config:\n${issues.join("\n")}`); // 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 + // coerces or reports an unsupported-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. + // too, but it should still be reported as an unsupported type. const resolveScalarGlobFallback = ( keyPath: string, value: unknown, @@ -2032,7 +2030,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( absentDefault: ReadonlyArray, ): { readonly patterns: ReadonlyArray; readonly issues: ReadonlyArray } => { if (override !== undefined) { - return { patterns: splitGoSeedPaths(override), issues: [] }; + return { patterns: splitSeedPaths(override), issues: [] }; } if (Array.isArray(raw)) { return { @@ -2044,7 +2042,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( }; } if (typeof raw === "string") { - return { patterns: splitGoSeedPaths(raw), issues: [] }; + return { patterns: splitSeedPaths(raw), issues: [] }; } if (raw === undefined) { return { patterns: absentDefault, issues: [] }; 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 37e65f0246..a4a58f2147 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 @@ -213,7 +213,7 @@ describe("readDbToml", () => { ); }); - it.effect("collapses. and .. in relative seed sql_paths like Go's path.Join", () => { + it.effect("collapses. and .. in relative seed sql_paths", () => { const dir = withConfig( ["[db.seed]", 'sql_paths = ["../seed.sql", "sub/../other.sql", "./plain.sql"]', ""].join( "\n", @@ -261,7 +261,7 @@ describe("readDbToml", () => { ); }); - it.effect("treats an empty-string db.seed.sql_paths as no patterns (Go []string{})", () => { + it.effect("treats an empty-string db.seed.sql_paths as no patterns", () => { const dir = withConfig(["[db.seed]", 'sql_paths = ""', ""].join("\n")); return read(dir).pipe( Effect.tap((v) => @@ -296,7 +296,7 @@ describe("readDbToml", () => { }, ); - it.effect("expands an env() array element but does NOT split it (Go array asymmetry)", () => { + it.effect("expands an env() array element but does NOT split it", () => { const previous = process.env["SEEDS"]; process.env["SEEDS"] = "a.sql,b.sql"; const dir = withConfig(["[db.seed]", 'sql_paths = ["env(SEEDS)"]', ""].join("\n")); @@ -316,20 +316,17 @@ describe("readDbToml", () => { ); }); - it.effect( - "weakly coerces non-string db.seed.sql_paths array elements (Go mapstructure parity)", - () => { - const dir = withConfig(["[db.seed]", 'sql_paths = [42, true, "seed.sql"]', ""].join("\n")); - return read(dir).pipe( - Effect.tap((v) => - Effect.sync(() => { - expect(v.seed.sqlPaths).toEqual(["supabase/42", "supabase/1", "supabase/seed.sql"]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); + it.effect("weakly coerces non-string db.seed.sql_paths array elements", () => { + const dir = withConfig(["[db.seed]", 'sql_paths = [42, true, "seed.sql"]', ""].join("\n")); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.seed.sqlPaths).toEqual(["supabase/42", "supabase/1", "supabase/seed.sql"]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); it.effect( "honors SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS over the TOML array (comma split, no trim)", @@ -370,7 +367,7 @@ 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)", + "on Windows, resolves a leading-slash schema/seed path pattern under supabase/ instead of treating it as absolute", () => { // 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 @@ -394,31 +391,25 @@ describe("readDbToml", () => { }, ); - it.effect( - "weakly coerces non-string db.migrations.schema_paths array elements (Go mapstructure parity)", - () => { - // `v.UnmarshalExact` never sets `WeaklyTypedInput: false`, so viper's - // `defaultDecoderConfig` default of `true` stands — mapstructure's `decodeString` - // coerces a bool to "1"/"0" and a number to its decimal string rather than - // erroring or dropping the element. Verified empirically: - // `schema_paths = [42, true, "schemas/*.sql"]` resolves to - // `supabase/{42,1,schemas/*.sql}`, not a filtered two-element list. - const dir = withConfig( - ["[db.migrations]", 'schema_paths = [42, true, "schemas/*.sql"]', ""].join("\n"), - ); - return read(dir).pipe( - Effect.tap((v) => - Effect.sync(() => { - expect(v.schemaPaths).toEqual(["supabase/42", "supabase/1", "supabase/schemas/*.sql"]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); + it.effect("weakly coerces non-string db.migrations.schema_paths array elements", () => { + // A bool coerces to "1"/"0" and a number to its decimal string rather than erroring or + // dropping the element: `schema_paths = [42, true, "schemas/*.sql"]` resolves to + // `supabase/{42,1,schemas/*.sql}`, not a filtered two-element list. + const dir = withConfig( + ["[db.migrations]", 'schema_paths = [42, true, "schemas/*.sql"]', ""].join("\n"), + ); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.schemaPaths).toEqual(["supabase/42", "supabase/1", "supabase/schemas/*.sql"]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); it.effect( - "formats a large numeric db.migrations.schema_paths entry as fixed decimal, not scientific notation (Go strconv.FormatFloat parity)", + "formats a large numeric db.migrations.schema_paths entry as fixed decimal, not scientific notation", () => { const dir = withConfig(["[db.migrations]", "schema_paths = [1e21]", ""].join("\n")); return read(dir).pipe( @@ -433,7 +424,7 @@ describe("readDbToml", () => { ); it.effect( - "formats TOML special-float db.migrations.schema_paths entries like Go's strconv.FormatFloat, not JS's toString (Go parity)", + "formats TOML special-float db.migrations.schema_paths entries as +Inf/-Inf/NaN, not JS's toString", () => { const dir = withConfig(["[db.migrations]", "schema_paths = [inf, -inf, nan]", ""].join("\n")); return read(dir).pipe( @@ -447,47 +438,41 @@ describe("readDbToml", () => { }, ); - it.effect( - "weakly coerces a TOP-LEVEL scalar db.migrations.schema_paths (Go mapstructure weak-decode of a []string field)", - () => { - 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( - Effect.tap(([numberResult, boolResult]) => - Effect.sync(() => { - expect(numberResult.schemaPaths).toEqual(["supabase/42"]); - expect(boolResult.schemaPaths).toEqual(["supabase/1"]); - rmSync(dirNumber, { recursive: true, force: true }); - rmSync(dirBool, { recursive: true, force: true }); - }), - ), - ); - }, - ); + it.effect("weakly coerces a TOP-LEVEL scalar db.migrations.schema_paths", () => { + 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( + Effect.tap(([numberResult, boolResult]) => + Effect.sync(() => { + expect(numberResult.schemaPaths).toEqual(["supabase/42"]); + expect(boolResult.schemaPaths).toEqual(["supabase/1"]); + rmSync(dirNumber, { recursive: true, force: true }); + rmSync(dirBool, { recursive: true, force: true }); + }), + ), + ); + }); - it.effect( - "treats a TOP-LEVEL empty-table db.migrations.schema_paths as no patterns (Go mapstructure zero-length-map special case)", - () => { - const dir = withConfig(["[db.migrations]", "schema_paths = {}", ""].join("\n")); - return read(dir).pipe( - Effect.tap((v) => - Effect.sync(() => { - expect(v.schemaPaths).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); + it.effect("treats a TOP-LEVEL empty-table db.migrations.schema_paths as no patterns", () => { + const dir = withConfig(["[db.migrations]", "schema_paths = {}", ""].join("\n")); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.schemaPaths).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); it.effect.each([ - { name: "offset date-time", literal: "1979-05-27T07:32:00Z", goType: "time.Time" }, - { name: "local date-time", literal: "1979-05-27T07:32:00", goType: "toml.LocalDateTime" }, - { name: "local date", literal: "1979-05-27", goType: "toml.LocalDate" }, - { name: "local time", literal: "07:32:00", goType: "toml.LocalTime" }, + { name: "offset date-time", literal: "1979-05-27T07:32:00Z", typeName: "offset date-time" }, + { name: "local date-time", literal: "1979-05-27T07:32:00", typeName: "local date-time" }, + { name: "local date", literal: "1979-05-27", typeName: "local date" }, + { name: "local time", literal: "07:32:00", typeName: "local time" }, ])( - "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 }) => { + "aborts the whole config load on a TOP-LEVEL bare $name db.migrations.schema_paths instead of silently treating it as empty", + ({ literal, typeName }) => { // `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 @@ -502,7 +487,7 @@ describe("readDbToml", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(JSON.stringify(exit.cause)).toContain( - `'db.migrations.schema_paths[0]' expected type 'string', got unconvertible type '${goType}'`, + `db.migrations.schema_paths[0]: expected a string, got ${typeName}`, ); } rmSync(dir, { recursive: true, force: true }); @@ -513,7 +498,7 @@ 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)", + "aborts the whole config load on a bare datetime db.migrations.schema_paths ARRAY element", () => { // 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 @@ -530,7 +515,7 @@ describe("readDbToml", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(JSON.stringify(exit.cause)).toContain( - "'db.migrations.schema_paths[1]' expected type 'string', got unconvertible type 'time.Time'", + "db.migrations.schema_paths[1]: expected a string, got offset date-time", ); } rmSync(dir, { recursive: true, force: true }); @@ -551,7 +536,7 @@ describe("readDbToml", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(JSON.stringify(exit.cause)).toContain( - "'db.seed.sql_paths[0]' expected type 'string', got unconvertible type 'time.Time'", + "db.seed.sql_paths[0]: expected a string, got offset date-time", ); } rmSync(dir, { recursive: true, force: true }); @@ -562,7 +547,7 @@ describe("readDbToml", () => { ); it.effect( - "aborts the whole config load on a TOP-LEVEL table db.migrations.schema_paths (Go mapstructure UnconvertibleTypeError, synthetic index 0)", + "aborts the whole config load on a TOP-LEVEL table db.migrations.schema_paths (synthetic index 0)", () => { // A non-empty map isn't weakly coercible, so it fails decoding element 0 the same way a // nested-array/table array element does. @@ -574,7 +559,7 @@ describe("readDbToml", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(JSON.stringify(exit.cause)).toContain( - "'db.migrations.schema_paths[0]' expected type 'string', got unconvertible type 'map[string]interface {}'", + "db.migrations.schema_paths[0]: expected a string, got table", ); } rmSync(dir, { recursive: true, force: true }); @@ -602,7 +587,7 @@ describe("readDbToml", () => { ); it.effect( - "aborts the whole config load on a non-scalar db.migrations.schema_paths element (Go mapstructure UnconvertibleTypeError)", + "aborts the whole config load on a non-scalar db.migrations.schema_paths element", () => { // Unlike a bool/number (weakly coerced above), a nested array/table fails the whole // config load rather than dropping just that element. @@ -614,7 +599,7 @@ describe("readDbToml", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(JSON.stringify(exit.cause)).toContain( - "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 {}'", + "failed to parse config:\\ndb.migrations.schema_paths[0]: expected a string, got array", ); } rmSync(dir, { recursive: true, force: true }); @@ -625,7 +610,7 @@ describe("readDbToml", () => { ); it.effect( - "aborts the whole config load on a table db.migrations.schema_paths element, reporting every bad index (Go mapstructure parity)", + "aborts the whole config load on a table db.migrations.schema_paths element, reporting every bad index", () => { const dir = withConfig( ["[db.migrations]", 'schema_paths = ["schemas/*.sql", { path = "x.sql" }]', ""].join("\n"), @@ -637,7 +622,7 @@ describe("readDbToml", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(JSON.stringify(exit.cause)).toContain( - "'db.migrations.schema_paths[1]' expected type 'string', got unconvertible type 'map[string]interface {}'", + "db.migrations.schema_paths[1]: expected a string, got table", ); } rmSync(dir, { recursive: true, force: true }); @@ -658,7 +643,7 @@ describe("readDbToml", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(JSON.stringify(exit.cause)).toContain( - "'db.seed.sql_paths[0]' expected type 'string', got unconvertible type '[]interface {}'", + "db.seed.sql_paths[0]: expected a string, got array", ); } rmSync(dir, { recursive: true, force: true }); @@ -669,7 +654,7 @@ 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)", + "aggregates non-string-entry issues from BOTH db.seed.sql_paths and db.migrations.schema_paths in one error", () => { const dir = withConfig( ["[db.seed]", "sql_paths = [[]]", "", "[db.migrations]", "schema_paths = [[]]", ""].join( @@ -683,10 +668,8 @@ describe("readDbToml", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const message = JSON.stringify(exit.cause); - const schemaIssue = - "'db.migrations.schema_paths[0]' expected type 'string', got unconvertible type '[]interface {}'"; - const seedIssue = - "'db.seed.sql_paths[0]' expected type 'string', got unconvertible type '[]interface {}'"; + const schemaIssue = "db.migrations.schema_paths[0]: expected a string, got array"; + const seedIssue = "db.seed.sql_paths[0]: expected a string, got array"; expect(message).toContain(schemaIssue); expect(message).toContain(seedIssue); expect(message.indexOf(schemaIssue)).toBeLessThan(message.indexOf(seedIssue)); @@ -729,7 +712,7 @@ describe("readDbToml", () => { }, ); - it.effect("decodes a numeric db.seed.enabled = 0 as false (Go weak-bool decode)", () => { + it.effect("decodes a numeric db.seed.enabled = 0 as false", () => { const dir = withConfig(["[db.seed]", "enabled = 0", ""].join("\n")); return read(dir).pipe( Effect.tap((v) => @@ -753,22 +736,19 @@ describe("readDbToml", () => { ); }); - it.effect( - "decodes a numeric experimental.pgdelta.enabled = 1 as true (Go weak-bool decode)", - () => { - const dir = withConfig(["[experimental.pgdelta]", "enabled = 1", ""].join("\n")); - return read(dir).pipe( - Effect.tap((v) => - Effect.sync(() => { - expect(v.pgDelta.enabled).toBe(true); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); + it.effect("decodes a numeric experimental.pgdelta.enabled = 1 as true", () => { + const dir = withConfig(["[experimental.pgdelta]", "enabled = 1", ""].join("\n")); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(v.pgDelta.enabled).toBe(true); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); - it.effect("rejects an explicit db.port = 0 (Go's Missing required field)", () => { + it.effect("rejects an explicit db.port = 0 as a missing required field", () => { const dir = withConfig(["[db]", "port = 0", ""].join("\n")); return read(dir).pipe( Effect.exit, @@ -787,8 +767,7 @@ describe("readDbToml", () => { }); it.effect("an explicit remote db.migrations.enabled beats SUPABASE_DB_MIGRATIONS_ENABLED", () => { - // Go applies each matched-remote key via v.Set (override tier) above AutomaticEnv, - // so an explicit remote value wins over the env var. + // A matched remote's keys override the environment, so an explicit remote value wins over the env var. const ref = "abcdefghijklmnopqrst"; const previous = process.env["SUPABASE_DB_MIGRATIONS_ENABLED"]; process.env["SUPABASE_DB_MIGRATIONS_ENABLED"] = "false"; @@ -832,7 +811,7 @@ describe("readDbToml", () => { ); }); - it.effect("collapses. and .. in relative db.migrations.schema_paths like Go's path.Join", () => { + it.effect("collapses. and .. in relative db.migrations.schema_paths", () => { const dir = withConfig( [ "[db.migrations]", @@ -877,7 +856,7 @@ describe("readDbToml", () => { }, ); - it.effect("defaults db.migrations.schema_paths to [] when absent (Go's Glob zero value)", () => { + it.effect("defaults db.migrations.schema_paths to [] when absent", () => { const dir = withConfig(["[db]", "port = 54322", ""].join("\n")); return read(dir).pipe( Effect.tap((v) => @@ -1385,7 +1364,7 @@ describe("readDbToml", () => { ); }); - it.effect("rejects deno_version = 0 with Go's missing-required message", () => { + it.effect("rejects deno_version = 0 with the missing-required message", () => { const dir = withConfig(["[edge_runtime]", "deno_version = 0", ""].join("\n")); return read(dir).pipe( Effect.exit, @@ -1494,7 +1473,7 @@ describe("readDbToml", () => { ); }); - it.effect("accepts an underscore bucket name like Go's permissive pattern", () => { + it.effect("accepts an underscore bucket name", () => { // 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"); @@ -1534,7 +1513,7 @@ describe("readDbToml", () => { ); }); - it.effect("parses [api] auto_expose_new_tables string with Go bool tokens (TRUE → true)", () => { + it.effect("parses [api] auto_expose_new_tables string with bool tokens (TRUE → 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( @@ -1655,7 +1634,7 @@ describe("readDbToml", () => { ); }); - it.effect("treats SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED=1 as true (Go strconv.ParseBool)", () => { + it.effect("treats SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED=1 as true", () => { const dir = withConfig(undefined); const saved = process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"]; process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"] = "1"; @@ -1675,7 +1654,7 @@ describe("readDbToml", () => { ); }); - it.effect("fails on a malformed SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED (Go config error)", () => { + it.effect("fails on a malformed SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED", () => { const dir = withConfig(undefined); const saved = process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"]; process.env["SUPABASE_EXPERIMENTAL_PGDELTA_ENABLED"] = "maybe"; @@ -1697,12 +1676,12 @@ describe("readDbToml", () => { ); }); - it.effect("parses [auth] enabled string forms via Go ParseBool and fails on malformed", () => { + it.effect("parses [auth] enabled string forms via bool parsing and fails on malformed", () => { const ok = withConfig(["[auth]", 'enabled = "0"', ""].join("\n")); const bad = withConfig(["[storage]", 'enabled = "nope"', ""].join("\n")); return Effect.gen(function* () { const v = yield* read(ok); - expect(v.baseline.authEnabled).toBe(false); // "0" → false (ParseBool) + expect(v.baseline.authEnabled).toBe(false); // "0" → false const exit = yield* read(bad).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { @@ -1774,7 +1753,7 @@ describe("readDbToml", () => { ); }); - it.effect("expands env(VAR) for password and port like Go's LoadEnvHook", () => { + it.effect("expands env(VAR) for password and port", () => { process.env["DB_PW"] = "from-env"; process.env["DB_PORT"] = "6000"; const dir = withConfig( @@ -1807,7 +1786,7 @@ describe("readDbToml", () => { ); }); - it.effect("honors SUPABASE_DB_SEED_ENABLED over the TOML value (Go AutomaticEnv)", () => { + it.effect("honors SUPABASE_DB_SEED_ENABLED over the TOML value", () => { process.env["SUPABASE_DB_SEED_ENABLED"] = "false"; const dir = withConfig(["[db.seed]", "enabled = true", ""].join("\n")); return read(dir).pipe( @@ -1821,7 +1800,7 @@ describe("readDbToml", () => { ); }); - it.effect("expands an env() indirection in SUPABASE_DB_SEED_ENABLED (Go LoadEnvHook)", () => { + it.effect("expands an env() indirection in SUPABASE_DB_SEED_ENABLED", () => { process.env["SUPABASE_DB_SEED_ENABLED"] = "env(SEED_ON)"; process.env["SEED_ON"] = "false"; const dir = withConfig(["[db.seed]", "enabled = true", ""].join("\n")); @@ -1837,7 +1816,7 @@ describe("readDbToml", () => { ); }); - it.effect("honors SUPABASE_DB_MIGRATIONS_ENABLED over the default (Go AutomaticEnv)", () => { + it.effect("honors SUPABASE_DB_MIGRATIONS_ENABLED over the default", () => { process.env["SUPABASE_DB_MIGRATIONS_ENABLED"] = "false"; const dir = withConfig(undefined); return read(dir).pipe( @@ -1866,22 +1845,19 @@ describe("readDbToml", () => { ); }); - it.effect( - "expands env(VAR) for the top-level project_id (Go config.Load before Docker IDs)", - () => { - process.env["PROJECT_REF"] = "abcdefghijklmnopqrst"; - const dir = withConfig(['project_id = "env(PROJECT_REF)"', ""].join("\n")); - return read(dir).pipe( - Effect.tap((v) => - Effect.sync(() => { - expect(Option.getOrNull(v.projectId)).toBe("abcdefghijklmnopqrst"); - delete process.env["PROJECT_REF"]; - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); + it.effect("expands env(VAR) for the top-level project_id", () => { + process.env["PROJECT_REF"] = "abcdefghijklmnopqrst"; + const dir = withConfig(['project_id = "env(PROJECT_REF)"', ""].join("\n")); + return read(dir).pipe( + Effect.tap((v) => + Effect.sync(() => { + expect(Option.getOrNull(v.projectId)).toBe("abcdefghijklmnopqrst"); + delete process.env["PROJECT_REF"]; + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); it.effect("does not merge a remote block whose project_id is a TOML env() literal", () => { // Remote matching happens on the raw `env(...)` literal, before expansion, so this block is @@ -2200,7 +2176,7 @@ describe("readDbToml", () => { }, ); - it.effect("fails when a present shadow_port cannot unmarshal into a uint16", () => { + it.effect("fails when a present shadow_port is not a valid port number", () => { const dir = withConfig(["[db]", "port = 5000", 'shadow_port = "nope"', ""].join("\n")); return read(dir).pipe( Effect.exit, @@ -2216,7 +2192,7 @@ describe("readDbToml", () => { ); }); - it.effect("resolves env(VAR) from the project supabase/.env file (Go loadNestedEnv)", () => { + it.effect("resolves env(VAR) from the project supabase/.env file", () => { delete process.env["DB_FILEVAR"]; const dir = withConfig( ["[db]", 'port = "env(DB_FILEVAR)"', 'password = "env(DB_FILEVAR)"', ""].join("\n"), @@ -2233,7 +2209,7 @@ describe("readDbToml", () => { ); }); - it.effect("lets the shell env win over a project .env value (godotenv no-override)", () => { + it.effect("lets the shell env win over a project .env value (no override)", () => { process.env["DB_FILEVAR"] = "shell-wins"; const dir = withConfig(["[db]", 'password = "env(DB_FILEVAR)"', ""].join("\n")); writeFileSync(join(dir, "supabase", ".env"), "DB_FILEVAR=from-file\n"); @@ -2248,7 +2224,7 @@ describe("readDbToml", () => { ); }); - it.effect("lets supabase/.env win over a repo-root .env (Go walks supabase/ first)", () => { + it.effect("lets supabase/.env win over a repo-root .env", () => { delete process.env["DB_FILEVAR"]; const dir = withConfig(["[db]", 'password = "env(DB_FILEVAR)"', ""].join("\n")); writeFileSync(join(dir, ".env"), "DB_FILEVAR=root\n"); @@ -2298,7 +2274,7 @@ describe("readDbToml", () => { ); }); - it.effect("lets SUPABASE_DB_* env vars override the [db] config (viper AutomaticEnv)", () => { + it.effect("lets SUPABASE_DB_* env vars override the [db] config", () => { const prev = { PORT: process.env["SUPABASE_DB_PORT"], SHADOW: process.env["SUPABASE_DB_SHADOW_PORT"], @@ -2347,7 +2323,7 @@ describe("readDbToml", () => { ); }); - it.effect("rejects db.major_version = 0 with Go's missing-required message", () => { + it.effect("rejects db.major_version = 0 with the missing-required message", () => { const dir = withConfig(["[db]", "major_version = 0", ""].join("\n")); return read(dir).pipe( Effect.exit, @@ -2365,7 +2341,7 @@ describe("readDbToml", () => { ); }); - it.effect("rejects db.major_version = 12 with Go's 12.x message", () => { + it.effect("rejects db.major_version = 12 with the 12.x message", () => { const dir = withConfig(["[db]", "major_version = 12", ""].join("\n")); return read(dir).pipe( Effect.exit, @@ -2429,7 +2405,7 @@ describe("readDbToml", () => { ); }); - it.effect("expands env(VAR) for db.major_version like Go's LoadEnvHook", () => { + it.effect("expands env(VAR) for db.major_version", () => { process.env["PG_MAJOR"] = "15"; const dir = withConfig(["[db]", 'major_version = "env(PG_MAJOR)"', ""].join("\n")); return read(dir).pipe( @@ -2493,7 +2469,7 @@ describe("readDbToml", () => { ); }); - it.effect("rejects a malformed [remotes.*] project_id on every load (Go Validate)", () => { + it.effect("rejects a malformed [remotes.*] project_id on every load", () => { const dir = withConfig(["[remotes.staging]", 'project_id = "staging"', ""].join("\n")); return read(dir).pipe( Effect.exit, @@ -2525,7 +2501,7 @@ describe("readDbToml", () => { ); }); - it.effect("ignores an empty SUPABASE_DB_PORT override (viper AllowEmptyEnv=false)", () => { + it.effect("ignores an empty SUPABASE_DB_PORT override", () => { const prev = process.env["SUPABASE_DB_PORT"]; process.env["SUPABASE_DB_PORT"] = ""; const dir = withConfig(["[db]", "port = 55555", ""].join("\n")); @@ -2543,7 +2519,7 @@ describe("readDbToml", () => { it.effect("loadProjectEnv surfaces SUPABASE_DB_PASSWORD from .env (linked-path source)", () => { // The --linked resolver reads SUPABASE_DB_PASSWORD via this map, so a value - // defined only in supabase/.env must be visible (Go's loadNestedEnv parity). + // defined only in supabase/.env must be visible. delete process.env["SUPABASE_DB_PASSWORD"]; const dir = mkdtempSync(join(tmpdir(), "db-toml-")); mkdirSync(join(dir, "supabase"), { recursive: true }); @@ -2612,7 +2588,7 @@ describe("readDbToml", () => { }, ); - it.effect("ignores a [db.pooler] connection_string in config.toml (Go reads .temp only)", () => { + it.effect("ignores a [db.pooler] connection_string in config.toml (only .temp is read)", () => { const dir = withConfig( [ "[db.pooler]", @@ -2727,7 +2703,7 @@ describe("resolveDeclarativeDir", () => { ); }); -describe("readDbToml auth.Enabled validation (Go config.Validate parity)", () => { +describe("readDbToml auth.Enabled validation", () => { // Fails the config load with `message` contained in the surfaced error. const failsWith = ( lines: ReadonlyArray, @@ -2754,7 +2730,7 @@ describe("readDbToml auth.Enabled validation (Go config.Validate parity)", () => it.effect("rejects an explicit empty auth.site_url", () => failsWith(["[auth]", 'site_url = ""'], "Missing required field in config: auth.site_url"), ); - it.effect("defaults an absent auth.site_url (Go template default) — no error", () => + it.effect("defaults an absent auth.site_url — no error", () => succeeds(["[auth]", "enabled = true"]), ); it.effect("skips all auth validation when auth.enabled = false", () => @@ -2933,7 +2909,7 @@ describe("readDbToml auth.Enabled validation (Go config.Validate parity)", () => ), ); - it.effect("defaults [auth.email.smtp] enabled=true when the table omits enabled (Go merge)", () => + it.effect("defaults [auth.email.smtp] enabled=true when the table omits enabled", () => failsWith( ["[auth.email.smtp]", 'user = "u"'], "Missing required field in config: auth.email.smtp.host", @@ -2968,15 +2944,15 @@ describe("readDbToml auth.Enabled validation (Go config.Validate parity)", () => ), ); - it.effect("rejects an unknown captcha provider (Go enum, regardless of enabled)", () => + it.effect("rejects an unknown captcha provider, regardless of enabled", () => failsWith( ["[auth.captcha]", "enabled = false", 'provider = "cloudflare"'], - "'auth.captcha.provider' must be one of [hcaptcha turnstile]", + "auth.captcha.provider: must be one of hcaptcha, turnstile", ), ); }); -describe("readDbToml encrypted secret decryption (Go DecryptSecretHookFunc parity)", () => { +describe("readDbToml encrypted secret decryption", () => { // An undecryptable `encrypted:` value anywhere in config.toml aborts the load with // `failed to parse config: `. const expectFails = (lines: ReadonlyArray, message: string) => @@ -3015,7 +2991,7 @@ describe("readDbToml encrypted secret decryption (Go DecryptSecretHookFunc parit it.effect("accepts a plain (non-encrypted) secret value", () => expectLoads(["[db]", 'root_key = "plaintext-not-encrypted"']), ); - it.effect("treats an unset env() secret as a no-op (verbatim, like Go's hook)", () => + it.effect("treats an unset env() secret as a no-op (verbatim)", () => expectLoads(["[db]", 'root_key = "env(SOME_UNSET_ROOT_KEY)"']), ); it.effect("does NOT decrypt a non-secret string that starts with encrypted:", () => @@ -3060,7 +3036,7 @@ describe("readDbToml encrypted secret decryption (Go DecryptSecretHookFunc parit ); }); -describe("readDbToml non-scalar config booleans (Go UnmarshalExact parity)", () => { +describe("readDbToml non-scalar config booleans", () => { // 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, field: string) => @@ -3081,7 +3057,7 @@ describe("readDbToml non-scalar config booleans (Go UnmarshalExact parity)", () ); }); -describe("readDbToml empty project_id (Go config.Validate parity)", () => { +describe("readDbToml empty project_id", () => { it.effect("rejects a present-but-empty top-level project_id", () => { const dir = withConfig('project_id = ""\n'); return read(dir).pipe( @@ -3113,7 +3089,7 @@ describe("readDbToml empty project_id (Go config.Validate parity)", () => { }); }); -describe("readDbToml [analytics] validation (Go config.Validate parity)", () => { +describe("readDbToml [analytics] validation", () => { const failsWith = (lines: ReadonlyArray, message: string) => Effect.gen(function* () { const dir = withConfig(lines.join("\n")); @@ -3133,7 +3109,7 @@ describe("readDbToml [analytics] validation (Go config.Validate parity)", () => it.effect("rejects an unknown analytics.backend regardless of enabled", () => failsWith( ["[analytics]", "enabled = false", 'backend = "clickhouse"'], - "'analytics.backend' must be one of [postgres bigquery]", + "analytics.backend: must be one of postgres, bigquery", ), ); it.effect("rejects bigquery analytics missing gcp_project_id", () => @@ -3196,7 +3172,7 @@ describe("readDbToml [analytics] validation (Go config.Validate parity)", () => }); }); -describe("readDbToml SUPABASE_PROJECT_ID override (Go AutomaticEnv parity)", () => { +describe("readDbToml SUPABASE_PROJECT_ID override", () => { const restore = (previous: string | undefined) => Effect.sync(() => { if (previous === undefined) delete process.env["SUPABASE_PROJECT_ID"]; @@ -3241,7 +3217,7 @@ describe("readDbToml SUPABASE_PROJECT_ID override (Go AutomaticEnv parity)", () ); }); - it.effect("ignores an empty SUPABASE_PROJECT_ID (viper AllowEmptyEnv=false)", () => { + it.effect("ignores an empty SUPABASE_PROJECT_ID", () => { const previous = process.env["SUPABASE_PROJECT_ID"]; process.env["SUPABASE_PROJECT_ID"] = ""; const dir = withConfig(['project_id = "toml-project"', ""].join("\n")); diff --git a/apps/cli/src/command-internal/db-connection.service.ts b/apps/cli/src/command-internal/db-connection.service.ts index abba632ff3..e743401536 100644 --- a/apps/cli/src/command-internal/db-connection.service.ts +++ b/apps/cli/src/command-internal/db-connection.service.ts @@ -143,7 +143,7 @@ export interface QueryResult { readonly fields: ReadonlyArray; /** * 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` + * in shortest round-trip form (exponent notation for very large or small magnitudes) while integer columns stay plain. Optional so other `queryRaw` * callers/mocks need not set it. */ readonly fieldTypeIds?: ReadonlyArray; 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 721ff27bc7..d91b73dd5a 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 @@ -428,37 +428,35 @@ describe("dbConnectionLayer connect failures", () => { }), ); - it.live( - "reproduces pgconn's server-error rendering for an auth failure and suggests SUPABASE_DB_PASSWORD", - () => - Effect.gen(function* () { - const server = yield* Effect.promise(() => - fakePostgresServer((socket) => { - socket.write( - errorResponse({ - S: "FATAL", - V: "FATAL", - C: "28P01", - M: 'password authentication failed for user "postgres"', - F: "auth.c", - L: "326", - R: "auth_failed", - }), - ); - socket.end(); - }), - ); - const error = yield* connectFailure({ port: server.port }).pipe( - Effect.ensuring(Effect.sync(server.close)), - ); - expect(error.message).toBe( - "failed to connect to postgres: failed to connect to `host=127.0.0.1 user=postgres database=postgres`: " + - 'server error (FATAL: password authentication failed for user "postgres" (SQLSTATE 28P01))', - ); - expect(error.suggestion).toBe(SUGGEST_ENV_VAR); - expect(error.message).not.toContain(SENTINEL_PASSWORD); - expect(error.retryable).toBeUndefined(); - }), + it.live("renders the server error for an auth failure and suggests SUPABASE_DB_PASSWORD", () => + Effect.gen(function* () { + const server = yield* Effect.promise(() => + fakePostgresServer((socket) => { + socket.write( + errorResponse({ + S: "FATAL", + V: "FATAL", + C: "28P01", + M: 'password authentication failed for user "postgres"', + F: "auth.c", + L: "326", + R: "auth_failed", + }), + ); + socket.end(); + }), + ); + const error = yield* connectFailure({ port: server.port }).pipe( + Effect.ensuring(Effect.sync(server.close)), + ); + expect(error.message).toBe( + "failed to connect to postgres: failed to connect to `host=127.0.0.1 user=postgres database=postgres`: " + + 'server error (FATAL: password authentication failed for user "postgres" (SQLSTATE 28P01))', + ); + expect(error.suggestion).toBe(SUGGEST_ENV_VAR); + expect(error.message).not.toContain(SENTINEL_PASSWORD); + expect(error.retryable).toBeUndefined(); + }), ); it.live( @@ -485,7 +483,7 @@ describe("dbConnectionLayer connect failures", () => { describe("dbConnectionLayer exec failures", () => { it.live( - "maps a real wire ErrorResponse to pgconn's PgError rendering with detail and position", + "maps a real wire ErrorResponse to the server-error 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 → 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 e8d99fea43..b6db3a0133 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 @@ -1031,7 +1031,7 @@ 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 %g-style formatting while integer columns stay plain. + // in shortest round-trip form 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 72e59e03d1..1aaf92ac78 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 @@ -76,7 +76,7 @@ describe("buildConnectionUrl", () => { ); }); - it("forwards runtimeParams as -c flags in the options startup param (Go RuntimeParams)", () => { + it("forwards runtimeParams as -c flags in the options startup param", () => { const url = buildConnectionUrl( { user: "postgres", @@ -135,7 +135,7 @@ describe("sslOptionFor", () => { expect(sslOptionFor(undefined, false, undefined)).toEqual({ rejectUnauthorized: false }); }); - it("treats prefer/require as TLS without verification (their pgconn primary)", () => { + it("treats prefer/require as TLS without verification (their primary mode)", () => { expect(sslOptionFor("prefer", false, undefined)).toEqual({ rejectUnauthorized: false }); expect(sslOptionFor("require", false, undefined)).toEqual({ rejectUnauthorized: false }); }); @@ -151,7 +151,7 @@ describe("sslOptionFor", () => { }); }); - it("verifies the CA chain but skips hostname for verify-ca (pgconn parity)", () => { + it("verifies the CA chain but skips hostname for verify-ca", () => { const ssl = sslOptionFor("verify-ca", false, undefined); expect(ssl).toMatchObject({ rejectUnauthorized: true }); if (typeof ssl === "object" && ssl !== null) { @@ -160,7 +160,7 @@ describe("sslOptionFor", () => { } }); - it("attaches the client cert (cert/key/passphrase) to every TLS mode (pgconn parity)", () => { + it("attaches the client cert (cert/key/passphrase) to every TLS mode", () => { const clientCert = { cert: "CERT", key: "KEY", passphrase: "pw" }; expect(sslOptionFor("verify-full", false, undefined, undefined, clientCert)).toMatchObject({ cert: "CERT", @@ -181,7 +181,7 @@ describe("sslOptionFor", () => { }); }); - it("carries the servername for non-verifying TLS modes too (Go enables sslsni by default)", () => { + it("carries the servername for non-verifying TLS modes too", () => { expect(sslOptionFor("require", false, "db.example.com")).toEqual({ rejectUnauthorized: false, servername: "db.example.com", @@ -203,7 +203,7 @@ describe("sslOptionFor", () => { }); }); -describe("sslConfigsFor (pgconn fallback list)", () => { +describe("sslConfigsFor (fallback list)", () => { it("local connections try a single plaintext (no-TLS) config", () => { expect(sslConfigsFor(undefined, true, undefined)).toEqual([false]); }); @@ -212,7 +212,7 @@ describe("sslConfigsFor (pgconn fallback list)", () => { expect(sslConfigsFor("disable", false, undefined)).toEqual([false]); }); - it("allow is plaintext primary with a TLS fallback ({nil, tlsConfig})", () => { + it("allow is plaintext primary with a TLS fallback (plaintext first, then TLS)", () => { expect(sslConfigsFor("allow", false, undefined)).toEqual([ false, { rejectUnauthorized: false }, @@ -433,7 +433,7 @@ describe("poolStepDownVerify", () => { expect(done).toBeUndefined(); }); - it("propagates a failing step-down to the pool callback so the checkout fails (Go AfterConnect parity)", async () => { + it("propagates a failing step-down to the pool callback so the checkout fails", async () => { const failure = new Error("permission denied to set role"); const client = { query: () => Promise.reject(failure) }; const done = await new Promise((resolve) => { @@ -520,14 +520,14 @@ describe("isUnixSocketHost", () => { expect(isUnixSocketHost("::1")).toBe(false); }); - it("treats an uppercase Windows drive path as a socket, lowercase as TCP (pgconn parity)", () => { + it("treats an uppercase Windows drive path as a socket, lowercase as TCP", () => { expect(isUnixSocketHost("C:\\pgsql")).toBe(true); expect(isUnixSocketHost("c:\\pgsql")).toBe(false); expect(isUnixSocketHost("C:")).toBe(false); }); }); -describe("isTerminalConnectError (pgconn fallback termination)", () => { +describe("isTerminalConnectError (fallback termination)", () => { it("terminates on auth/catalog/privilege SQLSTATEs carried on the error cause", () => { // The pg driver attaches the SQLSTATE as `code`; @effect/sql wraps it in `cause`. expect(isTerminalConnectError({ cause: { code: "28P01" } }, false)).toBe(true); @@ -535,7 +535,7 @@ describe("isTerminalConnectError (pgconn fallback termination)", () => { expect(isTerminalConnectError({ code: "42501" }, false)).toBe(true); }); - it("gates 28000 on the attempt having used TLS (pgconn fc.TLSConfig != nil)", () => { + it("gates 28000 on the attempt having used TLS (a TLS attempt is required)", () => { expect(isTerminalConnectError({ code: "28000" }, true)).toBe(true); expect(isTerminalConnectError({ code: "28000" }, false)).toBe(false); }); @@ -568,7 +568,7 @@ describe("toExecError (pg server-error extraction)", () => { }), }); - it("renders pgconn's PgError message and carries code, detail, and position", () => { + it("renders the server error message and carries code, detail, and position", () => { const error = toExecError( sqlErrorChain({ message: 'type "ltree" does not exist', @@ -584,7 +584,7 @@ describe("toExecError (pg server-error extraction)", () => { expect(error.position).toBe(25); }); - it("omits detail and position when the server sent none (Go's non-empty gates)", () => { + it("omits detail and position when the server sent none", () => { const error = toExecError( sqlErrorChain({ message: 'syntax error at or near "BOOM"', diff --git a/apps/cli/src/command-internal/db-image.unit.test.ts b/apps/cli/src/command-internal/db-image.unit.test.ts index d66aabd1b2..b66d3de899 100644 --- a/apps/cli/src/command-internal/db-image.unit.test.ts +++ b/apps/cli/src/command-internal/db-image.unit.test.ts @@ -68,7 +68,7 @@ describe("resolveDbImage", () => { }); }); - it.effect("rewrites to the OrioleDB image on a 15/17 project (Go config.Validate)", () => { + it.effect("rewrites to the OrioleDB image on a 15/17 project", () => { const dir = withTemp(); return Effect.gen(function* () { // > 15.1.1.13 → `-orioledb` 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 bc7f841570..6f5abb6126 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 @@ -135,7 +135,7 @@ describe("resolveDbTargetFlags", () => { expect(result.setFlags).toEqual(["local"]); }); - it("--schema -- --linked: -- consumed as schema value, --linked is a real flag (Go pflag parity)", () => { + it("--schema -- --linked: -- consumed as schema value, --linked is a real flag", () => { const result = resolveDbTargetFlags(["db", "lint", "--schema", "--", "--linked"]); expect(result.connType).toBe("linked"); expect(result.setFlags).toEqual(["linked"]); @@ -271,7 +271,7 @@ describe("changedLinkedLocalFlags", () => { expect(changedLinkedLocalFlags(["seed", "buckets", "--local"])).toEqual(["local"]); }); - it("returns both selectors in cobra's sorted order when both are set", () => { + it("returns both selectors in sorted order when both are set", () => { expect(changedLinkedLocalFlags(["seed", "buckets", "--local", "--linked"])).toEqual([ "linked", "local", diff --git a/apps/cli/src/command-internal/debug-logger.layer.ts b/apps/cli/src/command-internal/debug-logger.layer.ts index bc3c156247..8bf81d628c 100644 --- a/apps/cli/src/command-internal/debug-logger.layer.ts +++ b/apps/cli/src/command-internal/debug-logger.layer.ts @@ -5,7 +5,7 @@ import { DebugLogger } from "./debug-logger.service.ts"; const pad = (n: number): string => String(n).padStart(2, "0"); -/** Formats a timestamp matching Go's `log.LstdFlags`: `YYYY/MM/DD HH:MM:SS`. */ +/** Formats a timestamp as `YYYY/MM/DD HH:MM:SS`. */ function formatTimestamp(now: Date): string { return ( `${now.getFullYear()}/${pad(now.getMonth() + 1)}/${pad(now.getDate())} ` + diff --git a/apps/cli/src/command-internal/debug-logger.layer.unit.test.ts b/apps/cli/src/command-internal/debug-logger.layer.unit.test.ts index 8207e64115..67e7ef8628 100644 --- a/apps/cli/src/command-internal/debug-logger.layer.unit.test.ts +++ b/apps/cli/src/command-internal/debug-logger.layer.unit.test.ts @@ -46,7 +46,7 @@ describe("debugLoggerLayer", () => { ); }); - it.effect("http emits Go timestamp order and method/url format", () => { + it.effect("http emits timestamp, method and url in order", () => { vi.useFakeTimers(); vi.setSystemTime(new Date(2026, 5, 4, 8, 24, 47)); const stderr = captureStderr(); diff --git a/apps/cli/src/command-internal/detect-content-type.ts b/apps/cli/src/command-internal/detect-content-type.ts index 2c01986594..f6550503eb 100644 --- a/apps/cli/src/command-internal/detect-content-type.ts +++ b/apps/cli/src/command-internal/detect-content-type.ts @@ -1,6 +1,6 @@ /** * 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 + * byte-driven, not extension-driven, so * stored metadata stays reproducible. Signature order matters: first match wins. */ 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 70fc8d0483..8c93db206f 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 @@ -10,8 +10,6 @@ function bytes(s: string): Uint8Array { } describe("detectContentType", () => { - // Expected values were produced by running Go's `http.DetectContentType` over these same - // byte inputs. const corpus: ReadonlyArray = [ ["png", "\x89PNG\x0D\x0A\x1A\x0A\x00\x00", "image/png"], ["pdf", "%PDF-1.4\n...", "application/pdf"], @@ -37,7 +35,7 @@ describe("detectContentType", () => { ]; for (const [name, input, expected] of corpus) { - it(`matches Go http.DetectContentType for ${name}`, () => { + it(`detects the expected content type for ${name}`, () => { expect(detectContentType(bytes(input))).toBe(expected); }); } diff --git a/apps/cli/src/command-internal/diff-engine.unit.test.ts b/apps/cli/src/command-internal/diff-engine.unit.test.ts index 0c24c29cc4..c1b1978798 100644 --- a/apps/cli/src/command-internal/diff-engine.unit.test.ts +++ b/apps/cli/src/command-internal/diff-engine.unit.test.ts @@ -83,7 +83,7 @@ describe("resolvePullDiffEngine", () => { }); describe("parseBoolEnv", () => { - it("accepts only strconv.ParseBool truthy strings", () => { + it("accepts only the boolean truthy strings", () => { for (const v of ["1", "t", "T", "TRUE", "true", "True"]) { expect(parseBoolEnv(v)).toBe(true); } @@ -108,14 +108,14 @@ describe("resolveDeclarativeFromArgs", () => { expect(resolveDeclarativeFromArgs(["db", "pull", "--use-pg-delta"])).toBe(true); }); - it("parses an =value with strconv.ParseBool semantics", () => { + it("parses an =value with boolean semantics", () => { expect(resolveDeclarativeFromArgs(["--declarative=false"])).toBe(false); expect(resolveDeclarativeFromArgs(["--declarative=true"])).toBe(true); expect(resolveDeclarativeFromArgs(["--use-pg-delta=0"])).toBe(false); expect(resolveDeclarativeFromArgs(["--use-pg-delta=1"])).toBe(true); }); - it("lets the last occurrence win across both flag names (pflag single-variable bind)", () => { + it("lets the last occurrence win across both flag names", () => { expect(resolveDeclarativeFromArgs(["--declarative", "--use-pg-delta=false"])).toBe(false); expect(resolveDeclarativeFromArgs(["--use-pg-delta", "--declarative=false"])).toBe(false); expect(resolveDeclarativeFromArgs(["--declarative=false", "--use-pg-delta"])).toBe(true); 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 701e0740a6..a61cc4f195 100644 --- a/apps/cli/src/command-internal/docker-ids.unit.test.ts +++ b/apps/cli/src/command-internal/docker-ids.unit.test.ts @@ -10,7 +10,7 @@ import { localNetworkId, } from "./docker-ids.ts"; import { resolveDockerNetworkMode } from "../shared/functions/functions-docker.ts"; -import { viperEnvStringWithProjectFallback } from "./viper-env.ts"; +import { supabaseEnvStringWithProjectFallback } from "./supabase-env.ts"; describe("resolveLocalProjectId", () => { it("prefers SUPABASE_PROJECT_ID (env) over config.toml and the basename", () => { @@ -34,7 +34,7 @@ describe("resolveLocalProjectId", () => { }); describe("serviceContainerIds", () => { - it("returns the 13 service container ids in Go's GetDockerIds() order", () => { + it("returns the 13 service container ids in order", () => { expect(serviceContainerIds("my-app")).toEqual([ "supabase_kong_my-app", "supabase_auth_my-app", @@ -59,7 +59,7 @@ describe("serviceContainerIds", () => { }); describe("cliProjectFilterValue", () => { - it("returns the bare label when the project id is empty (Go's --all path)", () => { + it("returns the bare label when the project id is empty (the --all path)", () => { expect(cliProjectFilterValue("")).toBe(CLI_PROJECT_LABEL); }); @@ -74,7 +74,7 @@ describe("cliProjectFilterValue", () => { }); }); -describe("resolveDockerNetworkMode composed with viperEnvStringWithProjectFallback (start/db start call shape)", () => { +describe("resolveDockerNetworkMode composed with supabaseEnvStringWithProjectFallback (start/db start call shape)", () => { const KEY = "SUPABASE_NETWORK_ID"; afterEach(() => { @@ -84,7 +84,7 @@ describe("resolveDockerNetworkMode composed with viperEnvStringWithProjectFallba function resolve(flagValue: string | undefined, projectEnv: Record) { return resolveDockerNetworkMode({ explicit: flagValue, - envOverride: viperEnvStringWithProjectFallback(KEY, projectEnv), + envOverride: supabaseEnvStringWithProjectFallback(KEY, projectEnv), projectId: "my-app", }); } @@ -104,7 +104,7 @@ describe("resolveDockerNetworkMode composed with viperEnvStringWithProjectFallba expect(resolve(undefined, { [KEY]: "project-network" })).toBe("project-network"); }); - it("prefers the shell value over the project .env value (presence wins, matching godotenv.Load)", () => { + it("prefers the shell value over the project .env value (presence wins)", () => { process.env[KEY] = "shell-network"; expect(resolve(undefined, { [KEY]: "project-network" })).toBe("shell-network"); }); @@ -115,7 +115,7 @@ describe("resolveDockerNetworkMode composed with viperEnvStringWithProjectFallba expect(resolve("", {})).toBe(localNetworkId("my-app")); }); - it("an explicit-but-empty --network-id= skips the env var entirely (viper: a Changed pflag resolves before AutomaticEnv)", () => { + it("an explicit-but-empty --network-id= skips the env var entirely (an explicit flag beats the env var)", () => { process.env[KEY] = "env-network"; expect(resolve("", { [KEY]: "project-network" })).toBe(localNetworkId("my-app")); }); 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 97165a31c4..16ef30d51a 100644 --- a/apps/cli/src/command-internal/docker-lifecycle.unit.test.ts +++ b/apps/cli/src/command-internal/docker-lifecycle.unit.test.ts @@ -286,19 +286,16 @@ describe("inspectContainerState", () => { ); }); - it.live( - "treats a paused/restarting container as running, matching Go's boolean-based gate", - () => { - const mock = mockSpawner({ - stdout: JSON.stringify({ Status: "paused", Running: true, ExitCode: 0 }), - }); - return inspectContainerState(mock.spawner, "supabase_db_my-app").pipe( - Effect.map((state) => { - expect(state).toEqual({ running: true, status: "paused", exitCode: 0, oomKilled: false }); - }), - ); - }, - ); + it.live("treats a paused/restarting container as running", () => { + const mock = mockSpawner({ + stdout: JSON.stringify({ Status: "paused", Running: true, ExitCode: 0 }), + }); + return inspectContainerState(mock.spawner, "supabase_db_my-app").pipe( + Effect.map((state) => { + expect(state).toEqual({ running: true, status: "paused", exitCode: 0, oomKilled: false }); + }), + ); + }); it.live( "fails with DockerLifecycleInspectError, preserving the real stderr, when the container does not exist", 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 632948ed05..2253e9a04e 100644 --- a/apps/cli/src/command-internal/docker-registry.unit.test.ts +++ b/apps/cli/src/command-internal/docker-registry.unit.test.ts @@ -95,7 +95,7 @@ describe("getRegistryImageUrl", () => { ).toEqual(["my.mirror.example/supabase/postgres:17.6.1.138"]); }); - it("honors a projectEnvValues (dotenv)-only registry override, matching Go's post-Load os.Getenv", () => { + it("honors a projectEnvValues (dotenv)-only registry override", () => { expect( withRegistry(undefined, () => resolveImage("supabase/pg_prove:3.36", { diff --git a/apps/cli/src/command-internal/docker-run.args.unit.test.ts b/apps/cli/src/command-internal/docker-run.args.unit.test.ts index f38831461b..f448a9bc43 100644 --- a/apps/cli/src/command-internal/docker-run.args.unit.test.ts +++ b/apps/cli/src/command-internal/docker-run.args.unit.test.ts @@ -26,7 +26,7 @@ describe("applyBitbucketDockerFilter", () => { expect(applyBitbucketDockerFilter(pgDelta, false)).toBe(pgDelta); }); - test("drops named-volume binds and clears security-opt under Bitbucket (Go DockerStart)", () => { + test("drops named-volume binds and clears security-opt under Bitbucket", () => { const filtered = applyBitbucketDockerFilter(pgDelta, true); // Named Deno-cache volume dropped; the /repo:/workspace bind mount kept. expect(filtered.binds).toEqual(["/repo:/workspace"]); @@ -35,7 +35,7 @@ describe("applyBitbucketDockerFilter", () => { }); describe("buildDockerArgs", () => { - test("assembles run args in Go-parity order for a named network", () => { + test("assembles run args in order for a named network", () => { expect(buildDockerArgs(base)).toEqual([ "run", "--rm", @@ -114,7 +114,7 @@ describe("buildDockerArgs", () => { expect(buildDockerArgs(base)).not.toContain("--label"); }); - test("emits --label k=v for each entry, before the image (Go's DockerStart project labels)", () => { + test("emits --label k=v for each entry, before the image", () => { const args = buildDockerArgs({ ...base, labels: { 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 eb857679d8..52791dcf4d 100644 --- a/apps/cli/src/command-internal/docker-suggest.unit.test.ts +++ b/apps/cli/src/command-internal/docker-suggest.unit.test.ts @@ -4,7 +4,7 @@ import { containerRuntimeNotFoundMessage } from "./container-cli.ts"; import { SUGGEST_DOCKER_INSTALL, isDockerDaemonUnreachable } from "./docker-suggest.ts"; describe("isDockerDaemonUnreachable", () => { - it("detects the docker/podman daemon-down CLI messages (Go's IsErrConnectionFailed)", () => { + it("detects the docker/podman daemon-down CLI messages", () => { expect( isDockerDaemonUnreachable( "Cannot connect to the Docker daemon at unix:///var/run/docker.sock. Is the docker daemon running?", @@ -35,7 +35,7 @@ describe("isDockerDaemonUnreachable", () => { expect(isDockerDaemonUnreachable("")).toBe(false); }); - it("exposes Go's install hint verbatim", () => { + it("exposes the install hint verbatim", () => { expect(SUGGEST_DOCKER_INSTALL).toContain("https://docs.docker.com/desktop"); }); }); diff --git a/apps/cli/src/command-internal/dotenv.ts b/apps/cli/src/command-internal/dotenv.ts index 2fb5fbc016..77af4075c4 100644 --- a/apps/cli/src/command-internal/dotenv.ts +++ b/apps/cli/src/command-internal/dotenv.ts @@ -1,9 +1,9 @@ -// godotenv-compatible: `KEY=VALUE`/`KEY="VALUE"` lines, `#` comments, blank lines, and an +// Dotenv syntax: `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-compatible parser for project `.env` files. Throws when a variable name is + * Minimal dotenv parser for project `.env` files. Throws when a variable name is * empty or contains an invalid character. */ export function parseDotEnv(contents: string): Record { diff --git a/apps/cli/src/command-internal/dotenv.unit.test.ts b/apps/cli/src/command-internal/dotenv.unit.test.ts index 9f976b150c..c12e6f0ee8 100644 --- a/apps/cli/src/command-internal/dotenv.unit.test.ts +++ b/apps/cli/src/command-internal/dotenv.unit.test.ts @@ -11,7 +11,7 @@ describe("parseDotEnv", () => { }); }); - it("expands escape sequences in double-quoted values (godotenv parity)", () => { + it("expands escape sequences in double-quoted values", () => { expect(parseDotEnv('A="line1\\nline2"\nB="a\\"b\\\\c"')).toEqual({ A: "line1\nline2", B: 'a"b\\c', @@ -22,7 +22,7 @@ describe("parseDotEnv", () => { expect(parseDotEnv("A='line1\\nline2'")).toEqual({ A: "line1\\nline2" }); }); - it("strips unquoted inline comments preceded by whitespace (godotenv parity)", () => { + it("strips unquoted inline comments preceded by whitespace", () => { // A `#` after whitespace begins a comment; a `#` with no leading space is // part of the value. expect(parseDotEnv("DB_PORT=54323 # local db")).toEqual({ DB_PORT: "54323" }); @@ -35,7 +35,7 @@ describe("parseDotEnv", () => { expect(parseDotEnv("B='has # hash inside' # comment")).toEqual({ B: "has # hash inside" }); }); - it("accepts godotenv YAML-style colon assignments", () => { + it("accepts YAML-style colon assignments", () => { expect(parseDotEnv("SUPABASE_DB_PASSWORD: secret\nDB_PORT:54323")).toEqual({ SUPABASE_DB_PASSWORD: "secret", DB_PORT: "54323", @@ -48,11 +48,11 @@ describe("parseDotEnv", () => { expect(() => parseDotEnv('A="unterminated')).toThrow(/unterminated quoted value/); }); - it("throws Go's 'unexpected character' error on a malformed variable name", () => { + it("throws an 'unexpected character' error on a malformed variable name", () => { expect(() => parseDotEnv("!=")).toThrow(/unexpected character "!" in variable name/); }); - describe("multiline quoted values (godotenv parity)", () => { + describe("multiline quoted values", () => { it("scans a double-quoted value across newlines, preserving embedded newlines", () => { const pem = "-----BEGIN KEY-----\nline2\nline3\n-----END KEY-----"; const env = parseDotEnv(`KEY="${pem}"\nDB_PORT=54323`); @@ -78,7 +78,7 @@ describe("parseDotEnv", () => { }); }); - describe("variable expansion (godotenv parity)", () => { + describe("variable expansion", () => { it("expands $VAR and ${VAR} from earlier same-file definitions", () => { expect(parseDotEnv("DB_PORT=54323\nSUPABASE_DB_PORT=$DB_PORT\nURL=${DB_PORT}/db")).toEqual({ DB_PORT: "54323", diff --git a/apps/cli/src/command-internal/go-duration.ts b/apps/cli/src/command-internal/duration.ts similarity index 91% rename from apps/cli/src/command-internal/go-duration.ts rename to apps/cli/src/command-internal/duration.ts index f9ab1f3526..0e11cd8fc7 100644 --- a/apps/cli/src/command-internal/go-duration.ts +++ b/apps/cli/src/command-internal/duration.ts @@ -1,6 +1,5 @@ /** - * 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. + * Duration string parsing and formatting in the `1h30m0s` grammar with nanosecond rounding — the syntax the hosted API and container env vars expect. * * 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 @@ -15,7 +14,7 @@ const NS_PER_HOUR = 60 * NS_PER_MINUTE; const NS_PER_MS = 1_000_000; const NS_PER_US = 1_000; -// `BigInt` constants for `parseGoDuration`'s accumulator: near the int64 nanosecond ceiling +// `BigInt` constants for `parseDuration`'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; @@ -34,7 +33,7 @@ const MAX_INT64_NS = 9223372036854775807n; const UINT64_ACCUMULATOR_BOUND_NS = 1n << 63n; /** - * Parses a Go-style duration string to nanoseconds: a possibly-signed sequence of decimal + * Parses a 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. * @@ -43,7 +42,7 @@ const UINT64_ACCUMULATOR_BOUND_NS = 1n << 63n; * 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 { +export function parseDuration(value: string): number { const orig = value; let s = value; let neg = false; @@ -139,11 +138,11 @@ export function parseGoDuration(value: string): number { } /** - * Formats nanoseconds as a Go-style duration string. `0` formats as `"0s"`; otherwise only the + * Formats nanoseconds as a 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 { +export function formatDuration(nanoseconds: number): string { if (nanoseconds === 0) return "0s"; let ns = nanoseconds; @@ -202,10 +201,10 @@ function formatFraction(totalNs: number, unitNs: number): string { /** * 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 + * value, matching {@link parseDuration}; 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); + return Math.trunc(parseDuration(healthTimeout) / 1_000_000_000); } diff --git a/apps/cli/src/command-internal/duration.unit.test.ts b/apps/cli/src/command-internal/duration.unit.test.ts new file mode 100644 index 0000000000..c1390e67b5 --- /dev/null +++ b/apps/cli/src/command-internal/duration.unit.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it } from "vitest"; + +import { formatDuration, parseDuration } from "./duration.ts"; + +describe("parseDuration", () => { + it("parses a single unit", () => { + expect(parseDuration("5s")).toBe(5_000_000_000); + expect(parseDuration("300ms")).toBe(300_000_000); + expect(parseDuration("1h")).toBe(3_600_000_000_000); + }); + + it("parses multiple units concatenated", () => { + expect(parseDuration("1h30m")).toBe(5_400_000_000_000); + }); + + it("parses a fractional value", () => { + expect(parseDuration("1.5s")).toBe(1_500_000_000); + }); + + it("parses a signed value", () => { + expect(parseDuration("-5s")).toBe(-5_000_000_000); + expect(parseDuration("+5s")).toBe(5_000_000_000); + }); + + it('treats a bare "0" as zero regardless of sign', () => { + expect(parseDuration("0")).toBe(0); + }); + + it("rejects an empty string", () => { + expect(() => parseDuration("")).toThrow('time: invalid duration ""'); + }); + + it.each(["s", "m", "h", "ms", "us", "µs", "μs", "ns"])( + 'rejects a bare unit with no preceding digit ("%s")', + (input) => { + expect(() => parseDuration(input)).toThrow(`time: invalid duration "${input}"`); + }, + ); + + it.each(["us", "µs", "μs"])('accepts every microsecond unit spelling ("1%s")', (unit) => { + expect(parseDuration(`1${unit}`)).toBe(1_000); + }); + + it('rejects a bare unit following a valid unit ("1hs")', () => { + expect(() => parseDuration("1hs")).toThrow('time: invalid duration "1hs"'); + }); + + it('rejects a negative bare unit ("-s")', () => { + expect(() => parseDuration("-s")).toThrow('time: invalid duration "-s"'); + }); + + it.each([".s", ".", "-.", "+.", ".h"])('rejects a lone "." with no digits ("%s")', (input) => { + expect(() => parseDuration(input)).toThrow(`time: invalid duration "${input}"`); + }); + + it('accepts a fraction-only value with no leading digit (".5s")', () => { + expect(parseDuration(".5s")).toBe(500_000_000); + }); + + it("rejects a number with no unit", () => { + expect(() => parseDuration("5")).toThrow('time: missing unit in duration "5"'); + }); + + it("rejects an unknown unit", () => { + expect(() => parseDuration("5x")).toThrow('time: unknown unit in duration "5x"'); + }); + + it('truncates a sub-nanosecond fraction instead of rounding ("0.5ns")', () => { + expect(parseDuration("0.5ns")).toBe(0); + }); + + it('truncates a sub-nanosecond fraction instead of rounding ("1.9ns")', () => { + expect(parseDuration("1.9ns")).toBe(1); + }); + + it('rounds a long fractional remainder up ("0.999999999999999999s")', () => { + expect(parseDuration("0.999999999999999999s")).toBe(1_000_000_000); + }); + + it('rejects a duration that overflows math.MaxInt64 nanoseconds ("2562048h")', () => { + expect(() => parseDuration("2562048h")).toThrow('time: invalid duration "2562048h"'); + }); + + it("accepts the maximum parseable duration without overflowing", () => { + expect(() => parseDuration("2562047h47m16.854775807s")).not.toThrow(); + }); + + it('rejects a duration exactly 1ns past the int64 maximum ("9223372036854775808ns")', () => { + expect(() => parseDuration("9223372036854775808ns")).toThrow( + 'time: invalid duration "9223372036854775808ns"', + ); + }); + + it("accepts a duration exactly at the int64 maximum in nanoseconds", () => { + expect(() => parseDuration("9223372036854775807ns")).not.toThrow(); + }); + + it("accepts the minimum representable negative duration (int64 minimum ns)", () => { + expect(parseDuration("-9223372036854775808ns")).toBe(-9223372036854775808); + }); + + it("accepts the equivalent hours/minutes/seconds form of math.MinInt64 ns", () => { + expect(parseDuration("-2562047h47m16.854775808s")).toBe(-9223372036854775808); + }); + + it("rejects a duration 1ns past math.MinInt64", () => { + expect(() => parseDuration("-9223372036854775809ns")).toThrow( + 'time: invalid duration "-9223372036854775809ns"', + ); + }); +}); + +describe("formatDuration", () => { + it('formats zero as "0s"', () => { + expect(formatDuration(0)).toBe("0s"); + }); + + it("formats whole hours/minutes/seconds", () => { + expect(formatDuration(3_600_000_000_000)).toBe("1h0m0s"); + }); + + it("normalizes 90s to 1m30s", () => { + expect(formatDuration(90_000_000_000)).toBe("1m30s"); + }); + + it("formats a sub-second fraction", () => { + expect(formatDuration(1_500_000_000)).toBe("1.5s"); + }); + + it("round-trips through parseDuration", () => { + expect(formatDuration(parseDuration("1h"))).toBe("1h0m0s"); + expect(formatDuration(parseDuration("90s"))).toBe("1m30s"); + }); + + it("includes a sub-second fraction alongside minutes", () => { + expect(formatDuration(parseDuration("1m0.5s"))).toBe("1m0.5s"); + }); + + it("includes a sub-second fraction alongside hours", () => { + expect(formatDuration(parseDuration("1h0.5s"))).toBe("1h0m0.5s"); + }); + + it("includes a sub-second fraction alongside hours and minutes", () => { + expect(formatDuration(parseDuration("1h1m0.5s"))).toBe("1h1m0.5s"); + }); +}); diff --git a/apps/cli/src/command-internal/edge-runtime-script.unit.test.ts b/apps/cli/src/command-internal/edge-runtime-script.unit.test.ts index 4a2ede6846..d7f3e67eff 100644 --- a/apps/cli/src/command-internal/edge-runtime-script.unit.test.ts +++ b/apps/cli/src/command-internal/edge-runtime-script.unit.test.ts @@ -16,7 +16,7 @@ describe("buildEdgeRuntimeStartCmd", () => { ]); }); - it("drops --port when allocation failed (Go preserves prior behaviour)", () => { + it("drops --port when allocation failed", () => { expect(buildEdgeRuntimeStartCmd({ port: Option.none(), debug: false })).toEqual([ "edge-runtime", "start", 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 c0cdc0f60d..d34e016979 100644 --- a/apps/cli/src/command-internal/experimental-gate.unit.test.ts +++ b/apps/cli/src/command-internal/experimental-gate.unit.test.ts @@ -14,7 +14,7 @@ describe("requireExperimental", () => { requireExperimental.pipe(Effect.provide(withFlag(true))), ); - it.effect("fails with Go's byte-exact message when neither flag nor env is set", () => + it.effect("fails with the gate message when neither flag nor env is set", () => Effect.gen(function* () { const saved = process.env[ENV]; delete process.env[ENV]; @@ -26,7 +26,7 @@ describe("requireExperimental", () => { }), ); - it.effect("passes when SUPABASE_EXPERIMENTAL=1 even without the flag (viper AutomaticEnv)", () => + it.effect("passes when SUPABASE_EXPERIMENTAL=1 even without the flag", () => Effect.gen(function* () { const saved = process.env[ENV]; process.env[ENV] = "1"; @@ -37,20 +37,18 @@ describe("requireExperimental", () => { }), ); - it.effect( - "fails even with SUPABASE_EXPERIMENTAL=1 when --experimental=false is explicit (viper Changed wins)", - () => - Effect.gen(function* () { - const saved = process.env[ENV]; - process.env[ENV] = "1"; - const error = yield* requireExperimental.pipe( - Effect.provide(withFlag(false, ["--experimental=false"])), - Effect.flip, - ); - if (saved === undefined) delete process.env[ENV]; - else process.env[ENV] = saved; - expect(error).toBeInstanceOf(ExperimentalRequiredError); - }), + it.effect("fails even with SUPABASE_EXPERIMENTAL=1 when --experimental=false is explicit", () => + Effect.gen(function* () { + const saved = process.env[ENV]; + process.env[ENV] = "1"; + const error = yield* requireExperimental.pipe( + Effect.provide(withFlag(false, ["--experimental=false"])), + Effect.flip, + ); + if (saved === undefined) delete process.env[ENV]; + else process.env[ENV] = saved; + expect(error).toBeInstanceOf(ExperimentalRequiredError); + }), ); it.effect( @@ -69,32 +67,28 @@ describe("requireExperimental", () => { }), ); - it.effect( - "a repeated --experimental=false --experimental=true keeps the LAST occurrence (viper Set() wins)", - () => - Effect.gen(function* () { - const exit = yield* requireExperimental.pipe( - Effect.provide( - withFlag(false, ["db", "pull", "--experimental=false", "--experimental=true"]), - ), - Effect.exit, - ); - expect(exit._tag).toBe("Success"); - }), + it.effect("a repeated --experimental=false --experimental=true keeps the LAST occurrence", () => + Effect.gen(function* () { + const exit = yield* requireExperimental.pipe( + Effect.provide( + withFlag(false, ["db", "pull", "--experimental=false", "--experimental=true"]), + ), + Effect.exit, + ); + expect(exit._tag).toBe("Success"); + }), ); - it.effect( - "a repeated --experimental=true --experimental=false keeps the LAST occurrence (viper Set() wins)", - () => - Effect.gen(function* () { - const error = yield* requireExperimental.pipe( - Effect.provide( - withFlag(true, ["db", "pull", "--experimental=true", "--experimental=false"]), - ), - Effect.flip, - ); - expect(error).toBeInstanceOf(ExperimentalRequiredError); - }), + it.effect("a repeated --experimental=true --experimental=false keeps the LAST occurrence", () => + Effect.gen(function* () { + const error = yield* requireExperimental.pipe( + Effect.provide( + withFlag(true, ["db", "pull", "--experimental=true", "--experimental=false"]), + ), + Effect.flip, + ); + expect(error).toBeInstanceOf(ExperimentalRequiredError); + }), ); it.effect("a repeated --experimental=false --experimental (bare) keeps the LAST occurrence", () => diff --git a/apps/cli/src/command-internal/fail-on.unit.test.ts b/apps/cli/src/command-internal/fail-on.unit.test.ts index 5bf34de451..497cdf2b10 100644 --- a/apps/cli/src/command-internal/fail-on.unit.test.ts +++ b/apps/cli/src/command-internal/fail-on.unit.test.ts @@ -35,7 +35,7 @@ describe("makeLevelEnum (exact-ci matcher — db advisors)", () => { expect(advisors.toEnum("ERROR")).toBe(2); }); - it("does NOT match a mixed-case level (Go's switch is exact)", () => { + it("does NOT match a mixed-case level (matching is exact)", () => { expect(advisors.toEnum("Info")).toBe(-1); expect(advisors.toEnum("warning")).toBe(-1); }); diff --git a/apps/cli/src/command-internal/go-float.ts b/apps/cli/src/command-internal/format-float.ts similarity index 61% rename from apps/cli/src/command-internal/go-float.ts rename to apps/cli/src/command-internal/format-float.ts index 286cef0f98..d180250dff 100644 --- a/apps/cli/src/command-internal/go-float.ts +++ b/apps/cli/src/command-internal/format-float.ts @@ -1,14 +1,14 @@ /** - * 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. + * Formats a number in its shortest round-trip form, switching to exponent notation when the + * decimal exponent is `< -4` or `>= 6` (`1000000` → `1e+06`), with a signed, at-least-two-digit + * exponent. * * Shared by `db query`'s value formatter and `postgres-config`'s pretty table. */ -export function goFormatFloat(n: number): string { +export function formatGeneralFloat(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; `n === 0` is true for both `+0` and `-0`. + // Negative zero keeps its sign; `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/format-float.unit.test.ts b/apps/cli/src/command-internal/format-float.unit.test.ts new file mode 100644 index 0000000000..d82379056d --- /dev/null +++ b/apps/cli/src/command-internal/format-float.unit.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { formatGeneralFloat } from "./format-float.ts"; + +describe("formatGeneralFloat", () => { + it("renders fixed notation within the [-4, 6) decimal-exponent range", () => { + expect(formatGeneralFloat(100)).toBe("100"); + expect(formatGeneralFloat(100000)).toBe("100000"); + expect(formatGeneralFloat(0.5)).toBe("0.5"); + expect(formatGeneralFloat(0.0001)).toBe("0.0001"); + }); + + it("switches to signed exponent notation at exponent >= 6 or < -4", () => { + expect(formatGeneralFloat(1000000)).toBe("1e+06"); + expect(formatGeneralFloat(100000000000)).toBe("1e+11"); + expect(formatGeneralFloat(123456789)).toBe("1.23456789e+08"); + expect(formatGeneralFloat(0.00001)).toBe("1e-05"); + }); + + it("preserves the sign for negative exponent-notation values", () => { + expect(formatGeneralFloat(-1000000)).toBe("-1e+06"); + }); + + it("renders zero as a bare 0", () => { + expect(formatGeneralFloat(0)).toBe("0"); + }); +}); diff --git a/apps/cli/src/command-internal/functions-go-config.ts b/apps/cli/src/command-internal/functions-local-config.ts similarity index 94% rename from apps/cli/src/command-internal/functions-go-config.ts rename to apps/cli/src/command-internal/functions-local-config.ts index d107110529..50ff4991eb 100644 --- a/apps/cli/src/command-internal/functions-go-config.ts +++ b/apps/cli/src/command-internal/functions-local-config.ts @@ -1,5 +1,5 @@ import { Effect } from "effect"; -import type { FunctionsGoConfigCompat } from "../shared/functions/functions-config.ts"; +import type { FunctionsLocalConfigLoader } from "../shared/functions/functions-config.ts"; import { loadLocalProjectContext } from "./local-project-context.ts"; import { resolveLocalConfigValues } from "./local-config-values.ts"; @@ -17,7 +17,7 @@ function toError(cause: unknown): Error { * 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 = { +export const functionsLocalConfigLoader: FunctionsLocalConfigLoader = { load: ({ projectRoot, projectRef }) => Effect.gen(function* () { const context = yield* loadLocalProjectContext(projectRoot, toError, projectRef); diff --git a/apps/cli/src/command-internal/glob.unit.test.ts b/apps/cli/src/command-internal/glob.unit.test.ts index 5fa2f0164d..20325040d7 100644 --- a/apps/cli/src/command-internal/glob.unit.test.ts +++ b/apps/cli/src/command-internal/glob.unit.test.ts @@ -157,7 +157,7 @@ describe("walkSqlFiles", () => { }).pipe(Effect.provide(layer)); }); - it.effect("recurses into subdirectories, already sorted in Go's byte order", () => { + it.effect("recurses into subdirectories, already sorted in byte order", () => { const layer = fakeWalkFs( { "/schemas": ["nested", "top.sql"], "/schemas/nested": ["inner.sql"] }, { @@ -194,22 +194,19 @@ describe("walkSqlFiles", () => { }, ); - it.effect( - "does not descend into a symlinked subdirectory (Go's fs.WalkDir/afero.Walk no-follow)", - () => { - const layer = fakeWalkFs( - { "/schemas": ["linked", "top.sql"], "/schemas/linked": ["secret.sql"] }, - { "/schemas/linked": "Directory", "/schemas/top.sql": "File" }, - undefined, - new Set(["/schemas/linked"]), - ); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const files = yield* walkSqlFiles(fs, "/schemas", ""); - expect([...files]).toEqual(["top.sql"]); - }).pipe(Effect.provide(layer)); - }, - ); + it.effect("does not descend into a symlinked subdirectory", () => { + const layer = fakeWalkFs( + { "/schemas": ["linked", "top.sql"], "/schemas/linked": ["secret.sql"] }, + { "/schemas/linked": "Directory", "/schemas/top.sql": "File" }, + undefined, + new Set(["/schemas/linked"]), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const files = yield* walkSqlFiles(fs, "/schemas", ""); + expect([...files]).toEqual(["top.sql"]); + }).pipe(Effect.provide(layer)); + }); it.effect("excludes a symlinked .sql file instead of applying its target", () => { const layer = fakeWalkFs( diff --git a/apps/cli/src/command-internal/global-flags.ts b/apps/cli/src/command-internal/global-flags.ts index df8b051ac9..9e5c4e242f 100644 --- a/apps/cli/src/command-internal/global-flags.ts +++ b/apps/cli/src/command-internal/global-flags.ts @@ -3,7 +3,7 @@ import { Flag, GlobalFlag } from "effect/unstable/cli"; 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"; +import { supabaseEnvBool, supabaseEnvBoolWithProjectFallback } from "./supabase-env.ts"; // 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 @@ -39,7 +39,7 @@ export const OutputFlag = GlobalFlag.setting("output")({ * The TS-only `--output-format` global, accepted on any subcommand. * * 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. + * (`shared/cli/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( @@ -120,7 +120,7 @@ export const AgentFlag = GlobalFlag.setting("agent")({ * Every global/persistent flag declared above. * * 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 + * (`shared/cli/flag-groups.ts`), which the handler-side argv 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. */ @@ -165,7 +165,7 @@ export const globalFlagValues = Effect.gen(function* () { return values; }); -const PFLAG_FALSE_VALUES = new Set(["0", "f", "F", "false", "FALSE", "False"]); +const FALSE_VALUES = new Set(["0", "f", "F", "false", "FALSE", "False"]); /** * Raw argv truncated at the first bare `--` operand terminator. This CLI's own lexer stops parsing @@ -180,7 +180,7 @@ const argsBeforeOperandTerminator = (args: ReadonlyArray): ReadonlyArray }; /** - * Drops tokens that pflag would consume as a value-consuming flag's value in space-separated form + * Drops tokens consumed 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 @@ -214,7 +214,7 @@ const nonValueConsumedTokens = (args: ReadonlyArray): ReadonlyArray): boolean => nonValueConsumedTokens(argsBeforeOperandTerminator(args)).some( - (arg) => arg.startsWith("--yes=") && PFLAG_FALSE_VALUES.has(arg.slice("--yes=".length)), + (arg) => arg.startsWith("--yes=") && FALSE_VALUES.has(arg.slice("--yes=".length)), ); /** @@ -228,14 +228,14 @@ export const resolveYes = Effect.gen(function* () { if (yesFlagExplicitlyFalse(cliArgs.args)) { return false; } - return flag || viperEnvBool("SUPABASE_YES"); + return flag || supabaseEnvBool("SUPABASE_YES"); }); /** * `--yes` resolved with the project `.env` consulted too, for commands that load the nested * 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 + * {@link supabaseEnvBoolWithProjectFallback}); an explicit `--yes` wins over both. `projectEnv` is * the loaded map from `loadProjectEnv`. */ export const resolveYesWithProjectEnv = (projectEnv: Record) => @@ -245,7 +245,7 @@ export const resolveYesWithProjectEnv = (projectEnv: Record) => if (yesFlagExplicitlyFalse(cliArgs.args)) { return false; } - return flag || viperEnvBoolWithProjectFallback("SUPABASE_YES", projectEnv); + return flag || supabaseEnvBoolWithProjectFallback("SUPABASE_YES", projectEnv); }); /** @@ -261,7 +261,7 @@ const experimentalFlagFromArgs = (args: ReadonlyArray): boolean | undefi if (arg === "--experimental") { result = true; } else if (arg.startsWith("--experimental=")) { - result = !PFLAG_FALSE_VALUES.has(arg.slice("--experimental=".length)); + result = !FALSE_VALUES.has(arg.slice("--experimental=".length)); } } return result; @@ -279,12 +279,12 @@ export const resolveExperimental = Effect.gen(function* () { if (explicit !== undefined) { return explicit; } - return flag || viperEnvBool("SUPABASE_EXPERIMENTAL"); + return flag || supabaseEnvBool("SUPABASE_EXPERIMENTAL"); }); /** * `--experimental` with project `.env` fallback. Shell-env presence wins over the file; - * an explicit flag wins over both. See {@link viperEnvBoolWithProjectFallback}. + * an explicit flag wins over both. See {@link supabaseEnvBoolWithProjectFallback}. */ export const resolveExperimentalWithProjectEnv = (projectEnv: Record) => Effect.gen(function* () { @@ -294,7 +294,7 @@ export const resolveExperimentalWithProjectEnv = (projectEnv: Record): boolean => { if (arg === "--debug") { lastExplicitlyFalse = false; } else if (arg.startsWith("--debug=")) { - lastExplicitlyFalse = PFLAG_FALSE_VALUES.has(arg.slice("--debug=".length)); + lastExplicitlyFalse = FALSE_VALUES.has(arg.slice("--debug=".length)); } } return lastExplicitlyFalse; @@ -330,5 +330,5 @@ export const resolveDebugWithProjectEnv = (projectEnv: Record) = if (debugFlagExplicitlyFalse(cliArgs.args)) { return false; } - return flag || viperEnvBoolWithProjectFallback("SUPABASE_DEBUG", projectEnv); + return flag || supabaseEnvBoolWithProjectFallback("SUPABASE_DEBUG", projectEnv); }); diff --git a/apps/cli/src/command-internal/go-duration.unit.test.ts b/apps/cli/src/command-internal/go-duration.unit.test.ts deleted file mode 100644 index b7b58558a0..0000000000 --- a/apps/cli/src/command-internal/go-duration.unit.test.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { formatGoDuration, parseGoDuration } from "./go-duration.ts"; - -describe("parseGoDuration", () => { - it("parses a single unit", () => { - expect(parseGoDuration("5s")).toBe(5_000_000_000); - expect(parseGoDuration("300ms")).toBe(300_000_000); - expect(parseGoDuration("1h")).toBe(3_600_000_000_000); - }); - - it("parses multiple units concatenated", () => { - expect(parseGoDuration("1h30m")).toBe(5_400_000_000_000); - }); - - it("parses a fractional value", () => { - expect(parseGoDuration("1.5s")).toBe(1_500_000_000); - }); - - it("parses a signed value", () => { - expect(parseGoDuration("-5s")).toBe(-5_000_000_000); - expect(parseGoDuration("+5s")).toBe(5_000_000_000); - }); - - it('treats a bare "0" as zero regardless of sign', () => { - expect(parseGoDuration("0")).toBe(0); - }); - - it("rejects an empty string", () => { - expect(() => parseGoDuration("")).toThrow('time: invalid duration ""'); - }); - - it.each(["s", "m", "h", "ms", "us", "µs", "μs", "ns"])( - 'rejects a bare unit with no preceding digit ("%s")', - (input) => { - expect(() => parseGoDuration(input)).toThrow(`time: invalid duration "${input}"`); - }, - ); - - it.each(["us", "µs", "μs"])('accepts every microsecond unit spelling ("1%s")', (unit) => { - expect(parseGoDuration(`1${unit}`)).toBe(1_000); - }); - - it('rejects a bare unit following a valid unit ("1hs")', () => { - expect(() => parseGoDuration("1hs")).toThrow('time: invalid duration "1hs"'); - }); - - it('rejects a negative bare unit ("-s")', () => { - expect(() => parseGoDuration("-s")).toThrow('time: invalid duration "-s"'); - }); - - it.each([".s", ".", "-.", "+.", ".h"])('rejects a lone "." with no digits ("%s")', (input) => { - expect(() => parseGoDuration(input)).toThrow(`time: invalid duration "${input}"`); - }); - - it('accepts a fraction-only value with no leading digit (".5s")', () => { - expect(parseGoDuration(".5s")).toBe(500_000_000); - }); - - it("rejects a number with no unit", () => { - expect(() => parseGoDuration("5")).toThrow('time: missing unit in duration "5"'); - }); - - it("rejects an unknown unit", () => { - expect(() => parseGoDuration("5x")).toThrow('time: unknown unit in duration "5x"'); - }); - - it('truncates a sub-nanosecond fraction instead of rounding ("0.5ns")', () => { - expect(parseGoDuration("0.5ns")).toBe(0); - }); - - it('truncates a sub-nanosecond fraction instead of rounding ("1.9ns")', () => { - expect(parseGoDuration("1.9ns")).toBe(1); - }); - - it('rounds a long fractional remainder up like Go\'s float64 conversion ("0.999999999999999999s")', () => { - expect(parseGoDuration("0.999999999999999999s")).toBe(1_000_000_000); - }); - - it('rejects a duration that overflows math.MaxInt64 nanoseconds ("2562048h")', () => { - expect(() => parseGoDuration("2562048h")).toThrow('time: invalid duration "2562048h"'); - }); - - it("accepts Go's actual maximum parseable duration without overflowing", () => { - expect(() => parseGoDuration("2562047h47m16.854775807s")).not.toThrow(); - }); - - it('rejects a duration exactly 1ns past Go\'s true math.MaxInt64 ceiling ("9223372036854775808ns")', () => { - expect(() => parseGoDuration("9223372036854775808ns")).toThrow( - 'time: invalid duration "9223372036854775808ns"', - ); - }); - - it("accepts a duration exactly at Go's true math.MaxInt64 ceiling in nanoseconds", () => { - expect(() => parseGoDuration("9223372036854775807ns")).not.toThrow(); - }); - - it("accepts Go's exact minimum representable negative duration (math.MinInt64 ns)", () => { - expect(parseGoDuration("-9223372036854775808ns")).toBe(-9223372036854775808); - }); - - it("accepts the equivalent hours/minutes/seconds form of math.MinInt64 ns", () => { - expect(parseGoDuration("-2562047h47m16.854775808s")).toBe(-9223372036854775808); - }); - - it("rejects a duration 1ns past math.MinInt64", () => { - expect(() => parseGoDuration("-9223372036854775809ns")).toThrow( - 'time: invalid duration "-9223372036854775809ns"', - ); - }); -}); - -describe("formatGoDuration", () => { - it('formats zero as "0s"', () => { - expect(formatGoDuration(0)).toBe("0s"); - }); - - it("formats whole hours/minutes/seconds", () => { - expect(formatGoDuration(3_600_000_000_000)).toBe("1h0m0s"); - }); - - it("normalizes 90s to 1m30s", () => { - expect(formatGoDuration(90_000_000_000)).toBe("1m30s"); - }); - - it("formats a sub-second fraction", () => { - expect(formatGoDuration(1_500_000_000)).toBe("1.5s"); - }); - - it("round-trips through parseGoDuration", () => { - expect(formatGoDuration(parseGoDuration("1h"))).toBe("1h0m0s"); - expect(formatGoDuration(parseGoDuration("90s"))).toBe("1m30s"); - }); - - it("includes a sub-second fraction alongside minutes", () => { - expect(formatGoDuration(parseGoDuration("1m0.5s"))).toBe("1m0.5s"); - }); - - it("includes a sub-second fraction alongside hours", () => { - expect(formatGoDuration(parseGoDuration("1h0.5s"))).toBe("1h0m0.5s"); - }); - - it("includes a sub-second fraction alongside hours and minutes", () => { - expect(formatGoDuration(parseGoDuration("1h1m0.5s"))).toBe("1h1m0.5s"); - }); -}); diff --git a/apps/cli/src/command-internal/go-float.unit.test.ts b/apps/cli/src/command-internal/go-float.unit.test.ts deleted file mode 100644 index 7ebaa15172..0000000000 --- a/apps/cli/src/command-internal/go-float.unit.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { goFormatFloat } from "./go-float.ts"; - -describe("goFormatFloat", () => { - it("renders fixed notation within Go's [-4, 6) decimal-exponent range", () => { - expect(goFormatFloat(100)).toBe("100"); - expect(goFormatFloat(100000)).toBe("100000"); - expect(goFormatFloat(0.5)).toBe("0.5"); - expect(goFormatFloat(0.0001)).toBe("0.0001"); - }); - - it("switches to signed exponent notation at exponent >= 6 or < -4", () => { - expect(goFormatFloat(1000000)).toBe("1e+06"); - expect(goFormatFloat(100000000000)).toBe("1e+11"); - expect(goFormatFloat(123456789)).toBe("1.23456789e+08"); - expect(goFormatFloat(0.00001)).toBe("1e-05"); - }); - - it("preserves the sign for negative exponent-notation values", () => { - expect(goFormatFloat(-1000000)).toBe("-1e+06"); - }); - - it("renders zero as a bare 0", () => { - expect(goFormatFloat(0)).toBe("0"); - }); -}); diff --git a/apps/cli/src/command-internal/go-json.unit.test.ts b/apps/cli/src/command-internal/go-json.unit.test.ts deleted file mode 100644 index d00c4618f3..0000000000 --- a/apps/cli/src/command-internal/go-json.unit.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - encodeGoJsonCompact, - encodeGoJsonIndented, - escapeGoJsonString, - goJsonKindName, -} from "./go-json.ts"; - -describe("escapeGoJsonString", () => { - it("escapes quotes and backslashes like Go", () => { - expect(escapeGoJsonString(`a"b\\c`)).toBe('"a\\"b\\\\c"'); - }); - - it("HTML-escapes <, > and & (Go's default escapeHTML)", () => { - expect(escapeGoJsonString(" & ")).toBe('"\\u003ca\\u003e \\u0026 \\u003cb\\u003e"'); - }); - - it("uses short escapes for tab/newline/carriage-return", () => { - expect(escapeGoJsonString("a\tb\nc\rd")).toBe('"a\\tb\\nc\\rd"'); - }); - - it("uses \\u00xx for other control characters (no \\b / \\f shorthand)", () => { - expect(escapeGoJsonString("\b\f")).toBe('"\\u0008\\u000c"'); - }); - - it("escapes U+2028 and U+2029", () => { - expect(escapeGoJsonString("

")).toBe('"\\u2028\\u2029"'); - }); -}); - -describe("encodeGoJsonIndented", () => { - it("preserves object key insertion order (not alphabetical)", () => { - expect(encodeGoJsonIndented({ level: "error", message: "boom" })).toBe( - `{\n "level": "error",\n "message": "boom"\n}\n`, - ); - }); - - it("renders nested arrays of objects with 2-space indent and a trailing newline", () => { - const value = [{ function: "public.f1", issues: [{ level: "error", message: "test 1b" }] }]; - expect(encodeGoJsonIndented(value)).toBe( - [ - "[", - " {", - ' "function": "public.f1",', - ' "issues": [', - " {", - ' "level": "error",', - ' "message": "test 1b"', - " }", - " ]", - " }", - "]", - "", - ].join("\n"), - ); - }); - - it("renders empty arrays and objects compactly", () => { - expect(encodeGoJsonIndented([])).toBe("[]\n"); - expect(encodeGoJsonIndented({})).toBe("{}\n"); - expect(encodeGoJsonIndented({ issues: [] })).toBe(`{\n "issues": []\n}\n`); - }); -}); - -describe("encodeGoJsonCompact", () => { - it("matches Go's json.Marshal compact shape with HTML escaping", () => { - expect(encodeGoJsonCompact({ metadata_xml: "&stuff", type: "saml" })).toBe( - '{"metadata_xml":"\\u003cxml\\u003e\\u0026stuff\\u003c/xml\\u003e","type":"saml"}', - ); - }); - - it("keeps insertion order, compact separators, and no trailing newline", () => { - expect(encodeGoJsonCompact({ b: [1, 2], a: { c: true } })).toBe('{"b":[1,2],"a":{"c":true}}'); - expect(encodeGoJsonCompact([])).toBe("[]"); - expect(encodeGoJsonCompact(null)).toBe("null"); - }); - - 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}'); - expect(encodeGoJsonCompact({ extra: 0 })).toBe('{"extra":0}'); - }); - - it("iterates a Map in true insertion order, unlike a plain object with integer-like keys", () => { - const map = new Map([ - ["10", "a"], - ["2", "b"], - ]); - expect(encodeGoJsonCompact(map)).toBe('{"10":"a","2":"b"}'); - }); -}); - -describe("goJsonKindName", () => { - it("names every JSON-representable kind, including the generic fallback", () => { - expect(goJsonKindName([])).toBe("array"); - expect(goJsonKindName(1)).toBe("number"); - expect(goJsonKindName("s")).toBe("string"); - expect(goJsonKindName(true)).toBe("bool"); - expect(goJsonKindName(undefined)).toBe("value"); - }); -}); diff --git a/apps/cli/src/command-internal/go-json.ts b/apps/cli/src/command-internal/html-safe-json.ts similarity index 65% rename from apps/cli/src/command-internal/go-json.ts rename to apps/cli/src/command-internal/html-safe-json.ts index 1d9d8a3b89..39652e6361 100644 --- a/apps/cli/src/command-internal/go-json.ts +++ b/apps/cli/src/command-internal/html-safe-json.ts @@ -1,13 +1,12 @@ /** - * Byte-faithful reproduction of `encoding/json`'s encoder, for commands that must match Go's - * stdout exactly. + * Byte-stable JSON encoder for commands whose stdout format is fixed. * * 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). * * 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. + * (`\u0008`/`\u000c` instead of `\b`/`\f`). */ const HEX = "0123456789abcdef"; @@ -17,11 +16,11 @@ function unicodeEscape(codeUnit: number): string { } /** - * 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 + * Quotes and escapes a string with HTML escaping. + * Iterates by UTF-16 code unit; the only non-ASCII characters escaped (U+2028, U+2029) are single * BMP code units, so code units suffice. */ -export function escapeGoJsonString(value: string): string { +export function escapeHtmlSafeJsonString(value: string): string { let out = '"'; for (let i = 0; i < value.length; i++) { const code = value.charCodeAt(i); @@ -65,10 +64,10 @@ function walk(value: unknown, depth: number, pretty: boolean): string { if (value === null || value === undefined) return "null"; switch (typeof value) { case "string": - return escapeGoJsonString(value); + return escapeHtmlSafeJsonString(value); case "number": - // 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 + // JSON.stringify collapses `-0` to `"0"`, but a float64 negative + // zero marshals 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); @@ -86,7 +85,7 @@ function walk(value: unknown, depth: number, pretty: boolean): string { return `[${open}${items.join(separator)}${close}${closeIndent}]`; } // 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. + // enumeration, unlike lexicographic order; callers needing that order (e.g. // `sortKeysDeep`) pass a `Map`, whose iteration order is true insertion order. const entries = value instanceof Map @@ -95,34 +94,34 @@ function walk(value: unknown, depth: number, pretty: boolean): string { if (entries.length === 0) return "{}"; const colon = pretty ? ": " : ":"; const lines = entries.map( - ([key, val]) => `${indent}${escapeGoJsonString(key)}${colon}${walk(val, depth + 1, pretty)}`, + ([key, val]) => + `${indent}${escapeHtmlSafeJsonString(key)}${colon}${walk(val, depth + 1, pretty)}`, ); return `{${open}${lines.join(separator)}${close}${closeIndent}}`; } /** - * Encodes a value the way `json.Encoder` with `SetIndent("", " ")` + - * `Encode` does: 2-space indentation, object keys in insertion (struct) order, - * Go string escaping, and a trailing newline. + * Encodes a value with 2-space indentation, object keys in insertion order, + * HTML-safe string escaping, and a trailing newline. */ -export function encodeGoJsonIndented(value: unknown): string { +export function encodeHtmlSafeJsonIndented(value: unknown): string { return walk(value, 0, true) + "\n"; } /** - * Encodes a value the way `json.Marshal` does: compact separators - * (`{"k":v}`), object keys in insertion (struct) order, Go string escaping - * (HTML characters included), and no trailing newline. + * Encodes a value with compact separators + * (`{"k":v}`), object keys in insertion order, HTML-safe string escaping, + * and no trailing newline. */ -export function encodeGoJsonCompact(value: unknown): string { +export function encodeHtmlSafeJsonCompact(value: unknown): string { return walk(value, 0, false); } /** - * `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. + * Type names for the JSON-representable kinds a decode rejects, used in "expected X, got " + * decode errors. */ -export function goJsonKindName(value: unknown): string { +export function jsonKindName(value: unknown): string { if (Array.isArray(value)) return "array"; switch (typeof value) { case "number": @@ -130,7 +129,7 @@ export function goJsonKindName(value: unknown): string { case "string": return "string"; case "boolean": - return "bool"; + return "boolean"; default: return "value"; } diff --git a/apps/cli/src/command-internal/html-safe-json.unit.test.ts b/apps/cli/src/command-internal/html-safe-json.unit.test.ts new file mode 100644 index 0000000000..733067c7c8 --- /dev/null +++ b/apps/cli/src/command-internal/html-safe-json.unit.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; + +import { + encodeHtmlSafeJsonCompact, + encodeHtmlSafeJsonIndented, + escapeHtmlSafeJsonString, + jsonKindName, +} from "./html-safe-json.ts"; + +describe("escapeHtmlSafeJsonString", () => { + it("escapes quotes and backslashes", () => { + expect(escapeHtmlSafeJsonString(`a"b\\c`)).toBe('"a\\"b\\\\c"'); + }); + + it("HTML-escapes <, > and &", () => { + expect(escapeHtmlSafeJsonString(" & ")).toBe('"\\u003ca\\u003e \\u0026 \\u003cb\\u003e"'); + }); + + it("uses short escapes for tab/newline/carriage-return", () => { + expect(escapeHtmlSafeJsonString("a\tb\nc\rd")).toBe('"a\\tb\\nc\\rd"'); + }); + + it("uses \\u00xx for other control characters (no \\b / \\f shorthand)", () => { + expect(escapeHtmlSafeJsonString("\b\f")).toBe('"\\u0008\\u000c"'); + }); + + it("escapes U+2028 and U+2029", () => { + expect(escapeHtmlSafeJsonString("

")).toBe('"\\u2028\\u2029"'); + }); +}); + +describe("encodeHtmlSafeJsonIndented", () => { + it("preserves object key insertion order (not alphabetical)", () => { + expect(encodeHtmlSafeJsonIndented({ level: "error", message: "boom" })).toBe( + `{\n "level": "error",\n "message": "boom"\n}\n`, + ); + }); + + it("renders nested arrays of objects with 2-space indent and a trailing newline", () => { + const value = [{ function: "public.f1", issues: [{ level: "error", message: "test 1b" }] }]; + expect(encodeHtmlSafeJsonIndented(value)).toBe( + [ + "[", + " {", + ' "function": "public.f1",', + ' "issues": [', + " {", + ' "level": "error",', + ' "message": "test 1b"', + " }", + " ]", + " }", + "]", + "", + ].join("\n"), + ); + }); + + it("renders empty arrays and objects compactly", () => { + expect(encodeHtmlSafeJsonIndented([])).toBe("[]\n"); + expect(encodeHtmlSafeJsonIndented({})).toBe("{}\n"); + expect(encodeHtmlSafeJsonIndented({ issues: [] })).toBe(`{\n "issues": []\n}\n`); + }); +}); + +describe("encodeHtmlSafeJsonCompact", () => { + it("emits the compact shape with HTML escaping", () => { + expect(encodeHtmlSafeJsonCompact({ metadata_xml: "&stuff", type: "saml" })).toBe( + '{"metadata_xml":"\\u003cxml\\u003e\\u0026stuff\\u003c/xml\\u003e","type":"saml"}', + ); + }); + + it("keeps insertion order, compact separators, and no trailing newline", () => { + expect(encodeHtmlSafeJsonCompact({ b: [1, 2], a: { c: true } })).toBe( + '{"b":[1,2],"a":{"c":true}}', + ); + expect(encodeHtmlSafeJsonCompact([])).toBe("[]"); + expect(encodeHtmlSafeJsonCompact(null)).toBe("null"); + }); + + it("preserves negative zero's sign, unlike plain JSON.stringify", () => { + expect(encodeHtmlSafeJsonCompact({ extra: -0 })).toBe('{"extra":-0}'); + expect(encodeHtmlSafeJsonCompact({ extra: Number("-1e-10000") })).toBe('{"extra":-0}'); + expect(encodeHtmlSafeJsonCompact({ extra: 0 })).toBe('{"extra":0}'); + }); + + it("iterates a Map in true insertion order, unlike a plain object with integer-like keys", () => { + const map = new Map([ + ["10", "a"], + ["2", "b"], + ]); + expect(encodeHtmlSafeJsonCompact(map)).toBe('{"10":"a","2":"b"}'); + }); +}); + +describe("jsonKindName", () => { + it("names every JSON-representable kind, including the generic fallback", () => { + expect(jsonKindName([])).toBe("array"); + expect(jsonKindName(1)).toBe("number"); + expect(jsonKindName("s")).toBe("string"); + expect(jsonKindName(true)).toBe("boolean"); + expect(jsonKindName(undefined)).toBe("value"); + }); +}); diff --git a/apps/cli/src/command-internal/linked-state.ts b/apps/cli/src/command-internal/linked-state.ts index c7ad6cc1a3..aaea7a668d 100644 --- a/apps/cli/src/command-internal/linked-state.ts +++ b/apps/cli/src/command-internal/linked-state.ts @@ -202,7 +202,7 @@ export function formatLinkedStateBlock(state: LinkedState): string { * 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> { +export function linkedStateFields(state: LinkedState): Readonly> { if (!state.linked) return {}; return { linked_project_ref: state.projectRef, @@ -216,7 +216,7 @@ export function linkedStateGoFields(state: LinkedState): Readonly BigInt(MAX_PORT)) { throw new InvalidPortEnvOverrideError(dottedFieldPath, value); } @@ -241,7 +246,7 @@ export function envOverride( export class InvalidBoolEnvOverrideError extends Error { static readonly [ErrorActionabilityFingerprintId] = "InvalidBoolEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { - super(`Invalid config for ${dottedFieldPath}: cannot parse "${value}" as a bool`); + super(`Invalid config for ${dottedFieldPath}: "${value}" is not a valid boolean`); this.name = "InvalidBoolEnvOverrideError"; } get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { @@ -263,7 +268,7 @@ export function envOverrideBool( ): boolean { const value = envOverride(name, undefined, projectEnvValues); if (value === undefined) return configured; - const parsed = parseGoBool(value); + const parsed = parseBoolLiteral(value); if (parsed === undefined) { throw new InvalidBoolEnvOverrideError(dottedFieldPath, value); } @@ -275,7 +280,7 @@ export class InvalidAnalyticsBackendEnvOverrideError extends Error { static readonly [ErrorActionabilityFingerprintId] = "InvalidAnalyticsBackendEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { super( - `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "postgres", "bigquery"`, + `Invalid config for ${dottedFieldPath}: "${value}" must be one of "postgres", "bigquery"`, ); this.name = "InvalidAnalyticsBackendEnvOverrideError"; } @@ -308,9 +313,7 @@ export function envOverrideAnalyticsBackend( export class InvalidRealtimeIpVersionEnvOverrideError extends Error { static readonly [ErrorActionabilityFingerprintId] = "InvalidRealtimeIpVersionEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { - super( - `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "IPv4", "IPv6"`, - ); + super(`Invalid config for ${dottedFieldPath}: "${value}" must be one of "IPv4", "IPv6"`); this.name = "InvalidRealtimeIpVersionEnvOverrideError"; } @@ -357,7 +360,7 @@ export class InvalidPoolModeEnvOverrideError extends Error { static readonly [ErrorActionabilityFingerprintId] = "InvalidPoolModeEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { super( - `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "transaction", "session"`, + `Invalid config for ${dottedFieldPath}: "${value}" must be one of "transaction", "session"`, ); this.name = "InvalidPoolModeEnvOverrideError"; } @@ -384,7 +387,7 @@ export class InvalidEdgeRuntimePolicyEnvOverrideError extends Error { static readonly [ErrorActionabilityFingerprintId] = "InvalidEdgeRuntimePolicyEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { super( - `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "per_worker", "oneshot"`, + `Invalid config for ${dottedFieldPath}: "${value}" must be one of "per_worker", "oneshot"`, ); this.name = "InvalidEdgeRuntimePolicyEnvOverrideError"; } @@ -624,8 +627,8 @@ function resolveSignedKey( ): string { if (configured !== undefined && configured.length > 0) return configured; return signingKey !== undefined - ? generateAsymmetricGoJwt(signingKey, role) - : generateGoJwt(jwtSecret, role); + ? generateAsymmetricLocalJwt(signingKey, role) + : generateLocalJwt(jwtSecret, role); } /** JWK fields, matching {@link Jwk}. */ @@ -952,11 +955,11 @@ const UINT_MAX = 18446744073709551615n; // 2^64 - 1 /** * 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. + * also meaning octal — so `"010"` parses as `8`). 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 { +function parseBaseZeroUint(value: string): bigint | undefined { if (value.length === 0 || value.startsWith("+") || value.startsWith("-")) return undefined; let literal: string | undefined; @@ -985,7 +988,7 @@ function parseGoBaseZeroUint(value: string): bigint | undefined { /** * `SUPABASE_` sibling of {@link envOverridePort} for uncapped `uint`-typed fields - * (`db.major_version`, `auth.jwt_expiry`, …). Parses with {@link parseGoBaseZeroUint} and + * (`db.major_version`, `auth.jwt_expiry`, …). Parses with {@link parseBaseZeroUint} and * folds an invalid or out-of-{@link UINT_MAX} override into the generic "Invalid " * error message. */ @@ -997,7 +1000,7 @@ export function envOverrideUint( ): number { const value = envOverride(name, undefined, projectEnvValues); if (value === undefined) return configured; - const parsed = parseGoBaseZeroUint(value); + const parsed = parseBaseZeroUint(value); if (parsed === undefined || parsed > UINT_MAX) { throw new Error(`Failed reading config: Invalid ${dottedFieldPath}: ${value}.`); } @@ -1044,7 +1047,7 @@ function envOverrideOptionalUint( ): number | undefined { const value = envOverride(name, undefined, projectEnvValues); if (value === undefined) return configured; - const parsed = parseGoBaseZeroUint(value); + const parsed = parseBaseZeroUint(value); if (parsed === undefined || parsed > UINT_MAX) { throw new Error(`Failed reading config: Invalid ${dottedFieldPath}: ${value}.`); } @@ -1063,7 +1066,7 @@ function envOverrideOptionalBool( ): boolean | undefined { const value = envOverride(name, undefined, projectEnvValues); if (value === undefined) return configured; - const parsed = parseGoBool(value); + const parsed = parseBoolLiteral(value); if (parsed === undefined) { throw new InvalidBoolEnvOverrideError(dottedFieldPath, value); } @@ -1076,7 +1079,7 @@ export class InvalidSessionReplicationRoleEnvOverrideError extends Error { "InvalidSessionReplicationRoleEnvOverrideError"; constructor(dottedFieldPath: string, value: string) { super( - `Invalid config for ${dottedFieldPath}: cannot parse "${value}" as one of "origin", "replica", "local"`, + `Invalid config for ${dottedFieldPath}: "${value}" must be one of "origin", "replica", "local"`, ); this.name = "InvalidSessionReplicationRoleEnvOverrideError"; } @@ -2132,7 +2135,7 @@ export function rawUnmodeledBool(value: unknown, dottedFieldPath: string): boole if (typeof value === "boolean") return value; if (typeof value === "number") return value !== 0; if (typeof value === "string") { - const parsed = parseGoBool(value); + const parsed = parseBoolLiteral(value); if (parsed === undefined) { throw new InvalidBoolEnvOverrideError(dottedFieldPath, value); } @@ -2302,7 +2305,7 @@ function validateAuthExternalProviders( * {@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}. + * {@link resolveConfiguredSigningKeys} and {@link generateAsymmetricLocalJwt}. * @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 @@ -2417,9 +2420,7 @@ export function resolveLocalConfigValues( // `/etc/postgresql-custom/pgsodium_root.key` on every start. const rawRootKeyValue = asRecord(document?.["db"])?.["root_key"]; 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", - ); + throw new ConfigValidateError("failed to parse config:\ndb.root_key: expected a table"); } const rawRootKey = remoteWins("db.root_key") ? rawRootKeyValue 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 b0d58ddb37..dfec0c434f 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 @@ -9,7 +9,7 @@ import { importJWK, jwtVerify } from "jose"; import { afterEach, describe, expect, it, vi } from "vitest"; import { useTempWorkdir } from "../../tests/helpers/command-mocks.ts"; -import { DEFAULT_SIGNING_KEY } from "./go-jwt.ts"; +import { DEFAULT_SIGNING_KEY } from "./local-jwt.ts"; import { POSTGRES_DEFAULT_ROOT_KEY, InvalidAnalyticsBackendEnvOverrideError, @@ -189,21 +189,21 @@ describe("resolveLocalConfigValues", () => { expect(values.publishableKey).toBe("value"); }); - it("fails config loading for an encrypted: secret with no private key, matching Go", () => { + it("fails config loading for an encrypted: secret with no private key", () => { const config = baseConfig({ auth: { publishable_key: VAULT_ENCRYPTED } }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( "failed to parse config: missing private key", ); }); - it("decrypts an encrypted: auth.email.smtp.pass, matching Go's Secret-typed Smtp.Pass field", () => { + it("decrypts an encrypted: auth.email.smtp.pass", () => { process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; const document = { auth: { email: { smtp: { enabled: true, pass: VAULT_ENCRYPTED } } } }; const resolved = resolveAuthEmailSmtp(document.auth, undefined); expect(resolved?.pass).toBe("value"); }); - it("decrypts an encrypted: studio.openai_api_key, matching Go's Secret-typed OpenaiApiKey field", () => { + it("decrypts an encrypted: studio.openai_api_key", () => { process.env["DOTENV_PRIVATE_KEY"] = VAULT_PRIVATE_KEY; const config = baseConfig({ studio: { openai_api_key: VAULT_ENCRYPTED } }); const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); @@ -221,7 +221,7 @@ describe("resolveLocalConfigValues", () => { }); }); - it("rejects an explicit empty project_id, matching Go's Config.Validate", () => { + it("rejects an explicit empty project_id", () => { // An explicit `project_id = ""` overwrites the workdir-basename default with the literal // empty string, unlike an absent key. const config = baseConfig({ project_id: "" }); @@ -235,7 +235,7 @@ describe("resolveLocalConfigValues", () => { expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); - it("rejects an absent project_id when the workdir basename sanitizes to empty, matching Go", () => { + it("rejects an absent project_id when the workdir basename sanitizes to empty", () => { // 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)({}); @@ -262,7 +262,7 @@ describe("resolveLocalConfigValues", () => { ).not.toThrow(); }); - it("hardcodes the Go-parity local S3 credentials", () => { + it("hardcodes the local S3 credentials", () => { const config = baseConfig(); const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); expect(values.storageS3AccessKeyId).toBe("625729a08b95bf1b7ff351a663f3a23c"); @@ -317,7 +317,7 @@ describe("resolveLocalConfigValues", () => { expect(values.serviceRoleKey).toBe("env-service-role"); }); - it("treats an empty env var as unset, matching Viper's default", () => { + it("treats an empty env var as unset", () => { process.env["SUPABASE_AUTH_JWT_SECRET"] = ""; const config = baseConfig({ auth: { jwt_secret: "a".repeat(32) } }); const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); @@ -361,7 +361,7 @@ describe("resolveLocalConfigValues", () => { }); }); - describe("SUPABASE_* env(VAR) indirection (Go's LoadEnvHook)", () => { + describe("SUPABASE_* env(VAR) indirection ", () => { // `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"]; @@ -398,7 +398,7 @@ describe("resolveLocalConfigValues", () => { expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); }); - it("preserves the env(VAR) literal when the indirected var is unset, matching Go", () => { + it("preserves the env(VAR) literal when the indirected var is unset", () => { 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); @@ -457,7 +457,7 @@ describe("resolveLocalConfigValues", () => { expect(values.apiUrl).toBe("https://env-override.example"); }); - it("treats an empty non-auth env var as unset, matching Viper's default", () => { + it("treats an empty non-auth env var as unset", () => { process.env["SUPABASE_DB_PORT"] = ""; const config = baseConfig({ db: { port: 54322 } }); const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); @@ -477,7 +477,7 @@ describe("resolveLocalConfigValues", () => { ); }); - it("rejects a SUPABASE_DB_PORT override above the uint16 range", () => { + it("rejects a SUPABASE_DB_PORT override above the 65535 port maximum", () => { process.env["SUPABASE_DB_PORT"] = "99999"; const config = baseConfig(); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( @@ -507,7 +507,7 @@ describe("resolveLocalConfigValues", () => { expect(values.dbPort).toBe(5432); }); - it("rejects a 0x-prefixed SUPABASE_DB_PORT override exceeding the uint16 range", () => { + it("rejects a 0x-prefixed SUPABASE_DB_PORT override exceeding the 65535 port maximum", () => { process.env["SUPABASE_DB_PORT"] = "0x1FFFF"; const config = baseConfig(); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( @@ -517,7 +517,7 @@ describe("resolveLocalConfigValues", () => { // 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", () => { + it("rejects a zero SUPABASE_DB_PORT override", () => { process.env["SUPABASE_DB_PORT"] = "0"; const config = baseConfig(); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( @@ -646,7 +646,7 @@ describe("resolveLocalConfigValues", () => { ); }); - it("treats an empty SUPABASE_DB_MAJOR_VERSION override as unset, matching Viper's default", () => { + it("treats an empty SUPABASE_DB_MAJOR_VERSION override as unset", () => { process.env["SUPABASE_DB_MAJOR_VERSION"] = ""; const config = baseConfig({ db: { major_version: 17 } }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); @@ -673,7 +673,7 @@ describe("resolveLocalConfigValues", () => { InvalidBoolEnvOverrideError, ); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for db.network_restrictions.enabled: cannot parse "notabool" as a bool', + 'Invalid config for db.network_restrictions.enabled: "notabool" is not a valid boolean', ); }); }); @@ -692,14 +692,12 @@ describe("resolveLocalConfigValues", () => { expect(values.rootKey).toBe("custom-root-key"); }); - it("rejects a non-string root_key (e.g. a bare TOML integer), matching Go's Secret decode failure", () => { + it("rejects a non-string root_key (e.g. a bare TOML integer)", () => { const config = baseConfig(); const document = { db: { root_key: 12345 } }; expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow( - "failed to parse config: decoding failed due to the following error(s):\n\n'db.root_key' expected a map or struct", - ); + ).toThrow("failed to parse config:\ndb.root_key: expected a table"); }); }); @@ -738,7 +736,7 @@ describe("resolveLocalConfigValues", () => { ); }); - it("treats an empty SUPABASE_EDGE_RUNTIME_DENO_VERSION override as unset, matching Viper's default", () => { + it("treats an empty SUPABASE_EDGE_RUNTIME_DENO_VERSION override as unset", () => { process.env["SUPABASE_EDGE_RUNTIME_DENO_VERSION"] = ""; const config = baseConfig({ edge_runtime: { deno_version: 2 } }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).not.toThrow(); @@ -787,7 +785,7 @@ describe("resolveLocalConfigValues", () => { InvalidAnalyticsBackendEnvOverrideError, ); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for analytics.backend: cannot parse "mysql" as one of "postgres", "bigquery"', + 'Invalid config for analytics.backend: "mysql" must be one of "postgres", "bigquery"', ); }); }); @@ -826,7 +824,7 @@ describe("resolveLocalConfigValues", () => { expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), ).toThrow( - 'Invalid config for experimental.webhooks.enabled: cannot parse "notabool" as a bool', + 'Invalid config for experimental.webhooks.enabled: "notabool" is not a valid boolean', ); }); @@ -898,7 +896,7 @@ describe("resolveLocalConfigValues", () => { ); }); - it("treats an empty override as unset, matching Viper's default", () => { + it("treats an empty override as unset", () => { process.env["SUPABASE_API_TLS_ENABLED"] = ""; const config = baseConfig({ api: { tls: { enabled: true }, port: 54321 } }); const values = resolveLocalConfigValues(config, "127.0.0.1", WORKDIR); @@ -926,7 +924,7 @@ describe("resolveLocalConfigValues", () => { InvalidRealtimeIpVersionEnvOverrideError, ); expect(() => envOverrideRealtimeIpVersion("IPv4", undefined)).toThrow( - 'Invalid config for realtime.ip_version: cannot parse "IPv5" as one of "IPv4", "IPv6"', + 'Invalid config for realtime.ip_version: "IPv5" must be one of "IPv4", "IPv6"', ); }); }); @@ -953,7 +951,7 @@ describe("resolveLocalConfigValues", () => { ).toBe(16384); }); - it("rejects an override exceeding the uint64 max (2^64), matching Go's ParseUint failure", () => { + it("rejects an override exceeding the uint64 max (2^64)", () => { process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "18446744073709551616"; expect(() => envOverrideRealtimeMaxHeaderLength(4096, undefined)).toThrow( "Failed reading config: Invalid realtime.max_header_length: 18446744073709551616.", @@ -965,7 +963,7 @@ describe("resolveLocalConfigValues", () => { expect(() => envOverrideRealtimeMaxHeaderLength(4096, undefined)).not.toThrow(); }); - it("rejects a hex override exceeding the uint64 max (2^64), matching Go's ParseUint failure", () => { + it("rejects a hex override exceeding the uint64 max (2^64)", () => { process.env["SUPABASE_REALTIME_MAX_HEADER_LENGTH"] = "0x10000000000000000"; expect(() => envOverrideRealtimeMaxHeaderLength(4096, undefined)).toThrow( "Failed reading config: Invalid realtime.max_header_length: 0x10000000000000000.", @@ -1060,7 +1058,7 @@ describe("resolveLocalConfigValues", () => { InvalidPoolModeEnvOverrideError, ); expect(() => envOverridePoolMode("transaction", undefined)).toThrow( - 'Invalid config for db.pooler.pool_mode: cannot parse "invalid" as one of "transaction", "session"', + 'Invalid config for db.pooler.pool_mode: "invalid" must be one of "transaction", "session"', ); }); }); @@ -1085,7 +1083,7 @@ describe("resolveLocalConfigValues", () => { InvalidEdgeRuntimePolicyEnvOverrideError, ); expect(() => envOverrideEdgeRuntimePolicy("oneshot", undefined)).toThrow( - 'Invalid config for edge_runtime.policy: cannot parse "invalid" as one of "per_worker", "oneshot"', + 'Invalid config for edge_runtime.policy: "invalid" must be one of "per_worker", "oneshot"', ); }); }); @@ -1194,7 +1192,7 @@ describe("resolveLocalConfigValues", () => { { enabled: false, provider: "hcaptcha", secret: "shh" }, undefined, ), - ).toThrow('cannot parse "not-a-bool" as a bool'); + ).toThrow('"not-a-bool" is not a valid boolean'); }); it("suppresses a malformed SUPABASE_AUTH_CAPTCHA_SECRET when a remote block already set auth.captcha.secret", () => { @@ -1360,7 +1358,7 @@ describe("resolveLocalConfigValues", () => { process.env["SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED"] = "not-a-bool"; const authDocument = { hook: { custom_access_token: { enabled: false } } }; expect(() => resolveAuthHooks(authDocument, allHooks, undefined)).toThrow( - 'cannot parse "not-a-bool" as a bool', + '"not-a-bool" is not a valid boolean', ); }); @@ -1424,7 +1422,7 @@ describe("resolveLocalConfigValues", () => { it("still rejects a malformed SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED when no remote block matched", () => { process.env["SUPABASE_AUTH_MFA_TOTP_ENROLL_ENABLED"] = "not-a-bool"; const mfa = baseConfig().auth.mfa; - expect(() => resolveAuthMfa(mfa, undefined)).toThrow('cannot parse "not-a-bool" as a bool'); + expect(() => resolveAuthMfa(mfa, undefined)).toThrow('"not-a-bool" is not a valid boolean'); }); it("prefers a remote-set auth.mfa.phone.template over a conflicting SUPABASE_AUTH_MFA_PHONE_TEMPLATE", () => { @@ -1490,7 +1488,7 @@ describe("resolveLocalConfigValues", () => { process.env["SUPABASE_AUTH_EMAIL_SMTP_ENABLED"] = "not-a-bool"; const authDocument = { email: { smtp: { enabled: true } } }; expect(() => resolveAuthEmailSmtp(authDocument, undefined)).toThrow( - 'cannot parse "not-a-bool" as a bool', + '"not-a-bool" is not a valid boolean', ); }); @@ -1626,7 +1624,7 @@ describe("resolveLocalConfigValues", () => { }; expect(() => resolveAuthExternalProviders(authDocument, baseConfig().auth.external, undefined), - ).toThrow('cannot parse "not-a-bool" as a bool'); + ).toThrow('"not-a-bool" is not a valid boolean'); }); it("leaves an absent custom-provider boolean field at its schema default without throwing", () => { @@ -1641,7 +1639,7 @@ describe("resolveLocalConfigValues", () => { expect(resolved["my_custom"]?.enabled).toBe(false); }); - it("weakly coerces a raw numeric custom-provider boolean by truthiness, matching Go's WeaklyTypedInput decode", () => { + it("weakly coerces a raw numeric custom-provider boolean by truthiness", () => { const authDocument = { external: { my_custom: { enabled: 1, client_id: "custom-client-id" } }, }; @@ -1659,10 +1657,10 @@ describe("resolveLocalConfigValues", () => { }; expect(() => resolveAuthExternalProviders(authDocument, baseConfig().auth.external, undefined), - ).toThrow('cannot parse "1,2" as a bool'); + ).toThrow('"1,2" is not a valid boolean'); }); - it("resolves apple purely from env overrides even with no config.toml [auth.external] section at all, matching Go's ejected default template", () => { + it("resolves apple purely from env overrides even with no config.toml [auth.external] section at all", () => { const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_APPLE_ENABLED: "true", SUPABASE_AUTH_EXTERNAL_APPLE_CLIENT_ID: "apple-client-id", @@ -1685,7 +1683,7 @@ describe("resolveLocalConfigValues", () => { }); }); - it("does not synthesize any other provider purely from an env override with no TOML table, only apple gets Go's default-template exception", () => { + it("does not synthesize any other provider purely from an env override with no TOML table, only apple gets the default-template exception", () => { const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_GOOGLE_ENABLED: "true", SUPABASE_AUTH_EXTERNAL_GOOGLE_CLIENT_ID: "google-client-id", @@ -1743,7 +1741,7 @@ describe("resolveLocalConfigValues", () => { const projectEnvValues = { SUPABASE_AUTH_EXTERNAL_MY_CUSTOM_ENABLED: "not-a-bool" }; expect(() => resolveAuthExternalProviders(authDocument, baseConfig().auth.external, projectEnvValues), - ).toThrow('cannot parse "not-a-bool" as a bool'); + ).toThrow('"not-a-bool" is not a valid boolean'); }); it("prefers a remote-set auth.external..client_id over a conflicting SUPABASE_AUTH_EXTERNAL__CLIENT_ID", () => { @@ -1764,7 +1762,7 @@ describe("resolveLocalConfigValues", () => { }); describe("rawUnmodeledBool", () => { - it("returns false for an absent value, matching Go's zero-value bool default", () => { + it("returns false for an absent value", () => { expect(rawUnmodeledBool(undefined, "auth.passkey.enabled")).toBe(false); }); @@ -1773,13 +1771,13 @@ describe("resolveLocalConfigValues", () => { expect(rawUnmodeledBool(false, "auth.passkey.enabled")).toBe(false); }); - it("weakly coerces a raw number by truthiness, matching mapstructure's WeaklyTypedInput decodeBool", () => { + it("weakly coerces a raw number by truthiness", () => { expect(rawUnmodeledBool(123, "auth.passkey.enabled")).toBe(true); expect(rawUnmodeledBool(0, "auth.passkey.enabled")).toBe(false); expect(rawUnmodeledBool(1.5, "auth.passkey.enabled")).toBe(true); }); - it("parses a valid boolean-ish string the way Go's strconv.ParseBool does", () => { + it("parses a valid boolean-ish string", () => { expect(rawUnmodeledBool("true", "auth.passkey.enabled")).toBe(true); expect(rawUnmodeledBool("False", "auth.passkey.enabled")).toBe(false); expect(rawUnmodeledBool("", "auth.passkey.enabled")).toBe(false); @@ -1787,11 +1785,11 @@ describe("resolveLocalConfigValues", () => { it("throws on an unparsable string instead of silently disabling it", () => { expect(() => rawUnmodeledBool("not-a-bool", "auth.passkey.enabled")).toThrow( - 'cannot parse "not-a-bool" as a bool', + '"not-a-bool" is not a valid boolean', ); }); - it("throws on an array or table value — mapstructure's decodeBool errors on these unconditionally, never weakly coerced", () => { + it("throws on an array or table value — never weakly coerced", () => { expect(() => rawUnmodeledBool([1, 2], "auth.passkey.enabled")).toThrow( InvalidBoolEnvOverrideError, ); @@ -1874,7 +1872,7 @@ describe("resolveLocalConfigValues", () => { ).toBe(16); }); - it("rejects a uint override exceeding the uint64 max (2^64), matching Go's ParseUint failure", () => { + it("rejects a uint override exceeding the uint64 max (2^64)", () => { process.env["SUPABASE_DB_SETTINGS_MAX_CONNECTIONS"] = "18446744073709551616"; expect(() => resolveDbSettingsEnvOverrides({}, undefined)).toThrow( "Failed reading config: Invalid db.settings.max_connections: 18446744073709551616.", @@ -1914,7 +1912,7 @@ describe("resolveLocalConfigValues", () => { InvalidSessionReplicationRoleEnvOverrideError, ); expect(() => resolveDbSettingsEnvOverrides({}, undefined)).toThrow( - 'Invalid config for db.settings.session_replication_role: cannot parse "invalid" as one of "origin", "replica", "local"', + 'Invalid config for db.settings.session_replication_role: "invalid" must be one of "origin", "replica", "local"', ); }); @@ -1986,14 +1984,14 @@ describe("resolveLocalConfigValues", () => { }); }); - it("throws a Go-worded error when the signing keys file does not exist", () => { + it("throws a descriptive error when the signing keys file does not exist", () => { const config = baseConfig({ auth: { signing_keys_path: "missing.json" } }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", tempRoot.current)).toThrow( "failed to read signing keys: ", ); }); - it("throws a Go-worded error when the signing keys file is malformed JSON", () => { + it("throws a descriptive error when the signing keys file is malformed JSON", () => { const supabaseDir = join(tempRoot.current, "supabase"); mkdirSync(supabaseDir, { recursive: true }); writeFileSync(join(supabaseDir, "signing_keys.json"), "not valid json"); @@ -2173,7 +2171,7 @@ describe("resolveLocalConfigValues", () => { expect(values.authPasswordRequirements).toBe("lower_upper_letters_digits"); }); - it("rejects an unrecognized SUPABASE_AUTH_PASSWORD_REQUIREMENTS override, matching Go's UnmarshalText", () => { + it("rejects an unrecognized SUPABASE_AUTH_PASSWORD_REQUIREMENTS override", () => { process.env["SUPABASE_AUTH_PASSWORD_REQUIREMENTS"] = "bogus"; const config = baseConfig(); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( @@ -2270,7 +2268,7 @@ describe("resolveLocalConfigValues", () => { const document = { auth: { passkey: { enabled: "not-a-bool" } } }; expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR, undefined, document), - ).toThrow('cannot parse "not-a-bool" as a bool'); + ).toThrow('"not-a-bool" is not a valid boolean'); }); }); @@ -2879,7 +2877,7 @@ describe("resolveLocalConfigValues", () => { ).not.toThrow(); }); - it("only validates the first enabled provider in Go's fixed priority order", () => { + it("only validates the first enabled provider in fixed priority order", () => { process.env["SUPABASE_AUTH_SMS_TWILIO_ENABLED"] = "false"; process.env["SUPABASE_AUTH_SMS_MESSAGEBIRD_ENABLED"] = "true"; const config = baseConfig(); @@ -3024,7 +3022,7 @@ describe("resolveLocalConfigValues", () => { vonage: { ...baseConfig().auth.sms.vonage, enabled: true }, }; expect(() => resolveAuthSms(undefined, configured, undefined)).toThrow( - 'cannot parse "not-a-bool" as a bool', + '"not-a-bool" is not a valid boolean', ); }); @@ -3215,7 +3213,7 @@ describe("resolveLocalConfigValues", () => { }, }; expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'cannot parse "not-a-bool" as a bool', + '"not-a-bool" is not a valid boolean', ); expect(() => resolveLocalConfigValues( @@ -3247,7 +3245,7 @@ describe("resolveLocalConfigValues", () => { // 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", () => { + it("throws a descriptive error when the configured cert file does not exist", () => { writeTlsFile(tempRoot.current, "key.pem"); const config = baseConfig({ api: { tls: { enabled: true, cert_path: "missing-cert.pem", key_path: "key.pem" } }, @@ -3257,7 +3255,7 @@ describe("resolveLocalConfigValues", () => { ); }); - it("throws a Go-worded error when the configured key file does not exist", () => { + it("throws a descriptive error when the configured key file does not exist", () => { writeTlsFile(tempRoot.current, "cert.pem"); const config = baseConfig({ api: { tls: { enabled: true, cert_path: "cert.pem", key_path: "missing-key.pem" } }, @@ -3704,7 +3702,7 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; const config = baseConfig({ auth: { enabled: false } }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool', + 'Invalid config for auth.enabled: "not-a-bool" is not a valid boolean', ); }); @@ -3727,7 +3725,7 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi process.env["SUPABASE_ANALYTICS_ENABLED"] = "not-a-bool"; const config = baseConfig({ analytics: { enabled: false } }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for analytics.enabled: cannot parse "not-a-bool" as a bool', + 'Invalid config for analytics.enabled: "not-a-bool" is not a valid boolean', ); }); @@ -3942,7 +3940,7 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi auth: { enabled: true, third_party: { firebase: { enabled: false } } }, }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for auth.third_party.firebase.enabled: cannot parse "not-a-bool" as a bool', + 'Invalid config for auth.third_party.firebase.enabled: "not-a-bool" is not a valid boolean', ); }); @@ -3988,7 +3986,7 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi process.env["SUPABASE_API_ENABLED"] = "not-a-bool"; const config = baseConfig({ api: { enabled: false } }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for api.enabled: cannot parse "not-a-bool" as a bool', + 'Invalid config for api.enabled: "not-a-bool" is not a valid boolean', ); }); @@ -4011,7 +4009,7 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi process.env["SUPABASE_STUDIO_ENABLED"] = "not-a-bool"; const config = baseConfig({ studio: { enabled: false } }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for studio.enabled: cannot parse "not-a-bool" as a bool', + 'Invalid config for studio.enabled: "not-a-bool" is not a valid boolean', ); }); @@ -4049,7 +4047,7 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi process.env["SUPABASE_LOCAL_SMTP_ENABLED"] = "not-a-bool"; const config = baseConfig({ local_smtp: { enabled: false } }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for local_smtp.enabled: cannot parse "not-a-bool" as a bool', + 'Invalid config for local_smtp.enabled: "not-a-bool" is not a valid boolean', ); }); @@ -4072,7 +4070,7 @@ describe("resolveLocalConfigValues — remoteOverrideKeys (linked shadow provisi process.env["SUPABASE_AUTH_ENABLE_SIGNUP"] = "not-a-bool"; const config = baseConfig({ auth: { enable_signup: false } }); expect(() => resolveLocalConfigValues(config, "127.0.0.1", WORKDIR)).toThrow( - 'Invalid config for auth.enable_signup: cannot parse "not-a-bool" as a bool', + 'Invalid config for auth.enable_signup: "not-a-bool" is not a valid boolean', ); }); @@ -4224,14 +4222,14 @@ describe("resolveLocalJwks", () => { }); }); - it("throws a Go-worded error when the signing keys file does not exist", async () => { + it("throws a descriptive error when the signing keys file does not exist", async () => { const config = baseConfig({ auth: { signing_keys_path: "missing.json" } }); await expect(runLocalJwks(config, tempRoot.current, "a".repeat(32))).rejects.toThrow( "failed to read signing keys: ", ); }); - it("throws a Go-worded error when the signing keys file is malformed JSON", async () => { + it("throws a descriptive error when the signing keys file is malformed JSON", async () => { const supabaseDir = join(tempRoot.current, "supabase"); mkdirSync(supabaseDir, { recursive: true }); writeFileSync(join(supabaseDir, "signing_keys.json"), "not valid json"); @@ -4267,7 +4265,7 @@ describe("resolveLocalJwks", () => { ); }); - it("does not validate third-party providers when auth is disabled, matching Go's ResolveJWKS/IssuerURL", async () => { + it("does not validate third-party providers when auth is disabled", async () => { 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) => { @@ -4302,7 +4300,7 @@ describe("resolveLocalJwks", () => { fetchMock.mockRestore(); }); - 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 () => { + it("does not attempt a remote JWKS fetch for an enabled third-party provider with an empty issuer_url", async () => { const fetchMock = vi.spyOn(globalThis, "fetch"); const config = baseConfig({ auth: { @@ -4453,7 +4451,7 @@ describe("resolveLocalJwks", () => { process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; const config = baseConfig({ auth: { enabled: false } }); await expect(runLocalJwks(config, WORKDIR, "a".repeat(32))).rejects.toThrow( - 'Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool', + 'Invalid config for auth.enabled: "not-a-bool" is not a valid boolean', ); }); }); @@ -4524,7 +4522,7 @@ describe("resolveConfiguredSigningKeys — remoteOverrideKeys (linked shadow pro process.env["SUPABASE_AUTH_ENABLED"] = "not-a-bool"; const config = baseConfig({ auth: { enabled: false } }); expect(() => resolveConfiguredSigningKeys(config, tempRoot.current, undefined)).toThrow( - 'Invalid config for auth.enabled: cannot parse "not-a-bool" as a bool', + 'Invalid config for auth.enabled: "not-a-bool" is not a valid boolean', ); }); }); diff --git a/apps/cli/src/command-internal/go-jwt.ts b/apps/cli/src/command-internal/local-jwt.ts similarity index 83% rename from apps/cli/src/command-internal/go-jwt.ts rename to apps/cli/src/command-internal/local-jwt.ts index 175e399816..8526da8332 100644 --- a/apps/cli/src/command-internal/go-jwt.ts +++ b/apps/cli/src/command-internal/local-jwt.ts @@ -1,5 +1,5 @@ import { createHmac, createPrivateKey, createSign } from "node:crypto"; -import { encodeGoJsonCompact } from "./go-json.ts"; +import { encodeHtmlSafeJsonCompact } from "./html-safe-json.ts"; export { DEFAULT_SIGNING_KEY } from "@supabase/stack/defaults"; @@ -33,7 +33,7 @@ export interface Jwk { /** * HS256 signer for the default local-dev `anon`/`service_role` keys. {@link - * generateAsymmetricGoJwt} below covers the RS256/ES256 branch, taken when + * generateAsymmetricLocalJwt} below covers the RS256/ES256 branch, taken when * `auth.signing_keys_path` is configured. * * Does not reuse `@supabase/stack`'s `generateJwt`: that helper uses a different issuer, a @@ -42,17 +42,17 @@ export interface Jwk { * "now". */ -const GO_JWT_ISSUER = "supabase-demo"; -const GO_JWT_FIXED_EXP = 1983812996; +const LOCAL_JWT_ISSUER = "supabase-demo"; +const LOCAL_JWT_FIXED_EXP = 1983812996; function base64UrlEncode(input: string): string { return Buffer.from(input).toString("base64url"); } -export function generateGoJwt(secret: string, role: "anon" | "service_role"): string { +export function generateLocalJwt(secret: string, role: "anon" | "service_role"): string { const header = base64UrlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" })); const payload = base64UrlEncode( - JSON.stringify({ iss: GO_JWT_ISSUER, role, exp: GO_JWT_FIXED_EXP }), + JSON.stringify({ iss: LOCAL_JWT_ISSUER, role, exp: LOCAL_JWT_FIXED_EXP }), ); const data = `${header}.${payload}`; const signature = createHmac("sha256", secret).update(data).digest("base64url"); @@ -60,7 +60,7 @@ export function generateGoJwt(secret: string, role: "anon" | "service_role"): st } /** Asymmetric-JWT expiry: 10 years from now. */ -const GO_JWT_ASYMMETRIC_EXPIRY_SECONDS = 60 * 60 * 24 * 365 * 10; +const LOCAL_JWT_ASYMMETRIC_EXPIRY_SECONDS = 60 * 60 * 24 * 365 * 10; function base64UrlToBigInt(value: string): bigint { const hex = Buffer.from(value, "base64url").toString("hex"); @@ -87,7 +87,7 @@ function modInverse(a: bigint, m: bigint): bigint { /** * 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 + * rejects an RSA JWK without them, so they are derived 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). */ @@ -140,13 +140,11 @@ function assertSupportedKty(jwk: Jwk): void { } /** - * 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. + * Produces a `CorruptInputError` message for each numeric field, since unpadded base64 decoding + * rejects input Node's own JWK importer would silently accept (e.g. a `=`-padded coordinate). * - * 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. + * Checks fields in a fixed order (EC: x, y, d; RSA: n, e, d, p, q) so the first invalid field + * is reported. An absent field is skipped and decodes to zero bytes. */ function assertDecodableJwkNumericFields(jwk: Jwk): void { const assertField = (label: string, value: string | undefined): void => { @@ -196,8 +194,8 @@ function assertKeyMatchesAlgorithm(jwk: Jwk, algorithm: SupportedJwtAlgorithm): * * `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. + * {@link encodeHtmlSafeJsonCompact}, not `JSON.stringify`, since a `kid` containing `<`/`>`/`&` must + * HTML-escape so the signed bytes are stable. */ export function signJwtWithJwk(jwk: Jwk, payloadJson: string): string { try { @@ -226,7 +224,7 @@ export function signJwtWithJwk(jwk: Jwk, payloadJson: string): string { jwk.kid !== undefined && jwk.kid.length > 0 ? { alg: algorithm, kid: jwk.kid, typ: "JWT" } : { alg: algorithm, typ: "JWT" }; - const headerEncoded = base64UrlEncode(encodeGoJsonCompact(header)); + const headerEncoded = base64UrlEncode(encodeHtmlSafeJsonCompact(header)); const payloadEncoded = base64UrlEncode(payloadJson); const data = `${headerEncoded}.${payloadEncoded}`; @@ -248,9 +246,9 @@ export function signJwtWithJwk(jwk: Jwk, payloadJson: string): string { /** * 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. + * shape as {@link generateLocalJwt} (`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; - return signJwtWithJwk(jwk, JSON.stringify({ iss: GO_JWT_ISSUER, role, exp: expiresAt })); +export function generateAsymmetricLocalJwt(jwk: Jwk, role: "anon" | "service_role"): string { + const expiresAt = Math.floor(Date.now() / 1000) + LOCAL_JWT_ASYMMETRIC_EXPIRY_SECONDS; + return signJwtWithJwk(jwk, JSON.stringify({ iss: LOCAL_JWT_ISSUER, role, exp: expiresAt })); } diff --git a/apps/cli/src/command-internal/go-jwt.unit.test.ts b/apps/cli/src/command-internal/local-jwt.unit.test.ts similarity index 76% rename from apps/cli/src/command-internal/go-jwt.unit.test.ts rename to apps/cli/src/command-internal/local-jwt.unit.test.ts index c67c88212f..13cb67ab7c 100644 --- a/apps/cli/src/command-internal/go-jwt.unit.test.ts +++ b/apps/cli/src/command-internal/local-jwt.unit.test.ts @@ -4,11 +4,11 @@ import { describe, expect, it } from "vitest"; import { assertDecodableJwkAlgorithm, - generateAsymmetricGoJwt, - generateGoJwt, + generateAsymmetricLocalJwt, + generateLocalJwt, signJwtWithJwk, type Jwk, -} from "./go-jwt.ts"; +} from "./local-jwt.ts"; const SECRET = "super-secret-jwt-token-with-at-least-32-characters-long"; @@ -33,16 +33,16 @@ function decodeSegment(segment: string): string { return Buffer.from(segment, "base64url").toString("utf8"); } -describe("generateGoJwt", () => { - it("emits Go's exact JWT header (no extra fields, alg before typ)", () => { - const token = generateGoJwt(SECRET, "anon"); +describe("generateLocalJwt", () => { + it("emits the JWT header (no extra fields, alg before typ)", () => { + const token = generateLocalJwt(SECRET, "anon"); const [header] = token.split("."); expect(header).toBeDefined(); expect(decodeSegment(header ?? "")).toBe('{"alg":"HS256","typ":"JWT"}'); }); - it("emits the anon payload with Go's exact key order and fixed claims", () => { - const token = generateGoJwt(SECRET, "anon"); + it("emits the anon payload with the exact key order and fixed claims", () => { + const token = generateLocalJwt(SECRET, "anon"); const [, payload] = token.split("."); expect(payload).toBeDefined(); const raw = decodeSegment(payload ?? ""); @@ -55,15 +55,15 @@ describe("generateGoJwt", () => { expect(Object.keys(parsed)).not.toContain("is_anonymous"); }); - it("emits the service_role payload with Go's exact key order and fixed claims", () => { - const token = generateGoJwt(SECRET, "service_role"); + it("emits the service_role payload with the exact key order and fixed claims", () => { + const token = generateLocalJwt(SECRET, "service_role"); const [, payload] = token.split("."); const raw = decodeSegment(payload ?? ""); expect(raw).toBe('{"iss":"supabase-demo","role":"service_role","exp":1983812996}'); }); it("signs with plain HMAC-SHA256 over the base64url header.payload, base64url-encoded", () => { - const token = generateGoJwt(SECRET, "anon"); + const token = generateLocalJwt(SECRET, "anon"); const [header, payload, signature] = token.split("."); const expectedSignature = createHmac("sha256", SECRET) .update(`${header}.${payload}`) @@ -72,32 +72,32 @@ describe("generateGoJwt", () => { }); it("is deterministic across calls (no timestamp derived from Date.now())", () => { - const first = generateGoJwt(SECRET, "anon"); - const second = generateGoJwt(SECRET, "anon"); + const first = generateLocalJwt(SECRET, "anon"); + const second = generateLocalJwt(SECRET, "anon"); expect(first).toBe(second); }); it("produces different tokens for different secrets", () => { - const a = generateGoJwt(SECRET, "anon"); - const b = generateGoJwt("a-different-secret-value-1234567", "anon"); + const a = generateLocalJwt(SECRET, "anon"); + const b = generateLocalJwt("a-different-secret-value-1234567", "anon"); expect(a).not.toBe(b); }); }); -describe("generateAsymmetricGoJwt", () => { +describe("generateAsymmetricLocalJwt", () => { it("signs and verifies an RS256 token from an RSA JWK", async () => { const jwk = generateRsaJwk("rsa-kid"); - const token = generateAsymmetricGoJwt(jwk, "anon"); + const token = generateAsymmetricLocalJwt(jwk, "anon"); const publicKey = await importJWK(publicJwkOf(jwk), "RS256"); const { payload, protectedHeader } = await jwtVerify(token, publicKey); expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); expect(protectedHeader).toEqual({ alg: "RS256", kid: "rsa-kid", typ: "JWT" }); }); - it("signs an RS256 token from an RSA JWK missing CRT exponents (dp/dq/qi), matching Go", async () => { + it("signs an RS256 token from an RSA JWK missing CRT exponents (dp/dq/qi)", async () => { const jwk = generateRsaJwk("rsa-kid"); const { dp: _dp, dq: _dq, qi: _qi, ...jwkWithoutCrtParams } = jwk; - const token = generateAsymmetricGoJwt(jwkWithoutCrtParams, "anon"); + const token = generateAsymmetricLocalJwt(jwkWithoutCrtParams, "anon"); const publicKey = await importJWK(publicJwkOf(jwk), "RS256"); const { payload, protectedHeader } = await jwtVerify(token, publicKey); expect(payload).toMatchObject({ iss: "supabase-demo", role: "anon" }); @@ -106,7 +106,7 @@ describe("generateAsymmetricGoJwt", () => { it("signs and verifies an ES256 token from an EC JWK", async () => { const jwk = generateEcJwk("ec-kid"); - const token = generateAsymmetricGoJwt(jwk, "service_role"); + const token = generateAsymmetricLocalJwt(jwk, "service_role"); const publicKey = await importJWK(publicJwkOf(jwk), "ES256"); const { payload, protectedHeader } = await jwtVerify(token, publicKey); expect(payload).toMatchObject({ iss: "supabase-demo", role: "service_role" }); @@ -115,7 +115,7 @@ describe("generateAsymmetricGoJwt", () => { it("omits the kid header entirely when the JWK has no kid", () => { const jwk = generateRsaJwk(); - const token = generateAsymmetricGoJwt(jwk, "anon"); + const token = generateAsymmetricLocalJwt(jwk, "anon"); const [header] = token.split("."); const decoded = JSON.parse(Buffer.from(header ?? "", "base64url").toString()); expect(decoded).toEqual({ alg: "RS256", typ: "JWT" }); @@ -124,7 +124,7 @@ describe("generateAsymmetricGoJwt", () => { it("sets a ~10-year expiry computed from the current time, not a fixed timestamp", () => { const jwk = generateRsaJwk(); const before = Math.floor(Date.now() / 1000); - const token = generateAsymmetricGoJwt(jwk, "anon"); + const token = generateAsymmetricLocalJwt(jwk, "anon"); const [, payload] = token.split("."); const decoded = JSON.parse(Buffer.from(payload ?? "", "base64url").toString()); const tenYearsSeconds = 60 * 60 * 24 * 365 * 10; @@ -134,39 +134,41 @@ describe("generateAsymmetricGoJwt", () => { it("rejects an unsupported algorithm", () => { const jwk = { ...generateRsaJwk(), alg: "RS512" }; - expect(() => generateAsymmetricGoJwt(jwk, "anon")).toThrow("unsupported algorithm: RS512"); + expect(() => generateAsymmetricLocalJwt(jwk, "anon")).toThrow("unsupported algorithm: RS512"); }); it("rejects a JWK with no algorithm", () => { const { alg: _alg, ...jwkWithoutAlg } = generateRsaJwk(); - expect(() => generateAsymmetricGoJwt(jwkWithoutAlg, "anon")).toThrow("unsupported algorithm: "); + expect(() => generateAsymmetricLocalJwt(jwkWithoutAlg, "anon")).toThrow( + "unsupported algorithm: ", + ); }); it("rejects an EC key forged with alg: RS256 instead of signing garbage", () => { const jwk = { ...generateEcJwk(), alg: "RS256" }; - expect(() => generateAsymmetricGoJwt(jwk, "anon")).toThrow( + expect(() => generateAsymmetricLocalJwt(jwk, "anon")).toThrow( "failed to sign JWT: key is of invalid type: RSA sign expects *rsa.PrivateKey", ); }); it("rejects an RSA key forged with alg: ES256 instead of signing garbage", () => { const jwk = { ...generateRsaJwk(), alg: "ES256" }; - expect(() => generateAsymmetricGoJwt(jwk, "anon")).toThrow( + expect(() => generateAsymmetricLocalJwt(jwk, "anon")).toThrow( "failed to sign JWT: key is of invalid type: ECDSA sign expects *ecdsa.PrivateKey", ); }); - it("rejects an ES256 EC key whose curve is not P-256, wrapped like Go's GenerateAsymmetricJWT", () => { + it("rejects an ES256 EC key whose curve is not P-256, wrapped in the private-key conversion error", () => { const { privateKey } = generateKeyPairSync("ec", { namedCurve: "P-384" }); const jwk = { ...privateKey.export({ format: "jwk" }), kty: "EC", alg: "ES256" }; - expect(() => generateAsymmetricGoJwt(jwk, "anon")).toThrow( + expect(() => generateAsymmetricLocalJwt(jwk, "anon")).toThrow( "failed to convert JWK to private key: unsupported curve: P-384", ); }); - it("rejects a JWK with no kty at all, wrapped like Go's GenerateAsymmetricJWT", () => { + it("rejects a JWK with no kty at all, wrapped in the private-key conversion error", () => { const jwk = { kty: "oct" } as Jwk; - expect(() => generateAsymmetricGoJwt(jwk, "anon")).toThrow( + expect(() => generateAsymmetricLocalJwt(jwk, "anon")).toThrow( "failed to convert JWK to private key: unsupported key type: oct", ); }); @@ -174,15 +176,15 @@ describe("generateAsymmetricGoJwt", () => { it("rejects an ES256 EC key with no curve at all", () => { const jwk = generateEcJwk(); const { crv: _crv, ...jwkWithoutCurve } = jwk; - expect(() => generateAsymmetricGoJwt(jwkWithoutCurve, "anon")).toThrow( + expect(() => generateAsymmetricLocalJwt(jwkWithoutCurve, "anon")).toThrow( "failed to convert JWK to private key: unsupported curve: ", ); }); - it("rejects a padded EC coordinate instead of signing a token Go would refuse to produce (CLI-1961 Codex review finding)", () => { + it("rejects a padded EC coordinate instead of signing an invalid token", () => { const jwk = generateEcJwk("ec-kid"); const padded = { ...jwk, x: `${jwk.x}=` }; - expect(() => generateAsymmetricGoJwt(padded, "anon")).toThrow( + expect(() => generateAsymmetricLocalJwt(padded, "anon")).toThrow( /^failed to convert JWK to private key: failed to decode x coordinate: illegal base64 data at input byte \d+$/, ); }); @@ -190,21 +192,21 @@ describe("generateAsymmetricGoJwt", () => { it("rejects a padded RSA modulus the same way", () => { const jwk = generateRsaJwk("rsa-kid"); const padded = { ...jwk, n: `${jwk.n}=` }; - expect(() => generateAsymmetricGoJwt(padded, "anon")).toThrow( + expect(() => generateAsymmetricLocalJwt(padded, "anon")).toThrow( /^failed to convert JWK to private key: failed to decode modulus: illegal base64 data at input byte \d+$/, ); }); it("still signs successfully for unpadded (correctly-encoded) coordinates", () => { const jwk = generateEcJwk("ec-kid"); - expect(() => generateAsymmetricGoJwt(jwk, "anon")).not.toThrow(); + expect(() => generateAsymmetricLocalJwt(jwk, "anon")).not.toThrow(); }); }); describe("signJwtWithJwk", () => { it("signs the caller's exact pre-encoded payload string verbatim (no re-serialization)", async () => { const jwk = generateEcJwk("ec-kid"); - // Unsorted and containing `&`, which Go's encoder would normally HTML-escape: this function + // Unsorted and containing `&`, which the 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); @@ -216,7 +218,7 @@ describe("signJwtWithJwk", () => { expect(verified).toEqual({ role: "postgres", "sb-role": "mgmt-api & co" }); }); - it("HTML-escapes the kid in the header like Go's json.Marshal, unlike JSON.stringify (CLI-1961 Codex review finding)", () => { + it("HTML-escapes the kid in the header, unlike JSON.stringify", () => { const jwk = generateEcJwk("ac&d"); const token = signJwtWithJwk(jwk, '{"role":"anon"}'); const [header] = token.split("."); @@ -236,7 +238,7 @@ describe("assertDecodableJwkAlgorithm", () => { expect(() => assertDecodableJwkAlgorithm(undefined)).not.toThrow(); }); - it("rejects an unsupported algorithm with Go's exact UnmarshalText message", () => { + it("rejects an unsupported algorithm with the allowed-values message", () => { expect(() => assertDecodableJwkAlgorithm("HS256")).toThrow("must be one of [RS256 ES256]"); }); }); diff --git a/apps/cli/src/command-internal/local-project-context.ts b/apps/cli/src/command-internal/local-project-context.ts index 98487222a8..989d186fb4 100644 --- a/apps/cli/src/command-internal/local-project-context.ts +++ b/apps/cli/src/command-internal/local-project-context.ts @@ -4,7 +4,7 @@ import { type LoadedCliConfig, type CliConfig, } from "@supabase/config/effect"; -import { loadCliConfig } from "@supabase/config/internal"; +import { loadCliConfig } from "./cli-config-load.ts"; import { Crypto, Effect, FileSystem, Path, Schema } from "effect"; import { recordOrioleDbTelemetry } from "./db-image.ts"; @@ -68,7 +68,6 @@ export const loadLocalProjectContext = ( // 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, }).pipe( Effect.mapError((cause) => mapConfigLoadError(`failed to read config: ${String(cause)}`)), 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 58ac1a6667..27490c4b86 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 @@ -77,21 +77,18 @@ describe("loadLocalProjectContext", () => { ); }); - it.effect( - "does NOT install a project .env's DOCKER_HOST into process.env, matching Go's Docker client being frozen at binary startup, before godotenv.Load ever runs", - () => { - delete process.env[DOCKER_HOST_KEY]; - const workdir = tempRoot.current; - writeDotEnv(workdir, `DOCKER_HOST=tcp://project-dotenv-host:2375\n`); + it.effect("does NOT install a project .env's DOCKER_HOST into process.env", () => { + delete process.env[DOCKER_HOST_KEY]; + const workdir = tempRoot.current; + writeDotEnv(workdir, `DOCKER_HOST=tcp://project-dotenv-host:2375\n`); - return loadLocalProjectContext(workdir, (message) => new Error(message)).pipe( - Effect.map(() => { - expect(process.env[DOCKER_HOST_KEY]).toBeUndefined(); - }), - Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), - ); - }, - ); + return loadLocalProjectContext(workdir, (message) => new Error(message)).pipe( + Effect.map(() => { + expect(process.env[DOCKER_HOST_KEY]).toBeUndefined(); + }), + Effect.provide(Layer.mergeAll(BunServices.layer, runtimeInfoLayer)), + ); + }); it.effect( "leaves an already-set shell DOCKER_HOST untouched regardless of a conflicting project .env value", diff --git a/apps/cli/src/command-internal/login-crypto.layer.ts b/apps/cli/src/command-internal/login-crypto.layer.ts index 63ab3fc5fb..bcd98f57f4 100644 --- a/apps/cli/src/command-internal/login-crypto.layer.ts +++ b/apps/cli/src/command-internal/login-crypto.layer.ts @@ -27,7 +27,7 @@ export const loginCryptoLayer = Layer.sync(LoginCrypto, () => const host = hostname(); if (user && host) return `cli_${user}@${host}_${ts}`; } catch { - /* fall through to the fallback name (Go's generateTokenNameWithFallback) */ + /* fall through to the fallback name */ } return `cli_${ts}`; }), @@ -35,7 +35,7 @@ export const loginCryptoLayer = Layer.sync(LoginCrypto, () => Effect.try({ try: () => { const sharedSecret = ecdh.computeSecret(Buffer.from(payload.publicKey, "hex")); - // Go's `aesgcm.Open` expects the 16-byte GCM tag appended to the + // The payload carries the 16-byte GCM tag appended to the // ciphertext; Node wants it supplied separately via `setAuthTag`. const ciphertextHex = payload.ciphertext.slice(0, -32); const authTagHex = payload.ciphertext.slice(-32); 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 9676f442d1..c864bc3e84 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 @@ -421,38 +421,35 @@ describe("migrateAndSeed experimental declarative-schema branch", () => { }, ); - it.effect( - "attaches the failing schema file as Go's CmdSuggestion (See schema file: )", - () => { - const workdir = makeWorkdir(); - const schemaPath = "supabase/schemas/broken.sql"; - writeFile(workdir, schemaPath, "totally not valid sql;"); - const { session } = failingExecSession(); - const out = mockOutput(); - return run( - workdir, - "", - { - ...baseConfig, - experimental: true, - schemaPaths: [schemaPath], - }, - session, - out, - ).pipe( - Effect.flip, - Effect.tap((error) => - Effect.sync(() => { - expect(error).toBeInstanceOf(MigrationApplyError); - const suggestion = (error as MigrationApplyError).suggestion; - expect(suggestion).toBeDefined(); - expect(stripAnsi(suggestion ?? "")).toBe(`See schema file: ${schemaPath}`); - rmSync(workdir, { recursive: true, force: true }); - }), - ), - ); - }, - ); + it.effect("attaches the failing schema file as a suggestion (See schema file: )", () => { + const workdir = makeWorkdir(); + const schemaPath = "supabase/schemas/broken.sql"; + writeFile(workdir, schemaPath, "totally not valid sql;"); + const { session } = failingExecSession(); + const out = mockOutput(); + return run( + workdir, + "", + { + ...baseConfig, + experimental: true, + schemaPaths: [schemaPath], + }, + session, + out, + ).pipe( + Effect.flip, + Effect.tap((error) => + Effect.sync(() => { + expect(error).toBeInstanceOf(MigrationApplyError); + const suggestion = (error as MigrationApplyError).suggestion; + expect(suggestion).toBeDefined(); + expect(stripAnsi(suggestion ?? "")).toBe(`See schema file: ${schemaPath}`); + rmSync(workdir, { recursive: true, force: true }); + }), + ), + ); + }); }); describe("migrateAndSeed local pg_net remediation", () => { diff --git a/apps/cli/src/command-internal/migration-apply.ts b/apps/cli/src/command-internal/migration-apply.ts index 96aea47001..25331aae58 100644 --- a/apps/cli/src/command-internal/migration-apply.ts +++ b/apps/cli/src/command-internal/migration-apply.ts @@ -143,14 +143,14 @@ const utf8ByteLength = (value: string): number => new TextEncoder().encode(value // 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; +const SCANNER_START_BUF_SIZE = 4096; // 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; +const DEFAULT_MAX_SCANNER_CAPACITY = 256 * 1024; -const GO_MAX_INT64 = 9223372036854775807n; -const GO_MIN_INT64 = -9223372036854775808n; +const INT64_MAX = 9223372036854775807n; +const INT64_MIN = -9223372036854775808n; /** * Parses a base-0 integer literal: decimal, or `0x`/`0o`/`0b`-prefixed hex/octal/binary, or a @@ -158,7 +158,7 @@ const GO_MIN_INT64 = -9223372036854775808n; * 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 parseBaseZeroInt = (value: string): number | undefined => { const negative = value.startsWith("-"); const unsigned = negative || value.startsWith("+") ? value.slice(1) : value; if (unsigned.length === 0) return undefined; @@ -198,7 +198,7 @@ const parseGoBaseZeroInt = (value: string): number | undefined => { const bigPrefix = base === 16 ? "0x" : base === 8 ? "0o" : base === 2 ? "0b" : ""; const magnitude = BigInt(`${bigPrefix}${cleanDigits}`); const signedMagnitude = negative ? -magnitude : magnitude; - if (signedMagnitude > GO_MAX_INT64 || signedMagnitude < GO_MIN_INT64) return undefined; + if (signedMagnitude > INT64_MAX || signedMagnitude < INT64_MIN) return undefined; const n = Number.parseInt(cleanDigits, base); return negative ? -n : n; @@ -206,8 +206,8 @@ const parseGoBaseZeroInt = (value: string): number | undefined => { // 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 => { +// {@link parseBaseZeroInt} to accept or reject. +const trimDecimalFraction = (value: string): string => { if (!value.includes(".")) return value; const match = /^([+-]?\d*)(?:\.\d*)?$/.exec(value); if (!match) return value; @@ -220,7 +220,7 @@ const trimGoDecimal = (value: string): string => { * `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} + * unset (`0`), same as any other unparseable or non-positive value. See {@link parseBaseZeroInt} * for the accepted integer-literal grammar (hex/octal/binary prefixes, `_` separators). */ const parseScannerBufferSize = (raw: string): number => { @@ -246,7 +246,7 @@ const parseScannerBufferSize = (raw: string): number => { break; } } - const size = parseGoBaseZeroInt(trimGoDecimal(value)); + const size = parseBaseZeroInt(trimDecimalFraction(value)); return size !== undefined && Number.isFinite(size) && size > 0 ? size * multiplier : 0; }; @@ -269,16 +269,16 @@ export const checkScannerBufferSize = ( if (raw === undefined) return Effect.void; const configuredLimit = parseScannerBufferSize(raw); // 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 + // `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; - // The reported limit is the raw configured value, even below the `GO_SCANNER_START_BUF_SIZE` + ? Math.max(configuredLimit, SCANNER_START_BUF_SIZE) + : DEFAULT_MAX_SCANNER_CAPACITY; + // The reported limit is the raw configured value, even below the `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; + const reportedLimit = configuredLimit > 0 ? configuredLimit : DEFAULT_MAX_SCANNER_CAPACITY; let emitted = 0; let lastRaw = ""; for (const token of splitSqlTokens(content)) { @@ -292,7 +292,7 @@ export const checkScannerBufferSize = ( const suggestion = `Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is ${Math.floor(reportedLimit / 1024)}KB)`; return Effect.fail( mapError( - `bufio.Scanner: token too long\nAfter statement ${emitted}: ${lastRaw}\n${suggestion}`, + `scanner: token too long\nAfter statement ${emitted}: ${lastRaw}\n${suggestion}`, "read", ), ); 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 e1b33615b0..552fc8e478 100644 --- a/apps/cli/src/command-internal/migration-apply.unit.test.ts +++ b/apps/cli/src/command-internal/migration-apply.unit.test.ts @@ -292,27 +292,24 @@ describe("applyMigrationFile", () => { ); }); - it.effect( - "wraps a read failure with Go's parse-file error text (Go NewMigrationFromFile parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "apply-read-fail-")); - const missingFile = join(dir, "20240101120000_missing.sql"); - const { session } = fakeSession(); - return run(session, missingFile).pipe( - Effect.exit, - Effect.tap((exit) => - Effect.sync(() => { - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); - expect(msg).toContain("failed to open migration file: "); - } - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); + it.effect("wraps a read failure with the parse-file error text", () => { + const dir = mkdtempSync(join(tmpdir(), "apply-read-fail-")); + const missingFile = join(dir, "20240101120000_missing.sql"); + const { session } = fakeSession(); + return run(session, missingFile).pipe( + Effect.exit, + Effect.tap((exit) => + Effect.sync(() => { + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const msg = JSON.stringify(exit.cause); + expect(msg).toContain("failed to open migration file: "); + } + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); it.effect("runs a pipeline-incompatible statement outside the surrounding transaction", () => { const dir = mkdtempSync(join(tmpdir(), "apply-")); @@ -887,7 +884,7 @@ describe("hasTransactionControl", () => { }); }); -describe("migration failure rendering (Go ExecBatch parity)", () => { +describe("migration failure rendering", () => { // Error message layout: `\n[Detail]\n[42704 hint]\n` then // `At statement: \n`. const failing = ( @@ -1029,7 +1026,7 @@ describe("markError", () => { expect(markError("abcde", 5)).toBe("abcde\n ^"); }); - it("consumes the position in UTF-8 bytes like Go, not characters", () => { + it("consumes the position in UTF-8 bytes, not characters", () => { expect(markError("héllo\nworld", 8)).toBe("héllo\nworld\n^"); expect(markError("sélect 1", 3)).toBe("sélect 1\n ^"); }); @@ -1152,7 +1149,7 @@ describe("seedGlobals", () => { 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)", + "reports a read failure with the workdir-relative path, not the absolute path used to read it", () => { // 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 @@ -1189,7 +1186,7 @@ describe("applySchemaFiles", () => { ); it.effect( - "rejects an oversized statement when SUPABASE_SCANNER_BUFFER_SIZE is configured (Go bufio.Scanner: token too long parity)", + "rejects an oversized statement when SUPABASE_SCANNER_BUFFER_SIZE is configured", () => { const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-")); mkdirSync(join(dir, "supabase"), { recursive: true }); @@ -1215,7 +1212,7 @@ describe("applySchemaFiles", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const msg = JSON.stringify(exit.cause); - expect(msg).toContain("bufio.Scanner: token too long"); + expect(msg).toContain("scanner: token too long"); expect(msg).toContain("After statement 1: SELECT 1;"); expect(msg).toContain("Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB"); } @@ -1233,7 +1230,7 @@ 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)", + "reports the last scanned RAW token in the too-long error even when it trimmed to empty", () => { const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-empty-token-")); mkdirSync(join(dir, "supabase"), { recursive: true }); @@ -1257,7 +1254,7 @@ describe("applySchemaFiles", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const msg = JSON.stringify(exit.cause); - expect(msg).toContain("bufio.Scanner: token too long"); + expect(msg).toContain("scanner: token too long"); expect(msg).toContain("After statement 0: ;"); } }).pipe( @@ -1274,7 +1271,7 @@ describe("applySchemaFiles", () => { ); it.effect( - "applies an oversized statement fine when SUPABASE_SCANNER_BUFFER_SIZE is unset (Go's default auto-grows to file size)", + "applies an oversized statement fine when SUPABASE_SCANNER_BUFFER_SIZE is unset (the buffer auto-grows to file size)", () => { const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-default-")); mkdirSync(join(dir, "supabase"), { recursive: true }); @@ -1309,7 +1306,7 @@ 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)", + "falls back to the hardcoded default cap when SUPABASE_SCANNER_BUFFER_SIZE is set but unparseable (not '5M' == 5MiB)", () => { const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-garbage-")); mkdirSync(join(dir, "supabase"), { recursive: true }); @@ -1333,7 +1330,7 @@ describe("applySchemaFiles", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const msg = JSON.stringify(exit.cause); - expect(msg).toContain("bufio.Scanner: token too long"); + expect(msg).toContain("scanner: token too long"); expect(msg).toContain( "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 256KB)", ); @@ -1351,94 +1348,84 @@ describe("applySchemaFiles", () => { }, ); - it.effect( - "accepts a hex-literal SUPABASE_SCANNER_BUFFER_SIZE (Go strconv.ParseInt base-0 parity, review CLI-1958)", - () => { - const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-hex-")); - mkdirSync(join(dir, "supabase"), { recursive: true }); - const file = join(dir, "supabase", "big.sql"); - writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`); - const { session } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "0x1400"; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const exit = yield* applySchemaFiles( - session, - fs, - path, - dir, - ["supabase/big.sql"], - (message, suggestion) => - new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); - expect(msg).toContain("bufio.Scanner: token too long"); - expect(msg).toContain( - "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 5KB)", - ); - } - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide(BunServices.layer), - ); - }, - ); + it.effect("accepts a hex-literal SUPABASE_SCANNER_BUFFER_SIZE", () => { + const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-hex-")); + mkdirSync(join(dir, "supabase"), { recursive: true }); + const file = join(dir, "supabase", "big.sql"); + writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`); + const { session } = fakeSession(); + const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "0x1400"; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* applySchemaFiles( + session, + fs, + path, + dir, + ["supabase/big.sql"], + (message, suggestion) => + new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const msg = JSON.stringify(exit.cause); + expect(msg).toContain("scanner: token too long"); + expect(msg).toContain("Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 5KB)"); + } + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.provide(BunServices.layer), + ); + }); - it.effect( - "accepts underscore digit separators in a decimal SUPABASE_SCANNER_BUFFER_SIZE (Go strconv.ParseInt base-0 underscore-literal parity, review CLI-1958)", - () => { - const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-underscore-")); - mkdirSync(join(dir, "supabase"), { recursive: true }); - const file = join(dir, "supabase", "big.sql"); - writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`); - const { session } = fakeSession(); - const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "5_120"; - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const exit = yield* applySchemaFiles( - session, - fs, - path, - dir, - ["supabase/big.sql"], - (message, suggestion) => - new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), - ).pipe(Effect.exit); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const msg = JSON.stringify(exit.cause); - expect(msg).toContain("bufio.Scanner: token too long"); - expect(msg).toContain( - "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 5KB)", - ); - } - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide(BunServices.layer), - ); - }, - ); + it.effect("accepts underscore digit separators in a decimal SUPABASE_SCANNER_BUFFER_SIZE", () => { + const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-underscore-")); + mkdirSync(join(dir, "supabase"), { recursive: true }); + const file = join(dir, "supabase", "big.sql"); + writeFileSync(file, `SELECT 1;\nSELECT '${"a".repeat(5116)}';\n`); + const { session } = fakeSession(); + const previous = process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = "5_120"; + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const exit = yield* applySchemaFiles( + session, + fs, + path, + dir, + ["supabase/big.sql"], + (message, suggestion) => + new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + ).pipe(Effect.exit); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const msg = JSON.stringify(exit.cause); + expect(msg).toContain("scanner: token too long"); + expect(msg).toContain("Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 5KB)"); + } + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.provide(BunServices.layer), + ); + }); 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)", + "rejects an invalid underscore placement in SUPABASE_SCANNER_BUFFER_SIZE, unlike a valid digit separator", () => { const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-bad-underscore-")); mkdirSync(join(dir, "supabase"), { recursive: true }); @@ -1474,7 +1461,7 @@ 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)", + "falls back to the hardcoded default cap when SUPABASE_SCANNER_BUFFER_SIZE overflows the signed int range", () => { const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-int64-overflow-")); mkdirSync(join(dir, "supabase"), { recursive: true }); @@ -1498,7 +1485,7 @@ describe("applySchemaFiles", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const msg = JSON.stringify(exit.cause); - expect(msg).toContain("bufio.Scanner: token too long"); + expect(msg).toContain("scanner: token too long"); expect(msg).toContain( "Try setting SUPABASE_SCANNER_BUFFER_SIZE=5MB (current size is 256KB)", ); @@ -1517,7 +1504,7 @@ 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)", + "still accepts the exact int64 boundary magnitudes for SUPABASE_SCANNER_BUFFER_SIZE", () => { const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-int64-boundary-")); mkdirSync(join(dir, "supabase"), { recursive: true }); @@ -1553,7 +1540,7 @@ describe("applySchemaFiles", () => { ); it.effect( - "rejects an oversized statement when SUPABASE_SCANNER_BUFFER_SIZE is set only in the project env (Go loadNestedEnv parity)", + "rejects an oversized statement when SUPABASE_SCANNER_BUFFER_SIZE is set only in the project env", () => { const dir = mkdtempSync(join(tmpdir(), "schema-files-scanner-projectenv-")); mkdirSync(join(dir, "supabase"), { recursive: true }); @@ -1578,7 +1565,7 @@ describe("applySchemaFiles", () => { expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { const msg = JSON.stringify(exit.cause); - expect(msg).toContain("bufio.Scanner: token too long"); + expect(msg).toContain("scanner: token too long"); } }).pipe( Effect.ensuring( @@ -1592,44 +1579,41 @@ describe("applySchemaFiles", () => { }, ); - it.effect( - "shell env still wins over the project env for SUPABASE_SCANNER_BUFFER_SIZE (Go godotenv 'never overrides' parity)", - () => { - 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"]; - // "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; - const path = yield* Path.Path; - yield* applySchemaFiles( - session, - fs, - path, - dir, - ["supabase/big.sql"], - (message, suggestion) => - new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), - { SUPABASE_SCANNER_BUFFER_SIZE: "100b" }, - ); - expect(executedSql(calls).some((sql) => sql.startsWith("SELECT 'a"))).toBe(true); - }).pipe( - Effect.ensuring( - Effect.sync(() => { - if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; - else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; - rmSync(dir, { recursive: true, force: true }); - }), - ), - Effect.provide(BunServices.layer), + it.effect("shell env still wins over the project env for SUPABASE_SCANNER_BUFFER_SIZE", () => { + 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"]; + // "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; + const path = yield* Path.Path; + yield* applySchemaFiles( + session, + fs, + path, + dir, + ["supabase/big.sql"], + (message, suggestion) => + new TestError({ message: suggestion ? `${message} (${suggestion})` : message }), + { SUPABASE_SCANNER_BUFFER_SIZE: "100b" }, ); - }, - ); + expect(executedSql(calls).some((sql) => sql.startsWith("SELECT 'a"))).toBe(true); + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (previous === undefined) delete process.env["SUPABASE_SCANNER_BUFFER_SIZE"]; + else process.env["SUPABASE_SCANNER_BUFFER_SIZE"] = previous; + rmSync(dir, { recursive: true, force: true }); + }), + ), + Effect.provide(BunServices.layer), + ); + }); }); describe("revertsToLoginRole", () => { 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 944292147d..cd147689ae 100644 --- a/apps/cli/src/command-internal/migration-history.unit.test.ts +++ b/apps/cli/src/command-internal/migration-history.unit.test.ts @@ -69,24 +69,24 @@ describe("reconcileMigrations", () => { }); }); - it("skips empty / whitespace versions (matches strconv.Atoi, not Number())", () => { + it("skips empty / whitespace versions (not Number() coercion)", () => { expect(reconcileMigrations(["", "20240101000000"], [" ", "20240101000000"])).toEqual({ kind: "in-sync", }); }); - it("treats a version within Go's int64 range as a real conflict (BigInt parity)", () => { + it("treats a version within the int64 range as a real conflict", () => { expect(reconcileMigrations(["9999999999999999"], []).kind).toBe("conflict"); }); - it("skips a version beyond Go's int64 range instead of hanging the scan", () => { + it("skips a version beyond the int64 range instead of hanging the scan", () => { expect( reconcileMigrations(["20240101000000", "9999999999999999999"], ["20240101000000"]), ).toEqual({ kind: "in-sync" }); }); }); -describe("listRemoteMigrations (suppress only undefined_table, like Go)", () => { +describe("listRemoteMigrations (suppress only undefined_table)", () => { const run = (error: DbExecError) => Effect.runPromiseExit(listRemoteMigrations(failingSession(error))); @@ -122,7 +122,7 @@ describe("listRemoteMigrations (suppress only undefined_table, like Go)", () => }); }); -describe("findPendingMigrations (Go TestPendingMigrations / TestIgnoreVersionMismatch)", () => { +describe("findPendingMigrations", () => { it("returns the local paths after the remote count when in sync", () => { const local = ["0", "1", "2"].map(mig); const result = findPendingMigrations(local, ["0"]); @@ -185,7 +185,7 @@ describe("suggestRevertHistory", () => { }); }); -describe("resolveMigrationFile (byte-ordered match, Go's sort.Strings via afero match.go:91)", () => { +describe("resolveMigrationFile (byte-ordered match)", () => { 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()` ranks the surrogate pair first — diff --git a/apps/cli/src/command-internal/migration-timestamp.format.unit.test.ts b/apps/cli/src/command-internal/migration-timestamp.format.unit.test.ts index 76f8b6be16..d94efe30b9 100644 --- a/apps/cli/src/command-internal/migration-timestamp.format.unit.test.ts +++ b/apps/cli/src/command-internal/migration-timestamp.format.unit.test.ts @@ -11,7 +11,7 @@ describe("formatTimestampVersion", () => { expect(formatTimestampVersion("20220727064246")).toBe("2022-07-27 06:42:46"); }); - it("passes through short numeric versions (Go time.Parse errors → input)", () => { + it("passes through short numeric versions (parse errors → input)", () => { expect(formatTimestampVersion("0")).toBe("0"); expect(formatTimestampVersion("1")).toBe("1"); }); @@ -20,7 +20,7 @@ describe("formatTimestampVersion", () => { expect(formatTimestampVersion("abc")).toBe("abc"); }); - it("passes through out-of-range months/days like Go's strict parse", () => { + it("passes through out-of-range months/days", () => { expect(formatTimestampVersion("20221327064246")).toBe("20221327064246"); // month 13 expect(formatTimestampVersion("20220230000000")).toBe("20220230000000"); // Feb 30 expect(formatTimestampVersion("20220727256046")).toBe("20220727256046"); // hour 25 @@ -37,7 +37,7 @@ describe("parseMigrationVersion", () => { expect(parseMigrationVersion("0")).toBe(0n); }); - it("rejects non-integer versions like Go's strconv.Atoi", () => { + it("rejects non-integer versions", () => { expect(parseMigrationVersion("")).toBeUndefined(); expect(parseMigrationVersion("abc")).toBeUndefined(); expect(parseMigrationVersion(" 12")).toBeUndefined(); @@ -46,17 +46,17 @@ describe("parseMigrationVersion", () => { expect(parseMigrationVersion("+")).toBeUndefined(); }); - it("accepts a leading +/- sign like strconv.Atoi (for malformed history rows)", () => { - // Go's `Atoi` == `ParseInt(s, 10, 0)` accepts a leading sign, so `migration repair -1` + it("accepts a leading +/- sign (for malformed history rows)", () => { + // A leading sign is accepted, so `migration repair -1` // can delete a malformed `-1` row and the two-pointer merge orders `-1` before `0`. expect(parseMigrationVersion("-1")).toBe(-1n); expect(parseMigrationVersion("+5")).toBe(5n); }); it("accepts the full int64 range but rejects values outside it", () => { - // 16 digits Go accepts and surfaces as a conflict — must NOT be skipped. + // 16 digits parse and surface as a conflict — must NOT be skipped. expect(parseMigrationVersion("9999999999999999")).toBe(9999999999999999n); - // int64 max/min parse; one beyond either bound is rejected (Go's Atoi range error). + // int64 max/min parse; one beyond either bound is rejected. expect(parseMigrationVersion(MIGRATION_VERSION_MAX.toString())).toBe(MIGRATION_VERSION_MAX); expect(parseMigrationVersion((MIGRATION_VERSION_MAX + 1n).toString())).toBeUndefined(); expect(parseMigrationVersion("-9223372036854775808")).toBe(-9223372036854775808n); diff --git a/apps/cli/src/command-internal/go-output-flag.ts b/apps/cli/src/command-internal/output-formats.ts similarity index 89% rename from apps/cli/src/command-internal/go-output-flag.ts rename to apps/cli/src/command-internal/output-formats.ts index 8e6ad00f44..52d4e85d8b 100644 --- a/apps/cli/src/command-internal/go-output-flag.ts +++ b/apps/cli/src/command-internal/output-formats.ts @@ -20,7 +20,7 @@ export const QUERY_OUTPUT_FORMATS = ["json", "table", "csv"] as const; /** * 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`. + * has the form `invalid argument "" for "" flag: `. */ export class InvalidOutputFormatError extends Data.TaggedError("InvalidOutputFormatError")<{ readonly message: string; @@ -36,8 +36,8 @@ export function outputFormatEnumMessage(allowed: ReadonlyArray): string } /** - * The full rejection message for an invalid `-o` value, matching the established - * `invalid argument %q for %q flag: %v` pflag format. + * The full rejection message for an invalid `-o` value, in the + * `invalid argument "" for "" flag: ` 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-flag.unit.test.ts b/apps/cli/src/command-internal/output-formats.unit.test.ts similarity index 84% rename from apps/cli/src/command-internal/go-output-flag.unit.test.ts rename to apps/cli/src/command-internal/output-formats.unit.test.ts index 41b440f43f..d39cc59273 100644 --- a/apps/cli/src/command-internal/go-output-flag.unit.test.ts +++ b/apps/cli/src/command-internal/output-formats.unit.test.ts @@ -5,10 +5,10 @@ import { invalidOutputFormatMessage, outputFormatEnumMessage, unsupportedOutputFlagMessage, -} from "./go-output-flag.ts"; +} from "./output-formats.ts"; -describe("go-output-flag", () => { - it("joins the allowed set with Go's ` | ` bracket format", () => { +describe("output-formats", () => { + it("joins the allowed set with the ` | ` bracket format", () => { expect(outputFormatEnumMessage(RESOURCE_OUTPUT_FORMATS)).toBe( "must be one of [ env | pretty | json | toml | yaml ]", ); @@ -17,8 +17,7 @@ describe("go-output-flag", () => { ); }); - it("reproduces Go's pflag rejection message byte-for-byte", () => { - // pflag: `invalid argument %q for %q flag: %v`, shorthand-prefixed `-o, --output`. + it("formats the rejection message with the shorthand-prefixed flag name", () => { expect(invalidOutputFormatMessage("table", RESOURCE_OUTPUT_FORMATS)).toBe( 'invalid argument "table" for "-o, --output" flag: must be one of [ env | pretty | json | toml | yaml ]', ); diff --git a/apps/cli/src/command-internal/go-output.encoders.ts b/apps/cli/src/command-internal/output.encoders.ts similarity index 79% rename from apps/cli/src/command-internal/go-output.encoders.ts rename to apps/cli/src/command-internal/output.encoders.ts index 935e0c48b9..4bef8f0e48 100644 --- a/apps/cli/src/command-internal/go-output.encoders.ts +++ b/apps/cli/src/command-internal/output.encoders.ts @@ -1,17 +1,17 @@ import { stringify as stringifyToml } from "smol-toml"; import { stringify as stringifyYaml } from "yaml"; -import { encodeGoJsonCompact, encodeGoJsonIndented } from "./go-json.ts"; -import { goStringCompare } from "./go-struct-output.encoders.ts"; +import { encodeHtmlSafeJsonCompact, encodeHtmlSafeJsonIndented } from "./html-safe-json.ts"; +import { compareByCodepoint } from "./struct-output.encoders.ts"; /** - * Reproduces `json.Encoder` output for `-o json`: alphabetical key order, Go string escaping, and + * Encodes `-o json` output: alphabetical key order, HTML-safe string escaping, and * a trailing newline. * * `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( +export function encodeSortedJson( value: T, options?: { readonly nullForEmptyArrays?: ReadonlyArray }, ): string { @@ -33,24 +33,24 @@ export function encodeGoJson( } source = patched; } - return encodeGoJsonIndented(sortKeysDeep(source)); + return encodeHtmlSafeJsonIndented(sortKeysDeep(source)); } function sortKeysDeep(value: unknown): unknown { if (Array.isArray(value)) return value.map(sortKeysDeep); if (value === null || typeof value !== "object") return value; // 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 + // enumeration; building a `Map` instead carries a lexicographic sort through to `html-safe-json.ts`'s // `walk` intact, since `Map` iteration order is true insertion order. const sorted = new Map(); - // JS's default string sort orders by UTF-16 code unit, which diverges from Go's byte/codepoint + // JS's default string sort orders by UTF-16 code unit, which diverges from 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 + // `compareByCodepoint` sorts by byte/codepoint 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)) { + for (const key of Object.keys(value as Record).sort(compareByCodepoint)) { const child = (value as Record)[key]; - // Drop `undefined` properties here, matching `JSON.stringify`'s behavior (the Go-faithful - // walker below would otherwise render them as `null`). + // Drop `undefined` properties here, matching `JSON.stringify`'s behavior (the walker + // below would otherwise render them as `null`). if (child === undefined) continue; sorted.set(key, sortKeysDeep(child)); } @@ -58,29 +58,29 @@ function sortKeysDeep(value: unknown): unknown { } /** - * Serializes an outbound API request body with sorted keys, Go string escaping, no indentation, - * and no trailing newline, matching `json.Marshal`'s struct output. + * Serializes an outbound API request body with sorted keys, HTML-safe string escaping, no + * indentation, and no trailing newline. * * 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`. + * match exactly. {@link encodeSortedJson} is the parallel for human-facing `--output json`. */ -export function encodeGoStructJsonBody(value: unknown): string { - return encodeGoJsonCompact(sortKeysDeep(value)); +export function encodeSortedJsonBody(value: unknown): string { + return encodeHtmlSafeJsonCompact(sortKeysDeep(value)); } /** * 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. + * payloads must use `encodeStructYaml` in `struct-output.encoders.ts` instead, since YAML keys + * derive from struct field names, not JSON tags. */ export function encodeYaml(value: unknown): string { return stringifyYaml(value); } /** - * TOML for map payloads. Struct payloads must use `encodeGoToml` in - * `go-struct-output.encoders.ts` instead, since BurntSushi emits PascalCase Go field names with + * TOML for map payloads. Struct payloads must use `encodeStructToml` in + * `struct-output.encoders.ts` instead, since the struct output uses PascalCase field names with * 2-space table indentation. */ export function encodeToml(value: unknown): string { @@ -92,7 +92,7 @@ export function encodeToml(value: unknown): string { } /** - * Reproduces the established `godotenv.Marshal` byte shape for `--output env`. + * Emits the established dotenv byte shape for `--output env`. * * 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 diff --git a/apps/cli/src/command-internal/go-output.encoders.unit.test.ts b/apps/cli/src/command-internal/output.encoders.unit.test.ts similarity index 81% rename from apps/cli/src/command-internal/go-output.encoders.unit.test.ts rename to apps/cli/src/command-internal/output.encoders.unit.test.ts index 98738ed362..f43e55018f 100644 --- a/apps/cli/src/command-internal/go-output.encoders.unit.test.ts +++ b/apps/cli/src/command-internal/output.encoders.unit.test.ts @@ -1,7 +1,7 @@ import { V1ListAllBackupsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "vitest"; -import { encodeEnv, encodeGoJson, encodeToml, encodeYaml } from "./go-output.encoders.ts"; +import { encodeEnv, encodeSortedJson, encodeToml, encodeYaml } from "./output.encoders.ts"; // 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. @@ -23,9 +23,9 @@ const SAMPLE_RESPONSE: typeof V1ListAllBackupsOutput.Type = { }, }; -describe("encodeGoJson", () => { - it("emits Go's alphabetical struct-field order and trailing newline for a populated response", () => { - const out = encodeGoJson(SAMPLE_RESPONSE, { nullForEmptyArrays: ["backups"] }); +describe("encodeSortedJson", () => { + it("emits alphabetical struct-field order and trailing newline for a populated response", () => { + const out = encodeSortedJson(SAMPLE_RESPONSE, { nullForEmptyArrays: ["backups"] }); expect(out).toBe( `{ "backups": [ @@ -49,7 +49,7 @@ describe("encodeGoJson", () => { }); it("emits backups: null and an empty physical_backup_data object for a PITR-only response", () => { - const out = encodeGoJson( + const out = encodeSortedJson( { region: "ap-southeast-1", walg_enabled: false, @@ -72,7 +72,7 @@ describe("encodeGoJson", () => { }); it("leaves arrays intact when nullForEmptyArrays is not provided", () => { - const out = encodeGoJson({ items: [], name: "x" }); + const out = encodeSortedJson({ items: [], name: "x" }); expect(out).toBe( `{ "items": [], @@ -83,7 +83,7 @@ describe("encodeGoJson", () => { }); it("does not substitute null for non-empty arrays even when listed in nullForEmptyArrays", () => { - const out = encodeGoJson({ items: [1, 2], name: "x" }, { nullForEmptyArrays: ["items"] }); + const out = encodeSortedJson({ items: [1, 2], name: "x" }, { nullForEmptyArrays: ["items"] }); expect(out).toBe( `{ "items": [ @@ -96,8 +96,8 @@ describe("encodeGoJson", () => { ); }); - it("keeps Go's true lexicographic order for numeric-looking keys (CLI-1961 Codex review finding)", () => { - const out = encodeGoJson({ 10: "a", 2: "b", role: "anon" }); + it("keeps true lexicographic order for numeric-looking keys", () => { + const out = encodeSortedJson({ 10: "a", 2: "b", role: "anon" }); expect(out).toBe( `{ "10": "a", @@ -108,10 +108,10 @@ 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)", () => { + it("sorts keys by byte/code-point order, not JS's UTF-16 code-unit order", () => { const highBmp = String.fromCodePoint(0xe000); const astral = String.fromCodePoint(0x10000); - const out = encodeGoJson({ [astral]: 2, [highBmp]: 1 }); + const out = encodeSortedJson({ [astral]: 2, [highBmp]: 1 }); expect(out).toBe(`{\n "${highBmp}": 1,\n "${astral}": 2\n}\n`); }); }); @@ -145,21 +145,21 @@ describe("encodeEnv", () => { expect(lines).toContain('PITR_ENABLED="true"'); }); - it("emits integer-parseable values unquoted (matches godotenv strconv.Atoi branch)", () => { + it("emits integer-parseable values unquoted", () => { const out = encodeEnv(SAMPLE_RESPONSE); const lines = out.split("\n"); expect(lines).toContain("PHYSICAL_BACKUP_DATA_EARLIEST_PHYSICAL_BACKUP_DATE_UNIX=1700000000"); expect(lines).toContain("PHYSICAL_BACKUP_DATA_LATEST_PHYSICAL_BACKUP_DATE_UNIX=1700001000"); }); - it("collapses arrays to a single empty leaf (Go viper does not descend into slices)", () => { + it("collapses arrays to a single empty leaf", () => { const out = encodeEnv(SAMPLE_RESPONSE); const lines = out.split("\n"); expect(lines).toContain('BACKUPS=""'); expect(lines.some((line) => line.startsWith("BACKUPS_0_"))).toBe(false); }); - it("matches Go's full env output for the sample backup response", () => { + it("emits the full env output for the sample backup response", () => { expect(encodeEnv(SAMPLE_RESPONSE)).toBe( [ 'BACKUPS=""', @@ -177,7 +177,7 @@ describe("encodeEnv", () => { expect(out).toBe('MESSAGE="with \\"quotes\\" and \\\\backslash"'); }); - it("escapes embedded newlines, carriage returns, and tabs (Go %q parity)", () => { + it("escapes embedded newlines, carriage returns, and tabs", () => { const out = encodeEnv({ description: "line one\nline two\rwith\ttab" }); expect(out).toBe('DESCRIPTION="line one\\nline two\\rwith\\ttab"'); }); @@ -187,11 +187,11 @@ describe("encodeEnv", () => { expect(out.split("\n")).toEqual(["A=2", "M=3", "Z=1"]); }); - it("omits empty nested maps entirely (Go viper parity)", () => { + it("omits empty nested maps entirely", () => { expect(encodeEnv({ physical_backup_data: {} })).toBe(""); }); - it("matches Go for the PITR-only response shape with empty physical_backup_data", () => { + it("handles the PITR-only response shape with empty physical_backup_data", () => { expect( encodeEnv({ region: "ap-southeast-1", diff --git a/apps/cli/src/command-internal/parse-uint.ts b/apps/cli/src/command-internal/parse-uint.ts index e70a46761b..b644e664a8 100644 --- a/apps/cli/src/command-internal/parse-uint.ts +++ b/apps/cli/src/command-internal/parse-uint.ts @@ -1,5 +1,5 @@ /** - * Parses a numeric flag token the way `pflag`'s `uint64`/`int64` types do: base-0 prefixes + * Parses a numeric flag token as a `uint64`/`int64`: 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 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 c110eb1e9e..f97ecd0814 100644 --- a/apps/cli/src/command-internal/parse-uint.unit.test.ts +++ b/apps/cli/src/command-internal/parse-uint.unit.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { isValidBase0Int64, parseUintBase0 } from "./parse-uint.ts"; -describe("parseUintBase0 (Go strconv.ParseUint(s, 0, 64) parity)", () => { +describe("parseUintBase0", () => { it("parses plain decimal", () => { expect(parseUintBase0("0")).toEqual({ value: 0 }); expect(parseUintBase0("1")).toEqual({ value: 1 }); @@ -16,7 +16,7 @@ describe("parseUintBase0 (Go strconv.ParseUint(s, 0, 64) parity)", () => { expect(parseUintBase0("+1")).toEqual({ cause: "invalid syntax" }); }); - it("parses Go's base-0 prefix forms: hex, octal (bare leading zero!), binary", () => { + it("parses base-0 prefix forms: hex, octal (bare leading zero!), binary", () => { expect(parseUintBase0("0x10")).toEqual({ value: 16 }); expect(parseUintBase0("0X10")).toEqual({ value: 16 }); expect(parseUintBase0("0o10")).toEqual({ value: 8 }); @@ -58,7 +58,7 @@ describe("parseUintBase0 (Go strconv.ParseUint(s, 0, 64) parity)", () => { }); }); -describe("isValidBase0Int64 (Go strconv.ParseInt(s, 0, 64) parity)", () => { +describe("isValidBase0Int64", () => { it("accepts int64's exact bounds, both signs", () => { expect(isValidBase0Int64("9223372036854775807")).toBe(true); // int64 max expect(isValidBase0Int64("-9223372036854775808")).toBe(true); // int64 min @@ -73,7 +73,7 @@ describe("isValidBase0Int64 (Go strconv.ParseInt(s, 0, 64) parity)", () => { expect(isValidBase0Int64("18446744073709551616")).toBe(false); // one past uint64 max }); - it("accepts plain decimals and Go's base-0 prefix forms, signed", () => { + it("accepts plain decimals and base-0 prefix forms, signed", () => { expect(isValidBase0Int64("0")).toBe(true); expect(isValidBase0Int64("42")).toBe(true); expect(isValidBase0Int64("-42")).toBe(true); diff --git a/apps/cli/src/command-internal/path-match.ts b/apps/cli/src/command-internal/path-match.ts index 58898ffcb1..9258219760 100644 --- a/apps/cli/src/command-internal/path-match.ts +++ b/apps/cli/src/command-internal/path-match.ts @@ -1,11 +1,11 @@ /** - * 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`, + * Matches a glob pattern against a path using shell-pattern semantics (`*` and `?` never cross + * `/`), 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. * * 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. + * byte decodes as a single invalid code point that a `?` in the retried chunk can then consume. */ /** `badPattern` reports a malformed pattern instead of throwing. */ @@ -28,7 +28,7 @@ const UTF8_ENCODER = new TextEncoder(); */ type Bytes = Uint8Array; -const RUNE_ERROR = 0xfffd; +const REPLACEMENT_CHAR = 0xfffd; const SLASH = 0x2f; const STAR = 0x2a; const QUESTION = 0x3f; @@ -38,19 +38,19 @@ const CARET = 0x5e; const HYPHEN = 0x2d; const BACKSLASH = 0x5c; -interface DecodedRune { +interface DecodedCodePoint { readonly r: number; readonly size: number; } /** - * 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 + * Decodes the UTF-8 code point starting at byte offset `i` of `b`. Any invalid or truncated + * sequence decodes as `(REPLACEMENT_CHAR, 1)` — never throws, and never consumes more than the * single invalid lead byte. */ -const decodeRune = (b: Bytes, i: number): DecodedRune => { +const decodeCodePoint = (b: Bytes, i: number): DecodedCodePoint => { const n = b.length - i; - if (n <= 0) return { r: RUNE_ERROR, size: 0 }; + if (n <= 0) return { r: REPLACEMENT_CHAR, size: 0 }; const b0 = b[i]!; if (b0 < 0x80) return { r: b0, size: 1 }; let size: number; @@ -87,17 +87,17 @@ const decodeRune = (b: Bytes, i: number): DecodedRune => { } else { // 0x80-0xC1: a bare continuation byte or an overlong 2-byte lead. 0xF5-0xFF: past // the max valid lead byte. Both are invalid lead bytes. - return { r: RUNE_ERROR, size: 1 }; + return { r: REPLACEMENT_CHAR, size: 1 }; } - if (n < size) return { r: RUNE_ERROR, size: 1 }; + if (n < size) return { r: REPLACEMENT_CHAR, size: 1 }; const b1 = b[i + 1]!; - if (b1 < lo || b1 > hi) return { r: RUNE_ERROR, size: 1 }; + if (b1 < lo || b1 > hi) return { r: REPLACEMENT_CHAR, size: 1 }; if (size === 2) return { r: ((b0 & 0x1f) << 6) | (b1 & 0x3f), size: 2 }; const b2 = b[i + 2]!; - if (b2 < 0x80 || b2 > 0xbf) return { r: RUNE_ERROR, size: 1 }; + if (b2 < 0x80 || b2 > 0xbf) return { r: REPLACEMENT_CHAR, size: 1 }; if (size === 3) return { r: ((b0 & 0x0f) << 12) | ((b1 & 0x3f) << 6) | (b2 & 0x3f), size: 3 }; const b3 = b[i + 3]!; - if (b3 < 0x80 || b3 > 0xbf) return { r: RUNE_ERROR, size: 1 }; + if (b3 < 0x80 || b3 > 0xbf) return { r: REPLACEMENT_CHAR, size: 1 }; return { r: ((b0 & 0x07) << 18) | ((b1 & 0x3f) << 12) | ((b2 & 0x3f) << 6) | (b3 & 0x3f), size: 4, @@ -150,9 +150,9 @@ const getEsc = (chunk: Bytes): GetEsc => { c = c.subarray(1); if (c.length === 0) return { r: 0, rest: c, bad: true }; } - const { r, size } = decodeRune(c, 0); + const { r, size } = decodeCodePoint(c, 0); // 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 }; + if (r === REPLACEMENT_CHAR && size === 1) return { r, rest: c.subarray(1), bad: true }; const rest = c.subarray(size); return { r, rest, bad: rest.length === 0 }; }; @@ -181,7 +181,7 @@ const matchChunk = (chunkIn: Bytes, sIn: Bytes): MatchChunk => { if (op === LBRACKET) { let r = 0; if (!failed) { - const decoded = decodeRune(s, 0); + const decoded = decodeCodePoint(s, 0); r = decoded.r; s = s.subarray(decoded.size); } @@ -215,7 +215,7 @@ const matchChunk = (chunkIn: Bytes, sIn: Bytes): MatchChunk => { } else if (op === QUESTION) { if (!failed) { if (s[0] === SLASH) failed = true; - const { size } = decodeRune(s, 0); + const { size } = decodeCodePoint(s, 0); s = s.subarray(size); } chunk = chunk.subarray(1); @@ -239,8 +239,7 @@ 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) for a malformed pattern. + * Reports whether `name` matches the shell pattern `pattern`. `badPattern` is set (instead of throwing) for a malformed pattern. */ export const pathMatch = (pattern: string, name: string): PathMatchResult => { let pat = UTF8_ENCODER.encode(pattern); 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 6d0f8ab3d4..d8e60c9d41 100644 --- a/apps/cli/src/command-internal/path-match.unit.test.ts +++ b/apps/cli/src/command-internal/path-match.unit.test.ts @@ -18,7 +18,7 @@ describe("pathMatch", () => { }); }); - describe("`*` and `?` never cross `/` (Go's non-`/` rule)", () => { + describe("`*` and `?` never cross `/`", () => { it.each([ ["*.sql", "sub/seed.sql", false], ["seed?.sql", "seed/.sql", false], @@ -40,14 +40,14 @@ describe("pathMatch", () => { expect(pathMatch(pattern, name).matched).toBe(expected); }); - it("treats a leading `!` as a literal class member, NOT negation (Go parity)", () => { + it("treats a leading `!` as a literal class member, NOT negation", () => { 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); }); }); - describe("byte-offset `*` retry against multibyte characters (Go path.Match parity)", () => { + describe("byte-offset `*` retry against multibyte characters", () => { it.each([ // U+FF01 (fullwidth `!`) is 3 UTF-8 bytes; U+1F600 (😀) is 4 UTF-8 bytes. ["*??.sql", "!.sql", true], @@ -70,7 +70,7 @@ describe("pathMatch", () => { }); }); - describe("malformed patterns report badPattern (Go's path.ErrBadPattern)", () => { + describe("malformed patterns report badPattern", () => { it.each([ "[", // unterminated class "[a", // unterminated class with member diff --git a/apps/cli/src/command-internal/pg-dump.run.ts b/apps/cli/src/command-internal/pg-dump.run.ts index 8e50a6cf6c..1bfe0ea920 100644 --- a/apps/cli/src/command-internal/pg-dump.run.ts +++ b/apps/cli/src/command-internal/pg-dump.run.ts @@ -6,7 +6,7 @@ type StackRuntimePreference = | { readonly kind: "container"; readonly engine: "docker" | "podman" }; import { NetworkIdFlag } from "./global-flags.ts"; -import { viperEnvStringWithProjectFallback } from "./viper-env.ts"; +import { supabaseEnvStringWithProjectFallback } from "./supabase-env.ts"; import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { DockerRun } from "./docker-run.service.ts"; import { DockerRunError } from "./docker-run.errors.ts"; @@ -113,7 +113,10 @@ export const dumpNetworkMode = ( ): { readonly _tag: "named"; readonly name: string } | { readonly _tag: "host" } => { if (networkId !== undefined && networkId.length > 0) return { _tag: "named", name: networkId }; if (forceHostNetwork) return { _tag: "host" }; - const envNetworkId = viperEnvStringWithProjectFallback("SUPABASE_NETWORK_ID", projectEnvValues); + const envNetworkId = supabaseEnvStringWithProjectFallback( + "SUPABASE_NETWORK_ID", + projectEnvValues, + ); return envNetworkId.length > 0 ? { _tag: "named", name: envNetworkId } : { _tag: "host" }; }; diff --git a/apps/cli/src/command-internal/pgdelta.paths.ts b/apps/cli/src/command-internal/pgdelta.paths.ts index f4f10f139c..0523e8f2b6 100644 --- a/apps/cli/src/command-internal/pgdelta.paths.ts +++ b/apps/cli/src/command-internal/pgdelta.paths.ts @@ -3,7 +3,7 @@ * * Two roots: * - {@link pgDeltaTempPath}: project-local (`supabase/.temp/pgdelta`) — catalog - * snapshots and debug bundles (Go-shared, workspace-mounted). + * snapshots and debug bundles (workspace-mounted). * - {@link shadowBaselineCacheDir}: global under `SUPABASE_HOME` — the shadow * baseline PGDATA tars, shared across worktrees with the same settings. */ diff --git a/apps/cli/src/command-internal/postgres-url.ts b/apps/cli/src/command-internal/postgres-url.ts index 02aa1b22ef..bf72a4c761 100644 --- a/apps/cli/src/command-internal/postgres-url.ts +++ b/apps/cli/src/command-internal/postgres-url.ts @@ -13,7 +13,7 @@ function isIPv6Host(host: string): boolean { * and turns space into `+` (`application/x-www-form-urlencoded` query-escaping, not * `encodeURIComponent`, which differs on space and `!*'()`). */ -function goQueryEscape(value: string): string { +function queryEscape(value: string): string { let out = ""; for (const ch of value) { if (/[A-Za-z0-9\-_.~]/.test(ch)) { @@ -74,7 +74,7 @@ export function toPostgresURL(conn: PostgresUrlInput): string { // tenant on pooler fallback. const optionsParam = conn.options !== undefined && conn.options.length > 0 - ? `&options=${goQueryEscape(conn.options)}` + ? `&options=${queryEscape(conn.options)}` : ""; // Every other runtime param (search_path, statement_timeout, …), sorted for stable // serialization. @@ -83,7 +83,7 @@ export function toPostgresURL(conn: PostgresUrlInput): string { ? "" : Object.keys(conn.runtimeParams) .sort() - .map((key) => `&${goQueryEscape(key)}=${goQueryEscape(conn.runtimeParams![key]!)}`) + .map((key) => `&${queryEscape(key)}=${queryEscape(conn.runtimeParams![key]!)}`) .join(""); return `postgresql://${userinfo}@${host}:${conn.port}/${encodeURIComponent( conn.database, diff --git a/apps/cli/src/command-internal/postgres-url.unit.test.ts b/apps/cli/src/command-internal/postgres-url.unit.test.ts index 2088bdad7c..a8a948bbca 100644 --- a/apps/cli/src/command-internal/postgres-url.unit.test.ts +++ b/apps/cli/src/command-internal/postgres-url.unit.test.ts @@ -47,14 +47,14 @@ describe("toPostgresURL", () => { }); it("appends the pooler `options` runtime param after connect_timeout", () => { - // Go's ToPostgresURL appends RuntimeParams; the Supavisor tenant routing + // The Supavisor tenant routing // `options=reference=` must reach pg-delta (`=` escaped to %3D). expect(toPostgresURL({ ...base, options: "reference=abcdefghijklmnop" })).toBe( "postgresql://postgres:postgres@127.0.0.1:54322/postgres?connect_timeout=10&options=reference%3Dabcdefghijklmnop", ); }); - it("matches Go's url.QueryEscape for options (space → +)", () => { + it("query-escapes options (space → +)", () => { expect(toPostgresURL({ ...base, options: "-c search_path=public" })).toContain( "&options=-c+search_path%3Dpublic", ); @@ -67,7 +67,7 @@ describe("toPostgresURL", () => { ); }); - it("appends every runtimeParams entry (sorted) after options, like Go ToPostgresURL", () => { + it("appends every runtimeParams entry (sorted) after options", () => { expect( toPostgresURL({ ...base, @@ -79,7 +79,7 @@ describe("toPostgresURL", () => { ); }); - it("escapes runtimeParams values like Go's url.QueryEscape", () => { + it("query-escapes runtimeParams values", () => { expect(toPostgresURL({ ...base, runtimeParams: { search_path: "a b,c" } })).toContain( "&search_path=a+b%2Cc", ); diff --git a/apps/cli/src/command-internal/profile-load.ts b/apps/cli/src/command-internal/profile-load.ts index 28a1c66c65..9cd2e8d312 100644 --- a/apps/cli/src/command-internal/profile-load.ts +++ b/apps/cli/src/command-internal/profile-load.ts @@ -64,14 +64,14 @@ export function loadProfile( }; } - // An empty token falls back to the established "no config file" search-path error. + // An empty token has no config file to read. if (token === "") { - return yield* failRead(`Config File "config" Not Found in "[]"`); + return yield* failRead("no profile config file specified"); } - const ext = goFilepathExt(token); - if (!VIPER_SUPPORTED_EXTS.has(ext)) { - return yield* failRead(`Unsupported Config Type ${JSON.stringify(ext)}`); + const ext = fileExtension(token); + if (!PROFILE_FILE_EXTS.has(ext)) { + return yield* failRead(`unsupported config file type ${JSON.stringify(ext)}`); } const content = yield* fs @@ -94,17 +94,13 @@ export function loadProfile( try { parsed = parseYaml(content); } catch (cause) { - return yield* failRead(`While parsing config: ${parseDetail(cause)}`); + return yield* failRead(`invalid config file: ${parseDetail(cause)}`); } if (parsed === null || parsed === undefined) { parsed = {}; } if (typeof parsed !== "object" || Array.isArray(parsed)) { - // 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 {}`, - ); + return yield* failRead("invalid config file: expected a YAML mapping at the top level"); } // Configuration keys are lowercased before decoding, so `API_URL:`/`Name:` behave like // their lowercase spellings, and unknown-key errors report the lowercased form. A @@ -114,15 +110,15 @@ export function loadProfile( config[key.toLowerCase()] = value; } - // Unknown keys abort decoding, reported sorted in a padded multi-line block. + // Unknown keys abort decoding, reported sorted. const invalidKeys = Object.keys(config) .filter((key) => !PROFILE_STRUCT_KEYS.has(key)) .sort(); if (invalidKeys.length > 0) { - return yield* failDecode(`'utils.Profile' has invalid keys: ${invalidKeys.join(", ")}`); + return yield* failDecode(`unknown keys: ${invalidKeys.join(", ")}`); } - // Weak scalar decoding + per-field tag validation, in struct field order. + // Weak scalar decoding + per-field format validation, in field order. const decodeErrors: string[] = []; const validationErrors: string[] = []; const values = new Map(); @@ -131,26 +127,26 @@ export function loadProfile( const weak = weakString(raw); if (weak === undefined) { decodeErrors.push( - `'${field.goName}' expected type 'string', got unconvertible type '${goTypeName(raw)}', value: '${goValueString(raw)}'`, + `${field.key}: expected a string, got ${valueTypeName(raw)}: ${JSON.stringify(raw)}`, ); continue; } values.set(field.key, weak); if (weak === "") { if (field.required) { - validationErrors.push(validatorLine(field.goName, "required")); + validationErrors.push(`${field.key} is required`); } continue; } if (field.format !== undefined && !FORMAT_TAG_CHECKS[field.format](weak)) { - validationErrors.push(validatorLine(field.goName, field.format)); + validationErrors.push(`${field.key} ${FORMAT_TAG_DESCRIPTIONS[field.format]}`); } } if (decodeErrors.length > 0) { return yield* failDecode(decodeErrors.join("\n")); } if (validationErrors.length > 0) { - return yield* fail(padGoErrorBlock(`invalid profile: ${validationErrors.join("\n")}`)); + return yield* fail(`invalid profile:\n${validationErrors.join("\n")}`); } // All required fields passed validation above; pooler_host stays "" when absent. @@ -169,15 +165,10 @@ const fail = (message: string) => Effect.fail(new ProfileLoadError({ message })) const failRead = (detail: string) => fail(`failed to read profile: ${detail}`); /** Aggregate decode-error template: multiple failing fields render as one block. */ -const failDecode = (detail: string) => - fail( - padGoErrorBlock( - `failed to parse profile: decoding failed due to the following error(s):\n\n${detail}`, - ), - ); +const failDecode = (detail: string) => fail(`failed to parse profile:\n${detail}`); /** Recognized config file extensions, checked case-sensitively. */ -const VIPER_SUPPORTED_EXTS: ReadonlySet = new Set([ +const PROFILE_FILE_EXTS: ReadonlySet = new Set([ "json", "toml", "yaml", @@ -196,7 +187,7 @@ const VIPER_SUPPORTED_EXTS: ReadonlySet = new Set([ * 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 { +function fileExtension(token: string): string { for (let i = token.length - 1; i >= 0 && token[i] !== "/"; i--) { if (token[i] === ".") { return token.slice(i + 1); @@ -222,7 +213,6 @@ type FormatTag = "http_url" | "hostname_rfc1123" | "uuid4"; interface ProfileStringField { readonly key: string; - readonly goName: string; readonly required: boolean; readonly format?: FormatTag; } @@ -232,19 +222,29 @@ interface ProfileStringField { * `regions` (a slice) is exempt from weak string decoding and never validated. */ const PROFILE_STRING_FIELDS: ReadonlyArray = [ - { key: "name", goName: "Name", required: true }, - { key: "api_url", goName: "APIURL", required: true, format: "http_url" }, - { key: "dashboard_url", goName: "DashboardURL", required: true, format: "http_url" }, - { key: "docs_url", goName: "DocsURL", required: false, format: "http_url" }, - { key: "project_host", goName: "ProjectHost", required: true, format: "hostname_rfc1123" }, - { key: "pooler_host", goName: "PoolerHost", required: false, format: "hostname_rfc1123" }, - { key: "client_id", goName: "AuthClientID", required: false, format: "uuid4" }, - { key: "studio_image", goName: "StudioImage", required: false }, + { key: "name", required: true }, + { key: "api_url", required: true, format: "http_url" }, + { key: "dashboard_url", required: true, format: "http_url" }, + { key: "docs_url", required: false, format: "http_url" }, + { + key: "project_host", + required: true, + format: "hostname_rfc1123", + }, + { + key: "pooler_host", + required: false, + format: "hostname_rfc1123", + }, + { key: "client_id", required: false, format: "uuid4" }, + { key: "studio_image", required: false }, ]; -function validatorLine(goName: string, tag: string): string { - return `Key: 'Profile.${goName}' Error:Field validation for '${goName}' failed on the '${tag}' tag`; -} +const FORMAT_TAG_DESCRIPTIONS: Record = { + http_url: "must be an http(s) URL", + hostname_rfc1123: "must be a valid hostname", + uuid4: "must be a lowercase UUID v4", +}; /** 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})*?$/; @@ -268,7 +268,7 @@ const FORMAT_TAG_CHECKS: Record boolean> = { /** * Weak string coercion: strings pass through, booleans become `"1"`/`"0"`, numbers are - * stringified, and `null`/`undefined` decode to `""`. Arrays/objects are unconvertible + * stringified, and `null`/`undefined` decode to `""`. Arrays/objects are unsupported * (`undefined`, a decode error). */ function weakString(value: unknown): string | undefined { @@ -279,34 +279,12 @@ function weakString(value: unknown): string | undefined { return undefined; } -function goTypeName(value: unknown): string { - if (Array.isArray(value)) return "[]interface {}"; - if (typeof value === "object" && value !== null) return "map[string]interface {}"; +function valueTypeName(value: unknown): string { + if (Array.isArray(value)) return "array"; + if (typeof value === "object" && value !== null) return "object"; return typeof value; } -/** 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) { - const entries = Object.entries(value as Record) - .map(([key, entry]) => `${key}:${goValueString(entry)}`) - .join(" "); - return `map[${entries}]`; - } - return String(value); -} - -/** - * 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"); - const width = Math.max(...lines.map((line) => line.length)); - return lines.map((line) => line.padEnd(width)).join("\n"); -} - function parseDetail(cause: unknown): string { return cause instanceof Error ? cause.message : String(cause); } 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 d4978e6521..4cdd1c4e5e 100644 --- a/apps/cli/src/command-internal/profile-load.unit.test.ts +++ b/apps/cli/src/command-internal/profile-load.unit.test.ts @@ -6,7 +6,7 @@ import { BunServices } from "@effect/platform-bun"; import { afterAll, describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem } from "effect"; -import { loadProfile, padGoErrorBlock, type ProfileLoadError } from "./profile-load.ts"; +import { loadProfile, type ProfileLoadError } from "./profile-load.ts"; const tempRoot = mkdtempSync(join(tmpdir(), "supabase-profile-load-")); afterAll(() => rmSync(tempRoot, { recursive: true, force: true })); @@ -30,7 +30,7 @@ const writeProfile = (name: string, content: string): string => { }; describe("loadProfile", () => { - it.effect("resolves built-in profile names case-insensitively (Go strings.EqualFold)", () => + it.effect("resolves built-in profile names case-insensitively", () => Effect.gen(function* () { expect(yield* load("SUPABASE-LOCAL")).toBe("http://localhost:8080"); expect(yield* load("supabase")).toBe("https://api.supabase.com"); @@ -39,11 +39,9 @@ describe("loadProfile", () => { }), ); - it.effect("fails on an empty token with viper's search-mode error (Go `--profile=`)", () => + it.effect("fails an empty --profile= token with the search-mode error", () => Effect.gen(function* () { - expect(yield* loadError("")).toBe( - `failed to read profile: Config File "config" Not Found in "[]"`, - ); + expect(yield* loadError("")).toBe(`failed to read profile: no profile config file specified`); }), ); @@ -51,15 +49,15 @@ describe("loadProfile", () => { Effect.gen(function* () { // --profile --metadata-url … expect(yield* loadError("--metadata-url")).toBe( - `failed to read profile: Unsupported Config Type ""`, + `failed to read profile: unsupported config file type ""`, ); expect(yield* loadError("profile.txt")).toBe( - `failed to read profile: Unsupported Config Type "txt"`, + `failed to read profile: unsupported config file type "txt"`, ); }), ); - it.effect("uses Go filepath.Ext semantics for dot-files (`.yml` IS extension `yml`)", () => + it.effect("treats dot-files as having an extension (`.yml` IS extension `yml`)", () => Effect.gen(function* () { expect(yield* loadError(join(tempRoot, ".yml"))).toBe( `failed to read profile: open ${join(tempRoot, ".yml")}: no such file or directory`, @@ -67,7 +65,7 @@ describe("loadProfile", () => { }), ); - it.effect("fails on a missing file with Go's os.Open error", () => + it.effect("fails on a missing file with the open error", () => Effect.gen(function* () { expect(yield* loadError("missing.yml")).toBe( `failed to read profile: open missing.yml: no such file or directory`, @@ -75,7 +73,7 @@ describe("loadProfile", () => { }), ); - it.effect("fails on a directory with Go's read error", () => + it.effect("fails on a directory with the read error", () => Effect.gen(function* () { const dir = join(tempRoot, "dir.yml"); mkdirSync(dir, { recursive: true }); @@ -98,7 +96,7 @@ describe("loadProfile", () => { }), ); - it.effect("accepts mixed-case keys like viper's insensitive decode (probed on go1.26)", () => + it.effect("accepts mixed-case keys case-insensitively", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const file = writeProfile( @@ -141,7 +139,7 @@ describe("loadProfile", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("reports unknown keys LOWERCASED, like viper's pre-decode normalization", () => + it.effect("reports unknown keys LOWERCASED", () => Effect.gen(function* () { const file = writeProfile( "bogus-upper.yml", @@ -153,30 +151,28 @@ describe("loadProfile", () => { "BOGUS_KEY: x", ].join("\n"), ); - expect(yield* loadError(file)).toContain("'utils.Profile' has invalid keys: bogus_key"); + expect(yield* loadError(file)).toContain("unknown keys: bogus_key"); }), ); - it.effect( - "returns Go's CurrentProfile.Name — the canonical built-in or the file's name field", - () => - Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - expect((yield* loadProfile("SUPABASE-LOCAL", fs)).name).toBe("supabase-local"); - const file = writeProfile( - "named.yml", - [ - "name: harness", - "api_url: http://127.0.0.1:44444", - "dashboard_url: http://127.0.0.1:44444/dashboard", - "project_host: supabase.co", - ].join("\n"), - ); - expect((yield* loadProfile(file, fs)).name).toBe("harness"); - }).pipe(Effect.provide(BunServices.layer)), + it.effect("returns the profile name — the canonical built-in or the file's name field", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + expect((yield* loadProfile("SUPABASE-LOCAL", fs)).name).toBe("supabase-local"); + const file = writeProfile( + "named.yml", + [ + "name: harness", + "api_url: http://127.0.0.1:44444", + "dashboard_url: http://127.0.0.1:44444/dashboard", + "project_host: supabase.co", + ].join("\n"), + ); + expect((yield* loadProfile(file, fs)).name).toBe("harness"); + }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("rejects unknown keys with mapstructure's padded UnmarshalExact block", () => + it.effect("rejects unknown keys with a sorted unknown-keys line", () => Effect.gen(function* () { const file = writeProfile( "extra-keys.yml", @@ -189,27 +185,24 @@ describe("loadProfile", () => { "db_url: postgres://localhost:5432/db", ].join("\n"), ); - const line1 = "failed to parse profile: decoding failed due to the following error(s):"; - const line3 = "'utils.Profile' has invalid keys: db_url, gotrue_url"; expect(yield* loadError(file)).toBe( - [line1, "".padEnd(line1.length), line3.padEnd(line1.length)].join("\n"), + "failed to parse profile:\nunknown keys: db_url, gotrue_url", ); }), ); - it.effect( - "reports missing required fields with the validator's padded lines, in struct order", - () => - Effect.gen(function* () { - 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", - "Key: 'Profile.DashboardURL' Error:Field validation for 'DashboardURL' failed on the 'required' tag", - "Key: 'Profile.ProjectHost' Error:Field validation for 'ProjectHost' failed on the 'required' tag", - ]; - const width = Math.max(...lines.map((line) => line.length)); - expect(yield* loadError(file)).toBe(lines.map((line) => line.padEnd(width)).join("\n")); - }), + it.effect("reports missing required fields one per line, in field order", () => + Effect.gen(function* () { + const file = writeProfile("incomplete.yml", "name: incomplete\n"); + expect(yield* loadError(file)).toBe( + [ + "invalid profile:", + "api_url is required", + "dashboard_url is required", + "project_host is required", + ].join("\n"), + ); + }), ); it.effect("reports a missing name (only) — required covers empty strings", () => @@ -222,29 +215,23 @@ describe("loadProfile", () => { "project_host: supabase.co", ].join("\n"), ); - expect(yield* loadError(file)).toBe( - "invalid profile: Key: 'Profile.Name' Error:Field validation for 'Name' failed on the 'required' tag", - ); + expect(yield* loadError(file)).toBe("invalid profile:\nname is required"); }), ); - it.effect( - "weakly stringifies scalars like viper, so `api_url: 123` fails http_url, not decoding", - () => - Effect.gen(function* () { - const file = writeProfile( - "typebad.yml", - [ - "name: t", - "api_url: 123", - "dashboard_url: http://127.0.0.1:44444/dashboard", - "project_host: supabase.co", - ].join("\n"), - ); - expect(yield* loadError(file)).toBe( - "invalid profile: Key: 'Profile.APIURL' Error:Field validation for 'APIURL' failed on the 'http_url' tag", - ); - }), + it.effect("weakly stringifies scalars, so `api_url: 123` fails http_url, not decoding", () => + Effect.gen(function* () { + const file = writeProfile( + "typebad.yml", + [ + "name: t", + "api_url: 123", + "dashboard_url: http://127.0.0.1:44444/dashboard", + "project_host: supabase.co", + ].join("\n"), + ); + expect(yield* loadError(file)).toBe("invalid profile:\napi_url must be an http(s) URL"); + }), ); it.effect("validates the hostname_rfc1123 and http_url format tags", () => @@ -258,24 +245,25 @@ describe("loadProfile", () => { "project_host: 'bad host!'", ].join("\n"), ); - const lines = [ - "invalid profile: Key: 'Profile.APIURL' Error:Field validation for 'APIURL' failed on the 'http_url' tag", - "Key: 'Profile.ProjectHost' Error:Field validation for 'ProjectHost' failed on the 'hostname_rfc1123' tag", - ]; - const width = Math.max(...lines.map((line) => line.length)); - expect(yield* loadError(file)).toBe(lines.map((line) => line.padEnd(width)).join("\n")); + expect(yield* loadError(file)).toBe( + [ + "invalid profile:", + "api_url must be an http(s) URL", + "project_host must be a valid hostname", + ].join("\n"), + ); }), ); - it.effect("fails a malformed YAML file closed with viper's parse prefix", () => + it.effect("fails a malformed YAML file closed with the parse-error prefix", () => Effect.gen(function* () { 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: /); + expect(message).toMatch(/^failed to read profile: invalid config file: /); }), ); - it.effect("fails closed on unconvertible values (array on a string field)", () => + it.effect("fails closed on non-scalar values (array on a string field)", () => Effect.gen(function* () { const file = writeProfile( "arrayval.yml", @@ -287,22 +275,9 @@ describe("loadProfile", () => { ].join("\n"), ); const message = yield* loadError(file); - expect(message).toContain( - "failed to parse profile: decoding failed due to the following error(s):", - ); - expect(message).toContain( - "'APIURL' expected type 'string', got unconvertible type '[]interface {}'", + expect(message).toBe( + 'failed to parse profile:\napi_url: expected a string, got array: ["http://a","http://b"]', ); }), ); }); - -describe("padGoErrorBlock", () => { - it("pads every line — including blank ones — to the longest line's width", () => { - expect(padGoErrorBlock("abc\n\nlonger line")).toBe("abc \n \nlonger line"); - }); - - it("leaves single-line messages untouched", () => { - expect(padGoErrorBlock("only line")).toBe("only line"); - }); -}); diff --git a/apps/cli/src/command-internal/profile.unit.test.ts b/apps/cli/src/command-internal/profile.unit.test.ts index a83ae15b7f..7bf618d3be 100644 --- a/apps/cli/src/command-internal/profile.unit.test.ts +++ b/apps/cli/src/command-internal/profile.unit.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from "vitest"; import { billingUrl, dashboardUrl, poolerHost, projectHost } from "./profile.ts"; describe("projectHost", () => { - it("maps built-in profile names to the Go project_host", () => { + it("maps built-in profile names to the project_host", () => { expect(projectHost("supabase")).toBe("supabase.co"); expect(projectHost("supabase-staging")).toBe("supabase.red"); expect(projectHost("supabase-local")).toBe("supabase.red"); @@ -16,7 +16,7 @@ describe("projectHost", () => { }); describe("poolerHost", () => { - it("maps built-in profile names to the Go pooler_host", () => { + it("maps built-in profile names to the pooler_host", () => { expect(poolerHost("supabase")).toBe("supabase.com"); expect(poolerHost("supabase-staging")).toBe("supabase.green"); expect(poolerHost("snap")).toBe("snapcloud.co"); @@ -32,7 +32,7 @@ describe("poolerHost", () => { }); describe("dashboardUrl", () => { - it("maps built-in profile names to the Go dashboard_url", () => { + it("maps built-in profile names to the dashboard_url", () => { expect(dashboardUrl("supabase")).toBe("https://supabase.com/dashboard"); expect(dashboardUrl("supabase-staging")).toBe("https://supabase.green/dashboard"); expect(dashboardUrl("supabase-local")).toBe("http://localhost:8082"); diff --git a/apps/cli/src/command-internal/project-create-core.ts b/apps/cli/src/command-internal/project-create-core.ts index 55238d6a59..1dd095f5f1 100644 --- a/apps/cli/src/command-internal/project-create-core.ts +++ b/apps/cli/src/command-internal/project-create-core.ts @@ -5,8 +5,13 @@ import { CommandPlatformApi } from "../auth/command-platform-api.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; import { OutputFlag } from "./global-flags.ts"; import { Output } from "../shared/output/output.service.ts"; -import { encodeEnv, encodeGoJson } from "./go-output.encoders.ts"; -import { encodeGoToml, encodeGoYaml, goString, goStruct } from "./go-struct-output.encoders.ts"; +import { encodeEnv, encodeSortedJson } from "./output.encoders.ts"; +import { + encodeStructToml, + encodeStructYaml, + shapeString, + shapeStruct, +} from "./struct-output.encoders.ts"; import { sanitizeErrorBody } from "./http-errors.ts"; import { ProjectsCreateNetworkError, @@ -27,15 +32,15 @@ import { type CreateInput = typeof V1CreateAProjectInput.Type; /** Struct spec driving `-o yaml|toml` key casing for `projects create`'s raw response. */ -const GO_PROJECT_RESPONSE = goStruct([ - ["created_at", goString], - ["id", goString], - ["name", goString], - ["organization_id", goString], - ["organization_slug", goString], - ["ref", goString], - ["region", goString], - ["status", goString], +const PROJECT_RESPONSE_SHAPE = shapeStruct([ + ["created_at", shapeString], + ["id", shapeString], + ["name", shapeString], + ["organization_id", shapeString], + ["organization_slug", shapeString], + ["ref", shapeString], + ["region", shapeString], + ["status", shapeString], ]); export interface ProjectCreateInput { @@ -72,7 +77,7 @@ export const projectCreateCore = Effect.fn("ProjectCreate.run")(function* ( input: ProjectCreateInput, ) { const output = yield* Output; - const goOutputFlag = yield* OutputFlag; + const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; const cliSettings = yield* CommandSettings; @@ -151,20 +156,20 @@ export const projectCreateCore = Effect.fn("ProjectCreate.run")(function* ( const projectUrl = `${dashboardUrlForProfile(cliSettings.profile)}/project/${id}`; yield* output.raw(`Created a new project at ${projectUrl}\n`, "stderr"); - const goFmt = Option.getOrUndefined(goOutputFlag); - if (goFmt === "json") { - yield* output.raw(encodeGoJson(created)); + const outputFlagFormat = Option.getOrUndefined(outputFlag); + if (outputFlagFormat === "json") { + yield* output.raw(encodeSortedJson(created)); return { ref: id, dbPassword }; } - if (goFmt === "yaml") { - yield* output.raw(encodeGoYaml(created, GO_PROJECT_RESPONSE)); + if (outputFlagFormat === "yaml") { + yield* output.raw(encodeStructYaml(created, PROJECT_RESPONSE_SHAPE)); return { ref: id, dbPassword }; } - if (goFmt === "toml") { - yield* output.raw(encodeGoToml(created, GO_PROJECT_RESPONSE)); + if (outputFlagFormat === "toml") { + yield* output.raw(encodeStructToml(created, PROJECT_RESPONSE_SHAPE)); return { ref: id, dbPassword }; } - if (goFmt === "env") { + if (outputFlagFormat === "env") { yield* output.raw(encodeEnv(created) + "\n"); return { ref: id, dbPassword }; } 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 3e6bdab5e7..ce43ce310b 100644 --- a/apps/cli/src/command-internal/project-environment.unit.test.ts +++ b/apps/cli/src/command-internal/project-environment.unit.test.ts @@ -49,7 +49,7 @@ describe("resolveProjectEnvironmentValues", () => { }); }); - it("fills in a value from a project-root .env file Go's loadNestedEnv would load", () => { + it("fills in a value from a project-root .env file", () => { writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=root-env-project\n"); const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); expect(merged["SUPABASE_PROJECT_ID"]).toBe("root-env-project"); @@ -91,7 +91,7 @@ describe("resolveProjectEnvironmentValues", () => { expect(merged["SUPABASE_PROJECT_ID"]).toBe("prod-local-project"); }); - it("skips .env.local when SUPABASE_ENV=test, matching Go's loadDefaultEnv", () => { + it("skips .env.local when SUPABASE_ENV=test", () => { process.env["SUPABASE_ENV"] = "test"; writeFileSync(join(root, ".env.local"), "SUPABASE_PROJECT_ID=local-project\n"); writeFileSync(join(root, ".env.test"), "SUPABASE_PROJECT_ID=test-project\n"); @@ -127,7 +127,7 @@ describe("resolveProjectEnvironmentValues", () => { expect(merged["SUPABASE_PROJECT_ID"]).toBe("commented-project"); }); - it("preserves a literal # in an unquoted value with no leading whitespace, matching godotenv", () => { + it("preserves a literal # in an unquoted value with no leading whitespace", () => { writeFileSync(root + "/.env", "SUPABASE_AUTH_JWT_SECRET=long#secret\n"); const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe("long#secret"); @@ -139,13 +139,13 @@ describe("resolveProjectEnvironmentValues", () => { expect(merged["SUPABASE_PROJECT_ID"]).toBe("54323"); }); - it("strips a trailing comment after a quoted value, matching godotenv", () => { + it("strips a trailing comment after a quoted value", () => { 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", () => { + it("accepts a colon-separated assignment (YAML-style key/value form)", () => { writeFileSync(root + "/.env", "SUPABASE_PROJECT_ID: colon-project\n"); const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); expect(merged["SUPABASE_PROJECT_ID"]).toBe("colon-project"); @@ -180,7 +180,7 @@ describe("resolveProjectEnvironmentValues", () => { expect(merged["SUPABASE_PROJECT_ID"]).toBe("ambient-project"); }); - it("throws on a malformed line, matching Go's loadEnvIfExists propagating godotenv's parse error", () => { + it("throws on a malformed line", () => { writeFileSync(join(root, ".env"), "not a valid line\n"); expect(() => resolveProjectEnvironmentValues(fakeProjectEnv(), root)).toThrow( /failed to parse environment file/, @@ -205,19 +205,19 @@ describe("resolveProjectEnvironmentValues", () => { expect(merged["SUPABASE_PROJECT_ID"]).toBe("$BASE"); }); - it("expands an unresolved bare reference to an empty string, matching Go's map zero-value", () => { + it("expands an unresolved bare reference to an empty string", () => { writeFileSync(join(root, ".env"), "SUPABASE_PROJECT_ID=$NOPE\n"); const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); expect(merged["SUPABASE_PROJECT_ID"]).toBe(""); }); - it("expands an unresolved braced reference to an empty string, matching Go's map zero-value", () => { + it("expands an unresolved braced reference to an empty string", () => { writeFileSync(join(root, ".env"), 'SUPABASE_AUTH_JWT_SECRET="${NOPE}"\n'); const merged = resolveProjectEnvironmentValues(fakeProjectEnv(), root); expect(merged["SUPABASE_AUTH_JWT_SECRET"]).toBe(""); }); - it("preserves a backslash-escaped $VAR reference as a literal, matching godotenv's escape rule", () => { + it("preserves a backslash-escaped $VAR reference as a literal", () => { 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"); @@ -235,7 +235,7 @@ describe("resolveProjectEnvironmentValues", () => { expect(merged["SUPABASE_PROJECT_ID"]).toBe("demo$"); }); - it("preserves a multiline quoted value alongside an unrelated SUPABASE_* key (godotenv parity)", () => { + it("preserves a multiline quoted value alongside an unrelated SUPABASE_* key", () => { // 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-----"; diff --git a/apps/cli/src/command-internal/schema-flags.ts b/apps/cli/src/command-internal/schema-flags.ts index a8599718d3..36b014a822 100644 --- a/apps/cli/src/command-internal/schema-flags.ts +++ b/apps/cli/src/command-internal/schema-flags.ts @@ -12,10 +12,10 @@ export { StringSliceFlagParseError as SchemaFlagParseError }; export const parseSchemaFlags = parseStringSliceFlag; -// Whether a CSV field must be quoted (matches Go's `encoding/csv` writer, so a printed +// Whether a CSV field must be quoted (so a printed // suggestion round-trips through the same CSV parsing rules `--schema` values use): never quote // the empty string; always quote `\.`; quote when the field contains `,`, `"`, `\r`, or `\n`; -// otherwise quote when the first rune is whitespace. +// otherwise quote when the first character is whitespace. function fieldNeedsQuotes(field: string): boolean { if (field === "") return false; if (field === "\\.") return true; 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 54d19e95f4..12dd8e8309 100644 --- a/apps/cli/src/command-internal/schema-flags.unit.test.ts +++ b/apps/cli/src/command-internal/schema-flags.unit.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { parseSchemaFlags, SchemaFlagParseError, schemaToCsvField } from "./schema-flags.ts"; -describe("parseSchemaFlags (pflag StringSlice CSV parity)", () => { +describe("parseSchemaFlags", () => { it("splits unquoted comma-separated values", () => { expect(parseSchemaFlags(["public,private"])).toEqual(["public", "private"]); }); @@ -34,7 +34,7 @@ describe("parseSchemaFlags (pflag StringSlice CSV parity)", () => { expect(parseSchemaFlags([])).toEqual([]); }); - it("preserves whitespace (Go does not trim)", () => { + it("preserves whitespace (no trimming)", () => { expect(parseSchemaFlags([" public , private "])).toEqual([" public ", " private "]); }); @@ -63,7 +63,7 @@ describe("schemaToCsvField (inverse — re-encode one value as a CSV field)", () expect(schemaToCsvField("public")).toBe("public"); }); - it("leaves the empty string unquoted (Go csv.Writer)", () => { + it("leaves the empty string unquoted", () => { expect(schemaToCsvField("")).toBe(""); }); diff --git a/apps/cli/src/command-internal/seed-buckets.ts b/apps/cli/src/command-internal/seed-buckets.ts index 0fda1b5e35..23c8228b57 100644 --- a/apps/cli/src/command-internal/seed-buckets.ts +++ b/apps/cli/src/command-internal/seed-buckets.ts @@ -1,5 +1,5 @@ import { type CliConfig, CliConfigSchema } from "@supabase/config/effect"; -import { loadCliConfig, type InternalLoadCliConfigOptions } from "@supabase/config/internal"; +import { loadCliConfig, type CliConfigLoadOptions } from "./cli-config-load.ts"; import { BunPath } from "@effect/platform-bun"; import { Effect, FileSystem, Path, Schema } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; @@ -171,10 +171,7 @@ export const seedBucketsRun = Effect.fnUntraced(function* (opts: { // An explicit `opts.workdir` is the exact project root; only the caller's own workdir // (`cliSettings.workdir`) may still search ancestors for `config.toml`. const search = opts.workdir === undefined && shouldSearchAncestors(cliSettings); - const loadOptions: InternalLoadCliConfigOptions = - projectRef !== "" - ? { projectRef, goViperCompat: true, search } - : { goViperCompat: true, search }; + const loadOptions: CliConfigLoadOptions = projectRef !== "" ? { projectRef, search } : { search }; const loaded = opts.resolvedConfig !== undefined ? null 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 85c91b5e5e..93979e1795 100644 --- a/apps/cli/src/command-internal/seed-ops.unit.test.ts +++ b/apps/cli/src/command-internal/seed-ops.unit.test.ts @@ -38,29 +38,26 @@ function fakeSeedSession(opts: { restoreRoleSql?: string } = {}) { // (`../../../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)", - () => { - const dir = mkdtempSync(join(tmpdir(), "seed-glob-")); - writeFileSync(join(dir, "a.sql"), "select 1;"); - writeFileSync(join(dir, "b.sql"), "select 2;"); - const { session } = fakeSeedSession(); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - // `[!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 }); - }).pipe( - Effect.provide(mockOutput({ format: "text" }).layer), - Effect.provide(BunServices.layer), - ); - }, - ); + it.effect("treats a leading `!` in a bracket class as literal, not negation", () => { + const dir = mkdtempSync(join(tmpdir(), "seed-glob-")); + writeFileSync(join(dir, "a.sql"), "select 1;"); + writeFileSync(join(dir, "b.sql"), "select 2;"); + const { session } = fakeSeedSession(); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + // `[!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 }); + }).pipe( + Effect.provide(mockOutput({ format: "text" }).layer), + Effect.provide(BunServices.layer), + ); + }); it.effect( - "warns Go's bad-pattern message for an unterminated bracket class, not a bogus no-match", + "warns the bad-pattern message for an unterminated bracket class, not a bogus no-match", () => { // An unclosed `[` is malformed, so `fs.Glob` reports a syntax error, not a bogus // "no files matched" for a well-formed-but-empty pattern. @@ -116,7 +113,7 @@ describe("seedData (dirty parse)", () => { }); it.effect( - "rejects an oversized seed statement when SUPABASE_SCANNER_BUFFER_SIZE is configured (Go SeedFile.ExecBatchWithCache parity)", + "rejects an oversized seed statement when SUPABASE_SCANNER_BUFFER_SIZE is configured", () => { const dir = mkdtempSync(join(tmpdir(), "seed-scanner-")); // The seed text must exceed the 4096-byte scanner floor regardless of the configured diff --git a/apps/cli/src/command-internal/seed.unit.test.ts b/apps/cli/src/command-internal/seed.unit.test.ts index 1863e6919f..c3c15d3cd9 100644 --- a/apps/cli/src/command-internal/seed.unit.test.ts +++ b/apps/cli/src/command-internal/seed.unit.test.ts @@ -78,38 +78,35 @@ describe("applySeedFiles seed glob", () => { ); }); - it.effect( - "expands a matched directory to its sorted, regular .sql files (Go's Glob.SQLFiles)", - () => { - const dir = mkdtempSync(join(tmpdir(), "seed-")); - mkdirSync(join(dir, "seeds")); - writeFileSync(join(dir, "seeds", "b.sql"), "insert into t values (2);"); - writeFileSync(join(dir, "seeds", "a.sql"), "insert into t values (1);"); - writeFileSync(join(dir, "seeds", "README.md"), "not a seed file"); - const { session, queries } = fakeSession(); - const out = mockOutput(); - return run(session, dir, ["seeds"], out).pipe( - Effect.tap(() => - Effect.sync(() => { - const upserts = queries.filter((q) => - q.sql.includes("INSERT INTO supabase_migrations.seed_files"), - ); - expect(upserts.map((q) => q.params?.[0])).toEqual(["seeds/a.sql", "seeds/b.sql"]); - expect(out.rawChunks.map((c) => c.text)).toEqual([ - "Seeding data from seeds/a.sql...\n", - "Seeding data from seeds/b.sql...\n", - ]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); + it.effect("expands a matched directory to its sorted, regular .sql files", () => { + const dir = mkdtempSync(join(tmpdir(), "seed-")); + mkdirSync(join(dir, "seeds")); + writeFileSync(join(dir, "seeds", "b.sql"), "insert into t values (2);"); + writeFileSync(join(dir, "seeds", "a.sql"), "insert into t values (1);"); + writeFileSync(join(dir, "seeds", "README.md"), "not a seed file"); + const { session, queries } = fakeSession(); + const out = mockOutput(); + return run(session, dir, ["seeds"], out).pipe( + Effect.tap(() => + Effect.sync(() => { + const upserts = queries.filter((q) => + q.sql.includes("INSERT INTO supabase_migrations.seed_files"), + ); + expect(upserts.map((q) => q.params?.[0])).toEqual(["seeds/a.sql", "seeds/b.sql"]); + expect(out.rawChunks.map((c) => c.text)).toEqual([ + "Seeding data from seeds/a.sql...\n", + "Seeding data from seeds/b.sql...\n", + ]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); }); describe("applySeedFiles scanner buffer size", () => { it.effect( - "rejects an oversized seed statement when SUPABASE_SCANNER_BUFFER_SIZE is configured (Go SeedFile.ExecBatchWithCache parity)", + "rejects an oversized seed statement when SUPABASE_SCANNER_BUFFER_SIZE is configured", () => { const dir = mkdtempSync(join(tmpdir(), "seed-scanner-")); // The seed text must exceed the 4096-byte scanner floor regardless of the configured diff --git a/apps/cli/src/command-internal/size-units.ts b/apps/cli/src/command-internal/size-units.ts index e8af1e3feb..17317229b4 100644 --- a/apps/cli/src/command-internal/size-units.ts +++ b/apps/cli/src/command-internal/size-units.ts @@ -1,9 +1,7 @@ /** - * 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. + * Parses `file_size_limit` config values (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 */ const BINARY_MAP: Readonly> = { @@ -48,7 +46,7 @@ export function ramInBytes(sizeStr: string): number { throw new Error(`invalid size: '${sizeStr}'`); } // Strip the (already-validated, between-digits) underscores before parsing: - // JS `Number.parseFloat("1_000")` stops at the underscore (→1), unlike Go. + // JS `Number.parseFloat("1_000")` stops at the underscore (→1). const size = Number.parseFloat(num.replace(/_/g, "")); // 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. 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 6d6e904185..9cefc995a1 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 @@ -15,70 +15,61 @@ const run = (patterns: ReadonlyArray, workdir: string) => }).pipe(Effect.provide(BunServices.layer)); describe("sqlFilesGlob", () => { - it.effect( - "treats an empty pattern as no match, not the workdir itself (Go fs.Glob parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "sql-glob-empty-")); - return run([""], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual([]); - expect(result.warnings).toEqual(["no files matched pattern: "]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); + it.effect("treats an empty pattern as no match, not the workdir itself", () => { + const dir = mkdtempSync(join(tmpdir(), "sql-glob-empty-")); + return run([""], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual([]); + expect(result.warnings).toEqual(["no files matched pattern: "]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); - it.effect( - "does not follow a symlinked .sql file below a matched directory (Go WalkDir parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "sql-glob-symlink-file-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "real.sql"), "select 1;"); - const outsideDir = join(dir, "outside"); - mkdirSync(outsideDir); - writeFileSync(join(outsideDir, "evil.sql"), "select 2;"); - symlinkSync(join(outsideDir, "evil.sql"), join(schemasDir, "linked.sql")); - return run(["schemas"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/real.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); + it.effect("does not follow a symlinked .sql file below a matched directory", () => { + const dir = mkdtempSync(join(tmpdir(), "sql-glob-symlink-file-")); + const schemasDir = join(dir, "schemas"); + mkdirSync(schemasDir); + writeFileSync(join(schemasDir, "real.sql"), "select 1;"); + const outsideDir = join(dir, "outside"); + mkdirSync(outsideDir); + writeFileSync(join(outsideDir, "evil.sql"), "select 2;"); + symlinkSync(join(outsideDir, "evil.sql"), join(schemasDir, "linked.sql")); + return run(["schemas"], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["schemas/real.sql"]); + expect(result.warnings).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); - it.effect( - "does not recurse into a symlinked subdirectory below a matched directory (Go WalkDir parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "sql-glob-symlink-dir-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "real.sql"), "select 1;"); - const outsideSubdir = join(dir, "outside-subdir"); - mkdirSync(outsideSubdir); - writeFileSync(join(outsideSubdir, "nested.sql"), "select 3;"); - symlinkSync(outsideSubdir, join(schemasDir, "linked-dir")); - return run(["schemas"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/real.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); + it.effect("does not recurse into a symlinked subdirectory below a matched directory", () => { + const dir = mkdtempSync(join(tmpdir(), "sql-glob-symlink-dir-")); + const schemasDir = join(dir, "schemas"); + mkdirSync(schemasDir); + writeFileSync(join(schemasDir, "real.sql"), "select 1;"); + const outsideSubdir = join(dir, "outside-subdir"); + mkdirSync(outsideSubdir); + writeFileSync(join(outsideSubdir, "nested.sql"), "select 3;"); + symlinkSync(outsideSubdir, join(schemasDir, "linked-dir")); + return run(["schemas"], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["schemas/real.sql"]); + expect(result.warnings).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); it.effect( - "surfaces a stat failure on a matched file as a warning instead of treating it as a regular file (Go parity)", + "surfaces a stat failure on a matched file as a warning instead of treating it as a regular file", () => { const dir = mkdtempSync(join(tmpdir(), "sql-glob-stat-fail-")); const schemasDir = join(dir, "schemas"); @@ -101,7 +92,7 @@ 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)", + "surfaces a stat failure for a LITERAL (no-metacharacter) pattern naming a broken symlink, instead of reporting no match", () => { const dir = mkdtempSync(join(tmpdir(), "sql-glob-literal-symlink-")); const schemasDir = join(dir, "schemas"); @@ -122,7 +113,7 @@ 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)", + "keeps a bare root ('/') as the directory when a glob pattern's meta character is in the first path component", () => { // The canary file lives in the workdir (never the real "/"), proving this doesn't // fall back to treating the root component as workdir-relative. @@ -143,7 +134,7 @@ describe("sqlFilesGlob", () => { ); it.effect( - "recurses through a root-anchored directory component without falling back to the workdir (Go afero.Glob parity)", + "recurses through a root-anchored directory component without falling back to the workdir", () => { // The workdir here contains a subdirectory that would match "foo*" only if the // recursive call incorrectly fell back to reading the workdir instead of "/". @@ -166,7 +157,7 @@ describe("sqlFilesGlob", () => { ); it.effect( - "preserves a Windows drive root ('C:/') as the directory when splitting a glob pattern (Go filepath.Split parity)", + "preserves a Windows drive root ('C:/') as the directory when splitting a glob pattern", () => { // Forces win32 path semantics (`BunPath.layerWin32` + `process.platform`) so the test // exercises the same branch a real Windows install takes. @@ -206,7 +197,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)", + "reports the raw backslash pattern in a 'no files matched' warning on Windows, not the slashed form used for matching", () => { // Forces win32 path semantics, same as the drive-root test above. const originalPlatform = process.platform; @@ -234,56 +225,53 @@ describe("sqlFilesGlob", () => { }, ); - it.effect( - "sorts raw backslash-joined Windows matches BEFORE slashing, not after (Go afero.Glob/filepath.ToSlash ordering parity)", - () => { - // 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" }); - const scratchDir = mkdtempSync(join(tmpdir(), "sql-glob-win-sort-")); - const canaryFile = join(scratchDir, "canary.sql"); - writeFileSync(canaryFile, "select 1;"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const fileInfo = yield* fs.stat(canaryFile); - const workdir = "/workdir"; - const winFs: FileSystem.FileSystem = { - ...fs, - readDirectory: (p: string) => { - if (p === workdir) return Effect.succeed(["a", "a0"]); - if (p === "\\workdir\\a" || p === "\\workdir\\a0") return Effect.succeed(["x.sql"]); - return fs.readDirectory(p); - }, - stat: (p: string) => - p === "\\workdir\\a\\x.sql" || p === "\\workdir\\a0\\x.sql" - ? Effect.succeed(fileInfo) - : fs.stat(p), - }; - return yield* sqlFilesGlob(winFs, path, ["a*/x.sql"], workdir); - }).pipe( - Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layerWin32)), - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["a0/x.sql", "a/x.sql"]); - expect(result.warnings).toEqual([]); - }), - ), - Effect.ensuring( - Effect.sync(() => { - Object.defineProperty(process, "platform", { value: originalPlatform }); - rmSync(scratchDir, { recursive: true, force: true }); - }), - ), - ); - }, - ); + it.effect("sorts raw backslash-joined Windows matches BEFORE slashing, not after", () => { + // 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" }); + const scratchDir = mkdtempSync(join(tmpdir(), "sql-glob-win-sort-")); + const canaryFile = join(scratchDir, "canary.sql"); + writeFileSync(canaryFile, "select 1;"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const fileInfo = yield* fs.stat(canaryFile); + const workdir = "/workdir"; + const winFs: FileSystem.FileSystem = { + ...fs, + readDirectory: (p: string) => { + if (p === workdir) return Effect.succeed(["a", "a0"]); + if (p === "\\workdir\\a" || p === "\\workdir\\a0") return Effect.succeed(["x.sql"]); + return fs.readDirectory(p); + }, + stat: (p: string) => + p === "\\workdir\\a\\x.sql" || p === "\\workdir\\a0\\x.sql" + ? Effect.succeed(fileInfo) + : fs.stat(p), + }; + return yield* sqlFilesGlob(winFs, path, ["a*/x.sql"], workdir); + }).pipe( + Effect.provide(Layer.mergeAll(BunFileSystem.layer, BunPath.layerWin32)), + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["a0/x.sql", "a/x.sql"]); + expect(result.warnings).toEqual([]); + }), + ), + Effect.ensuring( + Effect.sync(() => { + Object.defineProperty(process, "platform", { value: originalPlatform }); + rmSync(scratchDir, { recursive: true, force: true }); + }), + ), + ); + }); it.effect( - "normalizes a doubled slash when the matched directory itself has a trailing slash (Go path.Join parity)", + "normalizes a doubled slash when the matched directory itself has a trailing slash", () => { const dir = mkdtempSync(join(tmpdir(), "sql-glob-trailing-slash-")); const schemasDir = join(dir, "schemas"); @@ -301,89 +289,77 @@ describe("sqlFilesGlob", () => { }, ); - it.effect( - "drops the './' prefix when the matched directory cleans to '.' (Go path.Join parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "sql-glob-dot-root-")); - writeFileSync(join(dir, "a.sql"), "select 1;"); - const nestedDir = join(dir, "nested"); - mkdirSync(nestedDir); - writeFileSync(join(nestedDir, "b.sql"), "select 2;"); - return run(["."], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["a.sql", "nested/b.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); + it.effect("drops the './' prefix when the matched directory cleans to '.'", () => { + const dir = mkdtempSync(join(tmpdir(), "sql-glob-dot-root-")); + writeFileSync(join(dir, "a.sql"), "select 1;"); + const nestedDir = join(dir, "nested"); + mkdirSync(nestedDir); + writeFileSync(join(nestedDir, "b.sql"), "select 2;"); + return run(["."], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["a.sql", "nested/b.sql"]); + expect(result.warnings).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); - it.effect( - "cleans a '..'-segment matched directory when walking its children (Go path.Join parity)", - () => { - const dir = mkdtempSync(join(tmpdir(), "sql-glob-dotdot-segment-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "a.sql"), "select 1;"); - return run(["nested/../schemas"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/a.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); + it.effect("cleans a '..'-segment matched directory when walking its children", () => { + const dir = mkdtempSync(join(tmpdir(), "sql-glob-dotdot-segment-")); + const schemasDir = join(dir, "schemas"); + mkdirSync(schemasDir); + writeFileSync(join(schemasDir, "a.sql"), "select 1;"); + return run(["nested/../schemas"], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["schemas/a.sql"]); + expect(result.warnings).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); - it.effect( - "cleans a direct glob match whose directory portion has a '.' segment (Go afero.Glob parity)", - () => { - // 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); - writeFileSync(join(schemasDir, "a.sql"), "select 1;"); - return run(["schemas/./*.sql"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/a.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); + it.effect("cleans a direct glob match whose directory portion has a '.' segment", () => { + // 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); + writeFileSync(join(schemasDir, "a.sql"), "select 1;"); + return run(["schemas/./*.sql"], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["schemas/a.sql"]); + expect(result.warnings).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); - 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`), but for an embedded `..` - // segment instead of `.`. - const dir = mkdtempSync(join(tmpdir(), "sql-glob-direct-dotdot-segment-")); - const schemasDir = join(dir, "schemas"); - mkdirSync(schemasDir); - writeFileSync(join(schemasDir, "a.sql"), "select 1;"); - return run(["nested/../schemas/*.sql"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/a.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); + it.effect("cleans a direct glob match whose directory portion has a '..' segment", () => { + // 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); + writeFileSync(join(schemasDir, "a.sql"), "select 1;"); + return run(["nested/../schemas/*.sql"], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["schemas/a.sql"]); + expect(result.warnings).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); it.effect( - "normalizes a doubled slash for a direct glob match under a trailing-slash directory component (Go afero.Glob parity)", + "normalizes a doubled slash for a direct glob match under a trailing-slash directory component", () => { // `splitPath` on `"schemas//*.sql"` yields `dir: "schemas/"` (the trailing slash // survives the split), so the join must still collapse the doubled slash. @@ -403,42 +379,39 @@ describe("sqlFilesGlob", () => { }, ); - it.effect( - "still includes a '.sql' child whose stat fails after it's already listed (Go WalkDir parity)", - () => { - // 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); - const racyFile = join(schemasDir, "racy.sql"); - writeFileSync(racyFile, "select 1;"); - return Effect.gen(function* () { - const fs = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const racyFs: FileSystem.FileSystem = { - ...fs, - stat: (p: string) => - p === racyFile - ? Effect.sync(() => rmSync(racyFile)).pipe(Effect.andThen(fs.stat(p))) - : fs.stat(p), - }; - return yield* sqlFilesGlob(racyFs, path, ["schemas"], dir); - }).pipe( - Effect.provide(BunServices.layer), - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/racy.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); + it.effect("still includes a '.sql' child whose stat fails after it's already listed", () => { + // 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); + const racyFile = join(schemasDir, "racy.sql"); + writeFileSync(racyFile, "select 1;"); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const racyFs: FileSystem.FileSystem = { + ...fs, + stat: (p: string) => + p === racyFile + ? Effect.sync(() => rmSync(racyFile)).pipe(Effect.andThen(fs.stat(p))) + : fs.stat(p), + }; + return yield* sqlFilesGlob(racyFs, path, ["schemas"], dir); + }).pipe( + Effect.provide(BunServices.layer), + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["schemas/racy.sql"]); + expect(result.warnings).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); it.effect( - "fails the whole walk when a non-'.sql' child whose stat fails could have been a subdirectory (Go WalkDir parity)", + "fails the whole walk when a non-'.sql' child whose stat fails could have been a subdirectory", () => { const dir = mkdtempSync(join(tmpdir(), "sql-glob-dir-race-")); const schemasDir = join(dir, "schemas"); @@ -497,7 +470,7 @@ describe("sqlFilesGlob", () => { const isRoot = typeof process.getuid === "function" && process.getuid() === 0; it.effect.skipIf(isRoot)( - "surfaces a directory-read failure during walk as a warning instead of an empty match (Go WalkDir parity)", + "surfaces a directory-read failure during walk as a warning instead of an empty match", () => { const dir = mkdtempSync(join(tmpdir(), "sql-glob-walk-fail-")); const schemasDir = join(dir, "schemas"); @@ -525,7 +498,7 @@ 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)", + "surfaces a NESTED directory-read failure during walk with a workdir-relative path, not the matched root's", () => { const dir = mkdtempSync(join(tmpdir(), "sql-glob-walk-fail-nested-")); const schemasDir = join(dir, "schemas"); @@ -584,7 +557,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)", + "picks the lexically-first failing subdirectory as the fatal error", () => { // The fake `readDirectory` returns "schemas"'s children in reverse // order, to prove the walk sorts them back (`utf8Compare`) before iterating. @@ -626,26 +599,23 @@ describe("sqlFilesGlob", () => { }, ); - it.effect( - "sorts direct wildcard matches by UTF-8 byte order, not UTF-16 code units (Go sort.Strings parity)", - () => { - // 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); - writeFileSync(join(schemasDir, "\u{1F600}.sql"), "select 1;"); // 😀 - writeFileSync(join(schemasDir, "!.sql"), "select 2;"); // ! - return run(["schemas/*.sql"], dir).pipe( - Effect.tap((result) => - Effect.sync(() => { - expect(result.files).toEqual(["schemas/!.sql", "schemas/\u{1F600}.sql"]); - expect(result.warnings).toEqual([]); - rmSync(dir, { recursive: true, force: true }); - }), - ), - ); - }, - ); + it.effect("sorts direct wildcard matches by UTF-8 byte order, not UTF-16 code units", () => { + // 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); + writeFileSync(join(schemasDir, "\u{1F600}.sql"), "select 1;"); // 😀 + writeFileSync(join(schemasDir, "!.sql"), "select 2;"); // ! + return run(["schemas/*.sql"], dir).pipe( + Effect.tap((result) => + Effect.sync(() => { + expect(result.files).toEqual(["schemas/!.sql", "schemas/\u{1F600}.sql"]); + expect(result.warnings).toEqual([]); + rmSync(dir, { recursive: true, force: true }); + }), + ), + ); + }); }); diff --git a/apps/cli/src/command-internal/sql-split.ts b/apps/cli/src/command-internal/sql-split.ts index 66c7a71742..85b1b799db 100644 --- a/apps/cli/src/command-internal/sql-split.ts +++ b/apps/cli/src/command-internal/sql-split.ts @@ -9,7 +9,7 @@ interface State { /** Returns the next state, or `null` to emit a token (statement boundary). */ - next(rune: string, data: string): State | null; + next(char: string, data: string): State | null; } const BEGIN_ATOMIC = "ATOMIC"; @@ -17,17 +17,17 @@ const END_ATOMIC = "END"; // PostgreSQL's scan.l treats every code point at or above 0x80 as an identifier/dollar-tag // character (`ident_cont`/`dolq_cont`), whatever its Unicode category. -const isIdentifierRune = (rune: string): boolean => { - const codePoint = rune.codePointAt(0); - return codePoint !== undefined && (codePoint >= 0x80 || /[A-Za-z0-9_$]/u.test(rune)); +const isIdentifierChar = (char: string): boolean => { + const codePoint = char.codePointAt(0); + return codePoint !== undefined && (codePoint >= 0x80 || /[A-Za-z0-9_$]/u.test(char)); }; // A code point spans at most two UTF-16 units, so the last one before `offset` lies within // the preceding two. -const hasIdentifierRuneBefore = (data: string, offset: number): boolean => { +const hasIdentifierCharBefore = (data: string, offset: number): boolean => { if (offset <= 0) return false; - const rune = Array.from(data.slice(Math.max(0, offset - 2), offset)).at(-1); - return rune !== undefined && isIdentifierRune(rune); + const char = Array.from(data.slice(Math.max(0, offset - 2), offset)).at(-1); + return char !== undefined && isIdentifierChar(char); }; const asciiUpper = (text: string): string => text.replace(/[a-z]/g, (c) => c.toUpperCase()); @@ -35,13 +35,13 @@ const asciiUpper = (text: string): string => text.replace(/[a-z]/g, (c) => c.toU function endsWithKeyword(data: string, keyword: string): boolean { const offset = data.length - keyword.length; if (offset < 0 || asciiUpper(data.slice(offset)) !== keyword) return false; - return !hasIdentifierRuneBefore(data, offset); + return !hasIdentifierCharBefore(data, offset); } -const isSqlWhitespace = (rune: string): boolean => " \t\n\r\f\v".includes(rune); +const isSqlWhitespace = (char: string): boolean => " \t\n\r\f\v".includes(char); // scan.l `newline`: a `--` comment ends at either. -const isNewline = (rune: string): boolean => rune === "\n" || rune === "\r"; +const isNewline = (char: string): boolean => char === "\n" || char === "\r"; function isBeginAtomic(data: string): boolean { if (!endsWithKeyword(data, BEGIN_ATOMIC)) return false; @@ -78,22 +78,22 @@ function isCommentsAndWhitespace(text: string): boolean { } class ReadyState implements State { - next(rune: string, data: string): State | null { - switch (rune) { + next(char: string, data: string): State | null { + switch (char) { case "$": { - // A `$` after an identifier rune continues the identifier (`pending$$foo$`), not a + // A `$` after an identifier char continues the identifier (`pending$$foo$`), not a // dollar quote. A digit counts too (`1$$`), unlike PostgreSQL; valid SQL never has that. - const offset = data.length - rune.length; - if (hasIdentifierRuneBefore(data, offset)) return this; + const offset = data.length - char.length; + if (hasIdentifierCharBefore(data, offset)) return this; return new TagState(offset); } case "'": // `E'…'` is an escape string constant only when the `E` starts a token (scan.l // `xestart`); in `type'…'` it ends an identifier. A digit or `$` before the `E` // counts as one too, unlike PostgreSQL; valid SQL never has that. - return new QuoteState(rune, endsWithKeyword(data.slice(0, -1), "E")); + return new QuoteState(char, endsWithKeyword(data.slice(0, -1), "E")); case '"': - return new QuoteState(rune, false); + return new QuoteState(char, false); case "-": return new CommentState(); case "/": @@ -115,27 +115,27 @@ class ReadyState implements State { } class CommentState implements State { - next(rune: string, data: string): State | null { - if (rune === "-") return new LineCommentState(); - return new ReadyState().next(rune, data); + next(char: string, data: string): State | null { + if (char === "-") return new LineCommentState(); + return new ReadyState().next(char, data); } } class LineCommentState implements State { - next(rune: string): State { - return isNewline(rune) ? new ReadyState() : this; + next(char: string): State { + return isNewline(char) ? new ReadyState() : this; } } class BlockState implements State { private depth = 0; - next(rune: string, data: string): State | null { + next(char: string, data: string): State | null { const window = data.slice(-2); if (window === "/*") { this.depth += 1; return this; } - if (this.depth === 0) return new ReadyState().next(rune, data); + if (this.depth === 0) return new ReadyState().next(char, data); if (window === "*/") { this.depth -= 1; if (this.depth === 0) return new ReadyState(); @@ -151,26 +151,26 @@ class QuoteState implements State { private readonly delimiter: string, private readonly backslashEscapes: boolean, ) {} - next(rune: string, data: string): State | null { + next(char: string, data: string): State | null { if (this.escape) { // Preserve a doubled quote ('' or ""). - if (rune === this.delimiter) { + if (char === this.delimiter) { this.escape = false; return this; } - if (this.backslashEscapes) return new QuoteContinueState().next(rune, data); - return new ReadyState().next(rune, data); + if (this.backslashEscapes) return new QuoteContinueState().next(char, data); + return new ReadyState().next(char, data); } if (this.backslash) { - // Preserve the rune after a backslash (\' or \\). + // Preserve the char after a backslash (\' or \\). this.backslash = false; return this; } - if (this.backslashEscapes && rune === "\\") { + if (this.backslashEscapes && char === "\\") { this.backslash = true; return this; } - if (rune === this.delimiter) this.escape = true; + if (char === this.delimiter) this.escape = true; return this; } } @@ -180,32 +180,32 @@ class QuoteState implements State { class QuoteContinueState implements State { private newline = false; private dashes = 0; - next(rune: string, data: string): State | null { + next(char: string, data: string): State | null { if (this.dashes === 2) { - if (isNewline(rune)) { + if (isNewline(char)) { this.dashes = 0; this.newline = true; } return this; } - if (rune === "-") { + if (char === "-") { this.dashes += 1; return this; } if (this.dashes === 0) { - if (isSqlWhitespace(rune)) { - this.newline ||= isNewline(rune); + if (isSqlWhitespace(char)) { + this.newline ||= isNewline(char); return this; } - if (this.newline && rune === "'") return new QuoteState(rune, true); + if (this.newline && char === "'") return new QuoteState(char, true); } - return new ReadyState().next(rune, data); + return new ReadyState().next(char, data); } } class DollarState implements State { constructor(private readonly delimiter: string) {} - next(_rune: string, data: string): State | null { + next(_char: string, data: string): State | null { if (data.slice(-this.delimiter.length) === this.delimiter) return new ReadyState(); return this; } @@ -213,10 +213,10 @@ class DollarState implements State { class TagState implements State { constructor(private readonly offset: number) {} - next(rune: string, data: string): State | null { - if (rune === "$") return new DollarState(data.slice(this.offset)); - if (isIdentifierRune(rune)) return this; - return new ReadyState().next(rune, data); + next(char: string, data: string): State | null { + if (char === "$") return new DollarState(data.slice(this.offset)); + if (isIdentifierChar(char)) return this; + return new ReadyState().next(char, data); } } @@ -228,15 +228,15 @@ class EscapeState implements State { class ParenState implements State { constructor(private prev: State) {} - next(rune: string, data: string): State | null { - const curr = this.prev.next(rune, data); + next(char: string, data: string): State | null { + const curr = this.prev.next(char, data); if (curr === null) { this.prev = new ReadyState(); return this; } this.prev = curr; if (!(this.prev instanceof ReadyState)) return this; - return rune === ")" ? new ReadyState() : this; + return char === ")" ? new ReadyState() : this; } } @@ -250,12 +250,12 @@ class AtomicState implements State { ) { this.statementStart = start; } - next(rune: string, data: string): State | null { + next(char: string, data: string): State | null { const pendingEnd = this.pendingEnd; this.pendingEnd = false; - if (pendingEnd && !isIdentifierRune(rune)) return new ReadyState().next(rune, data); + if (pendingEnd && !isIdentifierChar(char)) return new ReadyState().next(char, data); // An `END` inside a nested quote/comment doesn't count. - const curr = this.prev.next(rune, data); + const curr = this.prev.next(char, data); if (curr === null) { this.prev = new ReadyState(); this.statementStart = data.length; @@ -294,14 +294,14 @@ function splitRaw(sql: string): RawToken[] { let state: State = new ReadyState(); const tokens: RawToken[] = []; // Slice each token from `sql` instead of growing it with `+=`: states read the token's tail every - // rune, which would rebuild the whole string each time and go quadratic on large tokens. `data` + // char, which would rebuild the whole string each time and go quadratic on large tokens. `data` // starts at the token, so offsets held by states are token-relative. let start = 0; let end = 0; - for (const rune of sql) { - end += rune.length; + for (const char of sql) { + end += char.length; const data = sql.slice(start, end); - const next = state.next(rune, data); + const next = state.next(char, data); if (next === null) { tokens.push({ text: data, terminated: true }); start = end; 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 b904d19e85..374275125c 100644 --- a/apps/cli/src/command-internal/sql-split.unit.test.ts +++ b/apps/cli/src/command-internal/sql-split.unit.test.ts @@ -234,7 +234,7 @@ describe("splitAndTrim", () => { it.each(["\u00A0", "\uFEFF", "\u0085"])( "does not treat BEGIN %s ATOMIC as the keyword pair", (gap) => { - // Only PostgreSQL's own whitespace separates the keywords; these are identifier runes. + // Only PostgreSQL's own whitespace separates the keywords; these are identifier characters. const sql = `BEGIN ${gap} ATOMIC; SELECT 1; end; SELECT 2;`; expect(splitAndTrim(sql)).toEqual([`BEGIN ${gap} ATOMIC`, "SELECT 1", "end", "SELECT 2"]); }, diff --git a/apps/cli/src/command-internal/stack-config.ts b/apps/cli/src/command-internal/stack-config.ts index b8094c3d99..830c100d7a 100644 --- a/apps/cli/src/command-internal/stack-config.ts +++ b/apps/cli/src/command-internal/stack-config.ts @@ -1,5 +1,5 @@ import { getDefaultCliConfig, type CliConfig } from "@supabase/config"; -import { resolveCliConfigSubtree } from "@supabase/config/internal"; +import { resolveCliConfigSubtree } from "./cli-config-load.ts"; import { validateCliConfig } from "@supabase/config/effect"; import { DEFAULT_LOCAL_S3_ACCESS_KEY_ID, @@ -15,7 +15,7 @@ import { loadLocalProjectContext, type LocalProjectContext } from "./local-proje import { RuntimeInfo } from "../shared/runtime/runtime-info.service.ts"; import { CLI_VERSION } from "../shared/cli/version.ts"; import { resolveAuthConfig } from "./stack-auth-config.ts"; -import { parseGoDuration } from "./go-duration.ts"; +import { parseDuration } from "./duration.ts"; import { parseFileSizeLimit } from "./storage-bucket-config.ts"; import { @@ -53,7 +53,7 @@ import { resolveGotrueWeb3, strToArr, } from "./local-config-values.ts"; -import { generateAsymmetricGoJwt } from "./go-jwt.ts"; +import { generateAsymmetricLocalJwt } from "./local-jwt.ts"; import { recordOrioleDbTelemetry } from "./db-image.ts"; import { resolveRemoteJwks, @@ -982,12 +982,12 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( ...(configuredAnonKey === undefined ? signingKey === undefined ? {} - : { anonKey: generateAsymmetricGoJwt(signingKey, "anon") } + : { anonKey: generateAsymmetricLocalJwt(signingKey, "anon") } : { anonKey: configuredAnonKey }), ...(configuredServiceRoleKey === undefined ? signingKey === undefined ? {} - : { serviceRoleKey: generateAsymmetricGoJwt(signingKey, "service_role") } + : { serviceRoleKey: generateAsymmetricLocalJwt(signingKey, "service_role") } : { serviceRoleKey: configuredServiceRoleKey }), anonKeyIsOverride: configuredAnonKey !== undefined, serviceRoleKeyIsOverride: configuredServiceRoleKey !== undefined, @@ -1029,7 +1029,6 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( config.env, { values: context.projectEnvValues }, `functions.${name}.env`, - { goViperCompat: true }, ).pipe( Effect.map( (env) => @@ -1098,7 +1097,7 @@ export const loadStackConfig = Effect.fn("StackConfig.load")( new StackConfigError({ message: `Invalid storage.file_size_limit: ${String(cause)}` }), }); const healthTimeoutMs = yield* Effect.try({ - try: () => parseGoDuration(validatedConfig.db.health_timeout) / 1_000_000, + try: () => parseDuration(validatedConfig.db.health_timeout) / 1_000_000, catch: (cause) => new StackConfigError({ message: `Invalid db.health_timeout: ${String(cause)}` }), }); diff --git a/apps/cli/src/command-internal/stack-shadow-cache.ts b/apps/cli/src/command-internal/stack-shadow-cache.ts index d53aec24de..340f7ba26f 100644 --- a/apps/cli/src/command-internal/stack-shadow-cache.ts +++ b/apps/cli/src/command-internal/stack-shadow-cache.ts @@ -7,7 +7,7 @@ import { type ShadowCacheKeyInputs, } from "./db-bootstrap/shadow-cache.ts"; import { resolveSetupWebhooksEnabled, type SetupDatabaseOptions } from "./db-bootstrap/db-setup.ts"; -import { viperEnvBoolWithProjectFallback } from "./viper-env.ts"; +import { supabaseEnvBoolWithProjectFallback } from "./supabase-env.ts"; interface StackShadowCacheEntry { readonly key: string; @@ -31,7 +31,7 @@ export const stackShadowCacheEntry = Effect.fn("StackShadowCache.entry")(functio ) { if ( bypassCache || - !viperEnvBoolWithProjectFallback(SHADOW_CACHE_ENV, input.setup.projectEnvValues ?? {}, { + !supabaseEnvBoolWithProjectFallback(SHADOW_CACHE_ENV, input.setup.projectEnvValues ?? {}, { whenUnset: true, }) ) diff --git a/apps/cli/src/command-internal/status-pretty.ts b/apps/cli/src/command-internal/status-pretty.ts index 62ab0232d5..ec400a0d9a 100644 --- a/apps/cli/src/command-internal/status-pretty.ts +++ b/apps/cli/src/command-internal/status-pretty.ts @@ -78,7 +78,7 @@ export function statusGroups( /** * Display width for this command's inputs: URLs/keys/labels are always plain ASCII, so every - * rune is width 1. The fixed group-title emoji are the only non-ASCII runes ever rendered, + * character is width 1. The fixed group-title emoji are the only non-ASCII characters ever rendered, * and their widths are hardcoded in {@link HEADER_DISPLAY_WIDTH} instead of computed * generically. */ 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 c98a8cb76b..f410a98633 100644 --- a/apps/cli/src/command-internal/status-pretty.unit.test.ts +++ b/apps/cli/src/command-internal/status-pretty.unit.test.ts @@ -45,7 +45,7 @@ const FULL_VALUES: Record = { }; describe("renderStatusPretty", () => { - it("matches the Go rounded-table fixture for a fully running stack", () => { + it("matches the rounded-table fixture for a fully running stack", () => { const out = stripAnsi(renderStatusPretty(FULL_VALUES, NAMES)); const expected = [ @@ -94,7 +94,7 @@ describe("renderStatusPretty", () => { }); // 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", () => { + it("matches the rounded-table fixture for a single-row group", () => { const out = stripAnsi( renderStatusPretty({ DB_URL: FULL_VALUES.DB_URL ?? "" }, { ...NAMES, dbUrl: "DB_URL" }), ); @@ -112,7 +112,7 @@ describe("renderStatusPretty", () => { }); // 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", () => { + it("matches the rounded-table fixture for a partial APIs group", () => { const out = stripAnsi(renderStatusPretty({ API_URL: "http://127.0.0.1:54321" }, NAMES)); const expectedTable = [ diff --git a/apps/cli/src/command-internal/status-values.ts b/apps/cli/src/command-internal/status-values.ts index 5f5e1d6d36..7d8cf61dc9 100644 --- a/apps/cli/src/command-internal/status-values.ts +++ b/apps/cli/src/command-internal/status-values.ts @@ -240,7 +240,7 @@ export interface StatusLocalState { * @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}. + * first key is unsupported — see {@link generateAsymmetricLocalJwt}. */ export function resolveStatusLocalState( config: CliConfig, @@ -253,7 +253,7 @@ export function resolveStatusLocalState( * 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 + * time-dependent asymmetric JWT (`auth.signing_keys_path` + {@link generateAsymmetricLocalJwt}'s * `exp` claim) with a different signature than the one baked into the already-running * containers. */ diff --git a/apps/cli/src/command-internal/storage-bucket-config.unit.test.ts b/apps/cli/src/command-internal/storage-bucket-config.unit.test.ts index 042e29ec97..c33d935f48 100644 --- a/apps/cli/src/command-internal/storage-bucket-config.unit.test.ts +++ b/apps/cli/src/command-internal/storage-bucket-config.unit.test.ts @@ -15,7 +15,7 @@ describe("parseFileSizeLimit", () => { expect(() => parseFileSizeLimit("not-a-size")).toThrow(); }); - it("accepts Go-valid numeral forms (strconv.ParseFloat parity)", () => { + it("accepts valid numeral forms", () => { expect(parseFileSizeLimit(".5MiB")).toBe(Math.trunc(0.5 * 1024 * 1024)); expect(parseFileSizeLimit("1.MiB")).toBe(1024 * 1024); expect(parseFileSizeLimit("1e6")).toBe(1_000_000); @@ -23,7 +23,7 @@ describe("parseFileSizeLimit", () => { expect(parseFileSizeLimit("1_0MiB")).toBe(10 * 1024 * 1024); }); - it("rejects badly-placed underscores (Go literal rule)", () => { + it("rejects badly-placed underscores", () => { expect(() => parseFileSizeLimit("_1000MiB")).toThrow("invalid size"); expect(() => parseFileSizeLimit("1__0MiB")).toThrow("invalid size"); }); @@ -34,7 +34,7 @@ describe("parseFileSizeLimit", () => { expect(() => parseFileSizeLimit("-5MiB")).toThrow("invalid size"); }); - it("rejects an overflowing numeral (Go ParseFloat range error)", () => { + it("rejects an overflowing numeral", () => { expect(() => parseFileSizeLimit("1e309")).toThrow("invalid size"); }); }); diff --git a/apps/cli/src/command-internal/storage-content-type.unit.test.ts b/apps/cli/src/command-internal/storage-content-type.unit.test.ts index 3c8c023247..16a0b55ffc 100644 --- a/apps/cli/src/command-internal/storage-content-type.unit.test.ts +++ b/apps/cli/src/command-internal/storage-content-type.unit.test.ts @@ -46,7 +46,7 @@ describe("contentTypeForUpload", () => { }); describe("refineUploadContentType", () => { - it("refines an explicit text/plain content-type by extension (Go refines the flag too)", () => { + it("refines an explicit text/plain content-type by extension", () => { expect(refineUploadContentType("text/plain", "/x/a.json")).toBe("application/json"); }); diff --git a/apps/cli/src/command-internal/storage-credentials.ts b/apps/cli/src/command-internal/storage-credentials.ts index 90303794ac..1fa23f56aa 100644 --- a/apps/cli/src/command-internal/storage-credentials.ts +++ b/apps/cli/src/command-internal/storage-credentials.ts @@ -6,7 +6,7 @@ import { resolveApiExternalUrl } from "./api-url.ts"; import { validateApiPort, validateApiTlsPresence } from "./config-validate.ts"; import { loadProjectEnv } from "./db-config.toml-read.ts"; import { mapTenantApiKeysError } from "./get-tenant-api-keys.ts"; -import { generateGoJwt } from "./go-jwt.ts"; +import { generateLocalJwt } from "./local-jwt.ts"; import { getHostname } from "./hostname.ts"; import { decryptAuthSecret, @@ -224,7 +224,7 @@ const resolveLocalServiceRoleKey = Effect.fnUntraced(function* ( }); return configuredKey !== undefined && configuredKey.length > 0 ? configuredKey - : generateGoJwt(jwtSecret, "service_role"); + : generateLocalJwt(jwtSecret, "service_role"); }); /** diff --git a/apps/cli/src/command-internal/storage-gateway.ts b/apps/cli/src/command-internal/storage-gateway.ts index 3fd73f6f1a..e35048188c 100644 --- a/apps/cli/src/command-internal/storage-gateway.ts +++ b/apps/cli/src/command-internal/storage-gateway.ts @@ -6,7 +6,7 @@ import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; import { kongAuthHeaders } from "./kong-auth.ts"; import { StorageGatewayNetworkError, StorageGatewayStatusError } from "./storage-gateway.errors.ts"; -import { goPathSplit } from "./storage-url.ts"; +import { splitDirAndFile } from "./storage-url.ts"; /** * Client for the Supabase Storage service gateway (Kong). See @@ -424,7 +424,7 @@ export const makeStorageGateway = Effect.fnUntraced(function* (opts: { Effect.flatMap((body) => decodeFieldResponse(body, "message")), ), listObjects: (bucket, prefix, page) => { - const [dir, name] = goPathSplit(prefix); + const [dir, name] = splitDirAndFile(prefix); const query: Record = { prefix: dir }; if (name.length > 0) query["search"] = name; query["limit"] = PAGE_LIMIT; diff --git a/apps/cli/src/command-internal/storage-gateway.unit.test.ts b/apps/cli/src/command-internal/storage-gateway.unit.test.ts index 59b38ed5b7..96e5b63831 100644 --- a/apps/cli/src/command-internal/storage-gateway.unit.test.ts +++ b/apps/cli/src/command-internal/storage-gateway.unit.test.ts @@ -10,7 +10,7 @@ import { StorageGatewayStatusError } from "./storage-gateway.errors.ts"; import { bucketBody, makeStorageGateway } from "./storage-gateway.ts"; describe("bucketBody", () => { - it("omits public when undefined (Go *bool nil / omitempty)", () => { + it("omits public when undefined", () => { expect(bucketBody({ public: undefined, fileSizeLimit: 0, allowedMimeTypes: [] })).toEqual({}); }); diff --git a/apps/cli/src/command-internal/storage-url.ts b/apps/cli/src/command-internal/storage-url.ts index 6bcc9c9447..22d22fbf28 100644 --- a/apps/cli/src/command-internal/storage-url.ts +++ b/apps/cli/src/command-internal/storage-url.ts @@ -6,8 +6,8 @@ import { } from "../shared/telemetry/error-actionability.ts"; /** - * Storage URL parsing, matching Go's `net/url.Parse` semantics for the `ss://` - * scheme. `goUrlParse` implements `net/url.Parse` for the fields storage + * Storage URL parsing for the `ss://` + * scheme. `parseUrl` parses the fields storage * commands need (scheme, host, path); `parseStorageUrl` additionally requires * scheme `ss` (case-insensitive), a non-empty path, and no host. * @@ -20,10 +20,11 @@ export const STORAGE_SCHEME = "ss"; const STORAGE_INVALID_URL_MESSAGE = "URL must match pattern ss:///bucket/[prefix]"; /** - * Thrown when `goUrlParse` fails: `parse "": `. Callers wrap + * Thrown when `parseUrl` fails: `parse "": `. Callers wrap * `.message` in their own `failed to parse … url: ` text. */ -export class GoUrlParseError extends Error { +export class UrlParseError extends Error { + // The fingerprint and `name` are the stable telemetry/output identity of this error. static readonly [ErrorActionabilityFingerprintId] = "GoUrlParseError"; constructor(rawURL: string, inner: string) { super(`parse "${rawURL}": ${inner}`); @@ -37,7 +38,7 @@ export class GoUrlParseError extends Error { /** * Thrown when a URL parses but does not match the `ss:///bucket/[prefix]` - * pattern. Distinct from `GoUrlParseError` so handlers can map it to a + * pattern. Distinct from `UrlParseError` so handlers can map it to a * separate tagged error. */ export class StorageUrlPatternError extends Error { @@ -52,7 +53,7 @@ export class StorageUrlPatternError extends Error { } } -export interface GoUrl { +export interface ParsedUrl { /** Lowercased scheme; `""` when none. */ readonly scheme: string; /** Authority host (after `//`); `""` when absent. */ @@ -166,12 +167,12 @@ function isValidOptionalPort(port: string): boolean { /** * Validates IP-literal (`[...]`) bracket/port syntax and `host[:port]` - * port-digit checks, matching Go's host validation for realistic inputs but + * port-digit checks, 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 { +function validateUrlHost(scheme: string, host: string): void { if (host.length === 0) return; const openBracketIdx = host.indexOf("["); if (openBracketIdx > 0) { @@ -199,16 +200,16 @@ function validateGoUrlHost(scheme: string, host: string): void { } /** - * Parses a URL for `scheme`/`host`/`path`, matching Go's `net/url.Parse`. - * Throws `GoUrlParseError` on parse failure. Query and fragment are stripped, + * Parses a URL for `scheme`/`host`/`path`. + * Throws `UrlParseError` on parse failure. Query and fragment are stripped, * though storage URLs never use them. */ -export function goUrlParse(rawURL: string): GoUrl { +export function parseUrl(rawURL: string): ParsedUrl { const hashIdx = rawURL.indexOf("#"); const u = hashIdx === -1 ? rawURL : rawURL.slice(0, hashIdx); if (containsCtlByte(u)) { - throw new GoUrlParseError(u, "net/url: invalid control character in URL"); + throw new UrlParseError(u, "net/url: invalid control character in URL"); } if (u === "*") { return { scheme: "", host: "", path: "*" }; @@ -221,7 +222,7 @@ export function goUrlParse(rawURL: string): GoUrl { scheme = parsed.scheme.toLowerCase(); rest = parsed.rest; } catch (cause) { - throw new GoUrlParseError(u, cause instanceof Error ? cause.message : String(cause)); + throw new UrlParseError(u, cause instanceof Error ? cause.message : String(cause)); } // Strip the query string. @@ -237,7 +238,7 @@ export function goUrlParse(rawURL: string): GoUrl { const slash = rest.indexOf("/"); const segment = slash === -1 ? rest : rest.slice(0, slash); if (segment.includes(":")) { - throw new GoUrlParseError(u, "first path segment in URL cannot contain colon"); + throw new UrlParseError(u, "first path segment in URL cannot contain colon"); } } @@ -251,9 +252,9 @@ export function goUrlParse(rawURL: string): GoUrl { rest = slash === -1 ? "" : afterSlashes.slice(slash); host = hostFromAuthority(authority); try { - validateGoUrlHost(scheme, host); + validateUrlHost(scheme, host); } catch (cause) { - throw new GoUrlParseError(u, cause instanceof Error ? cause.message : String(cause)); + throw new UrlParseError(u, cause instanceof Error ? cause.message : String(cause)); } } @@ -261,7 +262,7 @@ export function goUrlParse(rawURL: string): GoUrl { try { path = unescapePath(rest); } catch (cause) { - throw new GoUrlParseError(u, cause instanceof Error ? cause.message : String(cause)); + throw new UrlParseError(u, cause instanceof Error ? cause.message : String(cause)); } return { scheme, host, path }; } @@ -269,11 +270,11 @@ export function goUrlParse(rawURL: string): GoUrl { /** * 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 + * `UrlParseError` on a parse failure, or `StorageUrlPatternError` when it * doesn't match `ss:///bucket/[prefix]`. */ export function parseStorageUrl(objectURL: string): string { - const parsed = goUrlParse(objectURL); + const parsed = parseUrl(objectURL); if (parsed.scheme !== STORAGE_SCHEME || parsed.path.length === 0 || parsed.host.length > 0) { throw new StorageUrlPatternError(); } @@ -299,10 +300,10 @@ export function splitBucketPrefix(objectPath: string): readonly [string, string] /** * Lowercased scheme for a raw URL; `""` when the URL has no scheme (a local - * path). Throws `GoUrlParseError` on a malformed URL (e.g. `:`). + * path). Throws `UrlParseError` on a malformed URL (e.g. `:`). */ export function detectScheme(rawURL: string): string { - return goUrlParse(rawURL).scheme; + return parseUrl(rawURL).scheme; } /** An object prefix is a directory when it is empty or ends with `/`. */ @@ -315,7 +316,7 @@ export function storageIsDir(objectPrefix: string): boolean { * trailing slash: `folder/name.png` → `["folder/", "name.png"]`; `dir` → * `["", "dir"]`; `tmp/` → `["tmp/", ""]`; `""` → `["", ""]`. */ -export function goPathSplit(p: string): readonly [string, string] { +export function splitDirAndFile(p: string): readonly [string, string] { const i = p.lastIndexOf("/"); return [p.slice(0, i + 1), p.slice(i + 1)]; } 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 284b0ab346..c2f4d72719 100644 --- a/apps/cli/src/command-internal/storage-url.unit.test.ts +++ b/apps/cli/src/command-internal/storage-url.unit.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it } from "vitest"; import { - GoUrlParseError, + UrlParseError, StorageUrlPatternError, detectScheme, - goUrlParse, + parseUrl, parseStorageUrl, splitBucketPrefix, storageIsDir, @@ -31,7 +31,7 @@ describe("parseStorageUrl", () => { }); it("surfaces the url-parse error on a missing protocol scheme (:)", () => { - expect(() => parseStorageUrl(":")).toThrow(GoUrlParseError); + expect(() => parseStorageUrl(":")).toThrow(UrlParseError); expect(() => parseStorageUrl(":")).toThrow("missing protocol scheme"); }); @@ -40,7 +40,7 @@ describe("parseStorageUrl", () => { }); it("rejects a malformed percent-escape in the path", () => { - expect(() => parseStorageUrl("ss:///bucket/a%2")).toThrow(GoUrlParseError); + expect(() => parseStorageUrl("ss:///bucket/a%2")).toThrow(UrlParseError); expect(() => parseStorageUrl("ss:///bucket/a%2")).toThrow('invalid URL escape "%2"'); }); @@ -48,8 +48,8 @@ describe("parseStorageUrl", () => { expect(parseStorageUrl("ss:///bucket/a%20b")).toBe("/bucket/a b"); }); - it("decodes a multi-byte UTF-8 percent-escape as one rune (Go raw-byte path)", () => { - // %E4%B8%AD is the UTF-8 encoding of 中 — decoded as one rune, not three. + it("decodes a multi-byte UTF-8 percent-escape as one code point", () => { + // %E4%B8%AD is the UTF-8 encoding of 中 — decoded as one code point, not three. expect(parseStorageUrl("ss:///bucket/%E4%B8%AD.txt")).toBe("/bucket/中.txt"); }); }); @@ -85,44 +85,44 @@ describe("detectScheme", () => { }); // Host validation here also backs `studio.api_url` validation. -describe("goUrlParse (host validation)", () => { +describe("parseUrl (host validation)", () => { it("rejects an unterminated IPv6 literal", () => { - expect(() => goUrlParse("http://[::1")).toThrow(GoUrlParseError); - expect(() => goUrlParse("http://[::1")).toThrow(`parse "http://[::1": missing ']' in host`); + expect(() => parseUrl("http://[::1")).toThrow(UrlParseError); + expect(() => parseUrl("http://[::1")).toThrow(`parse "http://[::1": missing ']' in host`); }); it("accepts a bracketed IPv6 literal with no port", () => { - expect(goUrlParse("http://[::1]").host).toBe("[::1]"); + expect(parseUrl("http://[::1]").host).toBe("[::1]"); }); it("accepts a bracketed IPv6 literal with a valid port", () => { - expect(goUrlParse("http://[::1]:8080").host).toBe("[::1]:8080"); + expect(parseUrl("http://[::1]:8080").host).toBe("[::1]:8080"); }); it("rejects a bracketed IPv6 literal with a non-numeric port", () => { - expect(() => goUrlParse("http://[::1]:abc")).toThrow( + expect(() => parseUrl("http://[::1]:abc")).toThrow( `parse "http://[::1]:abc": invalid port ":abc" after host`, ); }); it("rejects a bracket that isn't the first character of the host", () => { - expect(() => goUrlParse("http://host[::1]")).toThrow( + expect(() => parseUrl("http://host[::1]")).toThrow( `parse "http://host[::1]": invalid IP-literal`, ); }); it("accepts a plain hostname with a numeric port", () => { - expect(goUrlParse("http://example.com:99999").host).toBe("example.com:99999"); + expect(parseUrl("http://example.com:99999").host).toBe("example.com:99999"); }); it("rejects more than one colon in an http(s) host (strict-colon default)", () => { - expect(() => goUrlParse("http://host:1:2")).toThrow( + expect(() => parseUrl("http://host:1:2")).toThrow( `parse "http://host:1:2": invalid port ":1:2" after host`, ); }); it("does not validate a host for a scheme with no authority (ss:///bucket)", () => { - expect(() => goUrlParse("ss:///bucket/x")).not.toThrow(); + expect(() => parseUrl("ss:///bucket/x")).not.toThrow(); }); }); diff --git a/apps/cli/src/command-internal/string-slice-flag.ts b/apps/cli/src/command-internal/string-slice-flag.ts index d754c79305..bebe3449b0 100644 --- a/apps/cli/src/command-internal/string-slice-flag.ts +++ b/apps/cli/src/command-internal/string-slice-flag.ts @@ -6,8 +6,8 @@ import { } from "../shared/telemetry/error-actionability.ts"; /** - * Parses a pflag `StringSliceVar` flag: CSV-splits each occurrence, matching - * `encoding/csv`, and accumulates across repeats. A naive `.split(",")` + * Parses a repeatable string-slice flag: CSV-splits each occurrence + * 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. @@ -64,8 +64,7 @@ export class StringSliceFlagParseError extends Error { } /** - * 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 + * Parses one CSV record from `val`. 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. @@ -242,10 +241,10 @@ export function parseStringSliceFlag(rawValues: ReadonlyArray): Readonly } /** - * Builds a repeatable CSV-split flag matching pflag's `StringSliceVar` - * behavior, including its `invalid argument %q for %q flag: %v` diagnostic. + * Builds a repeatable CSV-split flag, including its `invalid argument "" for "" flag: ` + * diagnostic. * - * `options.alias` must be registered here, not piped on afterwards: pflag's + * `options.alias` must be registered here, not piped on afterwards: the * diagnostic frames both spellings (`-x, --exclude`), so the alias must be * present when the error message is built. */ @@ -255,14 +254,14 @@ export function stringSliceFlag( options?: { readonly alias?: string }, ) { const alias = options?.alias; - const pflagName = alias === undefined ? `--${name}` : `-${alias}, --${name}`; + const flagLabel = alias === undefined ? `--${name}` : `-${alias}, --${name}`; const base = Flag.string(name).pipe(Flag.withDescription(description), Flag.atLeast(0)); return (alias === undefined ? base : base.pipe(Flag.withAlias(alias))).pipe( Flag.mapTryCatch( (rawValues) => parseStringSliceFlag(rawValues), (err) => err instanceof StringSliceFlagParseError - ? `invalid argument ${JSON.stringify(err.value)} for "${pflagName}" flag: ${err.message}` + ? `invalid argument ${JSON.stringify(err.value)} for "${flagLabel}" flag: ${err.message}` : err instanceof Error ? err.message : String(err), 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 7d70f88533..438e1bf775 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 @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import { parseStringSliceFlag, StringSliceFlagParseError } from "./string-slice-flag.ts"; -describe("parseStringSliceFlag (pflag StringSlice CSV parity)", () => { +describe("parseStringSliceFlag", () => { it("splits unquoted comma-separated values", () => { expect(parseStringSliceFlag(["public,private"])).toEqual(["public", "private"]); }); @@ -34,13 +34,13 @@ describe("parseStringSliceFlag (pflag StringSlice CSV parity)", () => { expect(parseStringSliceFlag([])).toEqual([]); }); - it("preserves whitespace (Go does not trim)", () => { + it("preserves whitespace (no trimming)", () => { expect(parseStringSliceFlag([" public , private "])).toEqual([" public ", " private "]); }); // Columns are 1-based byte offsets. - it("throws on an unterminated quoted field (column = byte length + 1, Go hits EOF)", () => { + it("throws on an unterminated quoted field (column = byte length + 1, reached EOF)", () => { expect(() => parseStringSliceFlag(['"tenant'])).toThrow(StringSliceFlagParseError); expect(() => parseStringSliceFlag(['"tenant'])).toThrow( 'parse error on line 1, column 8: extraneous or missing " in quoted-field', @@ -74,7 +74,7 @@ describe("parseStringSliceFlag (pflag StringSlice CSV parity)", () => { ); }); - it("counts columns in bytes, not code points (Go csv tracks byte offsets)", () => { + it("counts columns in bytes, not code points", () => { // é 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', @@ -85,7 +85,7 @@ describe("parseStringSliceFlag (pflag StringSlice CSV parity)", () => { ); }); - it("carries the offending occurrence and Go's exact message on the error for pflag framing", () => { + it("carries the offending occurrence and the exact message on the error for flag framing", () => { try { parseStringSliceFlag(["public", '"broken']); expect.unreachable("expected parseStringSliceFlag to throw"); @@ -106,7 +106,7 @@ describe("parseStringSliceFlag (pflag StringSlice CSV parity)", () => { // 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)", () => { + it("keeps only the first record when an unquoted newline ends it (only ONE record is read)", () => { expect(parseStringSliceFlag(["1.2.3.4\n5.6.7.8"])).toEqual(["1.2.3.4"]); // 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"]); @@ -116,7 +116,7 @@ describe("parseStringSliceFlag (pflag StringSlice CSV parity)", () => { expect(parseStringSliceFlag(["a\r\nb"])).toEqual(["a"]); }); - it("skips blank lines before the record and errors with pflag's EOF for blank-only values", () => { + it("skips blank lines before the record and errors with EOF for blank-only values", () => { expect(parseStringSliceFlag(["\n\na"])).toEqual(["a"]); expect(parseStringSliceFlag(["\r\na"])).toEqual(["a"]); // A value of only blank lines throws the bare `EOF` message. diff --git a/apps/cli/src/command-internal/rune-width.ts b/apps/cli/src/command-internal/string-width.ts similarity index 98% rename from apps/cli/src/command-internal/rune-width.ts rename to apps/cli/src/command-internal/string-width.ts index 110dbb7fb8..ac73acf2c2 100644 --- a/apps/cli/src/command-internal/rune-width.ts +++ b/apps/cli/src/command-internal/string-width.ts @@ -376,7 +376,7 @@ function inRanges(cp: number, ranges: ReadonlyArray): boolean { } /** Display width of a single code point (0, 1, or 2). */ -function runeWidth(cp: number): number { +function codePointWidth(cp: number): number { // C0/C1 controls (except those handled by the caller) have no print width. if (cp === 0) return 0; if (cp < 0x20 || (cp >= 0x7f && cp < 0xa0)) return 0; @@ -388,6 +388,6 @@ function runeWidth(cp: number): number { /** Display width of a string, summing per-code-point widths. */ export function stringWidth(text: string): number { let width = 0; - for (const ch of text) width += runeWidth(ch.codePointAt(0)!); + for (const ch of text) width += codePointWidth(ch.codePointAt(0)!); return width; } diff --git a/apps/cli/src/command-internal/rune-width.unit.test.ts b/apps/cli/src/command-internal/string-width.unit.test.ts similarity index 88% rename from apps/cli/src/command-internal/rune-width.unit.test.ts rename to apps/cli/src/command-internal/string-width.unit.test.ts index 92d4cb21f0..44ea607d25 100644 --- a/apps/cli/src/command-internal/rune-width.unit.test.ts +++ b/apps/cli/src/command-internal/string-width.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { stringWidth } from "./rune-width.ts"; +import { stringWidth } from "./string-width.ts"; describe("stringWidth", () => { it("counts ASCII as 1 each", () => { @@ -25,7 +25,7 @@ describe("stringWidth", () => { }); it("treats East Asian Ambiguous as width 1 (modern-terminal default)", () => { - // U+00A1 (¡) is Ambiguous; Go's runewidth with EastAsianWidth=false counts it as 1. + // U+00A1 (¡) is Ambiguous and counts as 1. expect(stringWidth("¡")).toBe(1); }); }); diff --git a/apps/cli/src/command-internal/go-struct-output.encoders.ts b/apps/cli/src/command-internal/struct-output.encoders.ts similarity index 74% rename from apps/cli/src/command-internal/go-struct-output.encoders.ts rename to apps/cli/src/command-internal/struct-output.encoders.ts index e8d07e326f..01713fe098 100644 --- a/apps/cli/src/command-internal/go-struct-output.encoders.ts +++ b/apps/cli/src/command-internal/struct-output.encoders.ts @@ -6,76 +6,75 @@ import { } from "../shared/telemetry/error-actionability.ts"; /** - * 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. + * Byte-stable `-o yaml`/`-o toml` output for struct payloads. Emitted keys are the + * PascalCase field names declared by the shape, not the snake_case JSON the API returns. * - * 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. + * Each payload family declares a {@link OutputShape} spec describing the struct so the decoded + * JSON can be re-expressed with the established casing, null handling, and quoting rules. */ -export type GoType = +export type OutputShape = | { readonly kind: "string" } | { readonly kind: "uuid" } | { readonly kind: "bool" } | { readonly kind: "int" } | { readonly kind: "float"; readonly bits: 32 | 64 } - /** Go `time.Time` — native TOML datetime, unquoted yaml timestamp. */ + /** RFC3339 timestamp — native TOML datetime, unquoted yaml timestamp. */ | { readonly kind: "time" } - /** Go `interface{}` — shape inferred from the JSON value like `encoding/json` decoding. */ + /** Untyped value — shape inferred from the JSON value. */ | { readonly kind: "any" } - | { readonly kind: "ptr"; readonly elem: GoType } - /** oapi-codegen `nullable.Nullable[T]` — a `map[bool]T` under the hood. */ - | { readonly kind: "nullable"; readonly elem: GoType } - | { readonly kind: "slice"; readonly elem: GoType } - | { readonly kind: "map"; readonly value: GoType } - | { readonly kind: "struct"; readonly fields: ReadonlyArray }; - -interface GoStructField { + | { readonly kind: "ptr"; readonly elem: OutputShape } + /** A nullable API field: absent, explicit null, or a value. */ + | { readonly kind: "nullable"; readonly elem: OutputShape } + | { readonly kind: "slice"; readonly elem: OutputShape } + | { readonly kind: "map"; readonly value: OutputShape } + | { readonly kind: "struct"; readonly fields: ReadonlyArray }; + +interface OutputShapeField { /** JSON tag name — the key present in the decoded payload. */ readonly json: string; - /** Go field name (PascalCase). */ - readonly go: string; - readonly type: GoType; + /** Struct field name (PascalCase). */ + readonly name: string; + readonly type: OutputShape; } -export const goString: GoType = { kind: "string" }; -export const goUuid: GoType = { kind: "uuid" }; -export const goBool: GoType = { kind: "bool" }; -export const goInt: GoType = { kind: "int" }; -export const goFloat32: GoType = { kind: "float", bits: 32 }; -export const goFloat64: GoType = { kind: "float", bits: 64 }; -export const goTime: GoType = { kind: "time" }; -export const goAny: GoType = { kind: "any" }; +export const shapeString: OutputShape = { kind: "string" }; +export const shapeUuid: OutputShape = { kind: "uuid" }; +export const shapeBool: OutputShape = { kind: "bool" }; +export const shapeInt: OutputShape = { kind: "int" }; +export const shapeFloat32: OutputShape = { kind: "float", bits: 32 }; +export const shapeFloat64: OutputShape = { kind: "float", bits: 64 }; +export const shapeTime: OutputShape = { kind: "time" }; +export const shapeAny: OutputShape = { kind: "any" }; -export function goPtr(elem: GoType): GoType { +export function shapePtr(elem: OutputShape): OutputShape { return { kind: "ptr", elem }; } -export function goNullable(elem: GoType): GoType { +export function shapeNullable(elem: OutputShape): OutputShape { return { kind: "nullable", elem }; } -export function goSlice(elem: GoType): GoType { +export function shapeSlice(elem: OutputShape): OutputShape { return { kind: "slice", elem }; } -export function goMap(value: GoType): GoType { +export function shapeMap(value: OutputShape): OutputShape { return { kind: "map", value }; } /** - * 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 + * A struct field spec entry: `[jsonName, type]` derives the field name mechanically (each + * snake_case token capitalized: `api_key` → `ApiKey`), or `[jsonName, type, fieldName]` for explicit * names. */ -export type GoFieldSpec = - | readonly [json: string, type: GoType] - | readonly [json: string, type: GoType, goName: string]; +export type OutputShapeFieldSpec = + | readonly [json: string, type: OutputShape] + | readonly [json: string, type: OutputShape, fieldName: string]; -export function goStruct(fields: ReadonlyArray): GoType { +export function shapeStruct(fields: ReadonlyArray): OutputShape { return { kind: "struct", - fields: fields.map(([json, type, goName]) => ({ + fields: fields.map(([json, type, fieldName]) => ({ json, - go: goName ?? goFieldName(json), + name: fieldName ?? pascalCaseFieldName(json), type, })), }; @@ -83,57 +82,61 @@ export function goStruct(fields: ReadonlyArray): GoType { /** * 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 + * key lowercase while the elements keep PascalCase 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) }] }; +export function shapeTomlListWrapper(key: string, elem: OutputShape): OutputShape { + return { kind: "struct", fields: [{ json: key, name: key, type: shapeSlice(elem) }] }; } /** `api_key` → `ApiKey`, `dbAllowedCidrs` → `DbAllowedCidrs`. */ -export function goFieldName(jsonName: string): string { +export function pascalCaseFieldName(jsonName: string): string { return jsonName .split("_") .map((part) => (part.length === 0 ? part : part[0]?.toUpperCase() + part.slice(1))) .join(""); } -type GoValue = +type NormalizedValue = | { readonly k: "nil" } | { readonly k: "str"; readonly v: string } | { readonly k: "bool"; readonly v: boolean } | { readonly k: "int"; readonly v: number } | { readonly k: "float"; readonly v: number; readonly bits: 32 | 64 } | { readonly k: "time"; readonly v: string } - | { readonly k: "struct"; readonly entries: ReadonlyArray } + | { readonly k: "struct"; readonly entries: ReadonlyArray } | { readonly k: "map"; readonly nil: boolean; - readonly entries: ReadonlyArray; + readonly entries: ReadonlyArray; + } + /** A nullable field: absent (nil), `{false: zero}` (explicit null) or `{true: value}`. */ + | { + readonly k: "nullable"; + readonly present: boolean | undefined; + readonly value?: NormalizedValue; } - /** `nullable.Nullable[T]`: nil map, `{false: zero}` (explicit null) or `{true: value}`. */ - | { readonly k: "nullable"; readonly present: boolean | undefined; readonly value?: GoValue } | { readonly k: "slice"; readonly nil: boolean; - readonly items: ReadonlyArray; + readonly items: ReadonlyArray; readonly tables: boolean; }; -const GO_ZERO_TIME = "0001-01-01T00:00:00Z"; -const GO_ZERO_UUID = "00000000-0000-0000-0000-000000000000"; +const ZERO_TIME = "0001-01-01T00:00:00Z"; +const ZERO_UUID = "00000000-0000-0000-0000-000000000000"; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } -function zeroValue(type: GoType): GoValue { +function zeroValue(type: OutputShape): NormalizedValue { switch (type.kind) { case "string": return { k: "str", v: "" }; case "uuid": - return { k: "str", v: GO_ZERO_UUID }; + return { k: "str", v: ZERO_UUID }; case "bool": return { k: "bool", v: false }; case "int": @@ -141,7 +144,7 @@ function zeroValue(type: GoType): GoValue { case "float": return { k: "float", v: 0, bits: type.bits }; case "time": - return { k: "time", v: GO_ZERO_TIME }; + return { k: "time", v: ZERO_TIME }; case "struct": return normalize(undefined, type); case "ptr": @@ -156,7 +159,7 @@ function zeroValue(type: GoType): GoValue { } } -function elementsAreTables(elem: GoType, items: ReadonlyArray): boolean { +function elementsAreTables(elem: OutputShape, items: ReadonlyArray): boolean { switch (elem.kind) { case "struct": case "map": @@ -166,15 +169,14 @@ function elementsAreTables(elem: GoType, items: ReadonlyArray): boolean case "slice": return elem.kind === "ptr" ? elementsAreTables(elem.elem, items) : false; case "any": - // Like Go's runtime type inspection: JSON objects decode to - // map[string]interface{} which BurntSushi treats as tables. + // JSON objects decode to maps, which are rendered as tables. return items.length > 0 && items.every(isRecord); default: return false; } } -function normalize(value: unknown, type: GoType): GoValue { +function normalize(value: unknown, type: OutputShape): NormalizedValue { switch (type.kind) { case "string": return typeof value === "string" ? { k: "str", v: value } : zeroValue(type); @@ -194,13 +196,12 @@ function normalize(value: unknown, type: GoType): GoValue { : zeroValue(type); case "time": return typeof value === "string" && value.length > 0 - ? { k: "time", v: normalizeGoTime(value) } + ? { k: "time", v: normalizeTime(value) } : zeroValue(type); case "ptr": return value === undefined || value === null ? { k: "nil" } : normalize(value, type.elem); case "nullable": - // oapi-codegen: absent key → nil map; explicit JSON null → {false: zero}; - // value → {true: value}. + // Absent key → nil; explicit JSON null → {false: zero}; value → {true: value}. if (value === undefined) return { k: "nullable", present: undefined }; if (value === null) return { k: "nullable", present: false, value: zeroValue(type.elem) }; return { k: "nullable", present: true, value: normalize(value, type.elem) }; @@ -228,7 +229,7 @@ function normalize(value: unknown, type: GoType): GoValue { return { k: "struct", entries: type.fields.map( - (field) => [field.go, normalize(record[field.json], field.type)] as const, + (field) => [field.name, normalize(record[field.json], field.type)] as const, ), }; } @@ -237,13 +238,13 @@ function normalize(value: unknown, type: GoType): GoValue { } } -/** Mirror `encoding/json` decoding into `interface{}`. */ -function normalizeAny(value: unknown): GoValue { +/** Normalize a decoded JSON value into an untyped value. */ +function normalizeAny(value: unknown): NormalizedValue { if (value === undefined || value === null) return { k: "nil" }; if (typeof value === "string") return { k: "str", v: value }; if (typeof value === "boolean") return { k: "bool", v: value }; if (typeof value === "number") { - // JSON numbers decode to float64 in Go's interface{} world. + // JSON numbers decode to float64. return { k: "float", v: value, bits: 64 }; } if (Array.isArray(value)) { @@ -265,13 +266,12 @@ function normalizeAny(value: unknown): GoValue { } /** - * Formats an RFC3339 input the way Go renders a decoded `time.Time` with `time.RFC3339Nano`: the + * Formats an RFC3339 input in `RFC3339Nano` form: 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 { - // Both `.` and `,` are accepted as the fractional separator on decode; normalize to the dot Go - // emits. +function normalizeTime(value: string): string { + // Both `.` and `,` are accepted as the fractional separator on decode; normalize to the dot. 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; @@ -285,10 +285,9 @@ function normalizeGoTime(value: string): string { } /** - * 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 + * Shortest round-trip digits for a float32 value (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}. + * Returns `e<±exp>` for {@link formatStructFloat}. */ function shortestFloat32(value: number): string { const rounded = Math.fround(value); @@ -357,16 +356,15 @@ function roundDecimalDigits( } /** - * `strconv.FormatFloat(f, 'g', -1, bits)`: shortest digits, switching to - * scientific notation when the decimal exponent is < -4 or >= 6 (Go uses - * `eprec = 6` for shortest formatting), with a sign and >= 2 exponent digits. + * Shortest round-trip digits, switching to scientific notation when the decimal exponent is + * < -4 or >= 6, with a sign and >= 2 exponent digits. */ -export function goFormatFloat(value: number, bits: 32 | 64): string { +export function formatStructFloat(value: number, bits: 32 | 64): string { if (Number.isNaN(value)) return "NaN"; if (value === Infinity) return "+Inf"; if (value === -Infinity) return "-Inf"; const repr = bits === 32 ? shortestFloat32(value) : String(value); - // JS String(-0) drops the sign; Go's FormatFloat keeps it ("-0"). + // JS String(-0) drops the sign; the output keeps it ("-0"). const negative = repr.startsWith("-") || Object.is(value, -0); const unsigned = negative ? repr.slice(1) : repr; // Decompose into digits + decimal exponent. @@ -403,14 +401,13 @@ export function goFormatFloat(value: number, bits: 32 | 64): string { } /** - * 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). + * Encode a decoded payload as `-o yaml` output for the given struct spec. Returns the full document bytes (trailing newline included). */ -export function encodeGoYaml(value: unknown, type: GoType): string { +export function encodeStructYaml(value: unknown, type: OutputShape): string { return yamlDocument(normalize(value, type)); } -function yamlDocument(root: GoValue): string { +function yamlDocument(root: NormalizedValue): string { switch (root.k) { case "slice": if (root.items.length === 0) return "[]\n"; @@ -431,43 +428,40 @@ function yamlDocument(root: GoValue): string { } /** - * A populated `nullable.Nullable[T]` is a `map[bool]T`; yaml.v3 renders the - * bool key plain (`true:` / `false:`), unlike the string keys `"true"` would - * produce. + * A populated nullable renders as a mapping with a plain bool key (`true:` / `false:`), + * unlike the quoted string keys `"true"` would produce. */ -function yamlNullableBlock(present: boolean, value: GoValue, indent: number): string { +function yamlNullableBlock(present: boolean, value: NormalizedValue, indent: number): string { const pad = " ".repeat(indent); return `${pad}${present ? "true" : "false"}:${yamlValueSuffix(value, indent)}`; } -/** yaml.v3's indent algorithm: children of a mapping align to the next 4-column stop. */ +/** Children of a mapping align to the next 4-column stop. */ function yamlNextIndent(indent: number): number { return 4 * Math.floor((indent + 4) / 4); } function yamlStructEntries( - entries: ReadonlyArray, -): ReadonlyArray { - // yaml.v3 lowercases Go field names wholesale (no yaml tags on these structs). - return entries.map(([go, value]) => [go.toLowerCase(), value] as const); + entries: ReadonlyArray, +): ReadonlyArray { + // Field names are lowercased wholesale in YAML output. + return entries.map(([name, value]) => [name.toLowerCase(), value] as const); } function yamlMapEntries( - entries: ReadonlyArray, -): ReadonlyArray { + entries: ReadonlyArray, +): ReadonlyArray { return [...entries].sort(([a], [b]) => (yamlKeyLess(a, b) ? -1 : yamlKeyLess(b, a) ? 1 : 0)); } /** - * 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). + * Unicode code-point string ordering (equivalent to UTF-8 byte order), which differs from JS `<` + * (UTF-16 code-unit order) when an astral character meets a high-BMP one (code-point order sorts + * U+E000 before U+1F600, UTF-16 the reverse). * - * Also used by `go-output.encoders.ts`'s `sortKeysDeep`, since `encoding/json`'s map-key sort is - * the same order. + * Also used by `output.encoders.ts`'s `sortKeysDeep`. */ -export function goStringCompare(a: string, b: string): number { +export function compareByCodepoint(a: string, b: string): number { let i = 0; while (i < a.length && i < b.length) { const ac = a.codePointAt(i) as number; @@ -479,8 +473,8 @@ export function goStringCompare(a: string, b: string): number { } /** - * 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 + * Natural string ordering: runs of digits compare numerically. Digit runs match 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 { @@ -496,7 +490,7 @@ function yamlKeyLess(a: string, b: string): boolean { } const al = isLetter(ac); const bl = isLetter(bc); - // Go compares runes (`ar[i] < br[i]`), i.e. code points, not UTF-16 units. + // Compare code points, not UTF-16 units. if (al && bl) return (ac.codePointAt(0) as number) < (bc.codePointAt(0) as number); if (al || bl) return digits ? al : bl; let an = 0n; @@ -512,7 +506,7 @@ function yamlKeyLess(a: string, b: string): boolean { } let ai = i; let bi = i; - // Go accumulates into `int64` without overflow checks, so a 19+-digit run wraps negative and + // Digit runs accumulate 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)); @@ -527,7 +521,7 @@ function yamlKeyLess(a: string, b: string): boolean { return ar.length < br.length; } -/** yaml.v3 sorter's `unicode.IsDigit` — any Unicode decimal digit (`Nd`). */ +/** Any Unicode decimal digit (`Nd`), as used by the key sorter. */ function isSortDigit(c: string): boolean { return /\p{Nd}/u.test(c); } @@ -540,7 +534,10 @@ function isLetter(c: string): boolean { return /\p{L}/u.test(c); } -function yamlMapping(entries: ReadonlyArray, indent: number): string { +function yamlMapping( + entries: ReadonlyArray, + indent: number, +): string { const pad = " ".repeat(indent); let out = ""; for (const [key, value] of entries) { @@ -554,7 +551,7 @@ function yamlMapping(entries: ReadonlyArray, indent: * Everything after `key:` — either ` \n`, a block-literal header plus * content lines, or `\n` plus an indented child block. */ -function yamlValueSuffix(value: GoValue, indent: number): string { +function yamlValueSuffix(value: NormalizedValue, indent: number): string { switch (value.k) { case "nil": return " null\n"; @@ -589,7 +586,7 @@ function yamlValueSuffix(value: GoValue, indent: number): string { } } -function yamlSequence(items: ReadonlyArray, indent: number): string { +function yamlSequence(items: ReadonlyArray, indent: number): string { const pad = " ".repeat(indent); let out = ""; for (const item of items) { @@ -603,7 +600,7 @@ function yamlSequence(items: ReadonlyArray, indent: number): string { break; } // Compact form: the first key rides on the `- ` line; the block keeps - // a +2 indent (yaml.v3 special-cases indent inside sequence items). + // a +2 indent (indent inside sequence items is special-cased). const block = yamlMapping(entries, indent + 2); out += `${pad}- ${block.slice(indent + 2)}`; break; @@ -642,7 +639,7 @@ function yamlSequence(items: ReadonlyArray, indent: number): string { return out; } -function yamlScalar(value: GoValue): string { +function yamlScalar(value: NormalizedValue): string { switch (value.k) { case "nil": return "null"; @@ -651,7 +648,7 @@ function yamlScalar(value: GoValue): string { case "int": return String(value.v); case "float": - return goFormatFloat(value.v, value.bits); + return formatStructFloat(value.v, value.bits); case "time": return value.v; case "str": { @@ -673,7 +670,7 @@ function yamlKeyScalar(key: string): string { type YamlStringStyle = "plain" | "single" | "double" | "literal"; /** - * yaml.v3's style selection: literal for multi-line strings, double quotes for a string that + * 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. */ @@ -693,7 +690,7 @@ function yamlStringStyle(s: string): YamlStringStyle { } /** - * Characters yaml.v3 treats as "special" (not printable) or line breaks other + * Characters treated as "special" (not printable) or line breaks other * than `\n` — all of these force double-quoted style with escapes. U+2028 and * U+2029 are technically YAML line breaks, but every realistic payload * containing them round-trips through the double-quoted `\L` / `\P` escapes. @@ -709,8 +706,7 @@ function yamlHasSpecialChars(s: string): boolean { } /** - * 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 + * YAML printability in code-point terms: 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 { @@ -736,7 +732,7 @@ function yamlPlainDisallowed(s: string): boolean { } /** - * Would yaml.v3's `resolve("", s)` produce a non-string tag? Also covers the + * Would the plain scalar `s` resolve to a non-string YAML tag? Also covers the * YAML 1.1 "old bool" and base-60 spellings the encoder force-quotes. */ function yamlResolvesToString(s: string): boolean { @@ -745,15 +741,15 @@ function yamlResolvesToString(s: string): boolean { if (YAML_BASE60.test(s)) return false; const first = s[0] as string; if (first === ".") { - // strconv.ParseFloat errors on overflow (±Inf), so an overflowing spelling like `1e999` stays + // Float parsing 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; - // An overflowing float (→ ±Inf) is a ParseFloat error, so it resolves as a string and stays + if (parsesAsBaseZeroInt(plain)) return false; + // An overflowing float (→ ±Inf) is a float parse 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; @@ -810,8 +806,8 @@ const YAML_RESOLVE_MAP = new Set([ const YAML_BASE60 = /^[-+]?[0-9][0-9_]*(?::[0-5]?[0-9])+(?:\.[0-9_]*)?$/; const YAML_STYLE_FLOAT = /^[-+]?(\.[0-9]+|[0-9]+(\.[0-9]*)?)([eE][-+]?[0-9]+)?$/; -/** `strconv.ParseInt(s, 0, 64)` / `ParseUint` success (underscores pre-stripped). */ -function goParseIntBase0(plain: string): boolean { +/** Whether `plain` parses as a base-0 int64/uint64 (underscores pre-stripped). */ +function parsesAsBaseZeroInt(plain: string): boolean { let body = plain; let negative = false; if (body.startsWith("+") || body.startsWith("-")) { @@ -844,20 +840,18 @@ function goParseIntBase0(plain: string): boolean { } else { return false; } - // resolve() falls back from ParseInt to ParseUint, so the accepted range is + // Resolution falls back from int64 to uint64, so the accepted range is // [-2^63, 2^64) — anything beyond either bound is not an int. if (negative) return parsed <= 9223372036854775808n; return parsed < 18446744073709551616n; } /** - * 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). + * Timestamp layouts with validated calendar dates and zone offsets (`2025-02-31` and + * `2100-02-29` stay plain, `2024-02-29` is a timestamp). */ function yamlIsTimestamp(s: string): boolean { - // The fraction separator is `.` or `,` — yaml.v3 resolves timestamps through `time.Parse`, - // which accepts either. + // The fraction separator is `.` or `,`. 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, @@ -870,7 +864,7 @@ 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: the hour is rejected above 24 and the minute above 60 + // 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; @@ -878,12 +872,12 @@ function yamlIsTimestamp(s: string): boolean { const month = Number(monthRaw); const day = Number(dayRaw); if (month < 1 || month > 12) return false; - if (day < 1 || day > goDaysInMonth(Number(yearRaw), month)) return false; + if (day < 1 || day > daysInMonth(Number(yearRaw), month)) return false; return true; } -/** `time.Parse`'s "day out of range" bound (`daysIn`, proleptic Gregorian). */ -function goDaysInMonth(year: number, month: number): number { +/** Days in a month of the proleptic Gregorian calendar. */ +function daysInMonth(year: number, month: number): number { if (month === 2) { const leap = year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0); return leap ? 29 : 28; @@ -950,8 +944,7 @@ function yamlDoubleQuoted(s: string): string { out += "\\P"; break; default: - // Non-printables escape by rune width like yaml.v3's double-quoted - // writer: `\xXX`, `\uXXXX`, or `\U00XXXXXX` with uppercase hex. + // Non-printables escape as `\xXX`, `\uXXXX`, or `\U00XXXXXX` with uppercase hex. if (yamlIsPrintable(code)) { out += ch; } else if (code <= 0xff) { @@ -971,10 +964,8 @@ function yamlDoubleQuoted(s: string): string { * explicit `4` indentation indicator when the first line starts with a space * or is empty, and content indented to the next 4-column stop. * - * Unlike Go's streaming bufio-backed encoder (which can flush partial output - * before a later error), this builds the whole document in memory — callers - * emit all-or-nothing, which only differs observably from Go on multi-KB - * payloads that fail mid-encode. + * Builds the whole document in memory — callers emit all-or-nothing, so no partial output + * is flushed when a later field fails mid-encode. */ function yamlBlockLiteral(s: string, indent: number): string { const contentIndent = yamlNextIndent(indent); @@ -984,7 +975,7 @@ 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 (e.g. "\nx" → `|4-\n + // A leading empty line's break merges 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"); @@ -992,13 +983,13 @@ function yamlBlockLiteral(s: string, indent: number): string { } /** - * Thrown when BurntSushi would refuse the payload: a populated - * `nullable.Nullable` field (`map[bool]T` has a non-string key type — observed - * on `snippets list -o toml`) or a `nil` element inside an inline array. + * Thrown when the payload has no TOML form: a populated nullable field (its bool key is not a + * valid TOML key — observed on `snippets list -o toml`) or a null element inside an inline array. */ -export class GoTomlEncodeError extends Error { +export class TomlEncodeError extends Error { + // The fingerprint and `name` are the stable telemetry/output identity of this error. static readonly [ErrorActionabilityFingerprintId] = "GoTomlEncodeError"; - constructor(message = "toml: cannot encode a map with non-string key type") { + constructor(message = "cannot encode a map with non-string keys") { super(message); this.name = "GoTomlEncodeError"; } @@ -1009,15 +1000,13 @@ export class GoTomlEncodeError extends Error { } /** - * 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. + * Encodes a decoded payload as `-o toml` output. Returns the full document, which can + * be empty (an all-nil payload emits nothing). Throws {@link TomlEncodeError} on + * a populated nullable field. * - * 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. + * Emits nothing on error rather than the accumulated prefix. */ -export function encodeGoToml(value: unknown, type: GoType): string { +export function encodeStructToml(value: unknown, type: OutputShape): string { const state = { out: "", hasWritten: false }; tomlEncode(state, [], normalize(value, type)); return state.out; @@ -1042,7 +1031,7 @@ function tomlIndent(key: ReadonlyArray): string { return " ".repeat(Math.max(key.length - 1, 0)); } -function tomlIsTable(value: GoValue): boolean { +function tomlIsTable(value: NormalizedValue): boolean { switch (value.k) { case "struct": return true; @@ -1051,15 +1040,14 @@ function tomlIsTable(value: GoValue): boolean { case "nullable": return true; case "slice": - // Array-of-tables only when non-empty with table elements (BurntSushi's - // isTableArray returns false for empty slices). + // Array-of-tables only when non-empty with table elements. return value.tables && value.items.length > 0; default: return false; } } -function tomlIsNil(value: GoValue): boolean { +function tomlIsNil(value: NormalizedValue): boolean { switch (value.k) { case "nil": return true; @@ -1074,7 +1062,7 @@ function tomlIsNil(value: GoValue): boolean { } } -function tomlEncode(state: TomlState, key: ReadonlyArray, value: GoValue): void { +function tomlEncode(state: TomlState, key: ReadonlyArray, value: NormalizedValue): void { if (tomlIsNil(value)) return; switch (value.k) { case "struct": @@ -1082,8 +1070,8 @@ function tomlEncode(state: TomlState, key: ReadonlyArray, value: GoValue tomlTable(state, key, value); return; case "nullable": - // Populated nullable.Nullable[T] is a map[bool]T — BurntSushi panics. - throw new GoTomlEncodeError(); + // A populated nullable has a bool key, which TOML cannot encode. + throw new TomlEncodeError(); case "slice": if (tomlIsTable(value)) { tomlArrayOfTables(state, key, value.items); @@ -1097,19 +1085,19 @@ function tomlEncode(state: TomlState, key: ReadonlyArray, value: GoValue } /** - * Map/struct entries in BurntSushi's write order: map entries sorted by - * {@link goStringCompare} (structs keep declaration order), then both + * Map/struct entries in TOML write order: map entries sorted by + * {@link compareByCodepoint} (structs keep declaration order), then both * partitioned into non-table ("direct") and table ("sub") groups via - * {@link tomlIsTable} — `eStruct`/`eMap` always write direct fields before + * {@link tomlIsTable} — direct fields are always written before * sub-tables. */ -function tomlOrderedEntries(value: Extract): { - direct: ReadonlyArray; - sub: ReadonlyArray; +function tomlOrderedEntries(value: Extract): { + direct: ReadonlyArray; + sub: ReadonlyArray; } { const entries = value.k === "map" - ? [...value.entries].sort(([a], [b]) => goStringCompare(a, b)) + ? [...value.entries].sort(([a], [b]) => compareByCodepoint(a, b)) : value.entries; return { direct: entries.filter(([, v]) => !tomlIsTable(v)), @@ -1120,7 +1108,7 @@ function tomlOrderedEntries(value: Extract): { function tomlTable( state: TomlState, key: ReadonlyArray, - value: Extract, + value: Extract, ): void { if (key.length === 1) { // Extra newline between top-level tables. @@ -1143,7 +1131,7 @@ function tomlTable( function tomlArrayOfTables( state: TomlState, key: ReadonlyArray, - items: ReadonlyArray, + items: ReadonlyArray, ): void { for (const item of items) { if (tomlIsNil(item)) continue; @@ -1156,12 +1144,12 @@ function tomlArrayOfTables( tomlEncode(state, [...key, name], v); } } else if (item.k === "nullable") { - throw new GoTomlEncodeError(); + throw new TomlEncodeError(); } } } -function tomlKeyValue(state: TomlState, key: ReadonlyArray, value: GoValue): void { +function tomlKeyValue(state: TomlState, key: ReadonlyArray, value: NormalizedValue): void { const name = key[key.length - 1] as string; tomlWrite(state, `${tomlIndent(key)}${tomlKeyName(name)} = ${tomlElement(value)}\n`); } @@ -1172,7 +1160,7 @@ function tomlKeyName(name: string): string { return TOML_BARE_KEY.test(name) ? name : tomlQuoted(name); } -function tomlElement(value: GoValue): string { +function tomlElement(value: NormalizedValue): string { switch (value.k) { case "str": return tomlQuoted(value.v); @@ -1181,7 +1169,7 @@ function tomlElement(value: GoValue): string { case "int": return String(value.v); case "float": { - const repr = goFormatFloat(value.v, value.bits); + const repr = formatStructFloat(value.v, value.bits); // TOML floats must carry a decimal point unless in exponent form. return repr.includes(".") || repr.includes("e") ? repr : `${repr}.0`; } @@ -1193,16 +1181,16 @@ function tomlElement(value: GoValue): string { case "map": return tomlInlineTable(value); case "nullable": - throw new GoTomlEncodeError(); + throw new TomlEncodeError(); case "nil": - // BurntSushi's `eElement` rejects nil inline-array elements (`[null, "x"]` fails), while nil + // Null inline-array elements are rejected (`[null, "x"]` fails), while null // map values are silently skipped. - throw new GoTomlEncodeError("toml: cannot encode array with nil element"); + throw new TomlEncodeError("cannot encode an array with a null element"); } } /** - * BurntSushi's inline-table form, used for map/struct elements of arrays that aren't + * The 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. * @@ -1210,14 +1198,14 @@ function tomlElement(value: GoValue): string { * 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 { +function tomlInlineTable(value: Extract): string { let out = "{"; if (value.k === "map") { - const sorted = [...value.entries].sort(([a], [b]) => goStringCompare(a, b)); + const sorted = [...value.entries].sort(([a], [b]) => compareByCodepoint(a, b)); const direct = sorted.filter(([, v]) => !tomlIsTable(v)); const sub = sorted.filter(([, v]) => tomlIsTable(v)); const writeGroup = ( - group: ReadonlyArray, + group: ReadonlyArray, trailingComma: boolean, ): void => { for (const [index, [name, v]] of group.entries()) { @@ -1241,7 +1229,7 @@ function tomlInlineTable(value: Extract): stri return `${out}}`; } -/** BurntSushi's `dblQuotedReplacer` escape set. */ +/** The double-quoted TOML string escape set. */ function tomlQuoted(s: string): string { let out = '"'; for (const ch of s) { diff --git a/apps/cli/src/command-internal/go-struct-output.encoders.unit.test.ts b/apps/cli/src/command-internal/struct-output.encoders.unit.test.ts similarity index 50% rename from apps/cli/src/command-internal/go-struct-output.encoders.unit.test.ts rename to apps/cli/src/command-internal/struct-output.encoders.unit.test.ts index 0c8900aac7..93af2144df 100644 --- a/apps/cli/src/command-internal/go-struct-output.encoders.unit.test.ts +++ b/apps/cli/src/command-internal/struct-output.encoders.unit.test.ts @@ -1,52 +1,46 @@ import { describe, expect, it } from "vitest"; import { - GoTomlEncodeError, - encodeGoToml, - encodeGoYaml, - goAny, - goBool, - goFieldName, - goFloat32, - goFloat64, - goFormatFloat, - goInt, - goMap, - goNullable, - goPtr, - goSlice, - goString, - goStruct, - goTime, - goTomlListWrapper, - goUuid, -} from "./go-struct-output.encoders.ts"; - -/** - * 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`. - */ + TomlEncodeError, + encodeStructToml, + encodeStructYaml, + shapeAny, + shapeBool, + pascalCaseFieldName, + shapeFloat32, + shapeFloat64, + formatStructFloat, + shapeInt, + shapeMap, + shapeNullable, + shapePtr, + shapeSlice, + shapeString, + shapeStruct, + shapeTime, + shapeTomlListWrapper, + shapeUuid, +} from "./struct-output.encoders.ts"; // Mirrors the branch response struct. -const BRANCH_RESPONSE = goStruct([ - ["created_at", goTime], - ["deletion_scheduled_at", goPtr(goTime)], - ["git_branch", goPtr(goString)], - ["id", goUuid], - ["is_default", goBool], - ["latest_check_run_id", goPtr(goFloat32)], - ["name", goString], - ["notify_url", goPtr(goString)], - ["parent_project_ref", goString], - ["persistent", goBool], - ["pr_number", goPtr(goInt)], - ["preview_project_status", goPtr(goString)], - ["project_ref", goString], - ["review_requested_at", goPtr(goTime)], - ["status", goString], - ["updated_at", goTime], - ["with_data", goBool], +const BRANCH_RESPONSE = shapeStruct([ + ["created_at", shapeTime], + ["deletion_scheduled_at", shapePtr(shapeTime)], + ["git_branch", shapePtr(shapeString)], + ["id", shapeUuid], + ["is_default", shapeBool], + ["latest_check_run_id", shapePtr(shapeFloat32)], + ["name", shapeString], + ["notify_url", shapePtr(shapeString)], + ["parent_project_ref", shapeString], + ["persistent", shapeBool], + ["pr_number", shapePtr(shapeInt)], + ["preview_project_status", shapePtr(shapeString)], + ["project_ref", shapeString], + ["review_requested_at", shapePtr(shapeTime)], + ["status", shapeString], + ["updated_at", shapeTime], + ["with_data", shapeBool], ]); const SAMPLE_BRANCH = { @@ -63,7 +57,7 @@ const SAMPLE_BRANCH = { with_data: true, }; -// All pointer fields absent — Go zero-fills the value fields. +// All pointer fields absent — the value fields are zero-filled. const ZERO_BRANCH = { name: "Production", is_default: true, @@ -72,10 +66,10 @@ const ZERO_BRANCH = { status: "FUNCTIONS_DEPLOYED", }; -describe("encodeGoToml", () => { - it("matches Go byte-for-byte for a branches list wrapper (PascalCase, nil pointers omitted, native datetimes)", () => { - const wrapper = goTomlListWrapper("branches", BRANCH_RESPONSE); - expect(encodeGoToml({ branches: [SAMPLE_BRANCH, ZERO_BRANCH] }, wrapper)).toBe( +describe("encodeStructToml", () => { + it("matches the golden output for a branches list wrapper (PascalCase, nil pointers omitted, native datetimes)", () => { + const wrapper = shapeTomlListWrapper("branches", BRANCH_RESPONSE); + expect(encodeStructToml({ branches: [SAMPLE_BRANCH, ZERO_BRANCH] }, wrapper)).toBe( `[[branches]] CreatedAt = 2026-05-27T01:02:03Z GitBranch = "feat-1" @@ -105,7 +99,7 @@ describe("encodeGoToml", () => { }); it("emits a top-level struct without a table header (branches create)", () => { - expect(encodeGoToml(SAMPLE_BRANCH, BRANCH_RESPONSE)).toBe( + expect(encodeStructToml(SAMPLE_BRANCH, BRANCH_RESPONSE)).toBe( `CreatedAt = 2026-05-27T01:02:03Z GitBranch = "feat-1" Id = "11111111-2222-3333-4444-555555555555" @@ -122,60 +116,60 @@ WithData = true }); it("emits nothing for a nil list and `key = []` for a decoded empty list", () => { - const wrapper = goTomlListWrapper("branches", BRANCH_RESPONSE); - // Go: `var result []api.BranchResponse` stays nil when empty → no output. - expect(encodeGoToml({ branches: undefined }, wrapper)).toBe(""); - // Go: a decoded `[]` is a non-nil empty slice → `branches = []`. - expect(encodeGoToml({ branches: [] }, wrapper)).toBe("branches = []\n"); + const wrapper = shapeTomlListWrapper("branches", BRANCH_RESPONSE); + // An absent list → no output. + expect(encodeStructToml({ branches: undefined }, wrapper)).toBe(""); + // A decoded `[]` is an empty list → `branches = []`. + expect(encodeStructToml({ branches: [] }, wrapper)).toBe("branches = []\n"); }); it("nests sub-tables after primitives with 2-space indentation (hostnames shape)", () => { // Mirrors the custom-hostname update response struct. - const spec = goStruct([ - ["custom_hostname", goString], + const spec = shapeStruct([ + ["custom_hostname", shapeString], [ "data", - goStruct([ - ["errors", goSlice(goAny)], - ["messages", goSlice(goAny)], + shapeStruct([ + ["errors", shapeSlice(shapeAny)], + ["messages", shapeSlice(shapeAny)], [ "result", - goStruct([ - ["custom_origin_server", goString], - ["hostname", goString], - ["id", goString], + shapeStruct([ + ["custom_origin_server", shapeString], + ["hostname", shapeString], + ["id", shapeString], [ "ownership_verification", - goStruct([ - ["name", goString], - ["type", goString], - ["value", goString], + shapeStruct([ + ["name", shapeString], + ["type", shapeString], + ["value", shapeString], ]), ], [ "ssl", - goStruct([ - ["status", goString], - ["validation_errors", goPtr(goSlice(goAny))], + shapeStruct([ + ["status", shapeString], + ["validation_errors", shapePtr(shapeSlice(shapeAny))], [ "validation_records", - goSlice( - goStruct([ - ["txt_name", goString], - ["txt_value", goString], + shapeSlice( + shapeStruct([ + ["txt_name", shapeString], + ["txt_value", shapeString], ]), ), ], ]), ], - ["status", goString], - ["verification_errors", goPtr(goSlice(goString))], + ["status", shapeString], + ["verification_errors", shapePtr(shapeSlice(shapeString))], ]), ], - ["success", goBool], + ["success", shapeBool], ]), ], - ["status", goString], + ["status", shapeString], ]); const payload = { custom_hostname: "custom.example.com", @@ -198,7 +192,7 @@ WithData = true }, }, }; - expect(encodeGoToml(payload, spec)).toBe( + expect(encodeStructToml(payload, spec)).toBe( `CustomHostname = "custom.example.com" Status = "2_initiated" @@ -223,35 +217,35 @@ Status = "2_initiated" ); }); - it("escapes strings like BurntSushi and quotes string-typed timestamps (sso provider)", () => { - const spec = goStruct([ - ["created_at", goPtr(goString)], + it("escapes TOML strings and quotes string-typed timestamps (sso provider)", () => { + const spec = shapeStruct([ + ["created_at", shapePtr(shapeString)], [ "domains", - goPtr( - goSlice( - goStruct([ - ["created_at", goPtr(goString)], - ["domain", goPtr(goString)], - ["updated_at", goPtr(goString)], + shapePtr( + shapeSlice( + shapeStruct([ + ["created_at", shapePtr(shapeString)], + ["domain", shapePtr(shapeString)], + ["updated_at", shapePtr(shapeString)], ]), ), ), ], - ["id", goString], + ["id", shapeString], [ "saml", - goPtr( - goStruct([ - ["attribute_mapping", goPtr(goStruct([["keys", goMap(goAny)]]))], - ["entity_id", goString], - ["metadata_url", goPtr(goString)], - ["metadata_xml", goPtr(goString)], - ["name_id_format", goPtr(goString)], + shapePtr( + shapeStruct([ + ["attribute_mapping", shapePtr(shapeStruct([["keys", shapeMap(shapeAny)]]))], + ["entity_id", shapeString], + ["metadata_url", shapePtr(shapeString)], + ["metadata_xml", shapePtr(shapeString)], + ["name_id_format", shapePtr(shapeString)], ]), ), ], - ["updated_at", goPtr(goString)], + ["updated_at", shapePtr(shapeString)], ]); const payload = { id: "8b64a95d-6e29-4c58-8f04-1d0ac6bcda31", @@ -264,7 +258,7 @@ Status = "2_initiated" '\n&', }, }; - expect(encodeGoToml(payload, spec)).toBe( + expect(encodeStructToml(payload, spec)).toBe( `CreatedAt = "2026-05-27T01:02:03.123456Z" Id = "8b64a95d-6e29-4c58-8f04-1d0ac6bcda31" UpdatedAt = "2026-05-27T01:02:03.123456Z" @@ -280,17 +274,17 @@ UpdatedAt = "2026-05-27T01:02:03.123456Z" ); }); - it("keeps hand-written Go struct declaration order (services imageVersion)", () => { - const spec = goTomlListWrapper( + it("keeps hand-written struct declaration order (services imageVersion)", () => { + const spec = shapeTomlListWrapper( "services", - goStruct([ - ["name", goString], - ["local", goString], - ["remote", goString], + shapeStruct([ + ["name", shapeString], + ["local", shapeString], + ["remote", shapeString], ]), ); expect( - encodeGoToml( + encodeStructToml( { services: [{ name: "supabase/postgres", local: "17.4.1.037", remote: "" }] }, spec, ), @@ -304,33 +298,33 @@ UpdatedAt = "2026-05-27T01:02:03.123456Z" }); it("renders inline primitive arrays (network bans wrapper)", () => { - const spec = goStruct([["banned_ips", goSlice(goString), "banned_ips"]]); - expect(encodeGoToml({ banned_ips: ["1.2.3.4", "5.6.7.8"] }, spec)).toBe( + const spec = shapeStruct([["banned_ips", shapeSlice(shapeString), "banned_ips"]]); + expect(encodeStructToml({ banned_ips: ["1.2.3.4", "5.6.7.8"] }, spec)).toBe( 'banned_ips = ["1.2.3.4", "5.6.7.8"]\n', ); }); - it("skips nil nullable fields and fails like Go on populated ones", () => { - const spec = goStruct([ - ["desc", goNullable(goString)], - ["name", goString], + it("skips nil nullable fields and fails on populated ones", () => { + const spec = shapeStruct([ + ["desc", shapeNullable(shapeString)], + ["name", shapeString], ]); - expect(encodeGoToml({ name: "x" }, spec)).toBe('Name = "x"\n'); - expect(() => encodeGoToml({ name: "x", desc: null }, spec)).toThrow( - new GoTomlEncodeError().message, + expect(encodeStructToml({ name: "x" }, spec)).toBe('Name = "x"\n'); + expect(() => encodeStructToml({ name: "x", desc: null }, spec)).toThrow( + new TomlEncodeError().message, ); - expect(() => encodeGoToml({ name: "x", desc: "d" }, spec)).toThrow( - "toml: cannot encode a map with non-string key type", + expect(() => encodeStructToml({ name: "x", desc: "d" }, spec)).toThrow( + "cannot encode a map with non-string keys", ); }); - it("renders floats with a decimal point and Go's exponent form", () => { - const spec = goStruct([ - ["f1", goFloat32], - ["f2", goFloat64], - ["f6", goFloat64], + it("renders floats with a decimal point and the exponent form", () => { + const spec = shapeStruct([ + ["f1", shapeFloat32], + ["f2", shapeFloat64], + ["f6", shapeFloat64], ]); - expect(encodeGoToml({ f1: 1, f2: 1000000, f6: 1234567 }, spec)).toBe( + expect(encodeStructToml({ f1: 1, f2: 1000000, f6: 1234567 }, spec)).toBe( `F1 = 1.0 F2 = 1e+06 F6 = 1.234567e+06 @@ -339,9 +333,9 @@ F6 = 1.234567e+06 }); it("sorts map keys and quotes non-bare keys (branches get envs)", () => { - const spec = goMap(goString); + const spec = shapeMap(shapeString); expect( - encodeGoToml( + encodeStructToml( { SUPABASE_ANON_KEY: "anon", POSTGRES_URL: "postgres://u:p@h:6543/postgres" }, spec, ), @@ -352,95 +346,95 @@ SUPABASE_ANON_KEY = "anon" ); }); - it("renders map elements of mixed interface{} arrays as inline tables like BurntSushi", () => { - const spec = goStruct([["default", goAny, "Default"]]); + it("renders map elements of mixed untyped arrays as inline tables", () => { + const spec = shapeStruct([["default", shapeAny, "Default"]]); // Sorted byte order, non-table values before table values. - expect(encodeGoToml({ default: [{ b: 2, a: 1, C: 3 }, "x"] }, spec)).toBe( + expect(encodeStructToml({ default: [{ b: 2, a: 1, C: 3 }, "x"] }, spec)).toBe( 'Default = [{C = 3.0, a = 1.0, b = 2.0}, "x"]\n', ); - expect(encodeGoToml({ default: [{ a: { b: 1 }, z: 2 }, "x"] }, spec)).toBe( + expect(encodeStructToml({ default: [{ a: { b: 1 }, z: 2 }, "x"] }, spec)).toBe( 'Default = [{z = 2.0, a = {b = 1.0}}, "x"]\n', ); - expect(encodeGoToml({ default: [{ a: [{ b: 1 }], z: 2 }, "x"] }, spec)).toBe( + expect(encodeStructToml({ default: [{ a: [{ b: 1 }], z: 2 }, "x"] }, spec)).toBe( 'Default = [{z = 2.0, a = [{b = 1.0}]}, "x"]\n', ); // Non-bare keys are quoted; empty and all-nil tables collapse to {}. - expect(encodeGoToml({ default: [{ "a b": 1 }, "x"] }, spec)).toBe( + expect(encodeStructToml({ default: [{ "a b": 1 }, "x"] }, spec)).toBe( 'Default = [{"a b" = 1.0}, "x"]\n', ); - expect(encodeGoToml({ default: [{}, "x"] }, spec)).toBe('Default = [{}, "x"]\n'); - expect(encodeGoToml({ default: [{ a: null }, "x"] }, spec)).toBe('Default = [{}, "x"]\n'); + expect(encodeStructToml({ default: [{}, "x"] }, spec)).toBe('Default = [{}, "x"]\n'); + expect(encodeStructToml({ default: [{ a: null }, "x"] }, spec)).toBe('Default = [{}, "x"]\n'); // eMap decides the ", " separator by group position before skipping nil // entries, so a nil in the final position leaves a dangling separator. - expect(encodeGoToml({ default: [{ "10": 78797, b: null }, false] }, spec)).toBe( + expect(encodeStructToml({ default: [{ "10": 78797, b: null }, false] }, spec)).toBe( "Default = [{10 = 78797.0, }, false]\n", ); }); - it("fails like Go on nil elements inside interface{} arrays", () => { - const spec = goStruct([["default", goAny, "Default"]]); - const message = "toml: cannot encode array with nil element"; - expect(() => encodeGoToml({ default: [null, "x"] }, spec)).toThrow(message); - expect(() => encodeGoToml({ default: [null] }, spec)).toThrow(message); - expect(() => encodeGoToml({ default: [[null], "x"] }, spec)).toThrow(message); + it("fails on null elements inside untyped arrays", () => { + const spec = shapeStruct([["default", shapeAny, "Default"]]); + const message = "cannot encode an array with a null element"; + expect(() => encodeStructToml({ default: [null, "x"] }, spec)).toThrow(message); + expect(() => encodeStructToml({ default: [null] }, spec)).toThrow(message); + expect(() => encodeStructToml({ default: [[null], "x"] }, spec)).toThrow(message); }); - it("truncates time fractions to nanoseconds like time.Time's decoder", () => { - const spec = goStruct([["t", goTime, "T"]]); - expect(encodeGoToml({ t: "2026-01-01T00:00:00.1234567895Z" }, spec)).toBe( + it("truncates time fractions to nanoseconds", () => { + const spec = shapeStruct([["t", shapeTime, "T"]]); + expect(encodeStructToml({ t: "2026-01-01T00:00:00.1234567895Z" }, spec)).toBe( "T = 2026-01-01T00:00:00.123456789Z\n", ); - expect(encodeGoToml({ t: "2026-01-01T00:00:00.1000000005Z" }, spec)).toBe( + expect(encodeStructToml({ t: "2026-01-01T00:00:00.1000000005Z" }, spec)).toBe( "T = 2026-01-01T00:00:00.1Z\n", ); }); - it("quotes comma-fraction timestamp-shaped STRINGS like yaml.v3's resolver", () => { - const spec = goStruct([["s", goString, "S"]]); - expect(encodeGoYaml({ s: "2026-01-01T00:00:00,123Z" }, spec)).toBe( + it("quotes comma-fraction timestamp-shaped strings", () => { + const spec = shapeStruct([["s", shapeString, "S"]]); + expect(encodeStructYaml({ s: "2026-01-01T00:00:00,123Z" }, spec)).toBe( 's: "2026-01-01T00:00:00,123Z"\n', ); }); - it("leaves overflowing float-shaped strings plain like yaml.v3's ParseFloat gate", () => { - const spec = goStruct([["s", goString, "S"]]); - expect(encodeGoYaml({ s: "1e999" }, spec)).toBe("s: 1e999\n"); - expect(encodeGoYaml({ s: "-1e999" }, spec)).toBe("s: -1e999\n"); - expect(encodeGoYaml({ s: ".5e999" }, spec)).toBe("s: .5e999\n"); - expect(encodeGoYaml({ s: "1e-999" }, spec)).toBe('s: "1e-999"\n'); - expect(encodeGoYaml({ s: "1e10" }, spec)).toBe('s: "1e10"\n'); + it("leaves overflowing float-shaped strings plain", () => { + const spec = shapeStruct([["s", shapeString, "S"]]); + expect(encodeStructYaml({ s: "1e999" }, spec)).toBe("s: 1e999\n"); + expect(encodeStructYaml({ s: "-1e999" }, spec)).toBe("s: -1e999\n"); + expect(encodeStructYaml({ s: ".5e999" }, spec)).toBe("s: .5e999\n"); + expect(encodeStructYaml({ s: "1e-999" }, spec)).toBe('s: "1e-999"\n'); + expect(encodeStructYaml({ s: "1e10" }, spec)).toBe('s: "1e10"\n'); }); - it("wraps 19+-digit numeric key runs like Go's unchecked int64 accumulation", () => { - const spec = goStruct([["default", goAny, "Default"]]); + it("wraps 19+-digit numeric key runs via unchecked int64 accumulation", () => { + const spec = shapeStruct([["default", shapeAny, "Default"]]); expect( - encodeGoYaml({ default: { a9000000000000000000: 1, a10000000000000000000: 2 } }, spec), + encodeStructYaml({ default: { a9000000000000000000: 1, a10000000000000000000: 2 } }, spec), ).toBe("default:\n a10000000000000000000: 2\n a9000000000000000000: 1\n"); }); - it("orders Unicode-digit map keys with yaml.v3's naive rune arithmetic", () => { - const spec = goStruct([["default", goAny, "Default"]]); - expect(encodeGoYaml({ default: { a٢: 1, a3: 2, a10: 3, a9: 4 } }, spec)).toBe( + it("orders Unicode-digit map keys by code point, with numeric runs compared by ASCII digits only", () => { + const spec = shapeStruct([["default", shapeAny, "Default"]]); + expect(encodeStructYaml({ 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", ); }); - it("normalizes Go's accepted comma fractional separator to the dot Go re-emits", () => { - const spec = goStruct([["t", goTime, "T"]]); - expect(encodeGoToml({ t: "2026-01-01T00:00:00,123Z" }, spec)).toBe( + it("normalizes a comma fractional separator to a dot", () => { + const spec = shapeStruct([["t", shapeTime, "T"]]); + expect(encodeStructToml({ t: "2026-01-01T00:00:00,123Z" }, spec)).toBe( "T = 2026-01-01T00:00:00.123Z\n", ); - expect(encodeGoYaml({ t: "2026-01-01T00:00:00,1234567895Z" }, spec)).toBe( + expect(encodeStructYaml({ t: "2026-01-01T00:00:00,1234567895Z" }, spec)).toBe( "t: 2026-01-01T00:00:00.123456789Z\n", ); }); - it("sorts map keys by UTF-8 byte order like Go's sort.Strings", () => { + it("sorts map keys by UTF-8 byte order", () => { // 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); + const spec = shapeMap(shapeString); expect( - encodeGoToml( + encodeStructToml( { "\u{1F600}": "emoji", "\uE000": "private-use", @@ -463,9 +457,9 @@ SUPABASE_ANON_KEY = "anon" }); }); -describe("encodeGoYaml", () => { - it("matches Go byte-for-byte for a branches list (lowercased keys, explicit nulls)", () => { - expect(encodeGoYaml([SAMPLE_BRANCH, ZERO_BRANCH], goSlice(BRANCH_RESPONSE))).toBe( +describe("encodeStructYaml", () => { + it("matches the golden output for a branches list (lowercased keys, explicit nulls)", () => { + expect(encodeStructYaml([SAMPLE_BRANCH, ZERO_BRANCH], shapeSlice(BRANCH_RESPONSE))).toBe( `- createdat: 2026-05-27T01:02:03Z deletionscheduledat: null gitbranch: feat-1 @@ -505,39 +499,39 @@ describe("encodeGoYaml", () => { }); it("renders an empty list as [] regardless of nil-ness", () => { - expect(encodeGoYaml([], goSlice(BRANCH_RESPONSE))).toBe("[]\n"); - expect(encodeGoYaml(undefined, goSlice(BRANCH_RESPONSE))).toBe("[]\n"); + expect(encodeStructYaml([], shapeSlice(BRANCH_RESPONSE))).toBe("[]\n"); + expect(encodeStructYaml(undefined, shapeSlice(BRANCH_RESPONSE))).toBe("[]\n"); }); it("uses 4-column indentation, block literals, and quoted string timestamps (sso show)", () => { - const spec = goStruct([ - ["created_at", goPtr(goString)], + const spec = shapeStruct([ + ["created_at", shapePtr(shapeString)], [ "domains", - goPtr( - goSlice( - goStruct([ - ["created_at", goPtr(goString)], - ["domain", goPtr(goString)], - ["updated_at", goPtr(goString)], + shapePtr( + shapeSlice( + shapeStruct([ + ["created_at", shapePtr(shapeString)], + ["domain", shapePtr(shapeString)], + ["updated_at", shapePtr(shapeString)], ]), ), ), ], - ["id", goString], + ["id", shapeString], [ "saml", - goPtr( - goStruct([ - ["attribute_mapping", goPtr(goStruct([["keys", goMap(goAny)]]))], - ["entity_id", goString], - ["metadata_url", goPtr(goString)], - ["metadata_xml", goPtr(goString)], - ["name_id_format", goPtr(goString)], + shapePtr( + shapeStruct([ + ["attribute_mapping", shapePtr(shapeStruct([["keys", shapeMap(shapeAny)]]))], + ["entity_id", shapeString], + ["metadata_url", shapePtr(shapeString)], + ["metadata_xml", shapePtr(shapeString)], + ["name_id_format", shapePtr(shapeString)], ]), ), ], - ["updated_at", goPtr(goString)], + ["updated_at", shapePtr(shapeString)], ]); const payload = { id: "8b64a95d-6e29-4c58-8f04-1d0ac6bcda31", @@ -550,7 +544,7 @@ describe("encodeGoYaml", () => { '\n&', }, }; - expect(encodeGoYaml(payload, spec)).toBe( + expect(encodeStructYaml(payload, spec)).toBe( `createdat: "2026-05-27T01:02:03.123456Z" domains: - createdat: "2026-05-27T01:02:03Z" @@ -570,27 +564,27 @@ updatedat: "2026-05-27T01:02:03.123456Z" ); }); - it("renders nullable fields the way yaml.v3 renders map[bool]T (api keys)", () => { + it("renders nullable fields as a mapping with a plain bool key (api keys)", () => { // Mirror of api.ApiKeyResponse. - const spec = goSlice( - goStruct([ - ["api_key", goNullable(goString)], - ["description", goNullable(goString)], - ["hash", goNullable(goString)], - ["id", goNullable(goString)], - ["inserted_at", goNullable(goTime)], - ["name", goString], - ["prefix", goNullable(goString)], - ["secret_jwt_template", goNullable(goMap(goAny))], - ["type", goNullable(goString)], - ["updated_at", goNullable(goTime)], + const spec = shapeSlice( + shapeStruct([ + ["api_key", shapeNullable(shapeString)], + ["description", shapeNullable(shapeString)], + ["hash", shapeNullable(shapeString)], + ["id", shapeNullable(shapeString)], + ["inserted_at", shapeNullable(shapeTime)], + ["name", shapeString], + ["prefix", shapeNullable(shapeString)], + ["secret_jwt_template", shapeNullable(shapeMap(shapeAny))], + ["type", shapeNullable(shapeString)], + ["updated_at", shapeNullable(shapeTime)], ]), ); const payload = [ { name: "anon", api_key: "anon-key-value", id: "key-id-1", type: "legacy" }, { name: "service_role" }, ]; - expect(encodeGoYaml(payload, spec)).toBe( + expect(encodeStructYaml(payload, spec)).toBe( `- apikey: true: anon-key-value description: {} @@ -619,11 +613,11 @@ updatedat: "2026-05-27T01:02:03.123456Z" }); it("renders an explicit JSON null nullable as a false-keyed zero (snippets description)", () => { - const spec = goStruct([ - ["desc", goNullable(goString)], - ["name", goString], + const spec = shapeStruct([ + ["desc", shapeNullable(shapeString)], + ["name", shapeString], ]); - expect(encodeGoYaml({ desc: null, name: "x" }, spec)).toBe( + expect(encodeStructYaml({ desc: null, name: "x" }, spec)).toBe( `desc: false: "" name: x @@ -632,28 +626,28 @@ name: x }); it("renders nil and empty slices as [] and nested maps at +4 (backups list)", () => { - const spec = goStruct([ + const spec = shapeStruct([ [ "backups", - goSlice( - goStruct([ - ["id", goInt], - ["inserted_at", goString], - ["is_physical_backup", goBool], - ["status", goString], + shapeSlice( + shapeStruct([ + ["id", shapeInt], + ["inserted_at", shapeString], + ["is_physical_backup", shapeBool], + ["status", shapeString], ]), ), ], [ "physical_backup_data", - goStruct([ - ["earliest_physical_backup_date_unix", goPtr(goInt)], - ["latest_physical_backup_date_unix", goPtr(goInt)], + shapeStruct([ + ["earliest_physical_backup_date_unix", shapePtr(shapeInt)], + ["latest_physical_backup_date_unix", shapePtr(shapeInt)], ]), ], - ["pitr_enabled", goBool], - ["region", goString], - ["walg_enabled", goBool], + ["pitr_enabled", shapeBool], + ["region", shapeString], + ["walg_enabled", shapeBool], ]); const payload = { backups: [], @@ -662,7 +656,7 @@ name: x region: "us-east-1", walg_enabled: true, }; - expect(encodeGoYaml(payload, spec)).toBe( + expect(encodeStructYaml(payload, spec)).toBe( `backups: [] physicalbackupdata: earliestphysicalbackupdateunix: 1687279254 @@ -674,8 +668,8 @@ walgenabled: true ); }); - it("quotes strings exactly like yaml.v3's resolver and emitter", () => { - const spec = goMap(goString); + it("quotes strings that would resolve to a non-string YAML tag", () => { + const spec = shapeMap(shapeString); const payload = { k01: "yes", k04: "~", @@ -708,7 +702,7 @@ walgenabled: true k52: "2001-12-14 21:59:43.10 -5", k53: "2001-12-15 2:59:43.10", }; - expect(encodeGoYaml(payload, spec)).toBe( + expect(encodeStructYaml(payload, spec)).toBe( `k01: "yes" k04: "~" k07: ' leading-space' @@ -743,10 +737,13 @@ k53: "2001-12-15 2:59:43.10" ); }); - it("renders block literal chomping indicators like yaml.v3", () => { - const spec = goMap(goString); + it("renders block literal chomping indicators", () => { + const spec = shapeMap(shapeString); expect( - encodeGoYaml({ k27: "line1\nline2\n", k28: "line1\nline2\n\n", k29: "with\rcarriage" }, spec), + encodeStructYaml( + { k27: "line1\nline2\n", k28: "line1\nline2\n\n", k29: "with\rcarriage" }, + spec, + ), ).toBe( `k27: | line1 @@ -760,9 +757,11 @@ k29: "with\\rcarriage" ); }); - it("renders floats with Go's g-format exponent switch", () => { - const spec = goMap(goAny); - expect(encodeGoYaml({ f2: 1000000, f3: 78125, f4: 0.5, f5: 0.000001, f6: 1234567 }, spec)).toBe( + it("renders floats with the g-format exponent switch", () => { + const spec = shapeMap(shapeAny); + expect( + encodeStructYaml({ f2: 1000000, f3: 78125, f4: 0.5, f5: 0.000001, f6: 1234567 }, spec), + ).toBe( `f2: 1e+06 f3: 78125 f4: 0.5 @@ -772,9 +771,9 @@ f6: 1.234567e+06 ); }); - it("sorts plain map keys with yaml.v3's natural ordering", () => { - const spec = goMap(goString); - expect(encodeGoYaml({ z: "1", a: "2", "10": "3", "2": "4", B: "5", b: "6" }, spec)).toBe( + it("sorts plain map keys in natural order", () => { + const spec = shapeMap(shapeString); + expect(encodeStructYaml({ z: "1", a: "2", "10": "3", "2": "4", B: "5", b: "6" }, spec)).toBe( `"2": "4" "10": "3" B: "5" @@ -785,12 +784,12 @@ 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; the - // emitter double-quotes astral characters as \U-escapes (not printable to libyaml). - const spec = goMap(goString); + it("sorts unicode map keys by code point and double-quotes astral keys with \\U escapes", () => { + // Keys compare by code point, so the astral U+1F600/U+1D400 sort after U+E000/U+FF21; the + // emitter double-quotes astral characters as \U-escapes (not printable in YAML). + const spec = shapeMap(shapeString); expect( - encodeGoYaml( + encodeStructYaml( { "\u{1F600}": "emoji", "\uE000": "private-use", @@ -813,9 +812,9 @@ z: ascii }); it("validates calendar dates and zone offsets like time.Parse before quoting timestamps", () => { - const spec = goMap(goString); + const spec = shapeMap(shapeString); expect( - encodeGoYaml( + encodeStructYaml( { t01: "2025-02-31", t02: "2024-02-29", @@ -849,29 +848,29 @@ t12: 1900-02-29 ); }); - it("truncates time fractions to nanoseconds like time.Time's decoder", () => { - const spec = goStruct([["t", goTime, "T"]]); - expect(encodeGoYaml({ t: "2026-01-01T00:00:00.1234567895Z" }, spec)).toBe( + it("truncates time fractions to nanoseconds", () => { + const spec = shapeStruct([["t", shapeTime, "T"]]); + expect(encodeStructYaml({ t: "2026-01-01T00:00:00.1234567895Z" }, spec)).toBe( "t: 2026-01-01T00:00:00.123456789Z\n", ); - expect(encodeGoYaml({ t: "2026-01-01T00:00:00.12345678901234Z" }, spec)).toBe( + expect(encodeStructYaml({ t: "2026-01-01T00:00:00.12345678901234Z" }, spec)).toBe( "t: 2026-01-01T00:00:00.123456789Z\n", ); - expect(encodeGoYaml({ t: "2026-01-01T00:00:00.9999999999Z" }, spec)).toBe( + expect(encodeStructYaml({ t: "2026-01-01T00:00:00.9999999999Z" }, spec)).toBe( "t: 2026-01-01T00:00:00.999999999Z\n", ); - expect(encodeGoYaml({ t: "2026-01-01T00:00:00.1000000005Z" }, spec)).toBe( + expect(encodeStructYaml({ t: "2026-01-01T00:00:00.1000000005Z" }, spec)).toBe( "t: 2026-01-01T00:00:00.1Z\n", ); - expect(encodeGoYaml({ t: "2026-01-01T00:00:00.1234567895+07:00" }, spec)).toBe( + expect(encodeStructYaml({ t: "2026-01-01T00:00:00.1234567895+07:00" }, spec)).toBe( "t: 2026-01-01T00:00:00.123456789+07:00\n", ); }); - it("escapes non-printable scalars with \\x/\\u/\\U like yaml.v3's emitter", () => { - const spec = goMap(goString); + it("escapes non-printable scalars with \\x/\\u/\\U", () => { + const spec = shapeMap(shapeString); expect( - encodeGoYaml( + encodeStructYaml( { e1: "a\uFEFFb", e2: "a\uFFFEb", @@ -892,50 +891,50 @@ e5: "nel\\Nbreak" }); }); -describe("goFieldName", () => { +describe("pascalCaseFieldName", () => { it("capitalizes snake_case tokens like oapi-codegen", () => { - expect(goFieldName("api_key")).toBe("ApiKey"); - expect(goFieldName("metadata_xml")).toBe("MetadataXml"); - expect(goFieldName("parent_project_ref")).toBe("ParentProjectRef"); - expect(goFieldName("ezbr_sha256")).toBe("EzbrSha256"); + expect(pascalCaseFieldName("api_key")).toBe("ApiKey"); + expect(pascalCaseFieldName("metadata_xml")).toBe("MetadataXml"); + expect(pascalCaseFieldName("parent_project_ref")).toBe("ParentProjectRef"); + expect(pascalCaseFieldName("ezbr_sha256")).toBe("EzbrSha256"); }); it("capitalizes the first letter of camelCase tags", () => { - expect(goFieldName("appliedSuccessfully")).toBe("AppliedSuccessfully"); - expect(goFieldName("currentConfig")).toBe("CurrentConfig"); + expect(pascalCaseFieldName("appliedSuccessfully")).toBe("AppliedSuccessfully"); + expect(pascalCaseFieldName("currentConfig")).toBe("CurrentConfig"); }); }); -describe("goFormatFloat", () => { - it("matches strconv.FormatFloat(f, 'g', -1, 64)", () => { - expect(goFormatFloat(1, 64)).toBe("1"); - expect(goFormatFloat(123456, 64)).toBe("123456"); - expect(goFormatFloat(1000000, 64)).toBe("1e+06"); - expect(goFormatFloat(1234567, 64)).toBe("1.234567e+06"); - expect(goFormatFloat(0.5, 64)).toBe("0.5"); - expect(goFormatFloat(0.000001, 64)).toBe("1e-06"); - expect(goFormatFloat(0, 64)).toBe("0"); - expect(goFormatFloat(-2.5, 64)).toBe("-2.5"); +describe("formatStructFloat", () => { + it("formats shortest round-trip digits for 64-bit floats", () => { + expect(formatStructFloat(1, 64)).toBe("1"); + expect(formatStructFloat(123456, 64)).toBe("123456"); + expect(formatStructFloat(1000000, 64)).toBe("1e+06"); + expect(formatStructFloat(1234567, 64)).toBe("1.234567e+06"); + expect(formatStructFloat(0.5, 64)).toBe("0.5"); + expect(formatStructFloat(0.000001, 64)).toBe("1e-06"); + expect(formatStructFloat(0, 64)).toBe("0"); + expect(formatStructFloat(-2.5, 64)).toBe("-2.5"); }); - it("rounds through float32 like Go's typed fields", () => { - expect(goFormatFloat(16777217, 32)).toBe("1.6777216e+07"); - expect(goFormatFloat(78125, 32)).toBe("78125"); - expect(goFormatFloat(0.5, 32)).toBe("0.5"); + it("rounds through float32 for typed fields", () => { + expect(formatStructFloat(16777217, 32)).toBe("1.6777216e+07"); + expect(formatStructFloat(78125, 32)).toBe("78125"); + expect(formatStructFloat(0.5, 32)).toBe("0.5"); }); it("breaks exact shortest-digit ties to even like Ryu, not half-up", () => { // 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"); - expect(goFormatFloat(1048575.5, 32)).toBe("1.0485755e+06"); - expect(goFormatFloat(8388607.5, 32)).toBe("8.3886075e+06"); + expect(formatStructFloat(4249.03125, 32)).toBe("4249.0312"); + expect(formatStructFloat(4249.09375, 32)).toBe("4249.0938"); + expect(formatStructFloat(123456789, 32)).toBe("1.2345679e+08"); + expect(formatStructFloat(1048575.5, 32)).toBe("1.0485755e+06"); + expect(formatStructFloat(8388607.5, 32)).toBe("8.3886075e+06"); // Boundaries: smallest subnormal, subnormal→normal edge, and max finite. - expect(goFormatFloat(1.401298464324817e-45, 32)).toBe("1e-45"); - expect(goFormatFloat(1.1754943508222875e-38, 32)).toBe("1.1754944e-38"); - expect(goFormatFloat(3.4028234663852886e38, 32)).toBe("3.4028235e+38"); - expect(goFormatFloat(-4249.03125, 32)).toBe("-4249.0312"); + expect(formatStructFloat(1.401298464324817e-45, 32)).toBe("1e-45"); + expect(formatStructFloat(1.1754943508222875e-38, 32)).toBe("1.1754944e-38"); + expect(formatStructFloat(3.4028234663852886e38, 32)).toBe("3.4028235e+38"); + expect(formatStructFloat(-4249.03125, 32)).toBe("-4249.0312"); }); }); diff --git a/apps/cli/src/command-internal/go-struct-output.openapi-drift.unit.test.ts b/apps/cli/src/command-internal/struct-output.openapi-drift.unit.test.ts similarity index 77% rename from apps/cli/src/command-internal/go-struct-output.openapi-drift.unit.test.ts rename to apps/cli/src/command-internal/struct-output.openapi-drift.unit.test.ts index 23ec1da4e8..78bccd0c81 100644 --- a/apps/cli/src/command-internal/go-struct-output.openapi-drift.unit.test.ts +++ b/apps/cli/src/command-internal/struct-output.openapi-drift.unit.test.ts @@ -1,23 +1,23 @@ import { describe, expect, it } from "vitest"; import openApiSpec from "@supabase/api/openapi.json"; -import { GO_BRANCH_RESPONSE } from "../commands/branches/branches.go-payload.ts"; -import { GO_ORGANIZATION_RESPONSE } from "../commands/orgs/orgs.go-payload.ts"; -import { GO_SSL_ENFORCEMENT_RESPONSE } from "../commands/ssl-enforcement/ssl-enforcement.go-payload.ts"; -import { GO_SSO_PROVIDER_RESPONSE } from "../commands/sso/sso.go-payload.ts"; -import type { GoType } from "./go-struct-output.encoders.ts"; +import { BRANCH_RESPONSE_SHAPE } from "../commands/branches/branches.response-shape.ts"; +import { ORGANIZATION_RESPONSE_SHAPE } from "../commands/orgs/orgs.response-shape.ts"; +import { SSL_ENFORCEMENT_RESPONSE_SHAPE } from "../commands/ssl-enforcement/ssl-enforcement.response-shape.ts"; +import { SSO_PROVIDER_RESPONSE_SHAPE } from "../commands/sso/sso.response-shape.ts"; +import type { OutputShape } from "./struct-output.encoders.ts"; /** - * Mechanical drift check for the `*.go-payload.ts` specs against the live OpenAPI schemas they - * mirror — field-name set, `goPtr` vs `required`, field order, and value type/kind must all + * Mechanical drift check for the `*.response-shape.ts` specs against the live OpenAPI schemas they + * mirror — field-name set, `shapePtr` vs `required`, field order, and value type/kind must all * match, or a future spec edit silently changes `-o yaml`/`-o toml` bytes instead of failing a * test. * - * oapi-codegen names its Go struct fields from the schema's JSON keys and declares them in - * alphabetical order, not the schema's own `properties` order, so field order here is compared - * against an ASCII sort of `properties` keys. That assumption holds for every schema below; - * `KNOWN_ORDER_EXCEPTIONS` records the real order for any future schema where oapi-codegen's - * Go-identifier sort diverges from a plain JSON-key sort. + * Struct fields are named from the schema's JSON keys and declared in alphabetical order, not + * the schema's own `properties` order, so field order here is compared against an ASCII sort of + * `properties` keys. That assumption holds for every schema below; `KNOWN_ORDER_EXCEPTIONS` + * records the real order for any future schema where the field-name sort diverges from a plain + * JSON-key sort. */ interface JsonSchema { @@ -51,23 +51,23 @@ function resolveSchema(schema: JsonSchema): JsonSchema { return resolved; } -interface GoStructField { +interface OutputShapeField { readonly json: string; - readonly type: GoType; + readonly type: OutputShape; } -function structFieldsOf(spec: GoType): ReadonlyArray { +function structFieldsOf(spec: OutputShape): ReadonlyArray { if (spec.kind !== "struct") { - throw new Error(`expected a struct GoType, got "${spec.kind}"`); + throw new Error(`expected a struct OutputShape, got "${spec.kind}"`); } return spec.fields; } -function isPointerType(type: GoType): boolean { +function isPointerType(type: OutputShape): boolean { return type.kind === "ptr" || type.kind === "nullable"; } -function unwrapPointer(type: GoType): GoType { +function unwrapPointer(type: OutputShape): OutputShape { return type.kind === "ptr" || type.kind === "nullable" ? type.elem : type; } @@ -79,7 +79,7 @@ interface DriftMismatch { } function compareFieldSet( - fields: ReadonlyArray, + fields: ReadonlyArray, schema: JsonSchema, path: string, ): ReadonlyArray { @@ -108,15 +108,15 @@ function compareFieldSet( } function comparePointerRequired( - fields: ReadonlyArray, + fields: ReadonlyArray, schema: JsonSchema, path: string, ): ReadonlyArray { const required = new Set(schema.required ?? []); const mismatches: Array = []; for (const field of fields) { - // oapi-codegen never pointer-wraps a Go map regardless of the schema's `required` list, so - // this is the one field kind the goPtr/required rule doesn't hold for. + // A map field is never pointer-wrapped regardless of the schema's `required` list, so + // this is the one field kind the shapePtr/required rule doesn't hold for. if (unwrapPointer(field.type).kind === "map") { continue; } @@ -126,8 +126,8 @@ function comparePointerRequired( mismatches.push({ path: `${path}.${field.json}`, message: isPointer - ? `spec marks "${field.json}" as goPtr, but the schema marks it required` - : `spec does not mark "${field.json}" as goPtr, but the schema marks it optional`, + ? `spec marks "${field.json}" as shapePtr, but the schema marks it required` + : `spec does not mark "${field.json}" as shapePtr, but the schema marks it optional`, }); } } @@ -135,7 +135,7 @@ function comparePointerRequired( } function compareValueType( - type: GoType, + type: OutputShape, schema: JsonSchema, path: string, ): ReadonlyArray { @@ -162,7 +162,7 @@ function compareValueType( return [ { path, - message: `expected type=string format=date-time (Go time.Time), got type=${resolved.type ?? ""} format=${resolved.format ?? ""}`, + message: `expected type=string format=date-time (timestamp), got type=${resolved.type ?? ""} format=${resolved.format ?? ""}`, }, ]; } @@ -232,7 +232,7 @@ function compareValueType( } function compareValueTypes( - fields: ReadonlyArray, + fields: ReadonlyArray, schema: JsonSchema, path: string, ): ReadonlyArray { @@ -248,7 +248,7 @@ function compareValueTypes( } function compareFieldOrder( - fields: ReadonlyArray, + fields: ReadonlyArray, schema: JsonSchema, schemaKey: string, path: string, @@ -269,13 +269,13 @@ function compareFieldOrder( } /** - * Walks a {@link GoType} struct spec and the corresponding OpenAPI schema in lockstep, returning + * Walks a {@link OutputShape} struct spec and the corresponding OpenAPI schema in lockstep, returning * every mismatch found. Recurses into a struct field, and into a slice field whose element is a * struct, so both `saml`'s nested fields and `domains`' array elements get the same field-set, * pointer/required, order, and value-type checks as the top-level struct. */ -function compareGoStructToSchema( - spec: GoType, +function compareShapeToSchema( + spec: OutputShape, schema: JsonSchema, schemaName: string, path: string, @@ -298,7 +298,7 @@ function compareGoStructToSchema( } if (inner.kind === "struct") { mismatches.push( - ...compareGoStructToSchema(inner, nestedSchema, schemaName, `${path}.${field.json}`), + ...compareShapeToSchema(inner, nestedSchema, schemaName, `${path}.${field.json}`), ); continue; } @@ -312,7 +312,7 @@ function compareGoStructToSchema( continue; } mismatches.push( - ...compareGoStructToSchema(elem, itemsSchema, schemaName, `${path}.${field.json}[]`), + ...compareShapeToSchema(elem, itemsSchema, schemaName, `${path}.${field.json}[]`), ); } } @@ -320,40 +320,44 @@ function compareGoStructToSchema( return mismatches; } -interface GoPayloadSpecEntry { +interface PayloadShapeEntry { readonly specName: string; - readonly spec: GoType; + readonly spec: OutputShape; readonly schemaName: string; } -const GO_PAYLOAD_SPEC_REGISTRY: ReadonlyArray = [ - { specName: "GO_BRANCH_RESPONSE", spec: GO_BRANCH_RESPONSE, schemaName: "BranchResponse_Output" }, +const PAYLOAD_SHAPE_REGISTRY: ReadonlyArray = [ { - specName: "GO_ORGANIZATION_RESPONSE", - spec: GO_ORGANIZATION_RESPONSE, + specName: "BRANCH_RESPONSE_SHAPE", + spec: BRANCH_RESPONSE_SHAPE, + schemaName: "BranchResponse_Output", + }, + { + specName: "ORGANIZATION_RESPONSE_SHAPE", + spec: ORGANIZATION_RESPONSE_SHAPE, schemaName: "OrganizationResponseV1_Output", }, { - specName: "GO_SSL_ENFORCEMENT_RESPONSE", - spec: GO_SSL_ENFORCEMENT_RESPONSE, + specName: "SSL_ENFORCEMENT_RESPONSE_SHAPE", + spec: SSL_ENFORCEMENT_RESPONSE_SHAPE, schemaName: "SslEnforcementResponse_Output", }, { - specName: "GO_SSO_PROVIDER_RESPONSE", - spec: GO_SSO_PROVIDER_RESPONSE, + specName: "SSO_PROVIDER_RESPONSE_SHAPE", + spec: SSO_PROVIDER_RESPONSE_SHAPE, schemaName: "GetProviderResponse_Output", }, ]; -describe("go-payload specs vs the OpenAPI schema (drift check)", () => { - it.each(GO_PAYLOAD_SPEC_REGISTRY)( +describe("response-shape specs vs the OpenAPI schema (drift check)", () => { + it.each(PAYLOAD_SHAPE_REGISTRY)( "$specName matches the $schemaName schema with zero drift", ({ spec, schemaName }) => { const schema = SCHEMAS[schemaName]; if (schema === undefined) { throw new Error(`missing OpenAPI schema "${schemaName}"`); } - expect(compareGoStructToSchema(spec, schema, schemaName, "$")).toEqual([]); + expect(compareShapeToSchema(spec, schema, schemaName, "$")).toEqual([]); }, ); @@ -368,8 +372,8 @@ describe("go-payload specs vs the OpenAPI schema (drift check)", () => { }, required: ["appliedSuccessfully", "currentConfig"], }; - const mismatches = compareGoStructToSchema( - GO_SSL_ENFORCEMENT_RESPONSE, + const mismatches = compareShapeToSchema( + SSL_ENFORCEMENT_RESPONSE_SHAPE, mutatedSchema, "SslEnforcementResponse", "$", @@ -382,7 +386,7 @@ describe("go-payload specs vs the OpenAPI schema (drift check)", () => { it("has teeth: reports a mismatch when a field's required-ness flips", () => { // A hand-mutated copy of the real BranchResponse schema with `git_branch` moved into - // `required`, though the spec still marks it `goPtr`. + // `required`, though the spec still marks it `shapePtr`. const mutatedSchema: JsonSchema = { type: "object", properties: { @@ -418,8 +422,8 @@ describe("go-payload specs vs the OpenAPI schema (drift check)", () => { "with_data", ], }; - const mismatches = compareGoStructToSchema( - GO_BRANCH_RESPONSE, + const mismatches = compareShapeToSchema( + BRANCH_RESPONSE_SHAPE, mutatedSchema, "BranchResponse", "$", @@ -427,7 +431,7 @@ describe("go-payload specs vs the OpenAPI schema (drift check)", () => { expect(mismatches).toContainEqual( expect.objectContaining({ path: "$.git_branch", - message: expect.stringContaining("goPtr"), + message: expect.stringContaining("shapePtr"), }), ); }); @@ -447,8 +451,8 @@ describe("go-payload specs vs the OpenAPI schema (drift check)", () => { }, required: ["appliedSuccessfully", "currentConfig"], }; - const mismatches = compareGoStructToSchema( - GO_SSL_ENFORCEMENT_RESPONSE, + const mismatches = compareShapeToSchema( + SSL_ENFORCEMENT_RESPONSE_SHAPE, mutatedSchema, "SslEnforcementResponse", "$", diff --git a/apps/cli/src/command-internal/viper-env.ts b/apps/cli/src/command-internal/supabase-env.ts similarity index 74% rename from apps/cli/src/command-internal/viper-env.ts rename to apps/cli/src/command-internal/supabase-env.ts index 01bc4257d7..0bf2ab15d1 100644 --- a/apps/cli/src/command-internal/viper-env.ts +++ b/apps/cli/src/command-internal/supabase-env.ts @@ -6,16 +6,16 @@ * so callers OR the parsed flag value with this read (flag-set wins). */ -const VIPER_TRUE = new Set(["1", "t", "T", "TRUE", "true", "True"]); +const TRUE_VALUES = new Set(["1", "t", "T", "TRUE", "true", "True"]); /** Truthiness for an already-resolved env value (see module doc). */ -function viperBool(raw: string | undefined): boolean { - return raw !== undefined && VIPER_TRUE.has(raw); +function parseEnvBool(raw: string | undefined): boolean { + return raw !== undefined && TRUE_VALUES.has(raw); } /** Reads a single `SUPABASE_*` boolean env var from `process.env` (see module doc). */ -export function viperEnvBool(name: string): boolean { - return viperBool(process.env[name]); +export function supabaseEnvBool(name: string): boolean { + return parseEnvBool(process.env[name]); } /** @@ -26,23 +26,23 @@ export function viperEnvBool(name: string): boolean { * project env, letting an opt-out gate default on while any present value * still disables. */ -export function viperEnvBoolWithProjectFallback( +export function supabaseEnvBoolWithProjectFallback( name: string, projectEnv: Record, opts: { readonly whenUnset?: boolean } = {}, ): boolean { const raw = process.env[name] ?? projectEnv[name]; if (raw === undefined) return opts.whenUnset ?? false; - return viperBool(raw); + return parseEnvBool(raw); } /** * Resolves a `SUPABASE_*` string with the same shell-presence-wins semantics - * as {@link viperEnvBoolWithProjectFallback}, but with no boolean coercion — + * as {@link supabaseEnvBoolWithProjectFallback}, but with no boolean coercion — * the raw merged string, or `""` when absent from both. `??` (not `||`) * encodes the presence check. */ -export function viperEnvStringWithProjectFallback( +export function supabaseEnvStringWithProjectFallback( name: string, projectEnv: Record, ): string { diff --git a/apps/cli/src/command-internal/supabase-env.unit.test.ts b/apps/cli/src/command-internal/supabase-env.unit.test.ts new file mode 100644 index 0000000000..a18e826e8e --- /dev/null +++ b/apps/cli/src/command-internal/supabase-env.unit.test.ts @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it } from "vitest"; + +import { + supabaseEnvBool, + supabaseEnvBoolWithProjectFallback, + supabaseEnvStringWithProjectFallback, +} from "./supabase-env.ts"; + +const KEY = "SUPABASE_TEST_ENV_BOOL"; +const STRING_KEY = "SUPABASE_TEST_ENV_STRING"; + +describe("supabaseEnvBool", () => { + afterEach(() => { + delete process.env[KEY]; + }); + + it("is true only for the boolean true set", () => { + for (const value of ["1", "t", "T", "TRUE", "true", "True"]) { + process.env[KEY] = value; + expect(supabaseEnvBool(KEY)).toBe(true); + } + }); + + it("is false for the false set and any unrecognized value", () => { + for (const value of ["0", "f", "F", "FALSE", "false", "False", "yes", "on", "", "nope"]) { + process.env[KEY] = value; + expect(supabaseEnvBool(KEY)).toBe(false); + } + }); + + it("is false when the env var is absent", () => { + delete process.env[KEY]; + expect(supabaseEnvBool(KEY)).toBe(false); + }); +}); + +describe("supabaseEnvBoolWithProjectFallback", () => { + afterEach(() => { + delete process.env[KEY]; + }); + + it("falls back to the project value only when the shell var is absent", () => { + delete process.env[KEY]; + expect(supabaseEnvBoolWithProjectFallback(KEY, { [KEY]: "true" })).toBe(true); + expect(supabaseEnvBoolWithProjectFallback(KEY, { [KEY]: "false" })).toBe(false); + expect(supabaseEnvBoolWithProjectFallback(KEY, {})).toBe(false); + }); + + it("keeps a false shell override even when the project .env says true", () => { + process.env[KEY] = "false"; + expect(supabaseEnvBoolWithProjectFallback(KEY, { [KEY]: "true" })).toBe(false); + }); + + it("treats an empty shell value as present (blocks the project value) and false", () => { + process.env[KEY] = ""; + expect(supabaseEnvBoolWithProjectFallback(KEY, { [KEY]: "true" })).toBe(false); + }); + + it("treats an unparsable shell value as present and false", () => { + process.env[KEY] = "banana"; + expect(supabaseEnvBoolWithProjectFallback(KEY, { [KEY]: "true" })).toBe(false); + }); + + it("keeps a true shell value over a false project value", () => { + process.env[KEY] = "true"; + expect(supabaseEnvBoolWithProjectFallback(KEY, { [KEY]: "false" })).toBe(true); + }); + + it("whenUnset: true resolves a key absent from both envs to true (opt-out gate default)", () => { + delete process.env[KEY]; + expect(supabaseEnvBoolWithProjectFallback(KEY, {}, { whenUnset: true })).toBe(true); + }); + + it("whenUnset: true still yields false for any present non-true value", () => { + process.env[KEY] = "0"; + expect(supabaseEnvBoolWithProjectFallback(KEY, {}, { whenUnset: true })).toBe(false); + process.env[KEY] = ""; + expect(supabaseEnvBoolWithProjectFallback(KEY, { [KEY]: "true" }, { whenUnset: true })).toBe( + false, + ); + process.env[KEY] = "banana"; + expect(supabaseEnvBoolWithProjectFallback(KEY, {}, { whenUnset: true })).toBe(false); + delete process.env[KEY]; + expect(supabaseEnvBoolWithProjectFallback(KEY, { [KEY]: "false" }, { whenUnset: true })).toBe( + false, + ); + }); +}); + +describe("supabaseEnvStringWithProjectFallback", () => { + afterEach(() => { + delete process.env[STRING_KEY]; + }); + + it("falls back to the project value only when the shell var is absent", () => { + delete process.env[STRING_KEY]; + expect( + supabaseEnvStringWithProjectFallback(STRING_KEY, { [STRING_KEY]: "project-value" }), + ).toBe("project-value"); + expect(supabaseEnvStringWithProjectFallback(STRING_KEY, {})).toBe(""); + }); + + it("keeps the shell value over a project value", () => { + process.env[STRING_KEY] = "shell-value"; + expect( + supabaseEnvStringWithProjectFallback(STRING_KEY, { [STRING_KEY]: "project-value" }), + ).toBe("shell-value"); + }); + + it("treats an empty shell value as present (blocks the project value)", () => { + process.env[STRING_KEY] = ""; + expect( + supabaseEnvStringWithProjectFallback(STRING_KEY, { [STRING_KEY]: "project-value" }), + ).toBe(""); + }); + + it("returns an empty string (not undefined) when absent from both", () => { + delete process.env[STRING_KEY]; + expect(supabaseEnvStringWithProjectFallback(STRING_KEY, {})).toBe(""); + }); +}); diff --git a/apps/cli/src/command-internal/tenant-versions.unit.test.ts b/apps/cli/src/command-internal/tenant-versions.unit.test.ts index da46e26ff8..6412841f52 100644 --- a/apps/cli/src/command-internal/tenant-versions.unit.test.ts +++ b/apps/cli/src/command-internal/tenant-versions.unit.test.ts @@ -12,7 +12,7 @@ describe("parsePostgrestVersion", () => { expect(parsePostgrestVersion({ info: { version: "12.2.0" } })).toEqual(Option.some("v12.2.0")); }); - it("uses only the first whitespace-delimited field (Go strings.Fields)", () => { + it("uses only the first whitespace-delimited field", () => { expect(parsePostgrestVersion({ info: { version: "12.2.0 (abc123)" } })).toEqual( Option.some("v12.2.0"), ); 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 539b199f80..679ae17107 100644 --- a/apps/cli/src/command-internal/test-db.integration.test.ts +++ b/apps/cli/src/command-internal/test-db.integration.test.ts @@ -624,7 +624,7 @@ describe("test db integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("json mode: streams TAP only — emits no result envelope (Go parity)", () => { + it.live("json mode: streams TAP only — emits no result envelope", () => { const { layer, out } = setup({ format: "json", exitCode: 0 }); return Effect.gen(function* () { yield* testDb(flags()); @@ -632,7 +632,7 @@ describe("test db integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("stream-json mode: emits no result envelope (Go parity)", () => { + it.live("stream-json mode: emits no result envelope", () => { const { layer, out } = setup({ format: "stream-json", exitCode: 0 }); return Effect.gen(function* () { yield* testDb(flags()); @@ -721,7 +721,7 @@ describe("test db integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("writes the connection diagnostic to stderr, keeping stdout for TAP (Go parity)", () => { + it.live("writes the connection diagnostic to stderr, keeping stdout for TAP", () => { const { layer, out } = setup(); return Effect.gen(function* () { yield* testDb(flags()); @@ -744,7 +744,7 @@ describe("test db integration", () => { }); const tempWorkdir = useTempWorkdir(); - it.live("sanitizes a configured project_id when naming the local network (Go parity)", () => { + it.live("sanitizes a configured project_id when naming the local network", () => { const workdir = tempWorkdir.current; mkdirSync(join(workdir, "supabase"), { recursive: true }); writeFileSync(join(workdir, "supabase", "config.toml"), 'project_id = "My Project"\n'); diff --git a/apps/cli/src/command-internal/timestamp.format.unit.test.ts b/apps/cli/src/command-internal/timestamp.format.unit.test.ts index c7efdef426..9b0a4157c8 100644 --- a/apps/cli/src/command-internal/timestamp.format.unit.test.ts +++ b/apps/cli/src/command-internal/timestamp.format.unit.test.ts @@ -12,7 +12,7 @@ describe("formatTimestamp", () => { }); it("falls back to the original value for already-formatted timestamps", () => { - // Go's time.Parse(time.RFC3339, ...) rejects "2026-02-08 16:44:07" (space, not T). + // RFC3339 parsing rejects "2026-02-08 16:44:07" (space, not T). expect(formatTimestamp("2026-02-08 16:44:07")).toBe("2026-02-08 16:44:07"); }); 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 26d7067b1a..089f40101a 100644 --- a/apps/cli/src/command-internal/upgrade-notice.unit.test.ts +++ b/apps/cli/src/command-internal/upgrade-notice.unit.test.ts @@ -28,7 +28,7 @@ import { } from "./upgrade-notice.ts"; describe("updateNotifierDisabled", () => { - it.each(["1", "t", "T", "TRUE", "true", "True"])("suppresses for ParseBool-true %j", (value) => { + it.each(["1", "t", "T", "TRUE", "true", "True"])("suppresses for boolean-true %j", (value) => { expect(updateNotifierDisabled(value)).toBe(true); }); @@ -73,7 +73,7 @@ describe("isNewerCliVersion", () => { }); describe("formatUpgradeNotice", () => { - it("matches the Go CLI's message bytes modulo styling", () => { + it("emits the expected message bytes modulo styling", () => { expect(stripVTControlCharacters(formatUpgradeNotice("v2.114.0", "2.113.0"))).toBe( "A new version of Supabase CLI is available: v2.114.0 (currently installed v2.113.0)\n" + "We recommend updating regularly for new features and bug fixes: " + @@ -205,14 +205,14 @@ describe("runUpgradeNotice", () => { ctx.cleanup(); }); - it("SUPABASE_DEBUG surfaces fetch failures without the --debug flag, like viper's AutomaticEnv", async () => { + it("SUPABASE_DEBUG surfaces fetch failures without the --debug flag", async () => { const ctx = setup({ fetchFails: true, project: false, env: { SUPABASE_DEBUG: "1" } }); await runUpgradeNotice(ctx.deps); expect(ctx.stderr).toContain("Failed to fetch latest release"); ctx.cleanup(); }); - it("--debug=true surfaces fetch failures and --debug=false silences them, like pflag", async () => { + it("--debug=true surfaces fetch failures and --debug=false silences them", async () => { const on = setup({ fetchFails: true, project: false, args: ["db", "start", "--debug=true"] }); await runUpgradeNotice(on.deps); expect(on.stderr).toContain("Failed to fetch latest release"); @@ -230,7 +230,7 @@ describe("runUpgradeNotice", () => { off.cleanup(); }); - it("a built-in ignores SUPABASE_DEBUG but still honors the --debug flag, like cobra's init order", async () => { + it("a built-in ignores SUPABASE_DEBUG but still honors the --debug flag", async () => { const viaEnv = setup({ fetchFails: true, project: false, @@ -263,7 +263,7 @@ describe("runUpgradeNotice", () => { ctx.cleanup(); }); - it("a --debug operand after -- or consumed by a value flag is not the debug flag, like pflag", async () => { + it("a --debug operand after -- or consumed by a value flag is not the debug flag", async () => { const afterTerminator = setup({ fetchFails: true, project: false, @@ -298,7 +298,7 @@ describe("runUpgradeNotice", () => { ); it.skipIf(process.platform === "win32")( - "creates the cache directory and file with Go-compatible modes", + "creates the cache directory and file with the expected modes", async () => { const ctx = setup({}); const previousUmask = process.umask(0); @@ -333,7 +333,7 @@ describe("runUpgradeNotice", () => { }, ); - it("a --debug consumed by the leaf command's own value flag is not the debug flag, like pflag", async () => { + it("a --debug consumed by the leaf command's own value flag is not the debug flag", async () => { // `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"] }); @@ -360,7 +360,7 @@ describe("runUpgradeNotice", () => { }); it.skipIf(process.getuid?.() === 0)( - "an existing read-only cache file fails the backoff write, like Go's direct open", + "an existing read-only cache file fails the backoff write", async () => { const ctx = setup({ args: ["db", "start", "--debug"], @@ -376,7 +376,7 @@ describe("runUpgradeNotice", () => { }, ); - it("project dotenv SUPABASE_DEBUG surfaces diagnostics, like godotenv before the Execute tail", async () => { + it("project dotenv SUPABASE_DEBUG surfaces diagnostics", async () => { // The symlinked .temp disables the backoff write, so the fetch error is // what remains to log. const ctx = setup({ fetchFails: true }); @@ -397,7 +397,7 @@ describe("runUpgradeNotice", () => { blocked.cleanup(); }); - it("a failed fetch inside a project stays silent under --debug, matching Go's backoff", async () => { + it("a failed fetch inside a project stays silent under --debug", async () => { const ctx = setup({ fetchFails: true, args: ["db", "start", "--debug"] }); await runUpgradeNotice(ctx.deps); // The empty-cache backoff write succeeds, so no debug line is emitted. @@ -444,7 +444,7 @@ describe("runUpgradeNotice", () => { ctx.cleanup(); }); - it("an explicit empty --workdir beats SUPABASE_WORKDIR and falls back to the walk, like viper", async () => { + it("an explicit empty --workdir beats SUPABASE_WORKDIR and falls back to the walk", async () => { const ctx = setup({}); const envDir = join(workdir, "env-project"); mkdirSync(join(envDir, "supabase"), { recursive: true }); @@ -459,7 +459,7 @@ describe("runUpgradeNotice", () => { ctx.cleanup(); }); - it("a project dotenv SUPABASE_NO_UPDATE_NOTIFIER opt-out suppresses the notice, like godotenv", async () => { + it("a project dotenv SUPABASE_NO_UPDATE_NOTIFIER opt-out suppresses the notice", async () => { const ctx = setup({}); writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_NO_UPDATE_NOTIFIER=1\n"); await runUpgradeNotice(ctx.deps); @@ -468,7 +468,7 @@ describe("runUpgradeNotice", () => { ctx.cleanup(); }); - it("a shell env that defines the key beats the project dotenv, like godotenv's no-override", async () => { + it("a shell env that defines the key beats the project dotenv", async () => { // 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: "" } }); @@ -478,7 +478,7 @@ describe("runUpgradeNotice", () => { ctx.cleanup(); }); - it("--help ignores the project dotenv opt-out — Go never loads config for the built-ins", async () => { + it("--help ignores the project dotenv opt-out — built-ins never load config", async () => { const ctx = setup({ args: ["--help"] }); writeFileSync(join(workdir, "supabase", ".env"), "SUPABASE_NO_UPDATE_NOTIFIER=1\n"); await runUpgradeNotice(ctx.deps); @@ -486,7 +486,7 @@ describe("runUpgradeNotice", () => { ctx.cleanup(); }); - it("resolves --help and --version against the bare cwd, ignoring --workdir, like Go", async () => { + it("resolves --help and --version against the bare cwd, ignoring --workdir", async () => { const ctx = setup({ project: false }); const flagged = join(workdir, "flagged"); mkdirSync(join(flagged, "supabase"), { recursive: true }); @@ -514,7 +514,7 @@ describe("runUpgradeNotice", () => { ctx.cleanup(); }); - it("does not treat Effect's -v shorthand as Go's root version flag", async () => { + it("does not treat Effect's -v shorthand as the root version flag", async () => { const ctx = setup({ project: false }); const flagged = join(workdir, "flagged"); const cacheFile = join(flagged, "supabase", ".temp", "cli-latest"); @@ -545,7 +545,7 @@ describe("runUpgradeNotice", () => { ctx.cleanup(); }); - it("ignores a --workdir operand after the -- terminator, like cobra", async () => { + it("ignores a --workdir operand after the -- terminator", async () => { const ctx = setup({}); const elsewhere = join(workdir, "elsewhere"); mkdirSync(join(elsewhere, "supabase"), { recursive: true }); 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 4e21dd66aa..aeb421c12e 100644 --- a/apps/cli/src/command-internal/vault-decrypt.unit.test.ts +++ b/apps/cli/src/command-internal/vault-decrypt.unit.test.ts @@ -20,7 +20,7 @@ describe("isEncryptedSecret", () => { }); describe("decryptSecret", () => { - it("decrypts Go's test vector to the expected plaintext", () => { + it("decrypts the test vector to the expected plaintext", () => { const result = decryptSecret(ENCRYPTED_VALUE, [PRIVATE_KEY]); expect(result).toEqual({ ok: true, value: "value" }); }); diff --git a/apps/cli/src/command-internal/viper-env.unit.test.ts b/apps/cli/src/command-internal/viper-env.unit.test.ts deleted file mode 100644 index 019307500e..0000000000 --- a/apps/cli/src/command-internal/viper-env.unit.test.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; - -import { - viperEnvBool, - viperEnvBoolWithProjectFallback, - viperEnvStringWithProjectFallback, -} from "./viper-env.ts"; - -const KEY = "SUPABASE_TEST_VIPER_BOOL"; -const STRING_KEY = "SUPABASE_TEST_VIPER_STRING"; - -describe("viperEnvBool", () => { - afterEach(() => { - delete process.env[KEY]; - }); - - it("is true only for strconv.ParseBool's true set (viper.GetBool parity)", () => { - for (const value of ["1", "t", "T", "TRUE", "true", "True"]) { - process.env[KEY] = value; - expect(viperEnvBool(KEY)).toBe(true); - } - }); - - it("is false for the false set and any unrecognized value", () => { - for (const value of ["0", "f", "F", "FALSE", "false", "False", "yes", "on", "", "nope"]) { - process.env[KEY] = value; - expect(viperEnvBool(KEY)).toBe(false); - } - }); - - it("is false when the env var is absent", () => { - delete process.env[KEY]; - expect(viperEnvBool(KEY)).toBe(false); - }); -}); - -describe("viperEnvBoolWithProjectFallback", () => { - afterEach(() => { - delete process.env[KEY]; - }); - - 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); - expect(viperEnvBoolWithProjectFallback(KEY, { [KEY]: "false" })).toBe(false); - expect(viperEnvBoolWithProjectFallback(KEY, {})).toBe(false); - }); - - it("keeps a false shell override even when the project .env says true", () => { - process.env[KEY] = "false"; - expect(viperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" })).toBe(false); - }); - - it("treats an empty shell value as present (blocks the project value) and false", () => { - process.env[KEY] = ""; - expect(viperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" })).toBe(false); - }); - - it("treats an unparsable shell value as present and false (cast.ToBool swallows the error)", () => { - process.env[KEY] = "banana"; - expect(viperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" })).toBe(false); - }); - - it("keeps a true shell value over a false project value", () => { - process.env[KEY] = "true"; - expect(viperEnvBoolWithProjectFallback(KEY, { [KEY]: "false" })).toBe(true); - }); - - it("whenUnset: true resolves a key absent from both envs to true (opt-out gate default)", () => { - delete process.env[KEY]; - expect(viperEnvBoolWithProjectFallback(KEY, {}, { whenUnset: true })).toBe(true); - }); - - it("whenUnset: true still yields false for any present non-true value", () => { - process.env[KEY] = "0"; - expect(viperEnvBoolWithProjectFallback(KEY, {}, { whenUnset: true })).toBe(false); - process.env[KEY] = ""; - expect(viperEnvBoolWithProjectFallback(KEY, { [KEY]: "true" }, { whenUnset: true })).toBe( - false, - ); - process.env[KEY] = "banana"; - expect(viperEnvBoolWithProjectFallback(KEY, {}, { whenUnset: true })).toBe(false); - delete process.env[KEY]; - expect(viperEnvBoolWithProjectFallback(KEY, { [KEY]: "false" }, { whenUnset: true })).toBe( - false, - ); - }); -}); - -describe("viperEnvStringWithProjectFallback", () => { - afterEach(() => { - delete process.env[STRING_KEY]; - }); - - it("falls back to the project value only when the shell var is absent", () => { - delete process.env[STRING_KEY]; - expect(viperEnvStringWithProjectFallback(STRING_KEY, { [STRING_KEY]: "project-value" })).toBe( - "project-value", - ); - expect(viperEnvStringWithProjectFallback(STRING_KEY, {})).toBe(""); - }); - - it("keeps the shell value over a project value", () => { - process.env[STRING_KEY] = "shell-value"; - expect(viperEnvStringWithProjectFallback(STRING_KEY, { [STRING_KEY]: "project-value" })).toBe( - "shell-value", - ); - }); - - it("treats an empty shell value as present (blocks the project value)", () => { - process.env[STRING_KEY] = ""; - expect(viperEnvStringWithProjectFallback(STRING_KEY, { [STRING_KEY]: "project-value" })).toBe( - "", - ); - }); - - it("returns an empty string (not undefined) when absent from both, matching viper.GetString", () => { - delete process.env[STRING_KEY]; - expect(viperEnvStringWithProjectFallback(STRING_KEY, {})).toBe(""); - }); -}); diff --git a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md index 1609312f06..b3689bd4b1 100644 --- a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md @@ -56,7 +56,7 @@ The `--output {pretty,json,yaml,toml,env}` flag and the `--output-format {text,j ### `--output pretty` (default) / `--output-format text` -For PITR-only projects, prints a Glamour-styled markdown table with columns: `REGION`, `WALG`, `PITR`, `EARLIEST TIMESTAMP`, `LATEST TIMESTAMP`. For projects with logical/physical backups, prints columns: `REGION`, `BACKUP TYPE`, `STATUS`, `CREATED AT (UTC)`. The table is rendered byte-for-byte using `glamour.WithStandardStyle(styles.AsciiStyle)`. +For PITR-only projects, prints a styled markdown table with columns: `REGION`, `WALG`, `PITR`, `EARLIEST TIMESTAMP`, `LATEST TIMESTAMP`. For projects with logical/physical backups, prints columns: `REGION`, `BACKUP TYPE`, `STATUS`, `CREATED AT (UTC)`. ### `--output json` diff --git a/apps/cli/src/commands/backups/list/list.handler.ts b/apps/cli/src/commands/backups/list/list.handler.ts index adfaf43399..c6595152ff 100644 --- a/apps/cli/src/commands/backups/list/list.handler.ts +++ b/apps/cli/src/commands/backups/list/list.handler.ts @@ -9,45 +9,45 @@ import { OutputFlag } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { renderGlamourTable } from "../../../output/glamour-table.ts"; import { BackupListNetworkError, BackupListUnexpectedStatusError } from "../backups.errors.ts"; -import { encodeEnv, encodeGoJson } from "../../../command-internal/go-output.encoders.ts"; +import { encodeEnv, encodeSortedJson } from "../../../command-internal/output.encoders.ts"; import { - encodeGoToml, - encodeGoYaml, - goBool, - goInt, - goPtr, - goSlice, - goString, - goStruct, -} from "../../../command-internal/go-struct-output.encoders.ts"; + encodeStructToml, + encodeStructYaml, + shapeBool, + shapeInt, + shapePtr, + shapeSlice, + shapeString, + shapeStruct, +} from "../../../command-internal/struct-output.encoders.ts"; import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { formatTimestamp } from "../../../command-internal/timestamp.format.ts"; import { formatRegion } from "../../../command-internal/region.format.ts"; import type { BackupsListFlags } from "./list.command.ts"; /** Struct shape for `-o yaml|toml` encoding of the backups response. */ -const GO_BACKUPS_RESPONSE = goStruct([ +const BACKUPS_RESPONSE_SHAPE = shapeStruct([ [ "backups", - goSlice( - goStruct([ - ["id", goInt], - ["inserted_at", goString], - ["is_physical_backup", goBool], - ["status", goString], + shapeSlice( + shapeStruct([ + ["id", shapeInt], + ["inserted_at", shapeString], + ["is_physical_backup", shapeBool], + ["status", shapeString], ]), ), ], [ "physical_backup_data", - goStruct([ - ["earliest_physical_backup_date_unix", goPtr(goInt)], - ["latest_physical_backup_date_unix", goPtr(goInt)], + shapeStruct([ + ["earliest_physical_backup_date_unix", shapePtr(shapeInt)], + ["latest_physical_backup_date_unix", shapePtr(shapeInt)], ]), ], - ["pitr_enabled", goBool], - ["region", goString], - ["walg_enabled", goBool], + ["pitr_enabled", shapeBool], + ["region", shapeString], + ["walg_enabled", shapeBool], ]); type BackupsResponse = typeof V1ListAllBackupsOutput.Type; @@ -91,7 +91,7 @@ function renderLogicalTable(response: BackupsResponse): string { export const backupsList = Effect.fn("backups.list")(function* (flags: BackupsListFlags) { const output = yield* Output; - const goOutputFlag = yield* OutputFlag; + const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; @@ -110,31 +110,31 @@ export const backupsList = Effect.fn("backups.list")(function* (flags: BackupsLi ); yield* fetching?.clear ?? Effect.void; - const goFmt = Option.getOrUndefined(goOutputFlag); + const outputFlagFormat = Option.getOrUndefined(outputFlag); - if (goFmt === "json") { - yield* output.raw(encodeGoJson(response, { nullForEmptyArrays: ["backups"] })); + if (outputFlagFormat === "json") { + yield* output.raw(encodeSortedJson(response, { nullForEmptyArrays: ["backups"] })); return; } - if (goFmt === "yaml") { - yield* output.raw(encodeGoYaml(response, GO_BACKUPS_RESPONSE)); + if (outputFlagFormat === "yaml") { + yield* output.raw(encodeStructYaml(response, BACKUPS_RESPONSE_SHAPE)); return; } - if (goFmt === "toml") { + if (outputFlagFormat === "toml") { // Treats an empty backups list as absent, matching the nullForEmptyArrays JSON handling // above. yield* output.raw( - encodeGoToml( + encodeStructToml( { ...response, backups: response.backups.length > 0 ? response.backups : undefined, }, - GO_BACKUPS_RESPONSE, + BACKUPS_RESPONSE_SHAPE, ), ); return; } - if (goFmt === "env") { + if (outputFlagFormat === "env") { yield* output.raw(encodeEnv(response) + "\n"); 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 d02a6126b9..16d4aa7e88 100644 --- a/apps/cli/src/commands/backups/list/list.integration.test.ts +++ b/apps/cli/src/commands/backups/list/list.integration.test.ts @@ -38,7 +38,7 @@ const LOGICAL_RESPONSE: typeof V1ListAllBackupsOutput.Type = { interface SetupOpts { format?: "text" | "json" | "stream-json"; - goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + outputFlag?: "env" | "pretty" | "json" | "toml" | "yaml"; response?: typeof V1ListAllBackupsOutput.Type; status?: number; network?: "fail"; @@ -65,7 +65,7 @@ function setup(opts: SetupOpts = {}) { out, api, cliSettings, - goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), }); return { layer, out, api }; } @@ -124,8 +124,8 @@ describe("backups list integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("emits indented JSON to stdout for --output json (Go-compat)", () => { - const { layer, out } = setup({ goOutput: "json", response: PITR_RESPONSE }); + it.live("emits indented JSON to stdout for --output json", () => { + const { layer, out } = setup({ outputFlag: "json", response: PITR_RESPONSE }); return Effect.gen(function* () { yield* backupsList({ projectRef: Option.none() }); expect(out.stdoutText).toBe( @@ -142,7 +142,7 @@ describe("backups list integration", () => { }); it.live("emits YAML to stdout for --output yaml", () => { - const { layer, out } = setup({ goOutput: "yaml", response: PITR_RESPONSE }); + const { layer, out } = setup({ outputFlag: "yaml", response: PITR_RESPONSE }); return Effect.gen(function* () { yield* backupsList({ projectRef: Option.none() }); expect(out.stdoutText).toContain("region: ap-southeast-1"); @@ -151,7 +151,7 @@ describe("backups list integration", () => { }); it.live("emits TOML to stdout for --output toml", () => { - const { layer, out } = setup({ goOutput: "toml", response: PITR_RESPONSE }); + const { layer, out } = setup({ outputFlag: "toml", response: PITR_RESPONSE }); return Effect.gen(function* () { yield* backupsList({ projectRef: Option.none() }); expect(out.stdoutText).toContain('Region = "ap-southeast-1"'); @@ -160,7 +160,7 @@ describe("backups list integration", () => { }); it.live("emits [[Backups]] array-of-tables for --output toml with logical backups", () => { - const { layer, out } = setup({ goOutput: "toml", response: LOGICAL_RESPONSE }); + const { layer, out } = setup({ outputFlag: "toml", response: LOGICAL_RESPONSE }); return Effect.gen(function* () { yield* backupsList({ projectRef: Option.none() }); expect(out.stdoutText).toBe(`PitrEnabled = true @@ -179,7 +179,7 @@ WalgEnabled = true }); it.live("emits KEY=VALUE lines for --output env", () => { - const { layer, out } = setup({ goOutput: "env", response: PITR_RESPONSE }); + const { layer, out } = setup({ outputFlag: "env", response: PITR_RESPONSE }); return Effect.gen(function* () { yield* backupsList({ projectRef: Option.none() }); expect(out.stdoutText).toContain('REGION="ap-southeast-1"'); @@ -188,7 +188,7 @@ WalgEnabled = true }); it.live("treats --output pretty as identical to text mode (Glamour table)", () => { - const { layer, out } = setup({ goOutput: "pretty", response: PITR_RESPONSE }); + const { layer, out } = setup({ outputFlag: "pretty", response: PITR_RESPONSE }); return Effect.gen(function* () { yield* backupsList({ projectRef: Option.none() }); expect(out.stdoutText).toContain("Southeast Asia (Singapore)"); @@ -198,7 +198,7 @@ WalgEnabled = true it.live("--output flag value wins over --output-format when both provided", () => { const { layer, out } = setup({ format: "json", - goOutput: "yaml", + outputFlag: "yaml", response: PITR_RESPONSE, }); return Effect.gen(function* () { @@ -313,20 +313,17 @@ WalgEnabled = true }).pipe(Effect.provide(layer)); }); - it.live( - "sends User-Agent SupabaseCLI/ and no X-Supabase-Command headers (Go parity)", - () => { - const { layer, api } = setup({ - response: PITR_RESPONSE, - userAgent: "SupabaseCLI/1.42.0", - }); - return Effect.gen(function* () { - yield* backupsList({ projectRef: Option.none() }); - const headers = api.requests[0]?.headers; - expect(headers?.["user-agent"]).toBe("SupabaseCLI/1.42.0"); - expect(headers?.["x-supabase-command"]).toBeUndefined(); - expect(headers?.["x-supabase-command-run-id"]).toBeUndefined(); - }).pipe(Effect.provide(layer)); - }, - ); + it.live("sends User-Agent SupabaseCLI/ and no X-Supabase-Command headers", () => { + const { layer, api } = setup({ + response: PITR_RESPONSE, + userAgent: "SupabaseCLI/1.42.0", + }); + return Effect.gen(function* () { + yield* backupsList({ projectRef: Option.none() }); + const headers = api.requests[0]?.headers; + expect(headers?.["user-agent"]).toBe("SupabaseCLI/1.42.0"); + expect(headers?.["x-supabase-command"]).toBeUndefined(); + expect(headers?.["x-supabase-command-run-id"]).toBeUndefined(); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/cli/src/commands/backups/restore/restore.handler.ts b/apps/cli/src/commands/backups/restore/restore.handler.ts index de62569194..389351aac1 100644 --- a/apps/cli/src/commands/backups/restore/restore.handler.ts +++ b/apps/cli/src/commands/backups/restore/restore.handler.ts @@ -5,7 +5,7 @@ import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; -import { encodeGoJson } from "../../../command-internal/go-output.encoders.ts"; +import { encodeSortedJson } from "../../../command-internal/output.encoders.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { BackupRestoreNetworkError, @@ -23,7 +23,7 @@ const mapRestoreError = mapHttpError({ export const backupsRestore = Effect.fn("backups.restore")(function* (flags: BackupsRestoreFlags) { const output = yield* Output; - const goOutputFlag = yield* OutputFlag; + const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; @@ -43,16 +43,19 @@ export const backupsRestore = Effect.fn("backups.restore")(function* (flags: Bac ); yield* restoring?.clear ?? Effect.void; - const goFmt = Option.getOrUndefined(goOutputFlag); + const outputFlagFormat = Option.getOrUndefined(outputFlag); // --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(encodeGoJson({ message: "Started PITR restore", project_ref: ref })); + if (outputFlagFormat === "json") { + yield* output.raw(encodeSortedJson({ message: "Started PITR restore", project_ref: ref })); return; } - if (goFmt === undefined && (output.format === "json" || output.format === "stream-json")) { + if ( + outputFlagFormat === undefined && + (output.format === "json" || output.format === "stream-json") + ) { yield* output.success("Started PITR restore", { project_ref: ref }); return; } 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 4877c63e84..bdbcc8b83c 100644 --- a/apps/cli/src/commands/backups/restore/restore.integration.test.ts +++ b/apps/cli/src/commands/backups/restore/restore.integration.test.ts @@ -15,7 +15,7 @@ import { backupsRestore } from "./restore.handler.ts"; interface SetupOpts { format?: "text" | "json" | "stream-json"; - goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + outputFlag?: "env" | "pretty" | "json" | "toml" | "yaml"; status?: number; network?: "fail"; } @@ -34,7 +34,7 @@ function setup(opts: SetupOpts = {}) { out, api, cliSettings, - goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), + outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), }); return { layer, out, api }; } @@ -63,7 +63,7 @@ describe("backups restore integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("writes 'Started PITR restore: \\n' to stderr in text mode (Go parity)", () => { + it.live("writes 'Started PITR restore: \\n' to stderr in text mode", () => { const { layer, out } = setup(); return Effect.gen(function* () { yield* backupsRestore({ @@ -100,8 +100,8 @@ describe("backups restore integration", () => { }).pipe(Effect.provide(layer)); }); - it.live("emits indented JSON to stdout for --output json (Go-compat)", () => { - const { layer, out } = setup({ goOutput: "json" }); + it.live("emits indented JSON to stdout for --output json", () => { + const { layer, out } = setup({ outputFlag: "json" }); return Effect.gen(function* () { yield* backupsRestore({ projectRef: Option.none(), @@ -114,9 +114,9 @@ describe("backups restore integration", () => { }); it.live( - "renders the stderr text line for --output {pretty,yaml,toml,env} (Go ignores --output)", + "renders the stderr text line for --output {pretty,yaml,toml,env} (--output is ignored)", () => { - const { layer, out } = setup({ goOutput: "yaml" }); + const { layer, out } = setup({ outputFlag: "yaml" }); return Effect.gen(function* () { yield* backupsRestore({ projectRef: Option.none(), diff --git a/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md b/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md index 26e46f18c5..5099d23869 100644 --- a/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md @@ -23,15 +23,15 @@ command — see Notes). ## Files Written -| Path | Format | When | -| ----------------------------------------------------------------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | blank/`scratch` path only (via `initProject`) | -| `/